import type { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome'; import type { AnimeBrowserPlayRequest, AnimeBrowserPlayResult, AnimeBrowserQueueEntry, AnimeBrowserQueueState, } from '../../types/anime-browser'; export interface AnimeBrowserQueueDeps { /** The same call a clicked episode makes; the queue only decides when. */ play: (request: AnimeBrowserPlayRequest) => Promise; /** Subscribe to mpv end-file events; the queue's only clock. */ onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => void) => () => void; readMpvProperty?: (name: string) => Promise; sendMpvCommand: (command: Array) => void; /** Pushed to the browser window on every change, advances included. */ onQueueState?: (state: AnimeBrowserQueueState) => void; showMpvOsd?: (message: string) => void; log: (message: string) => void; } /** * The episodes lined up behind the one playing. * * Deliberately not mpv's own playlist. An episode's stream has to be resolved * through the extension, cached, and have its external tracks attached while it * is the current file — all of which `playEpisode` already does, and none of * which an appended `loadfile` would. Holding the *request* instead of a * resolved URL also means an episode queued now is fetched when its turn comes, * so a signed stream URL cannot expire while it waits. */ export function createAnimeBrowserQueue(deps: AnimeBrowserQueueDeps) { let entries: AnimeBrowserQueueEntry[] = []; let lastError: string | null = null; let advances = 0; let lastStarted: AnimeBrowserQueueEntry | null = null; let unsubscribeEndFile: (() => void) | null = null; /** mpv's own `keep-open`, held while the queue overrides it; null when untouched. */ let heldKeepOpen: string | null = null; let readingKeepOpen = false; /** True while an advance is resolving, so a second end-file cannot double-load. */ let advancing = false; function state(): AnimeBrowserQueueState { return { entries: [...entries], lastError, advances, lastStarted }; } function publish(): AnimeBrowserQueueState { const next = state(); deps.onQueueState?.(next); return next; } /** * Watch the end of the file playing now. * * Re-subscribed on every playback rather than once for the queue's lifetime: * mpv can be restarted under the app, and a listener held by the client that * died never fires again. */ function arm(): void { if (entries.length === 0) return; unsubscribeEndFile?.(); unsubscribeEndFile = deps.onPlaybackEndFile?.(handleEndFile) ?? null; void holdKeepOpen(); } function disarm(): void { unsubscribeEndFile?.(); unsubscribeEndFile = null; releaseKeepOpen(); } /** * With `keep-open` on, mpv pauses at the end of the last playlist entry and * sends no `end-file` — the queue would wait for a file that never ends. A * real playlist advances regardless of the setting, so while the queue stands * in for one, mpv is told not to hold. The user's value goes back as soon as * the queue empties, which is when their setting starts mattering again. */ async function holdKeepOpen(): Promise { if (heldKeepOpen !== null || readingKeepOpen || !deps.readMpvProperty) return; readingKeepOpen = true; try { const current = await deps.readMpvProperty('keep-open'); const value = typeof current === 'string' ? current : ''; // Nothing to restore when mpv already lets files end, and nothing to do // if the queue drained while the read was in flight. if (value === '' || value === 'no' || entries.length === 0) return; heldKeepOpen = value; deps.sendMpvCommand(['set_property', 'keep-open', 'no']); } catch (error) { deps.log(`[anime-browser] could not read mpv keep-open: ${describeError(error)}`); } finally { readingKeepOpen = false; } } function releaseKeepOpen(): void { if (heldKeepOpen === null) return; const value = heldKeepOpen; heldKeepOpen = null; deps.sendMpvCommand(['set_property', 'keep-open', value]); } function handleEndFile(event: PlaybackEndFileEvent): void { // "stop" is the file being replaced by a newer pick, "quit" is mpv going // away, and "error" reported itself already. Only a file that ran to its // end hands the queue its turn. if (event.reason !== 'eof' || advancing) return; void advance(); } async function advance(): Promise { const next = entries[0]; if (!next) return; advancing = true; // Off the queue before it plays, so a stream that cannot be resolved is not // retried on the next end-file, and the browser shows it leaving at once. entries = entries.slice(1); lastError = null; // Published before the stream resolves: a browser window watching this is // how it learns the episode it had marked playing has finished. advances += 1; lastStarted = next; if (entries.length === 0) disarm(); publish(); try { const result = await deps.play(next); if (result.ok) { // Whatever is left now waits on the file this call just started. arm(); return; } reportFailure(next, result.error ?? 'Could not play that episode.'); } catch (error) { reportFailure(next, describeError(error)); } finally { advancing = false; } } /** * A queued episode that would not play stops the queue where it is. The rest * stays put: the user asked for those episodes, and the next thing they play * by hand re-arms the queue behind it. */ function reportFailure(entry: AnimeBrowserQueueEntry, error: string): void { lastError = `${entry.episodeName}: ${error}`; deps.log(`[anime-browser] queued episode did not play — ${lastError}`); deps.showMpvOsd?.(`Queue stopped — ${lastError}`); publish(); } function keyOf(entry: { sourceId: string; episodeUrl: string }): string { return `${entry.sourceId}${entry.episodeUrl}`; } return { getState: state, enqueue(request: AnimeBrowserPlayRequest): AnimeBrowserQueueState { if (!request.sourceId || !request.episodeUrl) { throw new Error('That episode cannot be queued.'); } // Queueing the same episode twice is the user clicking again, not a // request to watch it twice in a row. if (entries.some((entry) => keyOf(entry) === keyOf(request))) return state(); entries = [...entries, request]; lastError = null; arm(); return publish(); }, dequeue(sourceId: string, episodeUrl: string): AnimeBrowserQueueState { const key = keyOf({ sourceId, episodeUrl }); entries = entries.filter((entry) => keyOf(entry) !== key); lastError = null; if (entries.length === 0) disarm(); return publish(); }, clear(): AnimeBrowserQueueState { entries = []; lastError = null; disarm(); return publish(); }, /** * Playback started by something other than the queue — a clicked episode. * The queue now waits on that file, and on the mpv client it is playing * through, which may be a different one than the last time it armed. */ handlePlaybackStarted(): void { arm(); }, dispose(): void { entries = []; lastError = null; disarm(); }, }; } export type AnimeBrowserQueue = ReturnType; function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); }