mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 07:21:35 -07:00
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:
@@ -28,3 +28,4 @@ area: anime
|
|||||||
- Episodes already watched are dimmed and marked in the episode list, with a watched count in the header. The marks come from the stats history playback already writes (an episode is marked once a session passes the completion threshold), so they match the stats window and survive the stream URL changing between playbacks. They refresh when the browser window comes back to the front, and stay empty when immersion tracking is disabled.
|
- Episodes already watched are dimmed and marked in the episode list, with a watched count in the header. The marks come from the stats history playback already writes (an episode is marked once a session passes the completion threshold), so they match the stats window and survive the stream URL changing between playbacks. They refresh when the browser window comes back to the front, and stay empty when immersion tracking is disabled.
|
||||||
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
|
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
|
||||||
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
|
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
|
||||||
|
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, so episodes from different series can be lined up together, and it starts the next episode when the current one ends — resolving its stream at that point, so a signed stream URL cannot expire while it waits. Queueing with nothing playing just plays. mpv's `keep-open` is held off while the queue waits, since it otherwise pauses at the end of a file forever, and the setting is restored once the queue empties.
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
- Anime browser: extension bridge client, sidecar, and stream handling in `src/anime-bridge/`;
|
- Anime browser: extension bridge client, sidecar, and stream handling in `src/anime-bridge/`;
|
||||||
browser window UI in `src/animeui/` (preload `src/preload-animeui.ts`); runtime wiring in
|
browser window UI in `src/animeui/` (preload `src/preload-animeui.ts`); runtime wiring in
|
||||||
`src/main/runtime/anime-browser-runtime.ts`, `src/main/runtime/anime-browser-ipc-handlers.ts`,
|
`src/main/runtime/anime-browser-runtime.ts`, `src/main/runtime/anime-browser-ipc-handlers.ts`,
|
||||||
`src/main/runtime/anime-bridge-installer.ts`, `src/main/runtime/stream-playback-metadata.ts`
|
`src/main/runtime/anime-bridge-installer.ts`, `src/main/runtime/stream-playback-metadata.ts`.
|
||||||
|
The play queue is app-level rather than an mpv playlist (`src/main/runtime/anime-browser-queue.ts`):
|
||||||
|
a queued episode is resolved when its turn comes, driven off mpv's `end-file`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||||
in `src/core/services/stats-server/`
|
in `src/core/services/stats-server/`
|
||||||
|
|||||||
@@ -405,8 +405,15 @@ loadMoreButton.addEventListener('click', () => void loadNextPage());
|
|||||||
|
|
||||||
api.onBridgeState(renderBridgeState);
|
api.onBridgeState(renderBridgeState);
|
||||||
|
|
||||||
|
// The queue changes without this window asking: it advances by itself when an
|
||||||
|
// episode ends, whether or not anyone is looking at the browser.
|
||||||
|
api.onQueueState((state) => detailPanel.setQueue(state));
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
renderBridgeState({ stage: 'idle', progress: null, message: null });
|
renderBridgeState({ stage: 'idle', progress: null, message: null });
|
||||||
|
// A queue survives the window being closed and reopened, so start from what
|
||||||
|
// the main process already holds rather than from empty.
|
||||||
|
void api.getQueue().then((state) => detailPanel.setQueue(state));
|
||||||
try {
|
try {
|
||||||
const state = await api.ensureBridge();
|
const state = await api.ensureBridge();
|
||||||
renderBridgeState(state);
|
renderBridgeState(state);
|
||||||
|
|||||||
@@ -96,5 +96,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
|
|||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
isOpen: (): boolean => !detail.classList.contains('hidden'),
|
isOpen: (): boolean => !detail.classList.contains('hidden'),
|
||||||
|
/** The queue outlives the open anime, so it is pushed in from outside. */
|
||||||
|
setQueue: episodeList.setQueue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-1
@@ -124,6 +124,19 @@
|
|||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Everything lined up behind the episode playing now, across every anime. */
|
||||||
|
.episodes-queued {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-queue-clear {
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pushed to the far end so the counters stay next to the heading. */
|
/* Pushed to the far end so the counters stay next to the heading. */
|
||||||
.episodes-filter {
|
.episodes-filter {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -155,6 +168,17 @@
|
|||||||
background: var(--line);
|
background: var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The row is the cue plus the two things you can do with it. The cue still
|
||||||
|
* fills the row and still plays on click; the actions sit over its trailing
|
||||||
|
* edge, so a row with nothing hovered looks exactly as it did before them.
|
||||||
|
*/
|
||||||
|
.cue-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.cue {
|
.cue {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -162,7 +186,9 @@
|
|||||||
gap: 26px;
|
gap: 26px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 10px 9px 0;
|
/* Room at the trailing edge for the actions that sit over it, kept whether or
|
||||||
|
* not they are showing, so a hover never slides them across the title. */
|
||||||
|
padding: 9px 132px 9px 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -243,6 +269,72 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hidden until the row is hovered or something inside it has focus, so the
|
||||||
|
* rail stays a list of episodes rather than a grid of buttons. Queued rows keep
|
||||||
|
* theirs on: that button is the only way back out of the queue.
|
||||||
|
*/
|
||||||
|
.cue-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cue-row:hover .cue-actions,
|
||||||
|
.cue-row:focus-within .cue-actions,
|
||||||
|
.cue-row[data-queued='true'] .cue-actions {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cue-action {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel-elevated);
|
||||||
|
color: var(--faint);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
transition:
|
||||||
|
color 0.14s ease,
|
||||||
|
border-color 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cue-action:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cue-action[data-active='true'] {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The badge says where in line it is; the button beside it says it can leave. */
|
||||||
|
.cue-queued {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cue[data-queued='true']::after {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.cue[data-state='loading'] {
|
.cue[data-state='loading'] {
|
||||||
background: var(--panel-elevated);
|
background: var(--panel-elevated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { describe, el } from './dom';
|
|||||||
import { closeContextMenu, showContextMenu, type ContextMenuItem } from './context-menu';
|
import { closeContextMenu, showContextMenu, type ContextMenuItem } from './context-menu';
|
||||||
import { filterEpisodes } from './episode-filter';
|
import { filterEpisodes } from './episode-filter';
|
||||||
import { describeMarkCount, episodesInScope } from './episode-marks';
|
import { describeMarkCount, episodesInScope } from './episode-marks';
|
||||||
|
import { describeQueuePosition } from './episode-queue';
|
||||||
|
import { createEpisodeQueueControls } from './episode-queue-controls';
|
||||||
import type { AnimeBrowserAPI, AnimeBrowserEpisode } from '../types/anime-browser';
|
import type { AnimeBrowserAPI, AnimeBrowserEpisode } from '../types/anime-browser';
|
||||||
|
|
||||||
export interface SelectedAnime {
|
export interface SelectedAnime {
|
||||||
@@ -67,6 +69,25 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queue = createEpisodeQueueControls({
|
||||||
|
api,
|
||||||
|
setStatus,
|
||||||
|
selectedAnime,
|
||||||
|
playEpisode: (episode) => playEpisode(episode),
|
||||||
|
onChange: () => paint(),
|
||||||
|
onAdvance: (entry) => {
|
||||||
|
// The queue started this one, so the cue this window was holding belongs
|
||||||
|
// to an episode that has finished. The new one only earns the cue when it
|
||||||
|
// is an episode of the anime on screen.
|
||||||
|
const anime = selectedAnime();
|
||||||
|
const mine =
|
||||||
|
anime !== null && anime.sourceId === entry.sourceId && anime.url === entry.animeUrl;
|
||||||
|
cueState = mine ? { url: entry.episodeUrl, state: 'playing' } : null;
|
||||||
|
applyCueState();
|
||||||
|
setStatus(`Queue started ${entry.episodeName}.`, 'ok');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
async function playEpisode(episode: AnimeBrowserEpisode) {
|
async function playEpisode(episode: AnimeBrowserEpisode) {
|
||||||
const anime = selectedAnime();
|
const anime = selectedAnime();
|
||||||
if (!anime) return;
|
if (!anime) return;
|
||||||
@@ -116,6 +137,7 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
|
|||||||
function createRow(item: ListedEpisode): HTMLLIElement {
|
function createRow(item: ListedEpisode): HTMLLIElement {
|
||||||
const { episode } = item;
|
const { episode } = item;
|
||||||
const row = document.createElement('li');
|
const row = document.createElement('li');
|
||||||
|
row.className = 'cue-row';
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'cue';
|
button.className = 'cue';
|
||||||
@@ -138,6 +160,17 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
|
|||||||
mark.textContent = '✓ watched';
|
mark.textContent = '✓ watched';
|
||||||
name.append(mark);
|
name.append(mark);
|
||||||
}
|
}
|
||||||
|
const position = queue.positionOf(episode);
|
||||||
|
if (position !== undefined) {
|
||||||
|
button.dataset.queued = 'true';
|
||||||
|
// On the row too, so the actions can stay visible without asking the CSS
|
||||||
|
// to look inside for a queued cue.
|
||||||
|
row.dataset.queued = 'true';
|
||||||
|
const mark = document.createElement('span');
|
||||||
|
mark.className = 'cue-queued';
|
||||||
|
mark.textContent = describeQueuePosition(position);
|
||||||
|
name.append(mark);
|
||||||
|
}
|
||||||
if (episode.uploadedAt !== null) {
|
if (episode.uploadedAt !== null) {
|
||||||
const uploaded = safeUploadDate(episode.uploadedAt);
|
const uploaded = safeUploadDate(episode.uploadedAt);
|
||||||
if (uploaded) {
|
if (uploaded) {
|
||||||
@@ -154,10 +187,45 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openRowMenu(event, item);
|
openRowMenu(event, item);
|
||||||
});
|
});
|
||||||
row.append(button);
|
|
||||||
|
// The row itself plays, which leaves the other choice — after this one, not
|
||||||
|
// instead of it — with nothing to click. These two spell both out, and a
|
||||||
|
// right-click on the row says the same thing in words.
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'cue-actions';
|
||||||
|
actions.append(
|
||||||
|
createRowAction('Play', `Play ${episode.name} now`, () => void playEpisode(episode)),
|
||||||
|
createRowAction(
|
||||||
|
position === undefined ? 'Queue' : 'Queued',
|
||||||
|
position === undefined
|
||||||
|
? `Play ${episode.name} after the current episode`
|
||||||
|
: `Take ${episode.name} out of the queue`,
|
||||||
|
() => void queue.toggle(episode),
|
||||||
|
position !== undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
row.append(button, actions);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRowAction(
|
||||||
|
label: string,
|
||||||
|
title: string,
|
||||||
|
onClick: () => void,
|
||||||
|
active = false,
|
||||||
|
): HTMLButtonElement {
|
||||||
|
const action = document.createElement('button');
|
||||||
|
action.type = 'button';
|
||||||
|
action.className = 'cue-action';
|
||||||
|
action.textContent = label;
|
||||||
|
action.title = title;
|
||||||
|
action.setAttribute('aria-label', title);
|
||||||
|
if (active) action.dataset.active = 'true';
|
||||||
|
action.addEventListener('click', onClick);
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The right-click menu on one episode: this episode, or this one and every
|
* The right-click menu on one episode: this episode, or this one and every
|
||||||
* episode listed below it, which for a newest-first source is everything
|
* episode listed below it, which for a newest-first source is everything
|
||||||
@@ -169,8 +237,17 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
|
|||||||
const isWatched = watched.has(item.episode.url);
|
const isWatched = watched.has(item.episode.url);
|
||||||
const below = episodesInScope(listed, index, 'below');
|
const below = episodesInScope(listed, index, 'below');
|
||||||
const items: ContextMenuItem[] = [
|
const items: ContextMenuItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Play now',
|
||||||
|
onSelect: () => void playEpisode(item.episode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: queue.isQueued(item.episode) ? 'Remove from queue' : 'Play after current',
|
||||||
|
onSelect: () => void queue.toggle(item.episode),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: isWatched ? 'Mark unwatched' : 'Mark watched',
|
label: isWatched ? 'Mark unwatched' : 'Mark watched',
|
||||||
|
separated: true,
|
||||||
onSelect: () => void applyMark([item], !isWatched),
|
onSelect: () => void applyMark([item], !isWatched),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -335,5 +412,5 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
|
|||||||
});
|
});
|
||||||
window.addEventListener('focus', () => void refreshWatchState());
|
window.addEventListener('focus', () => void refreshWatchState());
|
||||||
|
|
||||||
return { render, clear, refreshWatchState };
|
return { render, clear, refreshWatchState, setQueue: queue.setState };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { capture } from './browse-state';
|
||||||
|
import { describe, el } from './dom';
|
||||||
|
import { describeQueuePosition, queueKey, queuePositions } from './episode-queue';
|
||||||
|
import type { SelectedAnime } from './episode-list';
|
||||||
|
import type {
|
||||||
|
AnimeBrowserAPI,
|
||||||
|
AnimeBrowserEpisode,
|
||||||
|
AnimeBrowserQueueEntry,
|
||||||
|
AnimeBrowserQueueState,
|
||||||
|
} from '../types/anime-browser';
|
||||||
|
|
||||||
|
interface EpisodeQueueControlsOptions {
|
||||||
|
api: AnimeBrowserAPI;
|
||||||
|
setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
|
||||||
|
/** The anime the open detail page belongs to, or null once it is closed. */
|
||||||
|
selectedAnime: () => SelectedAnime | null;
|
||||||
|
/** Play now, with the cue states and status line the episode list owns. */
|
||||||
|
playEpisode: (episode: AnimeBrowserEpisode) => Promise<void>;
|
||||||
|
/** Repaint the rows; their badges and buttons read from this queue. */
|
||||||
|
onChange: () => void;
|
||||||
|
/** The queue started this episode by itself, so nothing else is playing now. */
|
||||||
|
onAdvance: (entry: AnimeBrowserQueueEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The play queue as this window sees it.
|
||||||
|
*
|
||||||
|
* The queue itself lives in the main process — it has to, since it advances
|
||||||
|
* when an episode ends whether or not this window is open. Everything here is a
|
||||||
|
* view of what it pushes back, plus the two calls that change it.
|
||||||
|
*/
|
||||||
|
export function createEpisodeQueueControls(options: EpisodeQueueControlsOptions) {
|
||||||
|
const { api, setStatus, selectedAnime, playEpisode, onChange, onAdvance } = options;
|
||||||
|
const queuedLabel = el<HTMLSpanElement>('episodes-queued');
|
||||||
|
const clearButton = el<HTMLButtonElement>('episodes-queue-clear');
|
||||||
|
|
||||||
|
let queue: AnimeBrowserQueueState = {
|
||||||
|
entries: [],
|
||||||
|
lastError: null,
|
||||||
|
advances: 0,
|
||||||
|
lastStarted: null,
|
||||||
|
};
|
||||||
|
let positions = new Map<string, number>();
|
||||||
|
|
||||||
|
function positionOf(episode: AnimeBrowserEpisode): number | undefined {
|
||||||
|
const anime = selectedAnime();
|
||||||
|
return anime ? positions.get(queueKey(anime.sourceId, episode.url)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQueued(episode: AnimeBrowserEpisode): boolean {
|
||||||
|
return positionOf(episode) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The queue as the main process now holds it, from a call or from a push. */
|
||||||
|
function setState(next: AnimeBrowserQueueState): void {
|
||||||
|
const previousError = queue.lastError;
|
||||||
|
const advanced = next.advances > queue.advances;
|
||||||
|
queue = next;
|
||||||
|
positions = queuePositions(next.entries);
|
||||||
|
paintHeader();
|
||||||
|
onChange();
|
||||||
|
// The episode this window had marked playing has ended, whether or not the
|
||||||
|
// one that replaced it is in the list on screen.
|
||||||
|
if (advanced && next.lastStarted) onAdvance(next.lastStarted);
|
||||||
|
// An advance that failed happened with nobody watching this window, so it
|
||||||
|
// is reported once rather than on every repaint after it.
|
||||||
|
if (next.lastError && next.lastError !== previousError) {
|
||||||
|
setStatus(`Queue stopped — ${next.lastError}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The queue spans every anime, so it is counted whole even when none of its
|
||||||
|
* episodes are in the list on screen.
|
||||||
|
*/
|
||||||
|
function paintHeader(): void {
|
||||||
|
const queued = queue.entries.length;
|
||||||
|
queuedLabel.textContent = queued > 0 ? `Queue · ${queued}` : '';
|
||||||
|
queuedLabel.classList.toggle('hidden', queued === 0);
|
||||||
|
clearButton.classList.toggle('hidden', queued === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue the episode, or take it back out when it is already in line. */
|
||||||
|
async function toggle(episode: AnimeBrowserEpisode): Promise<void> {
|
||||||
|
const anime = selectedAnime();
|
||||||
|
if (!anime) return;
|
||||||
|
|
||||||
|
const queued = isQueued(episode);
|
||||||
|
// Queueing behind nothing would wait for an end that never comes, so with
|
||||||
|
// an idle player the second option collapses into the first.
|
||||||
|
if (!queued) {
|
||||||
|
const playing = await capture(() => api.isPlaying());
|
||||||
|
if (playing.ok && !playing.value) {
|
||||||
|
setStatus('Nothing was playing, so this starts now.');
|
||||||
|
await playEpisode(episode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = await capture(() =>
|
||||||
|
queued
|
||||||
|
? api.dequeueEpisode(anime.sourceId, episode.url)
|
||||||
|
: api.queueEpisode({
|
||||||
|
sourceId: anime.sourceId,
|
||||||
|
animeUrl: anime.url,
|
||||||
|
animeTitle: anime.title,
|
||||||
|
episodeUrl: episode.url,
|
||||||
|
episodeName: episode.name,
|
||||||
|
episodeNumber: episode.number,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!attempt.ok) {
|
||||||
|
setStatus(describe(attempt.error), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applied from what the call returned rather than waited for on the push
|
||||||
|
// channel, so the row settles on the click that changed it.
|
||||||
|
setState(attempt.value);
|
||||||
|
const position = positionOf(episode);
|
||||||
|
setStatus(
|
||||||
|
position === undefined
|
||||||
|
? `${episode.name} left the queue.`
|
||||||
|
: `${episode.name} is ${describeQueuePosition(position)}.`,
|
||||||
|
'ok',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear(): Promise<void> {
|
||||||
|
const attempt = await capture(() => api.clearQueue());
|
||||||
|
if (!attempt.ok) {
|
||||||
|
setStatus(describe(attempt.error), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(attempt.value);
|
||||||
|
setStatus('Queue cleared.', 'ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearButton.addEventListener('click', () => void clear());
|
||||||
|
|
||||||
|
return { positionOf, isQueued, setState, toggle };
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describeQueuePosition, queueKey, queuePositions } from './episode-queue';
|
||||||
|
import type { AnimeBrowserQueueEntry } from '../types/anime-browser';
|
||||||
|
|
||||||
|
function entry(overrides: Partial<AnimeBrowserQueueEntry> = {}): AnimeBrowserQueueEntry {
|
||||||
|
return {
|
||||||
|
sourceId: 'source',
|
||||||
|
animeUrl: '/anime',
|
||||||
|
animeTitle: 'Anime',
|
||||||
|
episodeUrl: '/episode-1',
|
||||||
|
episodeName: 'Episode 1',
|
||||||
|
episodeNumber: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('positions count across the whole queue, not within one anime', () => {
|
||||||
|
const positions = queuePositions([
|
||||||
|
entry(),
|
||||||
|
entry({ animeUrl: '/other', episodeUrl: '/other-1' }),
|
||||||
|
entry({ episodeUrl: '/episode-2' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(positions.get(queueKey('source', '/episode-1')), 1);
|
||||||
|
assert.equal(positions.get(queueKey('source', '/other-1')), 2);
|
||||||
|
assert.equal(positions.get(queueKey('source', '/episode-2')), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the same episode url on two sources keeps two positions', () => {
|
||||||
|
const positions = queuePositions([entry(), entry({ sourceId: 'other' })]);
|
||||||
|
|
||||||
|
assert.equal(positions.get(queueKey('source', '/episode-1')), 1);
|
||||||
|
assert.equal(positions.get(queueKey('other', '/episode-1')), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the episode about to play is named rather than numbered', () => {
|
||||||
|
assert.equal(describeQueuePosition(1), 'next up');
|
||||||
|
assert.equal(describeQueuePosition(4), '#4 in queue');
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { AnimeBrowserQueueEntry } from '../types/anime-browser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where each queued episode sits in line, keyed by source and episode url.
|
||||||
|
*
|
||||||
|
* The position is counted across the whole queue rather than within one anime:
|
||||||
|
* "3rd up" has to mean the same thing on every detail page, or the number is
|
||||||
|
* worse than no number at all.
|
||||||
|
*/
|
||||||
|
export function queuePositions(entries: AnimeBrowserQueueEntry[]): Map<string, number> {
|
||||||
|
const positions = new Map<string, number>();
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const key = queueKey(entry.sourceId, entry.episodeUrl);
|
||||||
|
// A duplicate should never reach the renderer, but if one did, the earlier
|
||||||
|
// position is the one that will actually play.
|
||||||
|
if (!positions.has(key)) positions.set(key, index + 1);
|
||||||
|
});
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueKey(sourceId: string, episodeUrl: string): string {
|
||||||
|
return `${sourceId} ${episodeUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Next up" reads better than "#1" for the episode about to play. */
|
||||||
|
export function describeQueuePosition(position: number): string {
|
||||||
|
return position === 1 ? 'next up' : `#${position} in queue`;
|
||||||
|
}
|
||||||
@@ -152,6 +152,14 @@
|
|||||||
<h3 class="episodes-title">Episodes</h3>
|
<h3 class="episodes-title">Episodes</h3>
|
||||||
<span class="episodes-count" id="episodes-count"></span>
|
<span class="episodes-count" id="episodes-count"></span>
|
||||||
<span class="episodes-watched hidden" id="episodes-watched"></span>
|
<span class="episodes-watched hidden" id="episodes-watched"></span>
|
||||||
|
<span class="episodes-queued hidden" id="episodes-queued"></span>
|
||||||
|
<button
|
||||||
|
class="ghost-button episodes-queue-clear hidden"
|
||||||
|
id="episodes-queue-clear"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear queue
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
class="text-input episodes-filter hidden"
|
class="text-input episodes-filter hidden"
|
||||||
id="episodes-filter"
|
id="episodes-filter"
|
||||||
|
|||||||
@@ -3379,6 +3379,12 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
|
|||||||
window.webContents.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
|
window.webContents.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onQueueState: (state) => {
|
||||||
|
const window = appState.animeBrowserWindow;
|
||||||
|
if (window && !window.isDestroyed()) {
|
||||||
|
window.webContents.send(IPC_CHANNELS.event.animeBrowserQueueState, state);
|
||||||
|
}
|
||||||
|
},
|
||||||
log: (message) => logger.info(message),
|
log: (message) => logger.info(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,17 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
|
|||||||
handle(channels.animeBrowserAddRepo, (_event, url) => runtime.addRepo(String(url)));
|
handle(channels.animeBrowserAddRepo, (_event, url) => runtime.addRepo(String(url)));
|
||||||
handle(channels.animeBrowserRemoveRepo, (_event, url) => runtime.removeRepo(String(url)));
|
handle(channels.animeBrowserRemoveRepo, (_event, url) => runtime.removeRepo(String(url)));
|
||||||
handle(channels.animeBrowserPlayEpisode, (_event, request) =>
|
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) =>
|
handle(channels.animeBrowserGetPreferences, (_event, sourceId) =>
|
||||||
runtime.getPreferences(String(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. */
|
/** Coerce a watch-state request; the renderer's arrays arrive untyped. */
|
||||||
function toWatchStateRequest(value: unknown): AnimeBrowserWatchStateRequest {
|
function toWatchStateRequest(value: unknown): AnimeBrowserWatchStateRequest {
|
||||||
const request = (value ?? {}) as Partial<AnimeBrowserWatchStateRequest>;
|
const request = (value ?? {}) as Partial<AnimeBrowserWatchStateRequest>;
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome';
|
||||||
|
import type { AnimeBrowserPlayRequest, AnimeBrowserQueueState } from '../../types/anime-browser';
|
||||||
|
import { createAnimeBrowserQueue, type AnimeBrowserQueueDeps } from './anime-browser-queue';
|
||||||
|
|
||||||
|
function makeRequest(overrides: Partial<AnimeBrowserPlayRequest> = {}): AnimeBrowserPlayRequest {
|
||||||
|
return {
|
||||||
|
sourceId: 'source',
|
||||||
|
animeUrl: '/anime',
|
||||||
|
animeTitle: 'Anime',
|
||||||
|
episodeUrl: '/episode-1',
|
||||||
|
episodeName: 'Episode 1',
|
||||||
|
episodeNumber: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
deps: AnimeBrowserQueueDeps;
|
||||||
|
played: AnimeBrowserPlayRequest[];
|
||||||
|
commands: Array<Array<string | number>>;
|
||||||
|
states: AnimeBrowserQueueState[];
|
||||||
|
osd: string[];
|
||||||
|
endFile: (event: PlaybackEndFileEvent) => void;
|
||||||
|
/** How many listeners are subscribed right now. */
|
||||||
|
listenerCount: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHarness(
|
||||||
|
options: {
|
||||||
|
play?: (request: AnimeBrowserPlayRequest) => Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
error: string | null;
|
||||||
|
quality: string | null;
|
||||||
|
}>;
|
||||||
|
keepOpen?: unknown;
|
||||||
|
withEndFile?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Harness {
|
||||||
|
const played: AnimeBrowserPlayRequest[] = [];
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const states: AnimeBrowserQueueState[] = [];
|
||||||
|
const osd: string[] = [];
|
||||||
|
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
|
||||||
|
|
||||||
|
const deps: AnimeBrowserQueueDeps = {
|
||||||
|
play: async (request) => {
|
||||||
|
played.push(request);
|
||||||
|
return options.play
|
||||||
|
? await options.play(request)
|
||||||
|
: { ok: true, error: null, quality: '1080p' };
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => void commands.push(command),
|
||||||
|
onQueueState: (state) => void states.push(state),
|
||||||
|
showMpvOsd: (message) => void osd.push(message),
|
||||||
|
log: () => undefined,
|
||||||
|
};
|
||||||
|
if (options.withEndFile !== false) {
|
||||||
|
deps.onPlaybackEndFile = (listener) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (options.keepOpen !== undefined) {
|
||||||
|
deps.readMpvProperty = async (name) => {
|
||||||
|
if (name !== 'keep-open') throw new Error(`unexpected property ${name}`);
|
||||||
|
return options.keepOpen;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deps,
|
||||||
|
played,
|
||||||
|
commands,
|
||||||
|
states,
|
||||||
|
osd,
|
||||||
|
endFile: (event) => {
|
||||||
|
for (const listener of [...listeners]) listener(event);
|
||||||
|
},
|
||||||
|
listenerCount: () => listeners.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EOF: PlaybackEndFileEvent = { reason: 'eof', fileError: null };
|
||||||
|
|
||||||
|
test('an episode that runs to its end hands the queue its turn', async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
queue.enqueue(makeRequest({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
|
||||||
|
harness.endFile(EOF);
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
harness.played.map((request) => request.episodeUrl),
|
||||||
|
['/episode-1'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
queue.getState().entries.map((entry) => entry.episodeUrl),
|
||||||
|
['/episode-2'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only a file that ended by itself advances the queue', async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
harness.endFile({ reason: 'stop', fileError: null });
|
||||||
|
harness.endFile({ reason: 'quit', fileError: null });
|
||||||
|
harness.endFile({ reason: 'error', fileError: 'dead host' });
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
|
||||||
|
assert.deepEqual(harness.played, []);
|
||||||
|
assert.equal(queue.getState().entries.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queueing the same episode twice leaves one entry', () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
const state = queue.enqueue(makeRequest());
|
||||||
|
|
||||||
|
assert.equal(state.entries.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an advance is counted and names the episode it started', async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
assert.equal(queue.getState().advances, 0);
|
||||||
|
harness.endFile(EOF);
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
|
||||||
|
const state = queue.getState();
|
||||||
|
assert.equal(state.advances, 1);
|
||||||
|
assert.equal(state.lastStarted?.episodeUrl, '/episode-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an episode dequeues by its own source and url', () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
queue.enqueue(makeRequest({ sourceId: 'other' }));
|
||||||
|
const state = queue.dequeue('source', '/episode-1');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
state.entries.map((entry) => entry.sourceId),
|
||||||
|
['other'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a queued episode that will not play stops the queue and reports why', async () => {
|
||||||
|
const harness = makeHarness({
|
||||||
|
play: async () => ({
|
||||||
|
ok: false,
|
||||||
|
error: 'That source returned no playable video.',
|
||||||
|
quality: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
queue.enqueue(makeRequest({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
|
||||||
|
harness.endFile(EOF);
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
|
||||||
|
const state = queue.getState();
|
||||||
|
assert.equal(state.lastError, 'Episode 1: That source returned no playable video.');
|
||||||
|
// The failed episode is gone; the rest is still the user's queue.
|
||||||
|
assert.deepEqual(
|
||||||
|
state.entries.map((entry) => entry.episodeUrl),
|
||||||
|
['/episode-2'],
|
||||||
|
);
|
||||||
|
assert.equal(harness.osd.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second end-file while an advance is resolving does not double-load', async () => {
|
||||||
|
let release = (): void => undefined;
|
||||||
|
const started = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const harness = makeHarness({
|
||||||
|
play: async () => {
|
||||||
|
await started;
|
||||||
|
return { ok: true, error: null, quality: null };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
queue.enqueue(makeRequest({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
|
||||||
|
harness.endFile(EOF);
|
||||||
|
harness.endFile(EOF);
|
||||||
|
release();
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
harness.played.map((request) => request.episodeUrl),
|
||||||
|
['/episode-1'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mpv is told not to hold the last file open while the queue waits', async () => {
|
||||||
|
const harness = makeHarness({ keepOpen: 'yes' });
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
assert.deepEqual(harness.commands, [['set_property', 'keep-open', 'no']]);
|
||||||
|
|
||||||
|
queue.clear();
|
||||||
|
assert.deepEqual(harness.commands[1], ['set_property', 'keep-open', 'yes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mpv that already lets files end is left alone', async () => {
|
||||||
|
const harness = makeHarness({ keepOpen: 'no' });
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
queue.clear();
|
||||||
|
|
||||||
|
assert.deepEqual(harness.commands, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the last queued episode plays under the user own keep-open setting', async () => {
|
||||||
|
const harness = makeHarness({ keepOpen: 'always' });
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
harness.endFile(EOF);
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
|
||||||
|
assert.deepEqual(harness.commands, [
|
||||||
|
['set_property', 'keep-open', 'no'],
|
||||||
|
['set_property', 'keep-open', 'always'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('playback started outside the queue re-points the end-file listener', async () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
queue.handlePlaybackStarted();
|
||||||
|
queue.handlePlaybackStarted();
|
||||||
|
|
||||||
|
// Each re-arm replaces the previous subscription rather than stacking on it,
|
||||||
|
// so one end-file cannot advance the queue several times over.
|
||||||
|
assert.equal(harness.listenerCount(), 1);
|
||||||
|
harness.endFile(EOF);
|
||||||
|
await new Promise(setImmediate);
|
||||||
|
assert.equal(harness.played.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an emptied queue stops listening for the end of the file', () => {
|
||||||
|
const harness = makeHarness();
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
queue.enqueue(makeRequest());
|
||||||
|
assert.equal(harness.listenerCount(), 1);
|
||||||
|
queue.dequeue('source', '/episode-1');
|
||||||
|
assert.equal(harness.listenerCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the queue still accepts episodes when mpv reports no events', () => {
|
||||||
|
const harness = makeHarness({ withEndFile: false });
|
||||||
|
const queue = createAnimeBrowserQueue(harness.deps);
|
||||||
|
|
||||||
|
assert.equal(queue.enqueue(makeRequest()).entries.length, 1);
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -11,7 +11,11 @@ import type { SubtitleCacheIo } from '../../anime-bridge/subtitle-cache';
|
|||||||
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
|
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
|
||||||
import { startSidecar } from '../../anime-bridge/sidecar-process';
|
import { startSidecar } from '../../anime-bridge/sidecar-process';
|
||||||
import { startStreamStripProxy } from '../../anime-bridge/stream-strip-proxy';
|
import { startStreamStripProxy } from '../../anime-bridge/stream-strip-proxy';
|
||||||
import type { AnimeBrowserBridgeState, AnimeBrowserSearchUpdate } from '../../types/anime-browser';
|
import type {
|
||||||
|
AnimeBrowserBridgeState,
|
||||||
|
AnimeBrowserQueueState,
|
||||||
|
AnimeBrowserSearchUpdate,
|
||||||
|
} from '../../types/anime-browser';
|
||||||
import type { InstallProgress } from './anime-bridge-installer';
|
import type { InstallProgress } from './anime-bridge-installer';
|
||||||
|
|
||||||
export interface AnimeBrowserRuntimeDeps {
|
export interface AnimeBrowserRuntimeDeps {
|
||||||
@@ -53,6 +57,8 @@ export interface AnimeBrowserRuntimeDeps {
|
|||||||
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
|
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
|
||||||
subtitleCacheIo?: SubtitleCacheIo;
|
subtitleCacheIo?: SubtitleCacheIo;
|
||||||
onBridgeState: (state: AnimeBrowserBridgeState) => void;
|
onBridgeState: (state: AnimeBrowserBridgeState) => void;
|
||||||
|
/** Pushes the play queue to the browser window, advances included. */
|
||||||
|
onQueueState?: (state: AnimeBrowserQueueState) => void;
|
||||||
/** Streams per-source progress while a search invoke is pending. */
|
/** Streams per-source progress while a search invoke is pending. */
|
||||||
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
|
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
|
||||||
preferredQuality?: () => string | undefined;
|
preferredQuality?: () => string | undefined;
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ import type {
|
|||||||
AnimeBrowserEntry,
|
AnimeBrowserEntry,
|
||||||
AnimeBrowserEpisode,
|
AnimeBrowserEpisode,
|
||||||
AnimeBrowserEpisodeWatchState,
|
AnimeBrowserEpisodeWatchState,
|
||||||
|
AnimeBrowserPlayRequest,
|
||||||
|
AnimeBrowserPlayResult,
|
||||||
|
AnimeBrowserQueueState,
|
||||||
AnimeBrowserSetWatchedRequest,
|
AnimeBrowserSetWatchedRequest,
|
||||||
AnimeBrowserWatchStateRequest,
|
AnimeBrowserWatchStateRequest,
|
||||||
AnimeBrowserSearchResult,
|
AnimeBrowserSearchResult,
|
||||||
@@ -47,6 +50,7 @@ import type {
|
|||||||
} from '../../types/anime-browser';
|
} from '../../types/anime-browser';
|
||||||
import type { BridgeAnimePage, BridgePreference } from '../../anime-bridge/types';
|
import type { BridgeAnimePage, BridgePreference } from '../../anime-bridge/types';
|
||||||
import { createAnimeBrowserPlayback } from './anime-browser-playback';
|
import { createAnimeBrowserPlayback } from './anime-browser-playback';
|
||||||
|
import { createAnimeBrowserQueue } from './anime-browser-queue';
|
||||||
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
|
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
|
||||||
export type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
|
export type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
|
||||||
|
|
||||||
@@ -350,6 +354,16 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
stripProxy: () => stripProxy,
|
stripProxy: () => stripProxy,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queue = createAnimeBrowserQueue({
|
||||||
|
play: (request) => playback.playEpisode(request),
|
||||||
|
onPlaybackEndFile: deps.onPlaybackEndFile,
|
||||||
|
readMpvProperty: deps.readMpvProperty,
|
||||||
|
sendMpvCommand: deps.sendMpvCommand,
|
||||||
|
onQueueState: deps.onQueueState,
|
||||||
|
showMpvOsd: deps.showMpvOsd,
|
||||||
|
log: deps.log,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getSnapshot(): AnimeBrowserSnapshot {
|
getSnapshot(): AnimeBrowserSnapshot {
|
||||||
return {
|
return {
|
||||||
@@ -573,7 +587,45 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
playEpisode: playback.playEpisode,
|
/**
|
||||||
|
* Play now, replacing whatever mpv has. The queue is left standing and
|
||||||
|
* re-armed behind this file, so an episode played by hand mid-queue is a
|
||||||
|
* detour rather than a reset.
|
||||||
|
*/
|
||||||
|
async playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
|
||||||
|
const result = await playback.playEpisode(request);
|
||||||
|
if (result.ok) queue.handlePlaybackStarted();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
queueEpisode(request: AnimeBrowserPlayRequest): AnimeBrowserQueueState {
|
||||||
|
return queue.enqueue(request);
|
||||||
|
},
|
||||||
|
|
||||||
|
dequeueEpisode(sourceId: string, episodeUrl: string): AnimeBrowserQueueState {
|
||||||
|
return queue.dequeue(sourceId, episodeUrl);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearQueue(): AnimeBrowserQueueState {
|
||||||
|
return queue.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
getQueue(): AnimeBrowserQueueState {
|
||||||
|
return queue.getState();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether mpv has a file open. An mpv that is not running cannot answer,
|
||||||
|
* and there is nothing playing in it either, so both read as false.
|
||||||
|
*/
|
||||||
|
async isPlaying(): Promise<boolean> {
|
||||||
|
if (!deps.readMpvProperty) return false;
|
||||||
|
try {
|
||||||
|
return (await deps.readMpvProperty('idle-active')) !== true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
const handle = sidecar;
|
const handle = sidecar;
|
||||||
@@ -582,6 +634,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
stripProxy = null;
|
stripProxy = null;
|
||||||
starting = null;
|
starting = null;
|
||||||
setState(IDLE_STATE);
|
setState(IDLE_STATE);
|
||||||
|
queue.dispose();
|
||||||
await playback.dispose();
|
await playback.dispose();
|
||||||
await proxy?.close();
|
await proxy?.close();
|
||||||
await handle?.stop();
|
await handle?.stop();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AnimeBrowserSetWatchedRequest,
|
AnimeBrowserSetWatchedRequest,
|
||||||
AnimeBrowserPlayRequest,
|
AnimeBrowserPlayRequest,
|
||||||
AnimeBrowserPlayResult,
|
AnimeBrowserPlayResult,
|
||||||
|
AnimeBrowserQueueState,
|
||||||
AnimeBrowserSearchResult,
|
AnimeBrowserSearchResult,
|
||||||
AnimeBrowserSearchUpdate,
|
AnimeBrowserSearchUpdate,
|
||||||
AnimeBrowserSnapshot,
|
AnimeBrowserSnapshot,
|
||||||
@@ -44,6 +45,14 @@ const animeBrowserAPI: AnimeBrowserAPI = {
|
|||||||
ipcRenderer.invoke(request.animeBrowserSetWatched, watchedRequest),
|
ipcRenderer.invoke(request.animeBrowserSetWatched, watchedRequest),
|
||||||
playEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> =>
|
playEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> =>
|
||||||
ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest),
|
ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest),
|
||||||
|
queueEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserQueueState> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserQueueEpisode, playRequest),
|
||||||
|
dequeueEpisode: (sourceId: string, episodeUrl: string): Promise<AnimeBrowserQueueState> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserDequeueEpisode, sourceId, episodeUrl),
|
||||||
|
clearQueue: (): Promise<AnimeBrowserQueueState> =>
|
||||||
|
ipcRenderer.invoke(request.animeBrowserClearQueue),
|
||||||
|
getQueue: (): Promise<AnimeBrowserQueueState> => ipcRenderer.invoke(request.animeBrowserGetQueue),
|
||||||
|
isPlaying: (): Promise<boolean> => ipcRenderer.invoke(request.animeBrowserIsPlaying),
|
||||||
getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> =>
|
getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> =>
|
||||||
ipcRenderer.invoke(request.animeBrowserGetPreferences, sourceId),
|
ipcRenderer.invoke(request.animeBrowserGetPreferences, sourceId),
|
||||||
setPreference: (
|
setPreference: (
|
||||||
@@ -72,6 +81,11 @@ const animeBrowserAPI: AnimeBrowserAPI = {
|
|||||||
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
|
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
|
||||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
|
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
|
||||||
},
|
},
|
||||||
|
onQueueState: (listener: (state: AnimeBrowserQueueState) => void): (() => void) => {
|
||||||
|
const handler = (_event: unknown, state: AnimeBrowserQueueState): void => listener(state);
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserQueueState, handler);
|
||||||
|
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserQueueState, handler);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('animeBrowserAPI', animeBrowserAPI);
|
contextBridge.exposeInMainWorld('animeBrowserAPI', animeBrowserAPI);
|
||||||
|
|||||||
@@ -135,6 +135,11 @@ export const IPC_CHANNELS = {
|
|||||||
animeBrowserGetWatchState: 'anime-browser:get-watch-state',
|
animeBrowserGetWatchState: 'anime-browser:get-watch-state',
|
||||||
animeBrowserSetWatched: 'anime-browser:set-watched',
|
animeBrowserSetWatched: 'anime-browser:set-watched',
|
||||||
animeBrowserPlayEpisode: 'anime-browser:play-episode',
|
animeBrowserPlayEpisode: 'anime-browser:play-episode',
|
||||||
|
animeBrowserQueueEpisode: 'anime-browser:queue-episode',
|
||||||
|
animeBrowserDequeueEpisode: 'anime-browser:dequeue-episode',
|
||||||
|
animeBrowserClearQueue: 'anime-browser:clear-queue',
|
||||||
|
animeBrowserGetQueue: 'anime-browser:get-queue',
|
||||||
|
animeBrowserIsPlaying: 'anime-browser:is-playing',
|
||||||
animeBrowserGetPreferences: 'anime-browser:get-preferences',
|
animeBrowserGetPreferences: 'anime-browser:get-preferences',
|
||||||
animeBrowserSetPreference: 'anime-browser:set-preference',
|
animeBrowserSetPreference: 'anime-browser:set-preference',
|
||||||
animeBrowserListAvailableExtensions: 'anime-browser:list-available-extensions',
|
animeBrowserListAvailableExtensions: 'anime-browser:list-available-extensions',
|
||||||
@@ -179,6 +184,7 @@ export const IPC_CHANNELS = {
|
|||||||
syncUiStateChanged: 'sync-ui:state-changed',
|
syncUiStateChanged: 'sync-ui:state-changed',
|
||||||
animeBrowserBridgeState: 'anime-browser:bridge-state',
|
animeBrowserBridgeState: 'anime-browser:bridge-state',
|
||||||
animeBrowserSearchUpdate: 'anime-browser:search-update',
|
animeBrowserSearchUpdate: 'anime-browser:search-update',
|
||||||
|
animeBrowserQueueState: 'anime-browser:queue-state',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,35 @@ export interface AnimeBrowserPlayResult {
|
|||||||
quality: string | null;
|
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 {
|
export interface AnimeBrowserAPI {
|
||||||
getSnapshot: () => Promise<AnimeBrowserSnapshot>;
|
getSnapshot: () => Promise<AnimeBrowserSnapshot>;
|
||||||
ensureBridge: () => Promise<AnimeBrowserBridgeState>;
|
ensureBridge: () => Promise<AnimeBrowserBridgeState>;
|
||||||
@@ -237,7 +266,18 @@ export interface AnimeBrowserAPI {
|
|||||||
) => Promise<AnimeBrowserEpisodeWatchState[]>;
|
) => Promise<AnimeBrowserEpisodeWatchState[]>;
|
||||||
/** Set or clear the mark by hand; resolves to the state after the write. */
|
/** Set or clear the mark by hand; resolves to the state after the write. */
|
||||||
setWatched: (request: AnimeBrowserSetWatchedRequest) => Promise<AnimeBrowserEpisodeWatchState[]>;
|
setWatched: (request: AnimeBrowserSetWatchedRequest) => Promise<AnimeBrowserEpisodeWatchState[]>;
|
||||||
|
/** Plays now, replacing whatever mpv is playing. */
|
||||||
playEpisode: (request: AnimeBrowserPlayRequest) => Promise<AnimeBrowserPlayResult>;
|
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[]>;
|
getPreferences: (sourceId: string) => Promise<SourcePreferenceView[]>;
|
||||||
setPreference: (
|
setPreference: (
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
@@ -252,6 +292,8 @@ export interface AnimeBrowserAPI {
|
|||||||
removeRepo: (url: string) => Promise<void>;
|
removeRepo: (url: string) => Promise<void>;
|
||||||
onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void) => () => void;
|
onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void) => () => void;
|
||||||
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => 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';
|
export type { SourcePreferenceView } from '../anime-bridge/preferences';
|
||||||
|
|||||||
Reference in New Issue
Block a user