Files
SubMiner/src/main/runtime/anime-browser-queue.ts
T
sudacode 0ecaec34b2 feat(anime): queue episodes to play next across anime
- Episode rows gain Play/Queue actions (and matching context-menu items); queued rows show their place in line, with a queue count and Clear queue in the episode header
- Queue lives in the main process (`anime-browser-queue.ts`) so it survives the browser window closing and advances on mpv's end-file even when nobody is watching; streams resolve at play time so a signed URL cannot expire while queued
- Holds mpv's keep-open off while the queue waits and restores it once empty; queueing with nothing playing just plays immediately
- Adds anime-browser-queue and episode-queue unit tests, IPC channels/contracts, and doc updates
2026-08-07 00:25:37 -07:00

214 lines
7.5 KiB
TypeScript

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<AnimeBrowserPlayResult>;
/** Subscribe to mpv end-file events; the queue's only clock. */
onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
readMpvProperty?: (name: string) => Promise<unknown>;
sendMpvCommand: (command: Array<string | number>) => 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<void> {
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<void> {
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<typeof createAnimeBrowserQueue>;
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}