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-15 21:44:51 -07:00
parent 51fc9034f7
commit 3ca7dcd664
19 changed files with 838 additions and 7 deletions
+28 -1
View File
@@ -60,8 +60,17 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
handle(channels.animeBrowserAddRepo, (_event, url) => runtime.addRepo(String(url)));
handle(channels.animeBrowserRemoveRepo, (_event, url) => runtime.removeRepo(String(url)));
handle(channels.animeBrowserPlayEpisode, (_event, request) =>
runtime.playEpisode(request as AnimeBrowserPlayRequest),
runtime.playEpisode(toPlayRequest(request)),
);
handle(channels.animeBrowserQueueEpisode, (_event, request) =>
runtime.queueEpisode(toPlayRequest(request)),
);
handle(channels.animeBrowserDequeueEpisode, (_event, sourceId, episodeUrl) =>
runtime.dequeueEpisode(String(sourceId ?? ''), String(episodeUrl ?? '')),
);
handle(channels.animeBrowserClearQueue, () => runtime.clearQueue());
handle(channels.animeBrowserGetQueue, () => runtime.getQueue());
handle(channels.animeBrowserIsPlaying, () => runtime.isPlaying());
handle(channels.animeBrowserGetPreferences, (_event, sourceId) =>
runtime.getPreferences(String(sourceId)),
);
@@ -70,6 +79,24 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
);
}
/**
* Coerce a play (or queue) request. A queued request is held until its turn
* comes, so a bad field would surface long after the click that sent it.
*/
function toPlayRequest(value: unknown): AnimeBrowserPlayRequest {
const request = (value ?? {}) as Partial<AnimeBrowserPlayRequest>;
return {
sourceId: String(request.sourceId ?? ''),
animeUrl: String(request.animeUrl ?? ''),
animeTitle: String(request.animeTitle ?? ''),
episodeUrl: String(request.episodeUrl ?? ''),
episodeName: String(request.episodeName ?? ''),
// NaN and Infinity are numbers as far as typeof is concerned, and either
// one would reach the stats row as a nonsense episode number.
episodeNumber: Number.isFinite(request.episodeNumber) ? request.episodeNumber! : null,
};
}
/** Coerce a watch-state request; the renderer's arrays arrive untyped. */
function toWatchStateRequest(value: unknown): AnimeBrowserWatchStateRequest {
const request = (value ?? {}) as Partial<AnimeBrowserWatchStateRequest>;