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 47c04678f67bc70b9cd01af4caf1cd93c2177598
truex <truex@equwal.com>
2026-09-26 13:40:01 -0700

Reject a player command that the player did not do

OverlayClock.play, pause and seek resolved when the player answered an error line (error=no_player, error=no_overlay, error=no_notification_access). The UI then said "Audio moved to 00:17." when the audio did not move. The clock now sets the error state and then rejects with a PlayerError. The reason field and the message hold the reason, for example "no_player".

LocalAudioClock.play and seek now reject with PlayerError("no_player") when no audio is loaded. Before, seek resolved and play failed with the error of the audio element.

 src/clock-source.ts       | 45 ++++++++++++++++++++-------
 test/clock-source.test.ts | 79 +++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 112 insertions(+), 12 deletions(-)
diff --git a/src/clock-source.ts b/src/clock-source.ts
index 9e92349..a8dc1f7 100644
--- a/src/clock-source.ts
+++ b/src/clock-source.ts
@@ -26,6 +26,18 @@ export interface ClockSource {
   seek(ms: number): Promise<void>;
 }
 
+/**
+ * The player did not do a command. The message is the reason, the same
+ * word as in `ClockState.error`: `no_player`, `no_overlay`,
+ * `no_notification_access`.
+ */
+export class PlayerError extends Error {
+  constructor(readonly reason: string) {
+    super(reason);
+    this.name = 'PlayerError';
+  }
+}
+
 /** The part of the SubRead plugin that the overlay clock uses. */
 export interface PlayerBridge {
   playerState(): Promise<StateLine>;
@@ -161,30 +173,37 @@ export class OverlayClock implements ClockSource {
     this.emit(positionMs !== undefined);
   }
 
-  private async send(command: Promise<StateLine>): Promise<string> {
+  /**
+   * Waits for the answer to a command. An error line sets the error state,
+   * and the promise rejects with a PlayerError for the reason.
+   */
+  private async send(command: Promise<StateLine>): Promise<void> {
+    let line: string;
     try {
-      return (await command).line;
+      ({ line } = await command);
     } catch {
-      return 'error=no_overlay';
+      line = 'error=no_overlay';
+    }
+    const parsed = parseState(line);
+    if (isPlayerError(parsed)) {
+      this.apply(line, this.now());
+      throw new PlayerError(parsed.error);
     }
   }
 
   async play(): Promise<void> {
-    const line = await this.send(this.bridge.play());
-    if (isPlayerError(parseState(line))) this.apply(line, this.now());
-    else this.local(true);
+    await this.send(this.bridge.play());
+    this.local(true);
   }
 
   async pause(): Promise<void> {
-    const line = await this.send(this.bridge.pause());
-    if (isPlayerError(parseState(line))) this.apply(line, this.now());
-    else this.local(false);
+    await this.send(this.bridge.pause());
+    this.local(false);
   }
 
   async seek(ms: number): Promise<void> {
-    const line = await this.send(this.bridge.seek({ ms: Math.max(0, Math.round(ms)) }));
-    if (isPlayerError(parseState(line))) this.apply(line, this.now());
-    else this.local(this.clock?.playing ?? false, ms);
+    await this.send(this.bridge.seek({ ms: Math.max(0, Math.round(ms)) }));
+    this.local(this.clock?.playing ?? false, ms);
   }
 }
 
@@ -222,6 +241,7 @@ export class LocalAudioClock implements ClockSource {
   }
 
   async play(): Promise<void> {
+    if (!this.audio.src) throw new PlayerError('no_player');
     await this.audio.play();
   }
 
@@ -231,6 +251,7 @@ export class LocalAudioClock implements ClockSource {
   }
 
   seek(ms: number): Promise<void> {
+    if (!this.audio.src) return Promise.reject(new PlayerError('no_player'));
     this.audio.currentTime = Math.max(0, ms) / 1000;
     return Promise.resolve();
   }
diff --git a/test/clock-source.test.ts b/test/clock-source.test.ts
index bdf54ac..a3f5775 100644
--- a/test/clock-source.test.ts
+++ b/test/clock-source.test.ts
@@ -1,7 +1,9 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import {
   JUMP_MS,
+  LocalAudioClock,
   OverlayClock,
+  PlayerError,
   POLL_ERROR_MS,
   POLL_MS,
   TICK_MS,
@@ -9,6 +11,12 @@ import {
   type PlayerBridge,
 } from '../src/clock-source';
 
+/** Expects `promise` to reject with a PlayerError for `reason`. */
+async function expectPlayerError(promise: Promise<void>, reason: string): Promise<void> {
+  await expect(promise).rejects.toMatchObject({ name: 'PlayerError', reason, message: reason });
+  await expect(promise).rejects.toBeInstanceOf(PlayerError);
+}
+
 /** A fake SubRead Overlay: answers the line set in `line`, counts the calls. */
 function fakeBridge(line: string) {
   const calls: string[] = [];
@@ -132,4 +140,75 @@ describe('OverlayClock', () => {
     expect(states.at(-1)!.playing).toBe(false);
     clock.stop();
   });
+
+  it('rejects play, pause and seek when the player answers an error, and keeps the place', async () => {
+    const bridge = fakeBridge('playing=1;position=5000;speed=1;package=p');
+    const states: ClockState[] = [];
+    const clock = new OverlayClock(bridge, now);
+    clock.start((s) => states.push(s));
+    await vi.advanceTimersByTimeAsync(0);
+    bridge.line = 'error=no_player';
+    await expectPlayerError(clock.seek(17000), 'no_player');
+    expect(bridge.calls.at(-1)).toBe('seek 17000');
+    expect(states.at(-1)).toEqual({
+      positionMs: 5000,
+      playing: false,
+      seeked: false,
+      error: 'no_player',
+    });
+    await expectPlayerError(clock.play(), 'no_player');
+    expect(states.at(-1)!.playing).toBe(false);
+    await expectPlayerError(clock.pause(), 'no_player');
+    bridge.line = 'error=no_notification_access';
+    await expectPlayerError(clock.play(), 'no_notification_access');
+    clock.stop();
+  });
+
+  it('rejects with no_overlay when the bridge fails', async () => {
+    const bridge = fakeBridge('playing=0;position=1000;speed=1;package=p');
+    bridge.play = () => Promise.reject(new Error('no plugin'));
+    const states: ClockState[] = [];
+    const clock = new OverlayClock(bridge, now);
+    clock.start((s) => states.push(s));
+    await vi.advanceTimersByTimeAsync(0);
+    await expectPlayerError(clock.play(), 'no_overlay');
+    expect(states.at(-1)!.error).toBe('no_overlay');
+    clock.stop();
+  });
+});
+
+describe('LocalAudioClock', () => {
+  /** A fake audio element. Like a browser, it cannot play without a source. */
+  function fakeAudio() {
+    const audio = {
+      src: '',
+      currentTime: 0,
+      paused: true,
+      play: () =>
+        audio.src
+          ? Promise.resolve()
+          : Promise.reject(new DOMException('No supported sources.', 'NotSupportedError')),
+      pause: () => undefined,
+      addEventListener: () => undefined,
+      removeEventListener: () => undefined,
+    };
+    return audio;
+  }
+
+  it('rejects play and seek with no_player when no audio is loaded', async () => {
+    const audio = fakeAudio();
+    const clock = new LocalAudioClock(audio as unknown as HTMLAudioElement);
+    await expectPlayerError(clock.play(), 'no_player');
+    await expectPlayerError(clock.seek(17000), 'no_player');
+    expect(audio.currentTime).toBe(0);
+  });
+
+  it('plays and seeks when audio is loaded', async () => {
+    const audio = fakeAudio();
+    audio.src = 'blob:audio';
+    const clock = new LocalAudioClock(audio as unknown as HTMLAudioElement);
+    await clock.play();
+    await clock.seek(17000);
+    expect(audio.currentTime).toBe(17);
+  });
 });