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