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 }