commit 7a10f92b4fbfbcce806ca9f1edff8e34261b83ca truex <truex@equwal.com> 2026-09-26 14:35:54 -0700 Hold the follow after a page turn by the user While the audio played, a page turn by hand lasted only to the next cue: the follow turned the page back 0.8 seconds later. Now a page turn by the user (an arrow, a key, a swipe or the page jump) holds the follow. While it is held, the page does not turn by itself. The mark still shows when the cue of now is on the page shown. A Follow button shows in the top bar, and the strip says "Follow paused". The hold ends when the user taps Follow, when the audio reaches the page shown, when the user moves the audio from this app (the cue list, "move the audio to this page"), or when the player jumps. A new alignment after a page is read is no seek: it has its own event, and it does not end the hold. So cueMoved is not necessary any more. follow() in follower.ts is now a function of the state: at each step it compares the page of the audio with the page shown. It gets the event (tick, realign, seek, follow) and the hold, and it gives the new hold. Property tests show that a held follow never turns a page without a seek or the Follow button, and that the hold ends when the audio is on the page shown. lastCueAt (subtitles.ts) gives the cue of now, also in the silence after a cue.
index.html | 9 +++ src/follower.ts | 80 ++++++++++---------- src/main.ts | 106 ++++++++++++++++---------- src/style.css | 15 +++- src/subtitles.ts | 13 +++- test/follower.test.ts | 197 +++++++++++++++++++++++++++---------------------- test/subtitles.test.ts | 34 ++++++++- 7 files changed, 284 insertions(+), 170 deletions(-)
diff --git a/index.html b/index.html index fd3affe..df31fd5 100644 --- a/index.html +++ b/index.html @@ -55,10 +55,19 @@ <path class="solid" d="M10 10v7l5.5-3.5z" /> </svg> </button> + <button + id="follow-now" + aria-label="Follow the audio again" + title="Follow the audio again: go to its page" + hidden + > + Follow + </button> </header> <div id="strip"> <span id="player-status"></span> + <span id="follow-note" hidden>Follow paused</span> <span id="pages-status" hidden></span> <span id="status" aria-live="polite"></span> </div> diff --git a/src/follower.ts b/src/follower.ts index 654ea0a..ec2dd85 100644 --- a/src/follower.ts +++ b/src/follower.ts @@ -1,21 +1,37 @@ // The reader follows the audio: which cue to mark, which page to show. Pure. // -// The alignment gives each matched cue a page. This reducer turns a change -// of the cue into at most one highlight and one page turn. +// The alignment gives each matched cue a page. At each step the follow +// compares the page of the audio with the page shown, and turns the page +// when they differ. A page turn by the user holds the follow: the page +// stays until the audio reaches it, the audio jumps, or the user asks to +// follow again. export type FollowMode = 'highlight' | 'pages' | 'off'; +/** + * Why the follow runs. + * + * - `tick`: a new state of the clock in the normal run. + * - `realign`: the alignment changed, because a page was read. + * - `seek`: the audio jumped. The player jumped, or the user moved the + * audio from this app. + * - `follow`: the user asked to follow: the Follow button, or a new + * follow mode. + */ +export type FollowEvent = 'tick' | 'realign' | 'seek' | 'follow'; + export interface FollowInput { - /** The page of each cue, from the alignment. Null for an unmatched cue. */ - cuePages: readonly (number | null)[]; mode: FollowMode; currentPage: number; - /** The cue of the last call, or -1. */ - previousCue: number; - /** The cue of now, or -1 between cues. */ + /** The cue of now, or -1 before the first cue. */ cue: number; - /** True when the audio jumped: a seek, not the normal run forward. */ - seeked: boolean; + /** True when the cue of now is matched: it has tokens on a page. */ + matched: boolean; + /** The page of the audio now, or null when no page stands for the cue of now. */ + page: number | null; + /** True while a page turn by the user holds the follow. */ + held: boolean; + event: FollowEvent; } export interface FollowOutput { @@ -23,13 +39,13 @@ export interface FollowOutput { highlightCue: number | null; /** The page to show, or null to stay. */ turnToPage: number | null; + /** True when the follow stays held. */ + held: boolean; } /** How many cues back an unmatched cue borrows the page of a matched one. */ export const LOOKBACK = 5; -const NOTHING: FollowOutput = { highlightCue: null, turnToPage: null }; - /** * The page that stands for `cue`: its own page when the cue is matched, * else the page of the nearest matched cue before it, within LOOKBACK. @@ -43,40 +59,24 @@ export function pageForCue(cuePages: readonly (number | null)[], cue: number): n return null; } -/** - * True when a new alignment moves the cue: its own page changed (for - * example, it became matched), or the page that stands for it changed. - * Only then must the follow act again after an alignment. Else a page - * that the user turned by hand stays. - */ -export function cueMoved( - before: readonly (number | null)[], - after: readonly (number | null)[], - cue: number, -): boolean { - if (cue < 0) return false; - return ( - (before[cue] ?? null) !== (after[cue] ?? null) || - pageForCue(before, cue) !== pageForCue(after, cue) - ); -} - /** * One step of the follow. * - * `off` does nothing. The same cue as before, without a seek, does nothing - * either: the user can turn pages by hand while a long cue plays. A page - * turn happens when the page of the cue is not the current page. In - * `highlight` mode a matched cue is marked; in `pages` mode nothing is. + * A seek or the Follow button ends the hold. The hold also ends when the + * audio is on the page shown. A tick or a new alignment does not end it. + * While the follow is held, the page does not turn. Else the page turns + * when the page of the audio is not the page shown. In `highlight` mode a + * matched cue is marked, also while the follow is held: the mark shows + * only on the page of the cue. `off` does nothing. */ export function follow(input: FollowInput): FollowOutput { - const { cuePages, mode, currentPage, previousCue, cue, seeked } = input; - if (mode === 'off' || cue < 0 || cue >= cuePages.length) return NOTHING; - if (cue === previousCue && !seeked) return NOTHING; - const own = cuePages[cue] ?? null; - const page = pageForCue(cuePages, cue); + const { mode, currentPage, cue, matched, page, event } = input; + let held = input.held && (event === 'tick' || event === 'realign'); + if (mode === 'off' || cue < 0) return { highlightCue: null, turnToPage: null, held }; + if (page === currentPage) held = false; return { - highlightCue: mode === 'highlight' && own !== null ? cue : null, - turnToPage: page !== null && page !== currentPage ? page : null, + highlightCue: mode === 'highlight' && matched ? cue : null, + turnToPage: !held && page !== null && page !== currentPage ? page : null, + held, }; } diff --git a/src/main.ts b/src/main.ts index e0d6397..c501a6d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align'; import { bookText } from './book-text'; import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source'; -import { cueMoved, follow, type FollowMode } from './follower'; +import { follow, pageForCue, type FollowEvent, type FollowMode } from './follower'; import { lookupText, tapTolerance, tokenAt } from './hit-test'; import { lineBoxes } from './line-boxes'; import { @@ -24,7 +24,7 @@ import { loadPdf, type PdfDoc } from './pdf'; import { nextPage } from './read-order'; import { say, showPlayer, showReading } from './status'; import { isAndroid, SubRead } from './subread'; -import { cueIndexAt, parseSubtitles, type Cue } from './subtitles'; +import { lastCueAt, parseSubtitles, type Cue } from './subtitles'; import { createPageView, type Marks } from './view'; import { bookKey, @@ -81,6 +81,8 @@ const ui = { syncPage: el<HTMLButtonElement>('sync-page'), pageLabel: el<HTMLButtonElement>('page-label'), pageInput: el<HTMLInputElement>('page-input'), + followNow: el<HTMLButtonElement>('follow-now'), + followNote: el<HTMLSpanElement>('follow-note'), rtl: el<HTMLInputElement>('rtl'), viewer: el<HTMLDivElement>('viewer'), empty: el<HTMLElement>('empty'), @@ -113,6 +115,8 @@ const state = { srt: null as string | null, /** True while the player waits for a dictionary lookup that this app paused. */ lookupPaused: false, + /** True while a page turn by the user holds the follow. */ + held: false, }; const view = createPageView(ui, marks); @@ -127,7 +131,7 @@ const nav = setupNav( rtl: () => ui.rtl.checked, drawerOpen: () => !desktop.matches && document.body.classList.contains('menu-open'), }, - (index) => void showPage(index), + turnByUser, ); // --- Settings --- @@ -173,11 +177,10 @@ ui.rtl.addEventListener('change', () => { nav.update(); }); +// A new follow mode follows the audio at once. ui.follow.addEventListener('change', () => { settings.set('follow', followMode()); - state.markedCue = -1; - view.mark(); - if (state.clock) applyClock({ ...state.clock, seeked: true }); + runFollow('follow'); }); ui.pauseLookup.addEventListener('change', () => settings.set('pauseLookup', ui.pauseLookup.checked ? '1' : '0'), @@ -237,6 +240,7 @@ ui.pdfFile.addEventListener('change', async () => { state.cuePages = []; state.activeCue = -1; state.markedCue = -1; + state.held = false; ui.makeSubs.disabled = false; ui.empty.hidden = true; ui.page.hidden = false; @@ -453,16 +457,13 @@ function align(): number | null { if (state.alignTimer) clearTimeout(state.alignTimer); state.alignTimer = null; if (!state.aligner || state.cues.length === 0 || state.tokens.length === 0) return null; - const before = state.cuePages; state.spans = state.aligner.spans(state.pages.map((p) => p?.tokens)); state.cuePages = state.spans.map((s) => (s.matched ? state.tokens[s.start]!.page : null)); renderCueList(); - // Follow again only when the alignment moved the cue of now, for - // example when it became matched. A page read in the background must not - // undo a page turn by hand. - if (state.clock && cueMoved(before, state.cuePages, clockCue(state.clock))) { - applyClock({ ...state.clock, seeked: true }); - } + view.mark(); + // The cue of now can have a new page. A new alignment is no seek, so a + // page that the user turned to stays. + runFollow('realign'); return state.cuePages.filter((p) => p !== null).length; } @@ -477,7 +478,7 @@ function renderCueList(): void { const span = state.spans[i]; if (span && !span.matched) li.classList.add('unmatched'); if (i === state.activeCue) li.classList.add('active'); - li.addEventListener('click', () => void clock.seek(cue.start * 1000).catch(sayError)); + li.addEventListener('click', () => seek(cue.start * 1000)); return li; }), ); @@ -507,33 +508,61 @@ function showPlayerStatus(s: ClockState): void { showPlayer(state.pdf ? playerMessage(s, isAndroid) : null); } -/** The cue at the position of a clock state, or -1. */ -function clockCue(s: ClockState): number { - return s.positionMs === null ? -1 : cueIndexAt(state.cues, s.positionMs / 1000); -} - -/** One state of the clock: mark the cue of now and turn the page. */ -function applyClock(s: ClockState): void { - state.clock = s; - showPlayerStatus(s); - const cue = clockCue(s); - // The last cue stays marked in the silence between two cues. - if (cue < 0) return; +/** + * The follow for the last state of the clock: mark the cue of now, and + * turn to its page unless the follow is held. In the silence between two + * cues, the cue before stays the cue of now. + */ +function runFollow(event: FollowEvent): void { + const s = state.clock; + const cue = s?.positionMs == null ? -1 : lastCueAt(state.cues, s.positionMs / 1000); + if (cue !== state.activeCue) { + markCueInList(cue); + state.activeCue = cue; + } + const mode = followMode(); + const page = cue < 0 ? null : pageForCue(state.cuePages, cue); const out = follow({ - cuePages: state.cuePages, - mode: followMode(), + mode, currentPage: state.currentPage, - previousCue: state.activeCue, cue, - seeked: s.seeked, + matched: cue >= 0 && state.cuePages[cue] != null, + page, + held: state.held, + event, }); - const changed = cue !== state.activeCue || s.seeked; - if (cue !== state.activeCue) markCueInList(cue); - state.activeCue = cue; - if (!changed || followMode() === 'off') return; - state.markedCue = out.highlightCue ?? -1; + state.held = out.held; + // The Follow button shows only when it has a page to go to. + const paused = out.held && mode !== 'off' && page !== null; + ui.followNow.hidden = !paused; + ui.followNote.hidden = !paused; + const marked = out.highlightCue ?? -1; + const markMoved = marked !== state.markedCue; + state.markedCue = marked; if (out.turnToPage !== null) void showPage(out.turnToPage); - else view.mark(); + else if (markMoved) view.mark(); +} + +/** One state of the clock. A jump of the audio is a seek. */ +function applyClock(s: ClockState): void { + state.clock = s; + showPlayerStatus(s); + runFollow(s.seeked ? 'seek' : 'tick'); +} + +/** A page turn by the user. It holds the follow, until the audio reaches the page. */ +function turnByUser(index: number): void { + state.held = true; + void showPage(index); + runFollow('tick'); +} + +/** Moves the audio from this app. The follow is not held after it. */ +function seek(ms: number): void { + clock.seek(ms).then(() => { + state.held = false; + say(`Audio moved to ${clockText(ms)}.`); + }, sayError); } const clock: ClockSource = isAndroid ? new OverlayClock(SubRead) : new LocalAudioClock(ui.audio); @@ -544,14 +573,15 @@ ui.play.addEventListener('click', () => { action.catch(sayError); }); +ui.followNow.addEventListener('click', () => runFollow('follow')); + ui.syncPage.addEventListener('click', () => { const i = state.cuePages.indexOf(state.currentPage); if (i < 0) { say('No cue of the subtitles is on this page.'); return; } - const ms = state.cues[i]!.start * 1000; - clock.seek(ms).then(() => say(`Audio moved to ${clockText(ms)}.`), sayError); + seek(state.cues[i]!.start * 1000); }); ui.audioFile.addEventListener('change', () => { diff --git a/src/style.css b/src/style.css index efa5215..f47e8db 100644 --- a/src/style.css +++ b/src/style.css @@ -2,6 +2,7 @@ color-scheme: light dark; font-family: system-ui, sans-serif; --highlight: rgba(255, 200, 0, 0.45); + --highlight-solid: rgb(255, 200, 0); --highlight-weak: rgba(120, 160, 255, 0.35); --topbar: 2.75rem; --panel-width: 22rem; @@ -104,6 +105,18 @@ body { font-variant-numeric: tabular-nums; } +/* The Follow button shows while a page turn by the user holds the follow. */ +#topbar #follow-now { + padding: 0 0.6rem; + font-weight: 600; + color: #1a1a1a; + background: var(--highlight-solid); +} + +#follow-note { + font-weight: 600; +} + /* The page jump takes the place of the page label. */ #page-input { flex: 1 1 auto; @@ -339,7 +352,7 @@ body.zoomed #viewer { border-radius: 8px; border: none; color: #1a1a1a; - background: rgb(255, 200, 0); + background: var(--highlight-solid); cursor: pointer; } diff --git a/src/subtitles.ts b/src/subtitles.ts index a67e2c8..fc677ab 100644 --- a/src/subtitles.ts +++ b/src/subtitles.ts @@ -49,8 +49,11 @@ export function parseSubtitles(input: string): Cue[] { return cues; } -/** Index of the cue active at time t, or -1. Cues must be sorted by start. */ -export function cueIndexAt(cues: readonly Cue[], t: number): number { +/** + * Index of the last cue that starts at or before time t, or -1. In the + * silence after a cue, this is still that cue. Cues must be sorted by start. + */ +export function lastCueAt(cues: readonly Pick<Cue, 'start'>[], t: number): number { let lo = 0; let hi = cues.length - 1; let found = -1; @@ -63,6 +66,12 @@ export function cueIndexAt(cues: readonly Cue[], t: number): number { hi = mid - 1; } } + return found; +} + +/** Index of the cue active at time t, or -1. Cues must be sorted by start. */ +export function cueIndexAt(cues: readonly Cue[], t: number): number { + const found = lastCueAt(cues, t); if (found >= 0 && t < cues[found]!.end) return found; return -1; } diff --git a/test/follower.test.ts b/test/follower.test.ts index 5006a85..44d3216 100644 --- a/test/follower.test.ts +++ b/test/follower.test.ts @@ -1,10 +1,10 @@ import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; import { - cueMoved, follow, LOOKBACK, pageForCue, + type FollowEvent, type FollowInput, type FollowMode, } from '../src/follower'; @@ -13,16 +13,27 @@ const pages: (number | null)[] = [0, 0, null, 1, 1, null, null, 2]; function input(over: Partial<FollowInput>): FollowInput { return { - cuePages: pages, mode: 'highlight', currentPage: 0, - previousCue: -1, cue: 0, - seeked: false, + matched: true, + page: 0, + held: false, + event: 'tick', ...over, }; } +/** The input of the follow for cue `cue` of `cuePages`. */ +function at(cue: number, over: Partial<FollowInput> = {}): FollowInput { + return input({ + cue, + matched: pages[cue] !== null && pages[cue] !== undefined, + page: pageForCue(pages, cue), + ...over, + }); +} + describe('pageForCue', () => { it('gives the page of a matched cue', () => { expect(pageForCue(pages, 3)).toBe(1); @@ -40,137 +51,146 @@ describe('pageForCue', () => { }); }); -describe('cueMoved', () => { - it('is false when the new alignment keeps the page of the cue', () => { - expect(cueMoved([0, 1, 1], [0, 1, 1], 2)).toBe(false); - }); - - it('is false when only other cues change, as when a page is read in the background', () => { - expect(cueMoved([0, 1, null, null], [0, 1, 2, 2], 1)).toBe(false); - }); - - it('is true when the cue becomes matched', () => { - expect(cueMoved([], [0, 1, 1], 1)).toBe(true); - expect(cueMoved([0, null, 1], [0, 1, 1], 1)).toBe(true); - }); - - it('is true when the cue moves to another page', () => { - expect(cueMoved([0, 1], [0, 2], 1)).toBe(true); - }); - - it('is true when an unmatched cue borrows another page', () => { - expect(cueMoved([0, null], [1, null], 1)).toBe(true); - }); - - it('is false between cues', () => { - expect(cueMoved([0], [1], -1)).toBe(false); - }); - - it('is true whenever the new alignment changes what the follow does', () => { - // Half of the cues are unmatched. The pages are few, so a change often - // gives the same page again. - const page = fc.option(fc.nat({ max: 3 }), { nil: null, freq: 2 }); - const step = fc.array(page, { minLength: 1, maxLength: 30 }).chain((before) => - fc.record({ - before: fc.constant(before), - cue: fc.integer({ min: -1, max: before.length - 1 }), - // Changes at the cue and in the cues that it can borrow a page from. - changes: fc.array(fc.tuple(fc.nat({ max: LOOKBACK + 1 }), page), { maxLength: 3 }), - mode: fc.constantFrom<FollowMode>('highlight', 'pages', 'off'), - currentPage: fc.nat({ max: 3 }), - previousCue: fc.integer({ min: -1, max: 30 }), - seeked: fc.boolean(), - }), - ); - fc.assert( - fc.property(step, ({ before, changes, ...i }) => { - const after = [...before]; - for (const [back, p] of changes) if (i.cue - back >= 0) after[i.cue - back] = p; - const old = follow({ ...i, cuePages: before }); - const now = follow({ ...i, cuePages: after }); - if (!cueMoved(before, after, i.cue)) expect(now).toEqual(old); - }), - { numRuns: 1000 }, - ); - }); -}); - describe('follow', () => { it('marks a matched cue and stays on its page', () => { - expect(follow(input({ cue: 1 }))).toEqual({ highlightCue: 1, turnToPage: null }); + expect(follow(at(1))).toEqual({ highlightCue: 1, turnToPage: null, held: false }); }); it('turns to the page of a matched cue', () => { - expect(follow(input({ cue: 3 }))).toEqual({ highlightCue: 3, turnToPage: 1 }); + expect(follow(at(3))).toEqual({ highlightCue: 3, turnToPage: 1, held: false }); }); it('turns for an unmatched cue with a matched cue before it, without a mark', () => { - expect(follow(input({ cue: 5, currentPage: 0 }))).toEqual({ + expect(follow(at(5, { currentPage: 0 }))).toEqual({ highlightCue: null, turnToPage: 1, + held: false, }); - expect(follow(input({ cue: 5, currentPage: 1 }))).toEqual({ + expect(follow(at(5, { currentPage: 1 }))).toEqual({ highlightCue: null, turnToPage: null, + held: false, }); }); - it('does nothing for an unmatched cue with no matched cue near it', () => { - const far = [0, ...new Array<null>(LOOKBACK + 1).fill(null)]; - expect(follow(input({ cuePages: far, cue: far.length - 1, currentPage: 5 }))).toEqual({ + it('does nothing for a cue that has no page', () => { + expect(follow(input({ cue: 9, matched: false, page: null, currentPage: 5 }))).toEqual({ highlightCue: null, turnToPage: null, + held: false, }); }); it('only turns pages in pages mode', () => { - expect(follow(input({ mode: 'pages', cue: 3 }))).toEqual({ + expect(follow(at(3, { mode: 'pages' }))).toEqual({ highlightCue: null, turnToPage: 1, + held: false, }); }); - it('does nothing when off', () => { - expect(follow(input({ mode: 'off', cue: 3 }))).toEqual({ + it('does nothing when off, and before the first cue', () => { + expect(follow(at(3, { mode: 'off' }))).toEqual({ highlightCue: null, turnToPage: null, + held: false, }); - }); - - it('does nothing for the same cue without a seek, and acts on a seek', () => { - expect(follow(input({ cue: 3, previousCue: 3 }))).toEqual({ + expect(follow(input({ cue: -1, page: null }))).toEqual({ highlightCue: null, turnToPage: null, - }); - expect(follow(input({ cue: 3, previousCue: 3, seeked: true }))).toEqual({ - highlightCue: 3, - turnToPage: 1, + held: false, }); }); - it('does nothing between cues', () => { - expect(follow(input({ cue: -1 }))).toEqual({ highlightCue: null, turnToPage: null }); + it('turns again inside the same cue when the page of the audio changes', () => { + // A cue on two pages: the page follows the time, so one cue gives two pages. + expect(follow(input({ cue: 7, page: 0, currentPage: 0 })).turnToPage).toBeNull(); + expect(follow(input({ cue: 7, page: 1, currentPage: 0 })).turnToPage).toBe(1); + }); + + it('keeps a page that the user turned to while the audio plays on', () => { + // The audio plays cue 1 on page 0. At 4.43 s the user turns to page 1. + // The next cue, on page 0, must not turn the page back. + let held = true; + let out = follow(at(1, { currentPage: 1, held })); + expect(out).toEqual({ highlightCue: 1, turnToPage: null, held: true }); + held = out.held; + out = follow(at(2, { currentPage: 1, held })); + expect(out).toEqual({ highlightCue: null, turnToPage: null, held: true }); + // A page that is read and aligned again does not end the hold either. + out = follow(at(2, { currentPage: 1, held, event: 'realign' })); + expect(out.held).toBe(true); + expect(out.turnToPage).toBeNull(); + // The audio reaches page 1: the hold ends, and the cue is marked. + out = follow(at(3, { currentPage: 1, held })); + expect(out).toEqual({ highlightCue: 3, turnToPage: null, held: false }); + // From here the follow turns the page again. + expect(follow(at(7, { currentPage: 1, held: out.held })).turnToPage).toBe(2); + }); + + it('ends the hold on a seek and on the Follow button, and turns at once', () => { + for (const event of ['seek', 'follow'] as const) { + expect(follow(at(3, { currentPage: 0, held: true, event }))).toEqual({ + highlightCue: 3, + turnToPage: 1, + held: false, + }); + } }); - const cuePages = fc.array(fc.option(fc.nat({ max: 9 }), { nil: null }), { maxLength: 30 }); const mode = fc.constantFrom<FollowMode>('highlight', 'pages', 'off'); + const event = fc.constantFrom<FollowEvent>('tick', 'realign', 'seek', 'follow'); const any = fc.record({ - cuePages, mode, - currentPage: fc.nat({ max: 9 }), - previousCue: fc.integer({ min: -1, max: 30 }), + currentPage: fc.nat({ max: 5 }), cue: fc.integer({ min: -1, max: 30 }), - seeked: fc.boolean(), + matched: fc.boolean(), + page: fc.option(fc.nat({ max: 5 }), { nil: null }), + held: fc.boolean(), + event, }); - it('never turns to a page that no cue maps to', () => { + it('never turns a page while held, without a seek or the Follow button', () => { + fc.assert( + fc.property(any, fc.constantFrom<FollowEvent>('tick', 'realign'), (i, quiet) => { + const out = follow({ ...i, held: true, event: quiet }); + if (out.held) expect(out.turnToPage).toBeNull(); + // A held follow that ends without a seek ends because the audio is on the page shown. + if (!out.held) expect(i.page).toBe(i.currentPage); + expect(out.turnToPage).toBeNull(); + }), + ); + }); + + it('ends the hold when the audio is on the page shown', () => { + fc.assert( + fc.property(any, (i) => { + const out = follow({ ...i, page: i.currentPage }); + if (i.mode !== 'off' && i.cue >= 0) expect(out.held).toBe(false); + expect(out.turnToPage).toBeNull(); + }), + ); + }); + + it('ends the hold on a seek and on the Follow button', () => { + fc.assert( + fc.property(any, fc.constantFrom<FollowEvent>('seek', 'follow'), (i, loud) => { + expect(follow({ ...i, event: loud }).held).toBe(false); + }), + ); + }); + + it('shows the page of the audio when the follow is not held', () => { fc.assert( fc.property(any, (i) => { const out = follow(i); if (out.turnToPage !== null) { - expect(i.cuePages).toContain(out.turnToPage); + expect(out.turnToPage).toBe(i.page); expect(out.turnToPage).not.toBe(i.currentPage); } + if (!out.held && i.mode !== 'off' && i.cue >= 0 && i.page !== null) { + expect(out.turnToPage ?? i.currentPage).toBe(i.page); + } }), ); }); @@ -182,7 +202,7 @@ describe('follow', () => { if (out.highlightCue !== null) { expect(out.highlightCue).toBe(i.cue); expect(i.mode).toBe('highlight'); - expect(i.cuePages[i.cue]).not.toBeNull(); + expect(i.matched).toBe(true); } }), ); @@ -192,7 +212,8 @@ describe('follow', () => { fc.assert( fc.property(any, (i) => { const out = follow({ ...i, mode: 'off' }); - expect(out).toEqual({ highlightCue: null, turnToPage: null }); + expect(out.highlightCue).toBeNull(); + expect(out.turnToPage).toBeNull(); expect(follow({ ...i, mode: 'pages' }).highlightCue).toBeNull(); }), ); diff --git a/test/subtitles.test.ts b/test/subtitles.test.ts index 4a39c19..e25e4ca 100644 --- a/test/subtitles.test.ts +++ b/test/subtitles.test.ts @@ -1,6 +1,6 @@ import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; -import { cueIndexAt, parseSubtitles } from '../src/subtitles'; +import { cueIndexAt, lastCueAt, parseSubtitles } from '../src/subtitles'; const SRT = `1 00:00:01,000 --> 00:00:03,500 @@ -81,3 +81,35 @@ describe('cueIndexAt', () => { ); }); }); + +describe('lastCueAt', () => { + const cues = [ + { start: 1, end: 2 }, + { start: 3, end: 4 }, + ]; + + it('gives the last cue that started, also in the silence after it', () => { + expect(lastCueAt(cues, 0.5)).toBe(-1); + expect(lastCueAt(cues, 1)).toBe(0); + expect(lastCueAt(cues, 2.5)).toBe(0); + expect(lastCueAt(cues, 3)).toBe(1); + expect(lastCueAt(cues, 99)).toBe(1); + expect(lastCueAt([], 1)).toBe(-1); + }); + + it('agrees with a linear scan', () => { + const starts = fc + .array(fc.nat({ max: 100 }), { maxLength: 30 }) + .map((s) => s.sort((a, b) => a - b)); + fc.assert( + fc.property(starts, fc.integer({ min: -5, max: 110 }), (sorted, t) => { + const list = sorted.map((start) => ({ start })); + let linear = -1; + list.forEach((c, i) => { + if (c.start <= t) linear = i; + }); + expect(lastCueAt(list, t)).toBe(linear); + }), + ); + }); +});