Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


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 });
+      }),
+    );
+  });
+});