fix(anime): keep browser state in sync and alias HLS segments

- Toggle the player Anime Browser without losing its state
- Share active playback state and support rotating fake segment extensions
This commit is contained in:
2026-08-15 23:35:37 -07:00
parent 7a1450ddb7
commit 877f350353
34 changed files with 612 additions and 43 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ area: anime
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery. - Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu. - Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English``en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead. - The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English``en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives `.image` segment URLs a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases. - HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases.
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback. - The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback.
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state. - The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request. - A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
@@ -29,4 +29,4 @@ area: anime
- 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, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays. - 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, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. It stays within the player bounds and shares playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. - Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state.
+13 -10
View File
@@ -7,16 +7,18 @@ Open it with `subminer anime`, with `SubMiner.AppImage --anime`, or from
**Browse Anime** in the tray menu. The window stays open while you watch, so you **Browse Anime** in the tray menu. The window stays open while you watch, so you
can queue the next episode without reopening it. can queue the next episode without reopening it.
During playback, `Ctrl+Alt+A` opens the same browser as a modal inside the mpv During playback, `Ctrl+Alt+A` toggles the same browser as a modal inside the mpv
player bounds. It uses a dedicated modal surface, so it stays above fullscreen player bounds. It uses a dedicated modal surface, so it stays above fullscreen
playback and closes like the other in-player tools. The standalone window and playback and closes like the other in-player tools. Toggling it off keeps its
the modal keep their own search, selected source, tab, and scroll state, so using current page and scroll position ready for the next toggle. The standalone
one does not replace or cancel what you were doing in the other. window and the modal keep their own search, selected source, tab, and scroll
state, so using one does not replace or cancel what you were doing in the other.
Both surfaces use the same playback queue, source configuration, and stats Both surfaces use the same playback queue, source configuration, and stats
history. Queue changes appear in both immediately, and watched marks come from history. Queue changes and the currently playing episode appear in both
the same history that playback and the stats window update. Closing and reopening immediately, and watched marks come from the same history that playback and the
the modal therefore picks up progress made from either browser surface. stats window update. Closing and reopening the modal therefore picks up progress
made from either browser surface.
While the window is open, SubMiner shows a tray icon and — on macOS — appears While the window is open, SubMiner shows a tray icon and — on macOS — appears
in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can
@@ -257,9 +259,10 @@ probes the segment as a picture and playback dies with "no audio or video data
played". The proxy scans each segment for the first genuine MPEG-TS packet run played". The proxy scans each segment for the first genuine MPEG-TS packet run
and drops whatever junk sits in front of it. Segments that are not TS (fMP4, and drops whatever junk sits in front of it. Segments that are not TS (fMP4,
subtitles, encryption keys) pass through untouched, and direct-file streams subtitles, encryption keys) pass through untouched, and direct-file streams
skip the proxy entirely. Disguised `.image` segment URLs are exposed locally skip the proxy entirely. Segment URLs disguised behind fake extensions
with a `.ts` suffix so current ffmpeg releases accept them when Anki extracts (`.image`, `.jpg`, `.css`, and friends) are exposed locally with a `.ts`
audio, screenshots, or animated images from the playing stream. suffix so current ffmpeg releases accept them when Anki extracts audio,
screenshots, or animated images from the playing stream.
"Playing" in the status bar means playing: after handing mpv the stream, "Playing" in the status bar means playing: after handing mpv the stream,
SubMiner waits until mpv actually configures a video output before reporting SubMiner waits until mpv actually configures a video output before reporting
+1 -1
View File
@@ -593,7 +593,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser | | `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
| `Ctrl+Alt+KeyA` | `["__anime-browser-open"]` | Open Anime Browser in the player | | `Ctrl+Alt+KeyA` | `["__anime-browser-open"]` | Toggle Anime Browser in the player |
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | | `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | | `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | | `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
+2 -2
View File
@@ -49,7 +49,7 @@ These control playback and subtitle display. They require overlay window focus.
| `J` | Cycle primary subtitle track | | `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track | | `Shift+J` | Cycle secondary subtitle track |
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue | | `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
| `Ctrl+Alt+A` | Open Anime Browser inside the player | | `Ctrl+Alt+A` | Toggle Anime Browser inside the player |
| `ArrowRight` | Seek forward 5 seconds | | `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds | | `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds | | `ArrowUp` | Seek forward 60 seconds |
@@ -68,7 +68,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Right-click` | Toggle pause (outside subtitle area) | | `Right-click` | Toggle pause (outside subtitle area) |
| `Right-click + drag` | Reposition subtitles (on subtitle area) | | `Right-click + drag` | Reposition subtitles (on subtitle area) |
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. The Anime Browser shortcut opens a dedicated overlay modal bounded to the player; the standalone Anime Browser window remains independent. The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. The Anime Browser shortcut toggles a dedicated overlay modal bounded to the player; the standalone Anime Browser window remains independent, while active playback is shared.
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions. On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
+6
View File
@@ -10,6 +10,9 @@
/** Where a stream came from, resolved into the fields consumers actually want. */ /** Where a stream came from, resolved into the fields consumers actually want. */
export interface AnimeStreamMetadata { export interface AnimeStreamMetadata {
sourceId: string;
animeUrl: string;
episodeUrl: string;
/** The URL handed to mpv. Matches what mpv reports as `path`. */ /** The URL handed to mpv. Matches what mpv reports as `path`. */
mediaPath: string; mediaPath: string;
/** /**
@@ -192,6 +195,9 @@ export function buildAnimeStreamMetadata(input: AnimeStreamMetadataInput): Anime
const displayTitle = buildStreamDisplayTitle(seriesTitle, season, episodeNumber, label.title); const displayTitle = buildStreamDisplayTitle(seriesTitle, season, episodeNumber, label.title);
return { return {
sourceId: input.sourceId,
animeUrl: input.animeUrl,
episodeUrl: input.episodeUrl,
mediaPath: input.mediaPath, mediaPath: input.mediaPath,
statsPath: buildAnimeStreamStatsPath(input.sourceId, input.animeUrl, input.episodeUrl), statsPath: buildAnimeStreamStatsPath(input.sourceId, input.animeUrl, input.episodeUrl),
seriesTitle, seriesTitle,
+28 -1
View File
@@ -61,7 +61,7 @@ test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lin
assert.ok(!rewritten.includes('41569')); assert.ok(!rewritten.includes('41569'));
}); });
test('rewritePlaylistOrigins gives proxied image segments an ffmpeg-safe TS suffix', () => { test('rewritePlaylistOrigins gives proxied disguised segments an ffmpeg-safe TS suffix', () => {
const body = [ const body = [
'#EXTM3U', '#EXTM3U',
'#EXTINF:6.006,', '#EXTINF:6.006,',
@@ -70,6 +70,14 @@ test('rewritePlaylistOrigins gives proxied image segments an ffmpeg-safe TS suff
'http://127.0.0.1:41569/video/absolute.image', 'http://127.0.0.1:41569/video/absolute.image',
'#EXTINF:3,', '#EXTINF:3,',
'https://cdn.example/video/external.image', 'https://cdn.example/video/external.image',
'#EXTINF:2,',
'/video/rotating.jpg',
'#EXTINF:2,',
'/video/rotating.css',
'#EXTINF:2,',
'/video/rotating.html',
'#EXTINF:2,',
'/video/extensionless',
].join('\n'); ].join('\n');
const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999'); const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999');
@@ -78,6 +86,25 @@ test('rewritePlaylistOrigins gives proxied image segments an ffmpeg-safe TS suff
rewritten.includes(`http://127.0.0.1:9999/video/absolute.image${TS_SEGMENT_ALIAS_SUFFIX}`), rewritten.includes(`http://127.0.0.1:9999/video/absolute.image${TS_SEGMENT_ALIAS_SUFFIX}`),
); );
assert.ok(rewritten.includes('https://cdn.example/video/external.image')); assert.ok(rewritten.includes('https://cdn.example/video/external.image'));
assert.ok(rewritten.includes(`/video/rotating.jpg${TS_SEGMENT_ALIAS_SUFFIX}`));
assert.ok(rewritten.includes(`/video/rotating.css${TS_SEGMENT_ALIAS_SUFFIX}`));
assert.ok(rewritten.includes(`/video/rotating.html${TS_SEGMENT_ALIAS_SUFFIX}`));
assert.ok(rewritten.includes(`/video/extensionless${TS_SEGMENT_ALIAS_SUFFIX}`));
});
test('rewritePlaylistOrigins leaves recognized media extensions unaliased', () => {
const body = [
'#EXTM3U',
'#EXTINF:6,',
'/video/plain.ts',
'#EXTINF:6,',
'/video/fragmented.m4s',
'#EXTINF:6,',
'/subs/line.vtt',
'/video/nested.m3u8',
].join('\n');
const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999');
assert.ok(!rewritten.includes(TS_SEGMENT_ALIAS_SUFFIX));
}); });
/* ---------- proxy end-to-end ---------- */ /* ---------- proxy end-to-end ---------- */
+47 -3
View File
@@ -25,10 +25,54 @@ const TS_SYNC_BYTE = 0x47;
const SYNC_RUN = 5; const SYNC_RUN = 5;
/** /**
* FFmpeg 8.1 rejects HLS media whose URL suffix is not in its segment allowlist. * FFmpeg 8.1 rejects HLS media whose URL suffix is not in its segment allowlist.
* The disguised MPEG-TS segments seen in the wild use `.image`, so the local * Hosts disguise MPEG-TS segments behind rotating fake extensions (`.image`,
* playlist gives them this safe alias and removes it again before forwarding. * `.jpg`, `.css`, ...), so the local playlist gives every proxied segment
* without a recognized media extension this safe alias and removes it again
* before forwarding.
*/ */
export const TS_SEGMENT_ALIAS_SUFFIX = '.subminer.ts'; export const TS_SEGMENT_ALIAS_SUFFIX = '.subminer.ts';
/** ffmpeg 8.1 hls demuxer `allowed_segment_extensions` defaults (minus `html`,
* which only newer builds accept and is a disguise whenever it shows up here). */
const FFMPEG_SAFE_SEGMENT_EXTENSIONS = new Set([
'3gp',
'aac',
'avi',
'ac3',
'eac3',
'flac',
'mkv',
'm3u8',
'm4a',
'm4s',
'm4v',
'mpg',
'mov',
'mp2',
'mp3',
'mp4',
'mpeg',
'mpegts',
'ogg',
'ogv',
'oga',
'ts',
'vob',
'vtt',
'wav',
'webvtt',
'cmfv',
'cmfa',
'ec3',
'fmp4',
]);
/** True when ffmpeg's picky segment-extension check would reject this path. */
function needsTsSegmentAlias(pathname: string): boolean {
const name = pathname.slice(pathname.lastIndexOf('/') + 1).toLowerCase();
const dot = name.lastIndexOf('.');
if (dot === -1) return true;
return !FFMPEG_SAFE_SEGMENT_EXTENSIONS.has(name.slice(dot + 1));
}
/** A disguise prefix is small; give up scanning after this much. */ /** A disguise prefix is small; give up scanning after this much. */
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024; export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
/** Bytes needed to either find a run within the limit or rule one out. */ /** Bytes needed to either find a run within the limit or rule one out. */
@@ -80,7 +124,7 @@ export function rewritePlaylistOrigins(
} catch { } catch {
return line; return line;
} }
if (resolved.origin !== proxyOrigin || !resolved.pathname.toLowerCase().endsWith('.image')) { if (resolved.origin !== proxyOrigin || !needsTsSegmentAlias(resolved.pathname)) {
return line; return line;
} }
+29 -1
View File
@@ -21,6 +21,10 @@ import type {
AnimeBrowserEntry, AnimeBrowserEntry,
AnimeBrowserSource, AnimeBrowserSource,
} from '../types/anime-browser'; } from '../types/anime-browser';
import {
ANIME_BROWSER_CLOSE_MESSAGE,
createAnimeBrowserKeydownMessage,
} from '../shared/anime-browser-embed';
const embeddedInOverlay = const embeddedInOverlay =
new URLSearchParams(window.location.search).get('embedded') === 'overlay-modal'; new URLSearchParams(window.location.search).get('embedded') === 'overlay-modal';
@@ -95,10 +99,21 @@ function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): voi
const detailPanel = createDetailPanel({ api, setStatus }); const detailPanel = createDetailPanel({ api, setStatus });
if (embeddedInOverlay) { if (embeddedInOverlay) {
const overlayOrigin = window.location.origin;
// Keyboard events do not bubble out of an iframe. Forward the physical key
// chord so the overlay can apply the user's configured Anime Browser binding.
document.addEventListener(
'keydown',
(event) => {
if (event.key === 'Escape' && detailPanel.isOpen()) return;
window.parent.postMessage(createAnimeBrowserKeydownMessage(event), overlayOrigin);
},
true,
);
document.addEventListener('keydown', (event) => { document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || event.defaultPrevented || detailPanel.isOpen()) return; if (event.key !== 'Escape' || event.defaultPrevented || detailPanel.isOpen()) return;
event.preventDefault(); event.preventDefault();
window.parent.postMessage('subminer:anime-browser-close', '*'); window.parent.postMessage(ANIME_BROWSER_CLOSE_MESSAGE, overlayOrigin);
}); });
} }
@@ -428,12 +443,25 @@ api.onBridgeState(renderBridgeState);
// The queue changes without this window asking: it advances by itself when an // The queue changes without this window asking: it advances by itself when an
// episode ends, whether or not anyone is looking at the browser. // episode ends, whether or not anyone is looking at the browser.
api.onQueueState((state) => detailPanel.setQueue(state)); api.onQueueState((state) => detailPanel.setQueue(state));
let receivedPlaybackStateEvent = false;
api.onPlaybackState((state) => {
receivedPlaybackStateEvent = true;
detailPanel.setPlaybackState(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 // A queue survives the window being closed and reopened, so start from what
// the main process already holds rather than from empty. // the main process already holds rather than from empty.
void api.getQueue().then((state) => detailPanel.setQueue(state)); void api.getQueue().then((state) => detailPanel.setQueue(state));
void api.getPlaybackState().then(
(state) => {
if (!receivedPlaybackStateEvent) detailPanel.setPlaybackState(state);
},
() => {
// A live playback event can still populate the cue after a failed snapshot request.
},
);
try { try {
const state = await api.ensureBridge(); const state = await api.ensureBridge();
renderBridgeState(state); renderBridgeState(state);
+7 -1
View File
@@ -1,7 +1,11 @@
import { LatestRequest } from './browse-state'; import { LatestRequest } from './browse-state';
import { describe, el } from './dom'; import { describe, el } from './dom';
import { createEpisodeList, type SelectedAnime } from './episode-list'; import { createEpisodeList, type SelectedAnime } from './episode-list';
import type { AnimeBrowserAPI, AnimeBrowserEntry } from '../types/anime-browser'; import type {
AnimeBrowserAPI,
AnimeBrowserEntry,
AnimeBrowserPlaybackState,
} from '../types/anime-browser';
interface DetailPanelOptions { interface DetailPanelOptions {
api: AnimeBrowserAPI; api: AnimeBrowserAPI;
@@ -102,5 +106,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
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. */ /** The queue outlives the open anime, so it is pushed in from outside. */
setQueue: episodeList.setQueue, setQueue: episodeList.setQueue,
setPlaybackState: (state: AnimeBrowserPlaybackState | null) =>
episodeList.setPlaybackState(state),
}; };
} }
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { nextPlaybackCue, playingEpisodeForAnime } from './episode-playback';
const selected = { sourceId: 'source.one', url: '/anime/one', title: 'One' };
test('playing episode sync applies only to the matching source and anime', () => {
assert.equal(
playingEpisodeForAnime(
{ sourceId: 'source.one', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
),
'/episode/3',
);
assert.equal(
playingEpisodeForAnime(
{ sourceId: 'source.two', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
),
null,
);
assert.equal(
playingEpisodeForAnime(
{ sourceId: 'source.one', animeUrl: '/anime/two', episodeUrl: '/episode/3' },
selected,
),
null,
);
assert.equal(playingEpisodeForAnime(null, selected), null);
});
test('live playback sync preserves a pending episode cue until resolution finishes', () => {
const loading = { url: '/episode/4', state: 'loading' as const };
assert.equal(
nextPlaybackCue(
{ sourceId: 'source.one', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
loading,
),
loading,
);
assert.deepEqual(
nextPlaybackCue(
{ sourceId: 'source.one', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
{ url: '/episode/2', state: 'playing' },
),
{ url: '/episode/3', state: 'playing' },
);
});
+33 -8
View File
@@ -5,7 +5,12 @@ import { filterEpisodes } from './episode-filter';
import { describeMarkCount, episodesInScope } from './episode-marks'; import { describeMarkCount, episodesInScope } from './episode-marks';
import { describeQueuePosition } from './episode-queue'; import { describeQueuePosition } from './episode-queue';
import { createEpisodeQueueControls } from './episode-queue-controls'; import { createEpisodeQueueControls } from './episode-queue-controls';
import type { AnimeBrowserAPI, AnimeBrowserEpisode } from '../types/anime-browser'; import { nextPlaybackCue, type EpisodePlaybackCue } from './episode-playback';
import type {
AnimeBrowserAPI,
AnimeBrowserEpisode,
AnimeBrowserPlaybackState,
} from '../types/anime-browser';
export interface SelectedAnime { export interface SelectedAnime {
url: string; url: string;
@@ -47,7 +52,8 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
* Which episode is resolving or playing. Kept here rather than only on the * Which episode is resolving or playing. Kept here rather than only on the
* button, so filtering mid-playback repaints the cue instead of dropping it. * button, so filtering mid-playback repaints the cue instead of dropping it.
*/ */
let cueState: { url: string; state: 'loading' | 'playing' } | null = null; let cueState: EpisodePlaybackCue | null = null;
let activePlayback: AnimeBrowserPlaybackState | null = null;
const watchStateRequests = new LatestRequest(); const watchStateRequests = new LatestRequest();
/** /**
* Mark writes carry their own token: a background refresh starting mid-write * Mark writes carry their own token: a background refresh starting mid-write
@@ -69,6 +75,11 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
} }
} }
function syncPlaybackCue(): void {
cueState = nextPlaybackCue(activePlayback, selectedAnime(), cueState);
applyCueState();
}
const queue = createEpisodeQueueControls({ const queue = createEpisodeQueueControls({
api, api,
setStatus, setStatus,
@@ -79,11 +90,12 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
// The queue started this one, so the cue this window was holding belongs // 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 // to an episode that has finished. The new one only earns the cue when it
// is an episode of the anime on screen. // is an episode of the anime on screen.
const anime = selectedAnime(); activePlayback = {
const mine = sourceId: entry.sourceId,
anime !== null && anime.sourceId === entry.sourceId && anime.url === entry.animeUrl; animeUrl: entry.animeUrl,
cueState = mine ? { url: entry.episodeUrl, state: 'playing' } : null; episodeUrl: entry.episodeUrl,
applyCueState(); };
syncPlaybackCue();
setStatus(`Queue started ${entry.episodeName}.`, 'ok'); setStatus(`Queue started ${entry.episodeName}.`, 'ok');
}, },
}); });
@@ -385,6 +397,7 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
displayIndex: list.length - index, displayIndex: list.length - index,
name: episode.name, name: episode.name,
})); }));
syncPlaybackCue();
paint(); paint();
void refreshWatchState(); void refreshWatchState();
} }
@@ -412,5 +425,17 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
}); });
window.addEventListener('focus', () => void refreshWatchState()); window.addEventListener('focus', () => void refreshWatchState());
return { render, clear, refreshWatchState, setQueue: queue.setState }; function setPlaybackState(state: AnimeBrowserPlaybackState | null): void {
activePlayback = state;
syncPlaybackCue();
void refreshWatchState();
}
return {
render,
clear,
refreshWatchState,
setQueue: queue.setState,
setPlaybackState,
};
} }
+36
View File
@@ -0,0 +1,36 @@
import type { AnimeBrowserPlaybackState } from '../types/anime-browser';
interface AnimeIdentity {
sourceId: string;
url: string;
}
export interface EpisodePlaybackCue {
url: string;
state: 'loading' | 'playing';
}
export function playingEpisodeForAnime(
playback: AnimeBrowserPlaybackState | null,
anime: AnimeIdentity | null,
): string | null {
if (
!playback ||
!anime ||
anime.sourceId !== playback.sourceId ||
anime.url !== playback.animeUrl
) {
return null;
}
return playback.episodeUrl;
}
export function nextPlaybackCue(
playback: AnimeBrowserPlaybackState | null,
anime: AnimeIdentity | null,
currentCue: EpisodePlaybackCue | null,
): EpisodePlaybackCue | null {
if (currentCue?.state === 'loading') return currentCue;
const episodeUrl = playingEpisodeForAnime(playback, anime);
return episodeUrl ? { url: episodeUrl, state: 'playing' } : null;
}
+15
View File
@@ -491,6 +491,7 @@ import { createMediaRuntimeService } from './main/media-runtime';
import { import {
createStreamPlaybackMetadataStore, createStreamPlaybackMetadataStore,
matchRequestedStreamPlaybackMetadata, matchRequestedStreamPlaybackMetadata,
toAnimeBrowserPlaybackState,
toAnilistMediaGuess, toAnilistMediaGuess,
toJimakuMediaInfo, toJimakuMediaInfo,
} from './main/runtime/stream-playback-metadata'; } from './main/runtime/stream-playback-metadata';
@@ -2468,6 +2469,7 @@ const mediaRuntime = createMediaRuntimeService(
getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR, getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR,
setCurrentMediaPath: (nextPath: string | null) => { setCurrentMediaPath: (nextPath: string | null) => {
appState.currentMediaPath = nextPath; appState.currentMediaPath = nextPath;
publishAnimeBrowserPlaybackState(nextPath);
}, },
clearPendingSubtitlePosition: () => { clearPendingSubtitlePosition: () => {
appState.pendingSubtitlePosition = null; appState.pendingSubtitlePosition = null;
@@ -2842,6 +2844,7 @@ function createOverlayHostedModalOpenDeps(): {
}, },
) => boolean; ) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
isModalOpen: (modal: OverlayHostedModal) => boolean;
logWarn: (message: string) => void; logWarn: (message: string) => void;
} { } {
return { return {
@@ -2851,6 +2854,7 @@ function createOverlayHostedModalOpenDeps(): {
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
overlayVisibilityComposer.sendToActiveOverlayWindow(channel, payload, runtimeOptions), overlayVisibilityComposer.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
isModalOpen: (modal) => overlayModalRuntime.isModalOpen(modal),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
}; };
} }
@@ -3326,6 +3330,7 @@ const {
const DEFAULT_ANIME_EXTENSIONS_DIR = path.join(USER_DATA_PATH, 'anime-extensions'); const DEFAULT_ANIME_EXTENSIONS_DIR = path.join(USER_DATA_PATH, 'anime-extensions');
const animeBrowserSessions = new Map<string, AnimeBrowserIpcSender>(); const animeBrowserSessions = new Map<string, AnimeBrowserIpcSender>();
let animeBrowserPlaybackState = toAnimeBrowserPlaybackState(getActiveStreamMetadata());
function broadcastAnimeBrowserEvent(channel: string, payload: unknown): void { function broadcastAnimeBrowserEvent(channel: string, payload: unknown): void {
const targets = new Set<AnimeBrowserIpcSender>(); const targets = new Set<AnimeBrowserIpcSender>();
@@ -3337,6 +3342,14 @@ function broadcastAnimeBrowserEvent(channel: string, payload: unknown): void {
for (const sender of targets) sender.send(channel, payload); for (const sender of targets) sender.send(channel, payload);
} }
function publishAnimeBrowserPlaybackState(mediaPath: string | null): void {
animeBrowserPlaybackState = toAnimeBrowserPlaybackState(streamPlaybackMetadata.match(mediaPath));
broadcastAnimeBrowserEvent(
IPC_CHANNELS.event.animeBrowserPlaybackState,
animeBrowserPlaybackState,
);
}
function resolveAnimeExtensionsDir(): string { function resolveAnimeExtensionsDir(): string {
const configured = configService.getConfig().anime?.extensionsDir?.trim(); const configured = configService.getConfig().anime?.extensionsDir?.trim();
return configured && configured.length > 0 ? configured : DEFAULT_ANIME_EXTENSIONS_DIR; return configured && configured.length > 0 ? configured : DEFAULT_ANIME_EXTENSIONS_DIR;
@@ -3396,6 +3409,7 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
}, },
onPlaybackMetadata: (metadata) => { onPlaybackMetadata: (metadata) => {
streamPlaybackMetadata.set(metadata); streamPlaybackMetadata.set(metadata);
publishAnimeBrowserPlaybackState(metadata.mediaPath);
// Set before mpv reports the path change, so the session that change starts // Set before mpv reports the path change, so the session that change starts
// is titled and grouped from the source's own listing rather than from the // is titled and grouped from the source's own listing rather than from the
// proxy URL, whose only readable part is the `.m3u8` extension. // proxy URL, whose only readable part is the `.m3u8` extension.
@@ -3442,6 +3456,7 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
registerAnimeBrowserIpcHandlers({ registerAnimeBrowserIpcHandlers({
ipcMain, ipcMain,
runtime: animeBrowserRuntime, runtime: animeBrowserRuntime,
getPlaybackState: () => animeBrowserPlaybackState,
registerSession: (sessionId, sender) => { registerSession: (sessionId, sender) => {
if (animeBrowserSessions.get(sessionId) === sender) return; if (animeBrowserSessions.get(sessionId) === sender) return;
animeBrowserSessions.set(sessionId, sender); animeBrowserSessions.set(sessionId, sender);
+31
View File
@@ -330,6 +330,37 @@ for (const platform of ['darwin', 'win32'] as const) {
}); });
} }
test('anime browser modal keeps its document warm across close on Linux', () => {
const modalWindow = createMockWindow();
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{ platform: 'linux' },
);
runtime.sendToActiveOverlayWindow('anime-browser:open', undefined, {
restoreOnModalClose: 'anime-browser',
preferModalWindow: true,
});
runtime.notifyOverlayModalOpened('anime-browser');
runtime.handleOverlayModalClosed('anime-browser');
assert.equal(modalWindow.isDestroyed(), false);
assert.equal(modalWindow.isVisible(), false);
assert.equal(runtime.isModalOpen('anime-browser'), false);
runtime.sendToActiveOverlayWindow('anime-browser:open', undefined, {
restoreOnModalClose: 'anime-browser',
preferModalWindow: true,
});
assert.equal(modalWindow.isVisible(), true);
});
test('primeModalWindow leaves Linux modal creation lazy', () => { test('primeModalWindow leaves Linux modal creation lazy', () => {
let createCalls = 0; let createCalls = 0;
const runtime = createOverlayModalRuntimeService( const runtime = createOverlayModalRuntimeService(
+6 -1
View File
@@ -56,6 +56,7 @@ export interface OverlayModalRuntime {
openTsukihime: () => void; openTsukihime: () => void;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void; handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void; notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
isModalOpen: (modal: OverlayHostedModal) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>; getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
} }
@@ -84,10 +85,12 @@ export function createOverlayModalRuntimeService(
let modalWindowPrimedForImmediateShow = false; let modalWindowPrimedForImmediateShow = false;
let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null; let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
let retainModalWindowState = false;
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>(); const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
const modalWindowPrimeListenersRegistered = new WeakSet<BrowserWindow>(); const modalWindowPrimeListenersRegistered = new WeakSet<BrowserWindow>();
const platform = options.platform ?? process.platform; const platform = options.platform ?? process.platform;
const keepModalWindowWarm = platform === 'darwin' || platform === 'win32'; const keepModalWindowWarm = platform === 'darwin' || platform === 'win32';
const shouldKeepModalWindowWarm = (): boolean => keepModalWindowWarm || retainModalWindowState;
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus; const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle => const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs); (options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
@@ -515,7 +518,7 @@ export function createOverlayModalRuntimeService(
if (restoreVisibleOverlayOnModalClose.size === 0) { if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal(); clearPendingModalWindowReveal();
if (modalWindow && !modalWindow.isDestroyed()) { if (modalWindow && !modalWindow.isDestroyed()) {
if (keepModalWindowWarm) { if (shouldKeepModalWindowWarm()) {
modalWindow.setIgnoreMouseEvents(true, { forward: true }); modalWindow.setIgnoreMouseEvents(true, { forward: true });
modalWindow.hide(); modalWindow.hide();
markModalWindowPrimed(modalWindow); markModalWindowPrimed(modalWindow);
@@ -538,6 +541,7 @@ export function createOverlayModalRuntimeService(
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => { const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return; if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
if (modal === 'anime-browser') retainModalWindowState = true;
openedModals.add(modal); openedModals.add(modal);
const waiters = modalOpenWaiters.get(modal) ?? []; const waiters = modalOpenWaiters.get(modal) ?? [];
modalOpenWaiters.delete(modal); modalOpenWaiters.delete(modal);
@@ -595,6 +599,7 @@ export function createOverlayModalRuntimeService(
openTsukihime, openTsukihime,
handleOverlayModalClosed, handleOverlayModalClosed,
notifyOverlayModalOpened, notifyOverlayModalOpened,
isModalOpen: (modal) => openedModals.has(modal),
waitForModalOpen, waitForModalOpen,
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
}; };
@@ -12,6 +12,7 @@ export interface AnimeBrowserIpcDeps {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown; handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
}; };
runtime: AnimeBrowserRuntime; runtime: AnimeBrowserRuntime;
getPlaybackState?: () => unknown;
registerSession?: (sessionId: string, sender: AnimeBrowserIpcSender) => void; registerSession?: (sessionId: string, sender: AnimeBrowserIpcSender) => void;
} }
@@ -97,6 +98,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
); );
handle(channels.animeBrowserClearQueue, () => runtime.clearQueue()); handle(channels.animeBrowserClearQueue, () => runtime.clearQueue());
handle(channels.animeBrowserGetQueue, () => runtime.getQueue()); handle(channels.animeBrowserGetQueue, () => runtime.getQueue());
handle(channels.animeBrowserGetPlaybackState, () => deps.getPlaybackState?.() ?? null);
handle(channels.animeBrowserIsPlaying, () => runtime.isPlaying()); handle(channels.animeBrowserIsPlaying, () => runtime.isPlaying());
handle(channels.animeBrowserGetPreferences, (_event, sourceId) => handle(channels.animeBrowserGetPreferences, (_event, sourceId) =>
runtime.getPreferences(String(sourceId)), runtime.getPreferences(String(sourceId)),
@@ -19,6 +19,7 @@ test('anime browser open uses a dedicated player-bounded modal window', async ()
return true; return true;
}, },
waitForModalOpen: async () => true, waitForModalOpen: async () => true,
isModalOpen: () => false,
logWarn: () => {}, logWarn: () => {},
}); });
@@ -36,9 +37,35 @@ test('anime browser open retries on a fresh modal window after a missed acknowle
attempts += 1; attempts += 1;
return attempts === 2; return attempts === 2;
}, },
isModalOpen: () => false,
logWarn: () => {}, logWarn: () => {},
}); });
assert.equal(opened, true); assert.equal(opened, true);
assert.equal(attempts, 2); assert.equal(attempts, 2);
}); });
test('anime browser shortcut closes the open modal without waiting for another open', async () => {
const calls: string[] = [];
const toggled = await openAnimeBrowserModal({
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
ensureOverlayWindowsReadyForVisibilityActions: () => calls.push('windows'),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
calls.push(channel);
assert.equal(payload, undefined);
assert.deepEqual(runtimeOptions, {
restoreOnModalClose: 'anime-browser',
preferModalWindow: true,
});
return true;
},
waitForModalOpen: async () => {
assert.fail('closing must not wait for an open acknowledgement');
},
isModalOpen: (modal) => modal === 'anime-browser',
logWarn: () => {},
});
assert.equal(toggled, true);
assert.deepEqual(calls, [IPC_CHANNELS.event.animeBrowserClose]);
});
+8
View File
@@ -17,8 +17,16 @@ export async function openAnimeBrowserModal(deps: {
}, },
) => boolean; ) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
isModalOpen: (modal: OverlayHostedModal) => boolean;
logWarn: (message: string) => void; logWarn: (message: string) => void;
}): Promise<boolean> { }): Promise<boolean> {
if (deps.isModalOpen(ANIME_BROWSER_MODAL)) {
return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.animeBrowserClose, undefined, {
restoreOnModalClose: ANIME_BROWSER_MODAL,
preferModalWindow: true,
});
}
return await retryOverlayModalOpen( return await retryOverlayModalOpen(
{ {
waitForModalOpen: deps.waitForModalOpen, waitForModalOpen: deps.waitForModalOpen,
@@ -25,6 +25,9 @@ function prepared(input: AnimeBrowserPlayRequest): PreparedAnimeBrowserPlayback
request: input, request: input,
stream: { url: mediaPath, quality: '1080p', headers: {}, audios: [], subtitles: [] }, stream: { url: mediaPath, quality: '1080p', headers: {}, audios: [], subtitles: [] },
metadata: { metadata: {
sourceId: input.sourceId,
animeUrl: input.animeUrl,
episodeUrl: input.episodeUrl,
mediaPath, mediaPath,
statsPath: `animebrowser://${encodeURIComponent(input.episodeUrl)}`, statsPath: `animebrowser://${encodeURIComponent(input.episodeUrl)}`,
seriesTitle: input.animeTitle, seriesTitle: input.animeTitle,
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { import {
createStreamPlaybackMetadataStore, createStreamPlaybackMetadataStore,
matchRequestedStreamPlaybackMetadata, matchRequestedStreamPlaybackMetadata,
toAnimeBrowserPlaybackState,
toAnilistMediaGuess, toAnilistMediaGuess,
toJimakuMediaInfo, toJimakuMediaInfo,
} from './stream-playback-metadata'; } from './stream-playback-metadata';
@@ -10,6 +11,9 @@ import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
function metadata(overrides: Partial<AnimeStreamMetadata> = {}): AnimeStreamMetadata { function metadata(overrides: Partial<AnimeStreamMetadata> = {}): AnimeStreamMetadata {
return { return {
sourceId: '9001',
animeUrl: '/anime/mushoku',
episodeUrl: '/watch/ep-4',
mediaPath: 'http://127.0.0.1:41234/video/abc.m3u8', mediaPath: 'http://127.0.0.1:41234/video/abc.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4', statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation', seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
@@ -21,6 +25,15 @@ function metadata(overrides: Partial<AnimeStreamMetadata> = {}): AnimeStreamMeta
}; };
} }
test('browser playback state keeps the source episode identity and drops other media', () => {
assert.deepEqual(toAnimeBrowserPlaybackState(metadata()), {
sourceId: '9001',
animeUrl: '/anime/mushoku',
episodeUrl: '/watch/ep-4',
});
assert.equal(toAnimeBrowserPlaybackState(null), null);
});
test('the store answers for the stream URL and for the stats path', () => { test('the store answers for the stream URL and for the stats path', () => {
const store = createStreamPlaybackMetadataStore(); const store = createStreamPlaybackMetadataStore();
const current = metadata(); const current = metadata();
@@ -1,6 +1,7 @@
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata'; import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import type { JimakuMediaInfo } from '../../types'; import type { JimakuMediaInfo } from '../../types';
import type { AnimeBrowserPlaybackState } from '../../types/anime-browser';
/** /**
* Holds what the anime browser resolved for active and queued streams. * Holds what the anime browser resolved for active and queued streams.
@@ -53,6 +54,17 @@ export function matchRequestedStreamPlaybackMetadata(
return store.match(requestedMediaPath ?? currentMediaPath); return store.match(requestedMediaPath ?? currentMediaPath);
} }
export function toAnimeBrowserPlaybackState(
metadata: AnimeStreamMetadata | null,
): AnimeBrowserPlaybackState | null {
if (!metadata) return null;
return {
sourceId: metadata.sourceId,
animeUrl: metadata.animeUrl,
episodeUrl: metadata.episodeUrl,
};
}
/** AniList counts whole episodes, so a special numbered 6.5 cannot drive it. */ /** AniList counts whole episodes, so a special numbered 6.5 cannot drive it. */
function wholeEpisode(episode: number | null): number | null { function wholeEpisode(episode: number | null): number | null {
return typeof episode === 'number' && Number.isInteger(episode) && episode > 0 ? episode : null; return typeof episode === 'number' && Number.isInteger(episode) && episode > 0 ? episode : null;
+5
View File
@@ -29,6 +29,7 @@ test('anime browser API keeps one opaque session per renderer bridge', async ()
await firstApi.search('frieren', 2); await firstApi.search('frieren', 2);
await firstApi.getDetails('/frieren'); await firstApi.getDetails('/frieren');
await firstApi.getEpisodes('/frieren', 'source.one'); await firstApi.getEpisodes('/frieren', 'source.one');
await firstApi.getPlaybackState();
await secondApi.getPopular(3); await secondApi.getPopular(3);
const firstSessionId = first.calls[0]?.args[0]; const firstSessionId = first.calls[0]?.args[0];
@@ -56,6 +57,10 @@ test('anime browser API keeps one opaque session per renderer bridge', async ()
channel: IPC_CHANNELS.request.animeBrowserGetEpisodes, channel: IPC_CHANNELS.request.animeBrowserGetEpisodes,
args: [firstSessionId, '/frieren', 'source.one'], args: [firstSessionId, '/frieren', 'source.one'],
}, },
{
channel: IPC_CHANNELS.request.animeBrowserGetPlaybackState,
args: [],
},
]); ]);
assert.deepEqual(second.calls, [ assert.deepEqual(second.calls, [
{ {
+12
View File
@@ -8,6 +8,7 @@ import type {
AnimeBrowserEpisodeWatchState, AnimeBrowserEpisodeWatchState,
AnimeBrowserPlayRequest, AnimeBrowserPlayRequest,
AnimeBrowserPlayResult, AnimeBrowserPlayResult,
AnimeBrowserPlaybackState,
AnimeBrowserQueueState, AnimeBrowserQueueState,
AnimeBrowserSearchResult, AnimeBrowserSearchResult,
AnimeBrowserSearchUpdate, AnimeBrowserSearchUpdate,
@@ -67,6 +68,8 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
ipcRenderer.invoke(request.animeBrowserClearQueue), ipcRenderer.invoke(request.animeBrowserClearQueue),
getQueue: (): Promise<AnimeBrowserQueueState> => getQueue: (): Promise<AnimeBrowserQueueState> =>
ipcRenderer.invoke(request.animeBrowserGetQueue), ipcRenderer.invoke(request.animeBrowserGetQueue),
getPlaybackState: (): Promise<AnimeBrowserPlaybackState | null> =>
ipcRenderer.invoke(request.animeBrowserGetPlaybackState),
isPlaying: (): Promise<boolean> => ipcRenderer.invoke(request.animeBrowserIsPlaying), 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),
@@ -101,5 +104,14 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserQueueState, handler); ipcRenderer.on(IPC_CHANNELS.event.animeBrowserQueueState, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserQueueState, handler); return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserQueueState, handler);
}, },
onPlaybackState: (
listener: (state: AnimeBrowserPlaybackState | null) => void,
): (() => void) => {
const handler = (_event: unknown, state: AnimeBrowserPlaybackState | null): void =>
listener(state);
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserPlaybackState, handler);
return () =>
ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserPlaybackState, handler);
},
}; };
} }
+2
View File
@@ -170,6 +170,7 @@ const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.run
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen); const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen); const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
const onOpenAnimeBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.animeBrowserOpen); const onOpenAnimeBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.animeBrowserOpen);
const onCloseAnimeBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.animeBrowserClose);
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener( const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryManagerOpen, IPC_CHANNELS.event.characterDictionaryManagerOpen,
); );
@@ -454,6 +455,7 @@ const electronAPI: ElectronAPI = {
onOpenSessionHelp: onOpenSessionHelpEvent, onOpenSessionHelp: onOpenSessionHelpEvent,
onOpenChangelog: onOpenChangelogEvent, onOpenChangelog: onOpenChangelogEvent,
onOpenAnimeBrowser: onOpenAnimeBrowserEvent, onOpenAnimeBrowser: onOpenAnimeBrowserEvent,
onCloseAnimeBrowser: onCloseAnimeBrowserEvent,
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> => getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options), ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
onOpenControllerSelect: onOpenControllerSelectEvent, onOpenControllerSelect: onOpenControllerSelectEvent,
+22
View File
@@ -53,3 +53,25 @@ test('modal registry dismisses every open descriptor and skips closed descriptor
assert.deepEqual(closed, ['first-open', 'second-open']); assert.deepEqual(closed, ['first-open', 'second-open']);
}); });
test('modal registry can dismiss stale modals while preserving the requested one', () => {
const openIds = new Set(['anime-browser', 'subtitle-sidebar']);
const closed: string[] = [];
const descriptors: ModalDescriptor<string>[] = ['anime-browser', 'subtitle-sidebar'].map(
(id) => ({
id,
isOpen: () => openIds.has(id),
close: () => {
openIds.delete(id);
closed.push(id);
},
suppressesSubtitles: true,
}),
);
const registry = createModalRegistry(descriptors);
registry.dismissOpenExcept('anime-browser');
assert.deepEqual(closed, ['subtitle-sidebar']);
assert.equal(openIds.has('anime-browser'), true);
});
+7
View File
@@ -20,5 +20,12 @@ export function createModalRegistry<TId extends string>(
} }
} }
}, },
dismissOpenExcept: (id: TId): void => {
for (const descriptor of descriptors) {
if (descriptor.id !== id && descriptor.isOpen()) {
descriptor.close();
}
}
},
}; };
} }
+107 -6
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createAnimeBrowserKeydownMessage } from '../../shared/anime-browser-embed';
import { createAnimeBrowserModal } from './anime-browser'; import { createAnimeBrowserModal } from './anime-browser';
function createClassList(initial: string[] = []) { function createClassList(initial: string[] = []) {
@@ -15,6 +16,8 @@ test('embedded Anime Browser closes only for its own close message', () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const messages: Array<(event: MessageEvent) => void> = []; const messages: Array<(event: MessageEvent) => void> = [];
const notifications: string[] = []; const notifications: string[] = [];
let staleModalOpen = true;
let dismissOtherModalCalls = 0;
const frameWindow = {}; const frameWindow = {};
const closeListeners: Array<() => void> = []; const closeListeners: Array<() => void> = [];
const removedCloseListeners: Array<() => void> = []; const removedCloseListeners: Array<() => void> = [];
@@ -39,6 +42,7 @@ test('embedded Anime Browser closes only for its own close message', () => {
notifyOverlayModalOpened: (name: string) => notifications.push(`open:${name}`), notifyOverlayModalOpened: (name: string) => notifications.push(`open:${name}`),
notifyOverlayModalClosed: (name: string) => notifications.push(`close:${name}`), notifyOverlayModalClosed: (name: string) => notifications.push(`close:${name}`),
}, },
location: { origin: 'file://', protocol: 'file:' },
addEventListener: (name: string, listener: (event: MessageEvent) => void) => { addEventListener: (name: string, listener: (event: MessageEvent) => void) => {
if (name === 'message') messages.push(listener); if (name === 'message') messages.push(listener);
}, },
@@ -47,7 +51,22 @@ test('embedded Anime Browser closes only for its own close message', () => {
}); });
try { try {
const state = { animeBrowserModalOpen: false, isOverSubtitle: false }; const state = {
animeBrowserModalOpen: false,
isOverSubtitle: false,
sessionBindingMap: new Map([
[
'Ctrl+Alt+KeyA',
{
actionType: 'session-action',
actionId: 'openAnimeBrowser',
sourcePath: 'animeBrowser.toggleKey',
originalKey: 'Ctrl+Alt+A',
key: { code: 'KeyA', modifiers: ['ctrl', 'alt'] },
},
],
]),
};
const modal = createAnimeBrowserModal( const modal = createAnimeBrowserModal(
{ {
state, state,
@@ -64,7 +83,13 @@ test('embedded Anime Browser closes only for its own close message', () => {
}, },
} as never, } as never,
{ {
modalStateReader: { isAnyModalOpen: () => state.animeBrowserModalOpen }, modalStateReader: {
isAnyModalOpen: () => state.animeBrowserModalOpen || staleModalOpen,
},
dismissOtherModals: () => {
dismissOtherModalCalls += 1;
staleModalOpen = false;
},
syncSettingsModalSubtitleSuppression: () => undefined, syncSettingsModalSubtitleSuppression: () => undefined,
}, },
); );
@@ -72,17 +97,93 @@ test('embedded Anime Browser closes only for its own close message', () => {
modal.wireDomEvents(); modal.wireDomEvents();
modal.openAnimeBrowserModal(); modal.openAnimeBrowserModal();
assert.equal(state.animeBrowserModalOpen, true); assert.equal(state.animeBrowserModalOpen, true);
assert.equal(dismissOtherModalCalls, 1, 'open clears stale retained-window modal state');
assert.equal(frame.src, frame.dataset.src); assert.equal(frame.src, frame.dataset.src);
assert.deepEqual(notifications, ['open:anime-browser']); assert.deepEqual(notifications, ['open:anime-browser']);
messages[0]?.({ source: {}, data: 'subminer:anime-browser-close' } as MessageEvent); messages[0]?.({
messages[0]?.({ source: frameWindow, data: 'not-the-close-message' } as MessageEvent); origin: 'null',
source: {},
data: 'subminer:anime-browser-close',
} as MessageEvent);
messages[0]?.({
origin: 'https://example.com',
source: frameWindow,
data: 'subminer:anime-browser-close',
} as MessageEvent);
messages[0]?.({
origin: 'null',
source: frameWindow,
data: 'not-the-close-message',
} as MessageEvent);
assert.equal(state.animeBrowserModalOpen, true); assert.equal(state.animeBrowserModalOpen, true);
messages[0]?.({ source: frameWindow, data: 'subminer:anime-browser-close' } as MessageEvent); messages[0]?.({
origin: 'null',
source: frameWindow,
data: createAnimeBrowserKeydownMessage({
code: 'KeyB',
ctrlKey: true,
altKey: true,
shiftKey: false,
metaKey: false,
repeat: false,
}),
} as MessageEvent);
assert.equal(state.animeBrowserModalOpen, true, 'unrelated binding leaves modal open');
messages[0]?.({
origin: 'null',
source: frameWindow,
data: createAnimeBrowserKeydownMessage({
code: 'KeyA',
ctrlKey: true,
altKey: true,
shiftKey: false,
metaKey: false,
repeat: false,
}),
} as MessageEvent);
assert.equal(state.animeBrowserModalOpen, false, 'configured binding toggles modal closed');
assert.deepEqual(notifications, ['open:anime-browser', 'close:anime-browser']);
modal.openAnimeBrowserModal();
messages[0]?.({
origin: 'null',
source: frameWindow,
data: 'subminer:anime-browser-close',
} as MessageEvent);
assert.equal(state.animeBrowserModalOpen, false); assert.equal(state.animeBrowserModalOpen, false);
assert.equal(modalElement.classList.contains('hidden'), true); assert.equal(modalElement.classList.contains('hidden'), true);
assert.deepEqual(notifications, ['open:anime-browser', 'close:anime-browser']); assert.deepEqual(notifications, [
'open:anime-browser',
'close:anime-browser',
'open:anime-browser',
'close:anime-browser',
]);
modal.openAnimeBrowserModal();
assert.equal(state.animeBrowserModalOpen, true);
assert.equal(modalElement.classList.contains('hidden'), false);
assert.equal(frame.src, frame.dataset.src, 'reopen keeps the existing iframe document');
assert.deepEqual(notifications, [
'open:anime-browser',
'close:anime-browser',
'open:anime-browser',
'close:anime-browser',
'open:anime-browser',
]);
const dismissCallsBeforeRepair = dismissOtherModalCalls;
modalElement.classList.add('hidden');
modal.openAnimeBrowserModal();
assert.equal(
modalElement.classList.contains('hidden'),
false,
'repeated open repairs stale retained-window DOM state',
);
assert.equal(dismissOtherModalCalls, dismissCallsBeforeRepair);
assert.equal(notifications.at(-1), 'open:anime-browser');
modal.disposeDomEvents(); modal.disposeDomEvents();
assert.deepEqual(removedCloseListeners, closeListeners); assert.deepEqual(removedCloseListeners, closeListeners);
} finally { } finally {
+27 -5
View File
@@ -1,11 +1,14 @@
import type { ModalStateReader, RendererContext } from '../context'; import type { ModalStateReader, RendererContext } from '../context';
import {
const CLOSE_MESSAGE = 'subminer:anime-browser-close'; ANIME_BROWSER_CLOSE_MESSAGE,
isAnimeBrowserKeydownMessage,
} from '../../shared/anime-browser-embed';
export function createAnimeBrowserModal( export function createAnimeBrowserModal(
ctx: RendererContext, ctx: RendererContext,
options: { options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>; modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
dismissOtherModals: () => void;
syncSettingsModalSubtitleSuppression: () => void; syncSettingsModalSubtitleSuppression: () => void;
}, },
) { ) {
@@ -16,8 +19,11 @@ export function createAnimeBrowserModal(
ctx.dom.animeBrowserFrame.src = source; ctx.dom.animeBrowserFrame.src = source;
} }
function openAnimeBrowserModal(): void { function openAnimeBrowserModal(): boolean {
if (ctx.state.animeBrowserModalOpen || options.modalStateReader.isAnyModalOpen()) return; if (!ctx.state.animeBrowserModalOpen) {
options.dismissOtherModals();
if (options.modalStateReader.isAnyModalOpen()) return false;
}
ctx.state.animeBrowserModalOpen = true; ctx.state.animeBrowserModalOpen = true;
options.syncSettingsModalSubtitleSuppression(); options.syncSettingsModalSubtitleSuppression();
@@ -26,6 +32,7 @@ export function createAnimeBrowserModal(
ctx.dom.animeBrowserModal.setAttribute('aria-hidden', 'false'); ctx.dom.animeBrowserModal.setAttribute('aria-hidden', 'false');
ensureFrameLoaded(); ensureFrameLoaded();
window.electronAPI.notifyOverlayModalOpened('anime-browser'); window.electronAPI.notifyOverlayModalOpened('anime-browser');
return true;
} }
function closeAnimeBrowserModal(): void { function closeAnimeBrowserModal(): void {
@@ -48,7 +55,22 @@ export function createAnimeBrowserModal(
} }
function handleFrameMessage(event: MessageEvent): void { function handleFrameMessage(event: MessageEvent): void {
if (event.source === ctx.dom.animeBrowserFrame.contentWindow && event.data === CLOSE_MESSAGE) { // Chromium serializes file URL message origins as "null" even though
// Location.origin reports "file://" for these bundled pages.
const expectedOrigin = window.location.protocol === 'file:' ? 'null' : window.location.origin;
if (
event.origin !== expectedOrigin ||
event.source !== ctx.dom.animeBrowserFrame.contentWindow
) {
return;
}
if (event.data === ANIME_BROWSER_CLOSE_MESSAGE) {
closeAnimeBrowserModal();
return;
}
if (!isAnimeBrowserKeydownMessage(event.data) || event.data.repeat) return;
const binding = ctx.state.sessionBindingMap.get(event.data.bindingKey);
if (binding?.actionType === 'session-action' && binding.actionId === 'openAnimeBrowser') {
closeAnimeBrowserModal(); closeAnimeBrowserModal();
} }
} }
+2 -2
View File
@@ -106,7 +106,7 @@ function describeCommand(command: (string | number)[]): string {
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku'; if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN) return 'Open TsukiHime'; if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN) return 'Open TsukiHime';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser'; if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.ANIME_BROWSER_OPEN) return 'Open Anime Browser'; if (first === SPECIAL_COMMANDS.ANIME_BROWSER_OPEN) return 'Toggle Anime Browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle'; if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
@@ -232,7 +232,7 @@ function describeSessionAction(
case 'openPlaylistBrowser': case 'openPlaylistBrowser':
return 'Open playlist browser'; return 'Open playlist browser';
case 'openAnimeBrowser': case 'openAnimeBrowser':
return 'Open Anime Browser'; return 'Toggle Anime Browser';
case 'replayCurrentSubtitle': case 'replayCurrentSubtitle':
return 'Replay current subtitle'; return 'Replay current subtitle';
case 'playNextSubtitle': case 'playNextSubtitle':
+6
View File
@@ -274,6 +274,7 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
}); });
const animeBrowserModal = createAnimeBrowserModal(ctx, { const animeBrowserModal = createAnimeBrowserModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
dismissOtherModals: () => modalRegistry.dismissOpenExcept('anime-browser'),
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const keyboardHandlers = createKeyboardHandlers(ctx, { const keyboardHandlers = createKeyboardHandlers(ctx, {
@@ -596,6 +597,11 @@ function registerModalOpenHandlers(): void {
animeBrowserModal.openAnimeBrowserModal(); animeBrowserModal.openAnimeBrowserModal();
}); });
}); });
window.electronAPI.onCloseAnimeBrowser(() => {
runGuarded('anime-browser:close', () => {
animeBrowserModal.closeAnimeBrowserModal();
});
});
window.electronAPI.onCancelYoutubeTrackPicker(() => { window.electronAPI.onCancelYoutubeTrackPicker(() => {
runGuarded('youtube:picker-cancel', () => { runGuarded('youtube:picker-cancel', () => {
youtubePickerModal.closeYoutubePickerModal(); youtubePickerModal.closeYoutubePickerModal();
+36
View File
@@ -0,0 +1,36 @@
export const ANIME_BROWSER_CLOSE_MESSAGE = 'subminer:anime-browser-close';
const ANIME_BROWSER_KEYDOWN_MESSAGE = 'subminer:anime-browser-keydown';
export interface AnimeBrowserKeydownMessage {
type: typeof ANIME_BROWSER_KEYDOWN_MESSAGE;
bindingKey: string;
repeat: boolean;
}
export function createAnimeBrowserKeydownMessage(
event: Pick<KeyboardEvent, 'altKey' | 'code' | 'ctrlKey' | 'metaKey' | 'repeat' | 'shiftKey'>,
): AnimeBrowserKeydownMessage {
const parts: string[] = [];
if (event.ctrlKey) parts.push('Ctrl');
if (event.altKey) parts.push('Alt');
if (event.shiftKey) parts.push('Shift');
if (event.metaKey) parts.push('Meta');
parts.push(event.code);
return {
type: ANIME_BROWSER_KEYDOWN_MESSAGE,
bindingKey: parts.join('+'),
repeat: event.repeat,
};
}
export function isAnimeBrowserKeydownMessage(value: unknown): value is AnimeBrowserKeydownMessage {
if (!value || typeof value !== 'object') return false;
const message = value as Partial<AnimeBrowserKeydownMessage>;
return (
message.type === ANIME_BROWSER_KEYDOWN_MESSAGE &&
typeof message.bindingKey === 'string' &&
message.bindingKey.length > 0 &&
typeof message.repeat === 'boolean'
);
}
+3
View File
@@ -140,6 +140,7 @@ export const IPC_CHANNELS = {
animeBrowserDequeueEpisode: 'anime-browser:dequeue-episode', animeBrowserDequeueEpisode: 'anime-browser:dequeue-episode',
animeBrowserClearQueue: 'anime-browser:clear-queue', animeBrowserClearQueue: 'anime-browser:clear-queue',
animeBrowserGetQueue: 'anime-browser:get-queue', animeBrowserGetQueue: 'anime-browser:get-queue',
animeBrowserGetPlaybackState: 'anime-browser:get-playback-state',
animeBrowserIsPlaying: 'anime-browser:is-playing', 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',
@@ -174,6 +175,7 @@ export const IPC_CHANNELS = {
sessionHelpOpen: 'session-help:open', sessionHelpOpen: 'session-help:open',
changelogOpen: 'changelog:open', changelogOpen: 'changelog:open',
animeBrowserOpen: 'anime-browser:open', animeBrowserOpen: 'anime-browser:open',
animeBrowserClose: 'anime-browser:close',
characterDictionaryManagerOpen: 'character-dictionary:manager-open', characterDictionaryManagerOpen: 'character-dictionary:manager-open',
controllerSelectOpen: 'controller-select:open', controllerSelectOpen: 'controller-select:open',
controllerDebugOpen: 'controller-debug:open', controllerDebugOpen: 'controller-debug:open',
@@ -187,6 +189,7 @@ export const IPC_CHANNELS = {
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', animeBrowserQueueState: 'anime-browser:queue-state',
animeBrowserPlaybackState: 'anime-browser:playback-state',
}, },
} as const; } as const;
+10
View File
@@ -222,6 +222,13 @@ export interface AnimeBrowserPlayResult {
quality: string | null; quality: string | null;
} }
/** The Anime Browser episode mpv is currently playing, shared by every browser surface. */
export interface AnimeBrowserPlaybackState {
sourceId: string;
animeUrl: string;
episodeUrl: string;
}
/** One resolved episode waiting in mpv's playlist. */ /** One resolved episode waiting in mpv's playlist. */
export type AnimeBrowserQueueEntry = AnimeBrowserPlayRequest; export type AnimeBrowserQueueEntry = AnimeBrowserPlayRequest;
@@ -267,6 +274,7 @@ export interface AnimeBrowserAPI {
dequeueEpisode: (sourceId: string, episodeUrl: string) => Promise<AnimeBrowserQueueState>; dequeueEpisode: (sourceId: string, episodeUrl: string) => Promise<AnimeBrowserQueueState>;
clearQueue: () => Promise<AnimeBrowserQueueState>; clearQueue: () => Promise<AnimeBrowserQueueState>;
getQueue: () => Promise<AnimeBrowserQueueState>; getQueue: () => Promise<AnimeBrowserQueueState>;
getPlaybackState: () => Promise<AnimeBrowserPlaybackState | null>;
/** /**
* Whether mpv has a file open. False when it is idle or not running at all, * 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. * which is when queueing has no end to wait for.
@@ -288,6 +296,8 @@ export interface AnimeBrowserAPI {
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void) => () => void; onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void) => () => void;
/** Pushed whenever the queue changes, including when it advances by itself. */ /** Pushed whenever the queue changes, including when it advances by itself. */
onQueueState: (listener: (state: AnimeBrowserQueueState) => void) => () => void; onQueueState: (listener: (state: AnimeBrowserQueueState) => void) => () => void;
/** Pushed when mpv starts an Anime Browser episode or moves to other media. */
onPlaybackState: (listener: (state: AnimeBrowserPlaybackState | null) => void) => () => void;
} }
export type { SourcePreferenceView } from '../anime-bridge/preferences'; export type { SourcePreferenceView } from '../anime-bridge/preferences';
+1
View File
@@ -512,6 +512,7 @@ export interface ElectronAPI {
onOpenSessionHelp: (callback: () => void) => void; onOpenSessionHelp: (callback: () => void) => void;
onOpenChangelog: (callback: () => void) => void; onOpenChangelog: (callback: () => void) => void;
onOpenAnimeBrowser: (callback: () => void) => void; onOpenAnimeBrowser: (callback: () => void) => void;
onCloseAnimeBrowser: (callback: () => void) => void;
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>; getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
onOpenControllerSelect: (callback: () => void) => void; onOpenControllerSelect: (callback: () => void) => void;
onOpenControllerDebug: (callback: () => void) => void; onOpenControllerDebug: (callback: () => void) => void;