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
This commit is contained in:
2026-08-07 00:25:37 -07:00
parent 51fc9034f7
commit 3ca7dcd664
19 changed files with 838 additions and 7 deletions
+42
View File
@@ -222,6 +222,35 @@ export interface AnimeBrowserPlayResult {
quality: string | null;
}
/**
* One episode waiting for its turn.
*
* It is the play request itself rather than a resolved stream: extension stream
* URLs are signed and short-lived, so a queued episode is resolved when it
* reaches the front, not when it was queued half an hour earlier.
*/
export type AnimeBrowserQueueEntry = AnimeBrowserPlayRequest;
export interface AnimeBrowserQueueState {
/** In play order; the first entry starts when the current episode ends. */
entries: AnimeBrowserQueueEntry[];
/**
* Why the last automatic advance failed, or null. Cleared by the next queue
* change, so it reports the failure the user has not seen yet rather than
* accumulating a history.
*/
lastError: string | null;
/**
* How many times the queue has started an episode by itself. A counter
* rather than a flag: it tells a browser window that just repainted whether
* an advance happened since the state it last saw, including one that
* started the same episode twice.
*/
advances: number;
/** The episode the last advance started, or null before the first one. */
lastStarted: AnimeBrowserQueueEntry | null;
}
export interface AnimeBrowserAPI {
getSnapshot: () => Promise<AnimeBrowserSnapshot>;
ensureBridge: () => Promise<AnimeBrowserBridgeState>;
@@ -237,7 +266,18 @@ export interface AnimeBrowserAPI {
) => Promise<AnimeBrowserEpisodeWatchState[]>;
/** Set or clear the mark by hand; resolves to the state after the write. */
setWatched: (request: AnimeBrowserSetWatchedRequest) => Promise<AnimeBrowserEpisodeWatchState[]>;
/** Plays now, replacing whatever mpv is playing. */
playEpisode: (request: AnimeBrowserPlayRequest) => Promise<AnimeBrowserPlayResult>;
/** Adds to the end of the queue; queueing an episode twice is a no-op. */
queueEpisode: (request: AnimeBrowserPlayRequest) => Promise<AnimeBrowserQueueState>;
dequeueEpisode: (sourceId: string, episodeUrl: string) => Promise<AnimeBrowserQueueState>;
clearQueue: () => Promise<AnimeBrowserQueueState>;
getQueue: () => Promise<AnimeBrowserQueueState>;
/**
* Whether mpv has a file open. False when it is idle or not running at all,
* which is when queueing has no end to wait for.
*/
isPlaying: () => Promise<boolean>;
getPreferences: (sourceId: string) => Promise<SourcePreferenceView[]>;
setPreference: (
sourceId: string,
@@ -252,6 +292,8 @@ export interface AnimeBrowserAPI {
removeRepo: (url: string) => Promise<void>;
onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void) => () => void;
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void) => () => void;
/** Pushed whenever the queue changes, including when it advances by itself. */
onQueueState: (listener: (state: AnimeBrowserQueueState) => void) => () => void;
}
export type { SourcePreferenceView } from '../anime-bridge/preferences';