mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 19:21:32 -07:00
0ecaec34b2
- 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
214 lines
7.5 KiB
TypeScript
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} |