All files / src/lib subtitle-clock.ts

100% Statements 33/33
100% Branches 22/22
100% Functions 5/5
100% Lines 24/24

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113                      4x               4x                                                     169x                         4x 123x 121x 121x             4x 289x             4x 36x 36x 12x 12x 9x                         4x             174x 170x 85x 85x 81x 78x 78x   38x    
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { cueAt, nextCueChange } from "./subtitle-timing";
import type { SubtitleCue } from "./types";
 
/**
 * The furthest the clock runs past the last position read, in seconds. The
 * player reads the position every second, so one that's older than this
 * means the readings have stopped.
 */
export const MAX_AHEAD_SECONDS = 1.5;
 
/**
 * The most a reading can be behind the clock, in seconds, and leave the clock
 * where it is. A reading arrives a little after mpv took it, so it's often a
 * few hundredths of a second behind. Going back for that would show a cue
 * that had just ended again for a moment.
 */
export const MAX_STEP_BACK_SECONDS = 0.15;
 
/** A position read from mpv. */
export interface Reading {
	/** Seconds into the video. */
	position: number;
	/** When it was read, in `performance.now()` milliseconds. */
	at: number;
	/** Whether the video plays on from it. */
	moving: boolean;
}
 
/** Where the video is between two readings. */
export interface SubtitleClock {
	/** The reading the clock follows. */
	reading: Reading;
	/** Seconds into the video at `at`. */
	position: number;
	/** In `performance.now()` milliseconds. */
	at: number;
	/** Whether the clock runs on from `position`. */
	moving: boolean;
	/** The furthest the clock runs, MAX_AHEAD_SECONDS past the reading. */
	limit: number;
}
 
/** A clock that starts at `reading`. */
export const startClock = (reading: Reading): SubtitleClock => ({
	reading,
	position: reading.position,
	at: reading.at,
	moving: reading.moving,
	limit: reading.position + MAX_AHEAD_SECONDS,
});
 
/**
 * The clock at `now`. A moving clock runs on in real time up to its limit,
 * since the app has no speed control. It never runs back, even for a `now`
 * before its own time.
 */
export const clockAt = (clock: SubtitleClock, now: number): SubtitleClock => {
	if (!clock.moving) return { ...clock, at: now };
	const ran = Math.max(0, now - clock.at) / 1000;
	return { ...clock, position: Math.min(clock.position + ran, clock.limit), at: now };
};
 
/**
 * Whether `position`, read at `at`, isn't the reading the clock follows. Two
 * readings can share a time, as a seek just after a load can.
 */
export const isNewReading = (clock: SubtitleClock, position: number, at: number): boolean =>
	clock.reading.at !== at || clock.reading.position !== position;
 
/**
 * The clock once `reading` arrives. It starts again from the reading, unless
 * both are moving and the reading is at most MAX_STEP_BACK_SECONDS behind
 * where the clock was when it was read. Then the clock goes on from there.
 */
export const followReading = (clock: SubtitleClock, reading: Reading): SubtitleClock => {
	const next = startClock(reading);
	if (!clock.moving || !reading.moving) return next;
	const { position } = clockAt(clock, reading.at);
	if (position - reading.position > MAX_STEP_BACK_SECONDS) return next;
	return { ...next, position: Math.max(position, reading.position) };
};
 
/**
 * Milliseconds until the clock should next move on, or null when nothing
 * changes until the next reading. `moving` is whether the video plays now,
 * and `delay` shows each cue later, as mpv's `sub-delay` does.
 *
 * The clock moves on at once when the video starts or stops, or when the cue
 * at the clock's position isn't the one for `now`, as after the cues or the
 * delay change. Otherwise it moves on just after the next cue starts or ends,
 * unless that's past its limit.
 */
export const nextTick = (
	clock: SubtitleClock,
	cues: SubtitleCue[],
	delay: number,
	moving: boolean,
	now: number
): number | null => {
	if (moving !== clock.moving) return 0;
	if (!moving) return null;
	const time = clockAt(clock, now).position;
	if (cueAt(cues, time - delay) !== cueAt(cues, clock.position - delay)) return 0;
	if (time >= clock.limit) return null;
	const wait = nextCueChange(cues, time - delay);
	if (wait === null || time + wait > clock.limit) return null;
	// A millisecond more puts the clock past the end of a cue.
	return Math.ceil(wait * 1000) + 1;
};