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


src/clock-source.ts (8191 bytes)

1 // Where the time of the audio comes from.
2 //
3 // On Android the player app plays the audio, and SubRead Overlay reports
4 // its position (OverlayClock). On the web the page plays the audio itself
5 // (LocalAudioClock). Both give the same states to the reader.
6 
7 import { positionAt, type PlayClock } from './play-clock';
8 import { isPlayerError, parseState } from './player-state';
9 import type { StateLine } from './subread';
10 
11 export interface ClockState {
12   /** Position in milliseconds. Null when no player reports one. */
13   positionMs: number | null;
14   playing: boolean;
15   /** True when the audio jumped: a seek, not the normal run forward. */
16   seeked: boolean;
17   /** `no_overlay`, `no_notification_access`, `no_player`, or null. */
18   error: string | null;
19 }
20 
21 export interface ClockSource {
22   start(onState: (state: ClockState) => void): void;
23   stop(): void;
24   play(): Promise<void>;
25   pause(): Promise<void>;
26   seek(ms: number): Promise<void>;
27 }
28 
29 /**
30  * The player did not do a command. The message is the reason, the same
31  * word as in `ClockState.error`: `no_player`, `no_overlay`,
32  * `no_notification_access`.
33  */
34 export class PlayerError extends Error {
35   constructor(readonly reason: string) {
36     super(reason);
37     this.name = 'PlayerError';
38   }
39 }
40 
41 /** The part of the SubRead plugin that the overlay clock uses. */
42 export interface PlayerBridge {
43   playerState(): Promise<StateLine>;
44   play(): Promise<StateLine>;
45   pause(): Promise<StateLine>;
46   seek(options: { ms: number }): Promise<StateLine>;
47 }
48 
49 /** Milliseconds between two reads of the player. */
50 export const POLL_MS = 2000;
51 /** Milliseconds between two reads while the last answer was an error. */
52 export const POLL_ERROR_MS = 5000;
53 /** Milliseconds between two computed positions between the reads. */
54 export const TICK_MS = 250;
55 /** A reported position this far from the expected one is a seek. */
56 export const JUMP_MS = 3000;
57 
58 /**
59  * The audio player, read through SubRead Overlay. The player reports its
60  * position and speed, so the clock is exact between two reads. A read
61  * only has to notice a pause, a seek or a new speed. After play, pause or
62  * seek sent from here, one read is skipped: the player takes a moment,
63  * and the read that comes at once after would bring the old state back.
64  */
65 export class OverlayClock implements ClockSource {
66   private clock: PlayClock | null = null;
67   private error: string | null = null;
68   private onState: ((state: ClockState) => void) | null = null;
69   private pollTimer: ReturnType<typeof setTimeout> | null = null;
70   private tickTimer: ReturnType<typeof setInterval> | null = null;
71   private skipPoll = false;
72 
73   constructor(
74     private readonly bridge: PlayerBridge,
75     private readonly now: () => number = () => performance.now(),
76   ) {}
77 
78   start(onState: (state: ClockState) => void): void {
79     this.stop();
80     this.onState = onState;
81     void this.poll();
82     this.tickTimer = setInterval(() => this.tick(), TICK_MS);
83   }
84 
85   stop(): void {
86     if (this.pollTimer) clearTimeout(this.pollTimer);
87     if (this.tickTimer) clearInterval(this.tickTimer);
88     this.pollTimer = null;
89     this.tickTimer = null;
90     this.onState = null;
91   }
92 
93   private schedulePoll(): void {
94     if (this.pollTimer) clearTimeout(this.pollTimer);
95     this.pollTimer = setTimeout(() => void this.poll(), this.error ? POLL_ERROR_MS : POLL_MS);
96   }
97 
98   private async poll(): Promise<void> {
99     if (!this.onState) return;
100     if (this.skipPoll) {
101       this.skipPoll = false;
102       this.schedulePoll();
103       return;
104     }
105     let line: string;
106     try {
107       ({ line } = await this.bridge.playerState());
108     } catch {
109       line = 'error=no_overlay';
110     }
111     if (!this.onState) return;
112     this.apply(line, this.now());
113     this.schedulePoll();
114   }
115 
116   /** Takes a state line of the player and reports the state it gives. */
117   private apply(line: string, at: number): void {
118     const parsed = parseState(line);
119     if (isPlayerError(parsed)) {
120       // The player is gone, or cannot be read. Keep the place, stop the clock.
121       this.error = parsed.error;
122       if (this.clock)
123         this.clock = {
124           ...this.clock,
125           positionMs: positionAt(this.clock, at),
126           reportedAtMs: at,
127           playing: false,
128         };
129       this.emit(false);
130       return;
131     }
132     this.error = null;
133     if (parsed.positionMs === null) {
134       this.clock = null;
135       this.emit(false);
136       return;
137     }
138     const expected = this.clock ? positionAt(this.clock, at) : null;
139     const seeked = expected !== null && Math.abs(parsed.positionMs - expected) > JUMP_MS;
140     this.clock = {
141       positionMs: parsed.positionMs,
142       reportedAtMs: at,
143       speed: parsed.speed,
144       playing: parsed.playing,
145     };
146     this.emit(seeked);
147   }
148 
149   /** Between two reads: the computed position, while the player plays. */
150   private tick(): void {
151     if (this.clock?.playing) this.emit(false);
152   }
153 
154   private emit(seeked: boolean): void {
155     if (!this.onState) return;
156     const clock = this.clock;
157     this.onState({
158       positionMs: clock ? positionAt(clock, this.now()) : null,
159       playing: clock?.playing ?? false,
160       seeked,
161       error: this.error,
162     });
163   }
164 
165   /** Sets the local clock as the command will leave the player, and skips one read. */
166   private local(playing: boolean, positionMs?: number): void {
167     const at = this.now();
168     const clock = this.clock;
169     const position = positionMs ?? (clock ? positionAt(clock, at) : null);
170     if (position === null) return;
171     this.clock = { positionMs: position, reportedAtMs: at, speed: clock?.speed ?? 1, playing };
172     this.skipPoll = true;
173     this.emit(positionMs !== undefined);
174   }
175 
176   /**
177    * Waits for the answer to a command. An error line sets the error state,
178    * and the promise rejects with a PlayerError for the reason.
179    */
180   private async send(command: Promise<StateLine>): Promise<void> {
181     let line: string;
182     try {
183       ({ line } = await command);
184     } catch {
185       line = 'error=no_overlay';
186     }
187     const parsed = parseState(line);
188     if (isPlayerError(parsed)) {
189       this.apply(line, this.now());
190       throw new PlayerError(parsed.error);
191     }
192   }
193 
194   async play(): Promise<void> {
195     await this.send(this.bridge.play());
196     this.local(true);
197   }
198 
199   async pause(): Promise<void> {
200     await this.send(this.bridge.pause());
201     this.local(false);
202   }
203 
204   async seek(ms: number): Promise<void> {
205     await this.send(this.bridge.seek({ ms: Math.max(0, Math.round(ms)) }));
206     this.local(this.clock?.playing ?? false, ms);
207   }
208 }
209 
210 /** The audio element of the page, for the web. */
211 export class LocalAudioClock implements ClockSource {
212   /** The events that give a new state. `loadedmetadata`: a file was chosen. */
213   private static readonly EVENTS = ['timeupdate', 'play', 'pause', 'loadedmetadata'];
214   private onState: ((state: ClockState) => void) | null = null;
215   private readonly onTime = (): void => this.emit(false);
216   private readonly onSeeked = (): void => this.emit(true);
217 
218   constructor(private readonly audio: HTMLAudioElement) {}
219 
220   start(onState: (state: ClockState) => void): void {
221     this.stop();
222     this.onState = onState;
223     for (const ev of LocalAudioClock.EVENTS) this.audio.addEventListener(ev, this.onTime);
224     this.audio.addEventListener('seeked', this.onSeeked);
225     this.emit(false);
226   }
227 
228   stop(): void {
229     for (const ev of LocalAudioClock.EVENTS) this.audio.removeEventListener(ev, this.onTime);
230     this.audio.removeEventListener('seeked', this.onSeeked);
231     this.onState = null;
232   }
233 
234   private emit(seeked: boolean): void {
235     this.onState?.({
236       positionMs: this.audio.src ? this.audio.currentTime * 1000 : null,
237       playing: !this.audio.paused,
238       seeked,
239       error: this.audio.src ? null : 'no_player',
240     });
241   }
242 
243   async play(): Promise<void> {
244     if (!this.audio.src) throw new PlayerError('no_player');
245     await this.audio.play();
246   }
247 
248   pause(): Promise<void> {
249     this.audio.pause();
250     return Promise.resolve();
251   }
252 
253   seek(ms: number): Promise<void> {
254     if (!this.audio.src) return Promise.reject(new PlayerError('no_player'));
255     this.audio.currentTime = Math.max(0, ms) / 1000;
256     return Promise.resolve();
257   }
258 }