commit 562ed86dea04d28c41b0428216f582dd30179263 truex <truex@equwal.com> 2026-09-25 01:54:03 -0700 Add the player state parser, the play clock and the follower Ports of player_state.lua (subread.koplugin) and PlayClock.kt (SubRead Overlay), and a pure reducer that turns a change of the cue into a highlight and a page turn. Three follow modes: highlight and turn pages, turn pages only, off.
src/follower.ts | 64 +++++++++++++++++++++ src/play-clock.ts | 30 ++++++++++ src/player-state.ts | 58 +++++++++++++++++++ test/follower.test.ts | 138 ++++++++++++++++++++++++++++++++++++++++++++++ test/play-clock.test.ts | 78 ++++++++++++++++++++++++++ test/player-state.test.ts | 76 +++++++++++++++++++++++++ 6 files changed, 444 insertions(+)
diff --git a/src/follower.ts b/src/follower.ts new file mode 100644 index 0000000..42e7e03 --- /dev/null +++ b/src/follower.ts @@ -0,0 +1,64 @@ +// 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. + +export type FollowMode = 'highlight' | 'pages' | 'off'; + +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. */ + cue: number; + /** True when the audio jumped: a seek, not the normal run forward. */ + seeked: boolean; +} + +export interface FollowOutput { + /** The cue to mark on the page, or null for no mark. */ + highlightCue: number | null; + /** The page to show, or null to stay. */ + turnToPage: number | null; +} + +/** 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. + * Null when there is none. + */ +export function pageForCue(cuePages: readonly (number | null)[], cue: number): number | null { + for (let i = cue; i >= 0 && i >= cue - LOOKBACK; i--) { + const page = cuePages[i]; + if (page !== null && page !== undefined) return page; + } + return null; +} + +/** + * 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. + */ +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); + return { + highlightCue: mode === 'highlight' && own !== null ? cue : null, + turnToPage: page !== null && page !== currentPage ? page : null, + }; +} diff --git a/src/play-clock.ts b/src/play-clock.ts new file mode 100644 index 0000000..eb6cc8f --- /dev/null +++ b/src/play-clock.ts @@ -0,0 +1,30 @@ +// What a media player said about its position, and when it said it. Pure. +// +// A player does not report each millisecond. It reports a position, the +// time of the report, and the speed. The position now is computed from +// these three. This is a port of PlayClock.kt from SubRead Overlay. + +export interface PlayClock { + positionMs: number; + /** The time of the report, on the same clock as the `nowMs` parameters. */ + reportedAtMs: number; + speed: number; + playing: boolean; +} + +/** The position in the media at `nowMs`. */ +export function positionAt(clock: PlayClock, nowMs: number): number { + if (!clock.playing) return clock.positionMs; + return clock.positionMs + (nowMs - clock.reportedAtMs) * clock.speed; +} + +/** + * How long to wait, on the clock of the device, until the media is at + * `targetMs`. Null when the media does not move forward, or is already there. + */ +export function waitUntil(clock: PlayClock, targetMs: number, nowMs: number): number | null { + if (!clock.playing || clock.speed <= 0) return null; + const left = targetMs - positionAt(clock, nowMs); + if (left <= 0) return null; + return Math.ceil(left / clock.speed); +} diff --git a/src/player-state.ts b/src/player-state.ts new file mode 100644 index 0000000..e497c5c --- /dev/null +++ b/src/player-state.ts @@ -0,0 +1,58 @@ +// The state line of the audio player, as SubRead Overlay reports it. Pure. +// +// SubRead Overlay is an Android app with notification access. It reads the +// media session of the player and gives one line to other apps: +// +// playing=1;position=96153;speed=1.0;package=de.ph1b.audiobook +// +// The position is in milliseconds, for the moment of the query. A problem is +// one line `error=<reason>`. This is a port of player_state.lua from the +// SubRead plugin for KOReader. + +export interface PlayerState { + playing: boolean; + /** Position in milliseconds. Null when the player reports none. */ + positionMs: number | null; + /** Playback speed. 1 when the line has none, or a value of zero or less. */ + speed: number; + /** Package name of the player app. Empty when the line has none. */ + package: string; +} + +export interface PlayerError { + /** `no_overlay`, `no_notification_access`, `no_player`, `empty` or `malformed`. */ + error: string; +} + +export function isPlayerError(x: PlayerState | PlayerError): x is PlayerError { + return 'error' in x; +} + +const FIELD = /([\w]+)=([^;]*)/g; + +/** Parses a state line. */ +export function parseState(line: string | null | undefined): PlayerState | PlayerError { + if (typeof line !== 'string' || line === '') return { error: 'empty' }; + const fields = new Map<string, string>(); + for (const m of line.matchAll(FIELD)) fields.set(m[1]!, m[2]!); + const error = fields.get('error'); + if (error !== undefined) return { error }; + const playing = fields.get('playing'); + const position = fields.get('position'); + if (playing === undefined || position === undefined) return { error: 'malformed' }; + const positionMs = Number(position); + if (position.trim() === '' || Number.isNaN(positionMs)) return { error: 'malformed' }; + let speed = Number(fields.get('speed') ?? '1'); + if (Number.isNaN(speed) || speed <= 0) speed = 1; + return { + playing: playing === '1', + positionMs: positionMs >= 0 ? positionMs : null, + speed, + package: fields.get('package') ?? '', + }; +} + +/** The argument of a seek call: the position in milliseconds, as a whole number. */ +export function seekArgument(ms: number): string { + return String(Math.max(0, Math.round(ms))); +} diff --git a/test/follower.test.ts b/test/follower.test.ts new file mode 100644 index 0000000..c236d80 --- /dev/null +++ b/test/follower.test.ts @@ -0,0 +1,138 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { follow, LOOKBACK, pageForCue, type FollowInput, type FollowMode } from '../src/follower'; + +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, + ...over, + }; +} + +describe('pageForCue', () => { + it('gives the page of a matched cue', () => { + expect(pageForCue(pages, 3)).toBe(1); + }); + + it('borrows the page of the nearest matched cue before an unmatched one', () => { + expect(pageForCue(pages, 2)).toBe(0); + expect(pageForCue(pages, 6)).toBe(1); + }); + + it('gives null when no matched cue is near enough', () => { + const far = [0, ...new Array<null>(LOOKBACK + 1).fill(null)]; + expect(pageForCue(far, far.length - 1)).toBeNull(); + expect(pageForCue(far, LOOKBACK)).toBe(0); + }); +}); + +describe('follow', () => { + it('marks a matched cue and stays on its page', () => { + expect(follow(input({ cue: 1 }))).toEqual({ highlightCue: 1, turnToPage: null }); + }); + + it('turns to the page of a matched cue', () => { + expect(follow(input({ cue: 3 }))).toEqual({ highlightCue: 3, turnToPage: 1 }); + }); + + it('turns for an unmatched cue with a matched cue before it, without a mark', () => { + expect(follow(input({ cue: 5, currentPage: 0 }))).toEqual({ + highlightCue: null, + turnToPage: 1, + }); + expect(follow(input({ cue: 5, currentPage: 1 }))).toEqual({ + highlightCue: null, + turnToPage: null, + }); + }); + + 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({ + highlightCue: null, + turnToPage: null, + }); + }); + + it('only turns pages in pages mode', () => { + expect(follow(input({ mode: 'pages', cue: 3 }))).toEqual({ + highlightCue: null, + turnToPage: 1, + }); + }); + + it('does nothing when off', () => { + expect(follow(input({ mode: 'off', cue: 3 }))).toEqual({ + highlightCue: null, + turnToPage: null, + }); + }); + + it('does nothing for the same cue without a seek, and acts on a seek', () => { + expect(follow(input({ cue: 3, previousCue: 3 }))).toEqual({ + highlightCue: null, + turnToPage: null, + }); + expect(follow(input({ cue: 3, previousCue: 3, seeked: true }))).toEqual({ + highlightCue: 3, + turnToPage: 1, + }); + }); + + it('does nothing between cues', () => { + expect(follow(input({ cue: -1 }))).toEqual({ highlightCue: null, turnToPage: null }); + }); + + const cuePages = fc.array(fc.option(fc.nat({ max: 9 }), { nil: null }), { maxLength: 30 }); + const mode = fc.constantFrom<FollowMode>('highlight', 'pages', 'off'); + const any = fc.record({ + cuePages, + mode, + currentPage: fc.nat({ max: 9 }), + previousCue: fc.integer({ min: -1, max: 30 }), + cue: fc.integer({ min: -1, max: 30 }), + seeked: fc.boolean(), + }); + + it('never turns to a page that no cue maps to', () => { + fc.assert( + fc.property(any, (i) => { + const out = follow(i); + if (out.turnToPage !== null) { + expect(i.cuePages).toContain(out.turnToPage); + expect(out.turnToPage).not.toBe(i.currentPage); + } + }), + ); + }); + + it('marks only the cue of now, and only when it is matched', () => { + fc.assert( + fc.property(any, (i) => { + const out = follow(i); + if (out.highlightCue !== null) { + expect(out.highlightCue).toBe(i.cue); + expect(i.mode).toBe('highlight'); + expect(i.cuePages[i.cue]).not.toBeNull(); + } + }), + ); + }); + + it('never acts when off, and never marks in pages mode', () => { + fc.assert( + fc.property(any, (i) => { + const out = follow({ ...i, mode: 'off' }); + expect(out).toEqual({ highlightCue: null, turnToPage: null }); + expect(follow({ ...i, mode: 'pages' }).highlightCue).toBeNull(); + }), + ); + }); +}); diff --git a/test/play-clock.test.ts b/test/play-clock.test.ts new file mode 100644 index 0000000..fdc8fd7 --- /dev/null +++ b/test/play-clock.test.ts @@ -0,0 +1,78 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { positionAt, waitUntil, type PlayClock } from '../src/play-clock'; + +const ms = fc.nat({ max: 10 ** 8 }); +const speed = fc.double({ min: 0.25, max: 4, noNaN: true, noDefaultInfinity: true }); +const clock = fc.record({ positionMs: ms, reportedAtMs: ms, speed, playing: fc.boolean() }); + +describe('positionAt', () => { + it('moves with the clock of the device at the speed', () => { + const c: PlayClock = { positionMs: 1000, reportedAtMs: 500, speed: 2, playing: true }; + expect(positionAt(c, 500)).toBe(1000); + expect(positionAt(c, 600)).toBe(1200); + }); + + it('holds the position while paused', () => { + fc.assert( + fc.property(clock, ms, (c, now) => { + const paused = { ...c, playing: false }; + expect(positionAt(paused, now)).toBe(c.positionMs); + }), + ); + }); + + it('never goes back while playing', () => { + fc.assert( + fc.property(clock, ms, ms, (c, t1, t2) => { + const playing = { ...c, playing: true }; + const [a, b] = t1 <= t2 ? [t1, t2] : [t2, t1]; + expect(positionAt(playing, b)).toBeGreaterThanOrEqual(positionAt(playing, a)); + }), + ); + }); + + it('scales the distance with the speed', () => { + fc.assert( + fc.property(clock, ms, fc.double({ min: 1, max: 4, noNaN: true }), (c, dt, k) => { + const playing = { ...c, playing: true }; + const faster = { ...playing, speed: playing.speed * k }; + const now = c.reportedAtMs + dt; + const d1 = positionAt(playing, now) - c.positionMs; + const d2 = positionAt(faster, now) - c.positionMs; + expect(d2).toBeCloseTo(d1 * k, 3); + }), + ); + }); +}); + +describe('waitUntil', () => { + it('gives the real time until the target', () => { + const c: PlayClock = { positionMs: 1000, reportedAtMs: 0, speed: 2, playing: true }; + expect(waitUntil(c, 3000, 0)).toBe(1000); + expect(waitUntil(c, 3000, 500)).toBe(500); + }); + + it('gives null when paused or already past the target', () => { + const paused: PlayClock = { positionMs: 0, reportedAtMs: 0, speed: 1, playing: false }; + expect(waitUntil(paused, 100, 0)).toBeNull(); + const playing = { ...paused, playing: true }; + expect(waitUntil(playing, 0, 0)).toBeNull(); + expect(waitUntil(playing, -5, 0)).toBeNull(); + }); + + it('lands on the target after the wait', () => { + fc.assert( + fc.property(clock, ms, ms, (c, now, target) => { + const playing = { ...c, playing: true }; + const wait = waitUntil(playing, target, now); + if (wait === null) { + expect(positionAt(playing, now)).toBeGreaterThanOrEqual(target); + } else { + expect(wait).toBeGreaterThan(0); + expect(positionAt(playing, now + wait)).toBeGreaterThanOrEqual(target); + } + }), + ); + }); +}); diff --git a/test/player-state.test.ts b/test/player-state.test.ts new file mode 100644 index 0000000..666cc26 --- /dev/null +++ b/test/player-state.test.ts @@ -0,0 +1,76 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isPlayerError, parseState, seekArgument, type PlayerState } from '../src/player-state'; + +function state(line: string): PlayerState { + const parsed = parseState(line); + if (isPlayerError(parsed)) throw new Error(`error ${parsed.error} for ${line}`); + return parsed; +} + +describe('parseState', () => { + it('reads a playing report', () => { + expect(state('playing=1;position=96153;speed=1.5;package=de.ph1b.audiobook')).toEqual({ + playing: true, + positionMs: 96153, + speed: 1.5, + package: 'de.ph1b.audiobook', + }); + }); + + it('reads a paused report', () => { + const s = state('playing=0;position=0;speed=1.0;package=p'); + expect(s.playing).toBe(false); + expect(s.positionMs).toBe(0); + }); + + it('gives no position for a player without one', () => { + const s = state('playing=1;position=-1;speed=1.0;package=p'); + expect(s.playing).toBe(true); + expect(s.positionMs).toBeNull(); + }); + + it('returns the error name of a problem line', () => { + expect(parseState('error=no_player')).toEqual({ error: 'no_player' }); + expect(parseState('error=no_notification_access')).toEqual({ + error: 'no_notification_access', + }); + expect(parseState('error=no_overlay')).toEqual({ error: 'no_overlay' }); + }); + + it('rejects an empty or broken line', () => { + expect(parseState(null)).toEqual({ error: 'empty' }); + expect(parseState(undefined)).toEqual({ error: 'empty' }); + expect(parseState('')).toEqual({ error: 'empty' }); + expect(parseState('hello')).toEqual({ error: 'malformed' }); + expect(parseState('playing=1;position=abc')).toEqual({ error: 'malformed' }); + expect(parseState('playing=1;position=')).toEqual({ error: 'malformed' }); + }); + + it('falls back to speed 1 for a missing or zero speed', () => { + expect(state('playing=1;position=5').speed).toBe(1); + expect(state('playing=1;position=5;speed=0').speed).toBe(1); + expect(state('playing=1;position=5;speed=-2').speed).toBe(1); + expect(state('playing=1;position=5').package).toBe(''); + }); + + it('round trips a position through the seek argument', () => { + for (const ms of [0, 1, 1000, 96153, 3600500, 12345678.9]) { + const argument = seekArgument(ms); + expect(argument).toMatch(/^\d+$/); + expect(state(`playing=0;position=${argument}`).positionMs).toBeCloseTo(ms, -1); + } + expect(seekArgument(-3)).toBe('0'); + }); + + it('round trips any state line', () => { + const pkg = fc.stringMatching(/^[a-z][a-z0-9_.]{0,30}$/); + const speed = fc.double({ min: 0.25, max: 4, noNaN: true, noDefaultInfinity: true }); + fc.assert( + fc.property(fc.boolean(), fc.nat({ max: 10 ** 9 }), speed, pkg, (playing, ms, sp, p) => { + const line = `playing=${playing ? 1 : 0};position=${ms};speed=${sp};package=${p}`; + expect(state(line)).toEqual({ playing, positionMs: ms, speed: sp, package: p }); + }), + ); + }); +});