From 877f3503534a02687119b1d3d067f239708cf110 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 15 Aug 2026 23:35:37 -0700 Subject: [PATCH] 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 --- changes/anime-browser.md | 4 +- docs-site/anime-browser.md | 23 ++-- docs-site/configuration.md | 2 +- docs-site/shortcuts.md | 4 +- src/anime-bridge/episode-metadata.ts | 6 + src/anime-bridge/stream-strip-proxy.test.ts | 29 ++++- src/anime-bridge/stream-strip-proxy.ts | 50 +++++++- src/animeui/animeui.ts | 30 ++++- src/animeui/detail-panel.ts | 8 +- src/animeui/episode-list.test.ts | 51 ++++++++ src/animeui/episode-list.ts | 41 +++++-- src/animeui/episode-playback.ts | 36 ++++++ src/main.ts | 15 +++ src/main/overlay-runtime.test.ts | 31 +++++ src/main/overlay-runtime.ts | 7 +- .../runtime/anime-browser-ipc-handlers.ts | 2 + src/main/runtime/anime-browser-open.test.ts | 27 +++++ src/main/runtime/anime-browser-open.ts | 8 ++ src/main/runtime/anime-browser-queue.test.ts | 3 + .../runtime/stream-playback-metadata.test.ts | 13 ++ src/main/runtime/stream-playback-metadata.ts | 12 ++ src/preload-anime-browser-api.test.ts | 5 + src/preload-anime-browser-api.ts | 12 ++ src/preload.ts | 2 + src/renderer/modal-registry.test.ts | 22 ++++ src/renderer/modal-registry.ts | 7 ++ src/renderer/modals/anime-browser.test.ts | 113 +++++++++++++++++- src/renderer/modals/anime-browser.ts | 32 ++++- src/renderer/modals/session-help-sections.ts | 4 +- src/renderer/renderer.ts | 6 + src/shared/anime-browser-embed.ts | 36 ++++++ src/shared/ipc/contracts.ts | 3 + src/types/anime-browser.ts | 10 ++ src/types/runtime.ts | 1 + 34 files changed, 612 insertions(+), 43 deletions(-) create mode 100644 src/animeui/episode-list.test.ts create mode 100644 src/animeui/episode-playback.ts create mode 100644 src/shared/anime-browser-embed.ts diff --git a/changes/anime-browser.md b/changes/anime-browser.md index 777298c3..fbaf993d 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -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. - 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. -- 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 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. @@ -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. - 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. -- 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. diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md index 3e461641..b3860034 100644 --- a/docs-site/anime-browser.md +++ b/docs-site/anime-browser.md @@ -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 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 -playback and closes like the other in-player tools. The standalone 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. +playback and closes like the other in-player tools. Toggling it off keeps its +current page and scroll position ready for the next toggle. The standalone +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 -history. Queue changes appear in both immediately, and watched marks come from -the same history that playback and the stats window update. Closing and reopening -the modal therefore picks up progress made from either browser surface. +history. Queue changes and the currently playing episode appear in both +immediately, and watched marks come from the same history that playback and the +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 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 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 -skip the proxy entirely. Disguised `.image` segment URLs are exposed locally -with a `.ts` suffix so current ffmpeg releases accept them when Anki extracts -audio, screenshots, or animated images from the playing stream. +skip the proxy entirely. Segment URLs disguised behind fake extensions +(`.image`, `.jpg`, `.css`, and friends) are exposed locally with a `.ts` +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, SubMiner waits until mpv actually configures a video output before reporting diff --git a/docs-site/configuration.md b/docs-site/configuration.md index d8633e06..a714841c 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -593,7 +593,7 @@ See `config.example.jsonc` for detailed configuration options and more examples. | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | | `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 | | `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | | `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index c69800ae..79abc24f 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -49,7 +49,7 @@ These control playback and subtitle display. They require overlay window focus. | `J` | Cycle primary subtitle track | | `Shift+J` | Cycle secondary subtitle track | | `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 | | `ArrowLeft` | Seek backward 5 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 + 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. diff --git a/src/anime-bridge/episode-metadata.ts b/src/anime-bridge/episode-metadata.ts index 3b935c32..87ad547a 100644 --- a/src/anime-bridge/episode-metadata.ts +++ b/src/anime-bridge/episode-metadata.ts @@ -10,6 +10,9 @@ /** Where a stream came from, resolved into the fields consumers actually want. */ export interface AnimeStreamMetadata { + sourceId: string; + animeUrl: string; + episodeUrl: string; /** The URL handed to mpv. Matches what mpv reports as `path`. */ mediaPath: string; /** @@ -192,6 +195,9 @@ export function buildAnimeStreamMetadata(input: AnimeStreamMetadataInput): Anime const displayTitle = buildStreamDisplayTitle(seriesTitle, season, episodeNumber, label.title); return { + sourceId: input.sourceId, + animeUrl: input.animeUrl, + episodeUrl: input.episodeUrl, mediaPath: input.mediaPath, statsPath: buildAnimeStreamStatsPath(input.sourceId, input.animeUrl, input.episodeUrl), seriesTitle, diff --git a/src/anime-bridge/stream-strip-proxy.test.ts b/src/anime-bridge/stream-strip-proxy.test.ts index dfc8942f..01e0a8d2 100644 --- a/src/anime-bridge/stream-strip-proxy.test.ts +++ b/src/anime-bridge/stream-strip-proxy.test.ts @@ -61,7 +61,7 @@ test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lin 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 = [ '#EXTM3U', '#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', '#EXTINF:3,', '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'); 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}`), ); 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 ---------- */ diff --git a/src/anime-bridge/stream-strip-proxy.ts b/src/anime-bridge/stream-strip-proxy.ts index 80da2d15..10dd0de4 100644 --- a/src/anime-bridge/stream-strip-proxy.ts +++ b/src/anime-bridge/stream-strip-proxy.ts @@ -25,10 +25,54 @@ const TS_SYNC_BYTE = 0x47; const SYNC_RUN = 5; /** * 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 - * playlist gives them this safe alias and removes it again before forwarding. + * Hosts disguise MPEG-TS segments behind rotating fake extensions (`.image`, + * `.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'; +/** 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. */ export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024; /** Bytes needed to either find a run within the limit or rule one out. */ @@ -80,7 +124,7 @@ export function rewritePlaylistOrigins( } catch { return line; } - if (resolved.origin !== proxyOrigin || !resolved.pathname.toLowerCase().endsWith('.image')) { + if (resolved.origin !== proxyOrigin || !needsTsSegmentAlias(resolved.pathname)) { return line; } diff --git a/src/animeui/animeui.ts b/src/animeui/animeui.ts index 3c840a17..74e669c6 100644 --- a/src/animeui/animeui.ts +++ b/src/animeui/animeui.ts @@ -21,6 +21,10 @@ import type { AnimeBrowserEntry, AnimeBrowserSource, } from '../types/anime-browser'; +import { + ANIME_BROWSER_CLOSE_MESSAGE, + createAnimeBrowserKeydownMessage, +} from '../shared/anime-browser-embed'; const embeddedInOverlay = 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 }); 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) => { if (event.key !== 'Escape' || event.defaultPrevented || detailPanel.isOpen()) return; 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 // episode ends, whether or not anyone is looking at the browser. api.onQueueState((state) => detailPanel.setQueue(state)); +let receivedPlaybackStateEvent = false; +api.onPlaybackState((state) => { + receivedPlaybackStateEvent = true; + detailPanel.setPlaybackState(state); +}); void (async () => { 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)); + 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 { const state = await api.ensureBridge(); renderBridgeState(state); diff --git a/src/animeui/detail-panel.ts b/src/animeui/detail-panel.ts index b2a1b00f..91f27e78 100644 --- a/src/animeui/detail-panel.ts +++ b/src/animeui/detail-panel.ts @@ -1,7 +1,11 @@ import { LatestRequest } from './browse-state'; import { describe, el } from './dom'; 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 { api: AnimeBrowserAPI; @@ -102,5 +106,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) { isOpen: (): boolean => !detail.classList.contains('hidden'), /** The queue outlives the open anime, so it is pushed in from outside. */ setQueue: episodeList.setQueue, + setPlaybackState: (state: AnimeBrowserPlaybackState | null) => + episodeList.setPlaybackState(state), }; } diff --git a/src/animeui/episode-list.test.ts b/src/animeui/episode-list.test.ts new file mode 100644 index 00000000..b30de88c --- /dev/null +++ b/src/animeui/episode-list.test.ts @@ -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' }, + ); +}); diff --git a/src/animeui/episode-list.ts b/src/animeui/episode-list.ts index 008b2ee2..5d642dc4 100644 --- a/src/animeui/episode-list.ts +++ b/src/animeui/episode-list.ts @@ -5,7 +5,12 @@ import { filterEpisodes } from './episode-filter'; 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 { nextPlaybackCue, type EpisodePlaybackCue } from './episode-playback'; +import type { + AnimeBrowserAPI, + AnimeBrowserEpisode, + AnimeBrowserPlaybackState, +} from '../types/anime-browser'; export interface SelectedAnime { 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 * 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(); /** * 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({ api, 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 // 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(); + activePlayback = { + sourceId: entry.sourceId, + animeUrl: entry.animeUrl, + episodeUrl: entry.episodeUrl, + }; + syncPlaybackCue(); setStatus(`Queue started ${entry.episodeName}.`, 'ok'); }, }); @@ -385,6 +397,7 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList displayIndex: list.length - index, name: episode.name, })); + syncPlaybackCue(); paint(); void refreshWatchState(); } @@ -412,5 +425,17 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList }); 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, + }; } diff --git a/src/animeui/episode-playback.ts b/src/animeui/episode-playback.ts new file mode 100644 index 00000000..5a902394 --- /dev/null +++ b/src/animeui/episode-playback.ts @@ -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; +} diff --git a/src/main.ts b/src/main.ts index f21fa3cd..6f86a6e1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -491,6 +491,7 @@ import { createMediaRuntimeService } from './main/media-runtime'; import { createStreamPlaybackMetadataStore, matchRequestedStreamPlaybackMetadata, + toAnimeBrowserPlaybackState, toAnilistMediaGuess, toJimakuMediaInfo, } from './main/runtime/stream-playback-metadata'; @@ -2468,6 +2469,7 @@ const mediaRuntime = createMediaRuntimeService( getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR, setCurrentMediaPath: (nextPath: string | null) => { appState.currentMediaPath = nextPath; + publishAnimeBrowserPlaybackState(nextPath); }, clearPendingSubtitlePosition: () => { appState.pendingSubtitlePosition = null; @@ -2842,6 +2844,7 @@ function createOverlayHostedModalOpenDeps(): { }, ) => boolean; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + isModalOpen: (modal: OverlayHostedModal) => boolean; logWarn: (message: string) => void; } { return { @@ -2851,6 +2854,7 @@ function createOverlayHostedModalOpenDeps(): { sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => overlayVisibilityComposer.sendToActiveOverlayWindow(channel, payload, runtimeOptions), waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), + isModalOpen: (modal) => overlayModalRuntime.isModalOpen(modal), logWarn: (message) => logger.warn(message), }; } @@ -3326,6 +3330,7 @@ const { const DEFAULT_ANIME_EXTENSIONS_DIR = path.join(USER_DATA_PATH, 'anime-extensions'); const animeBrowserSessions = new Map(); +let animeBrowserPlaybackState = toAnimeBrowserPlaybackState(getActiveStreamMetadata()); function broadcastAnimeBrowserEvent(channel: string, payload: unknown): void { const targets = new Set(); @@ -3337,6 +3342,14 @@ function broadcastAnimeBrowserEvent(channel: string, payload: unknown): void { 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 { const configured = configService.getConfig().anime?.extensionsDir?.trim(); return configured && configured.length > 0 ? configured : DEFAULT_ANIME_EXTENSIONS_DIR; @@ -3396,6 +3409,7 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({ }, onPlaybackMetadata: (metadata) => { streamPlaybackMetadata.set(metadata); + publishAnimeBrowserPlaybackState(metadata.mediaPath); // 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 // proxy URL, whose only readable part is the `.m3u8` extension. @@ -3442,6 +3456,7 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({ registerAnimeBrowserIpcHandlers({ ipcMain, runtime: animeBrowserRuntime, + getPlaybackState: () => animeBrowserPlaybackState, registerSession: (sessionId, sender) => { if (animeBrowserSessions.get(sessionId) === sender) return; animeBrowserSessions.set(sessionId, sender); diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index c030adbb..8ccfb94c 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -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', () => { let createCalls = 0; const runtime = createOverlayModalRuntimeService( diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index d1f3bd9f..ffad39e5 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -56,6 +56,7 @@ export interface OverlayModalRuntime { openTsukihime: () => void; handleOverlayModalClosed: (modal: OverlayHostedModal) => void; notifyOverlayModalOpened: (modal: OverlayHostedModal) => void; + isModalOpen: (modal: OverlayHostedModal) => boolean; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; getRestoreVisibleOverlayOnModalClose: () => Set; } @@ -84,10 +85,12 @@ export function createOverlayModalRuntimeService( let modalWindowPrimedForImmediateShow = false; let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null; + let retainModalWindowState = false; const modalWindowBoundsReconcileGenerations = new WeakMap(); const modalWindowPrimeListenersRegistered = new WeakSet(); const platform = options.platform ?? process.platform; const keepModalWindowWarm = platform === 'darwin' || platform === 'win32'; + const shouldKeepModalWindowWarm = (): boolean => keepModalWindowWarm || retainModalWindowState; const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus; const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle => (options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs); @@ -515,7 +518,7 @@ export function createOverlayModalRuntimeService( if (restoreVisibleOverlayOnModalClose.size === 0) { clearPendingModalWindowReveal(); if (modalWindow && !modalWindow.isDestroyed()) { - if (keepModalWindowWarm) { + if (shouldKeepModalWindowWarm()) { modalWindow.setIgnoreMouseEvents(true, { forward: true }); modalWindow.hide(); markModalWindowPrimed(modalWindow); @@ -538,6 +541,7 @@ export function createOverlayModalRuntimeService( const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => { if (!restoreVisibleOverlayOnModalClose.has(modal)) return; + if (modal === 'anime-browser') retainModalWindowState = true; openedModals.add(modal); const waiters = modalOpenWaiters.get(modal) ?? []; modalOpenWaiters.delete(modal); @@ -595,6 +599,7 @@ export function createOverlayModalRuntimeService( openTsukihime, handleOverlayModalClosed, notifyOverlayModalOpened, + isModalOpen: (modal) => openedModals.has(modal), waitForModalOpen, getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, }; diff --git a/src/main/runtime/anime-browser-ipc-handlers.ts b/src/main/runtime/anime-browser-ipc-handlers.ts index 921111f6..33b34ecc 100644 --- a/src/main/runtime/anime-browser-ipc-handlers.ts +++ b/src/main/runtime/anime-browser-ipc-handlers.ts @@ -12,6 +12,7 @@ export interface AnimeBrowserIpcDeps { handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown; }; runtime: AnimeBrowserRuntime; + getPlaybackState?: () => unknown; registerSession?: (sessionId: string, sender: AnimeBrowserIpcSender) => void; } @@ -97,6 +98,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void ); handle(channels.animeBrowserClearQueue, () => runtime.clearQueue()); handle(channels.animeBrowserGetQueue, () => runtime.getQueue()); + handle(channels.animeBrowserGetPlaybackState, () => deps.getPlaybackState?.() ?? null); handle(channels.animeBrowserIsPlaying, () => runtime.isPlaying()); handle(channels.animeBrowserGetPreferences, (_event, sourceId) => runtime.getPreferences(String(sourceId)), diff --git a/src/main/runtime/anime-browser-open.test.ts b/src/main/runtime/anime-browser-open.test.ts index 2dbff092..f95d175a 100644 --- a/src/main/runtime/anime-browser-open.test.ts +++ b/src/main/runtime/anime-browser-open.test.ts @@ -19,6 +19,7 @@ test('anime browser open uses a dedicated player-bounded modal window', async () return true; }, waitForModalOpen: async () => true, + isModalOpen: () => false, logWarn: () => {}, }); @@ -36,9 +37,35 @@ test('anime browser open retries on a fresh modal window after a missed acknowle attempts += 1; return attempts === 2; }, + isModalOpen: () => false, logWarn: () => {}, }); assert.equal(opened, true); 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]); +}); diff --git a/src/main/runtime/anime-browser-open.ts b/src/main/runtime/anime-browser-open.ts index bf1c2cb0..f3ec4ba0 100644 --- a/src/main/runtime/anime-browser-open.ts +++ b/src/main/runtime/anime-browser-open.ts @@ -17,8 +17,16 @@ export async function openAnimeBrowserModal(deps: { }, ) => boolean; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + isModalOpen: (modal: OverlayHostedModal) => boolean; logWarn: (message: string) => void; }): Promise { + if (deps.isModalOpen(ANIME_BROWSER_MODAL)) { + return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.animeBrowserClose, undefined, { + restoreOnModalClose: ANIME_BROWSER_MODAL, + preferModalWindow: true, + }); + } + return await retryOverlayModalOpen( { waitForModalOpen: deps.waitForModalOpen, diff --git a/src/main/runtime/anime-browser-queue.test.ts b/src/main/runtime/anime-browser-queue.test.ts index 4f2214d2..55b470ce 100644 --- a/src/main/runtime/anime-browser-queue.test.ts +++ b/src/main/runtime/anime-browser-queue.test.ts @@ -25,6 +25,9 @@ function prepared(input: AnimeBrowserPlayRequest): PreparedAnimeBrowserPlayback request: input, stream: { url: mediaPath, quality: '1080p', headers: {}, audios: [], subtitles: [] }, metadata: { + sourceId: input.sourceId, + animeUrl: input.animeUrl, + episodeUrl: input.episodeUrl, mediaPath, statsPath: `animebrowser://${encodeURIComponent(input.episodeUrl)}`, seriesTitle: input.animeTitle, diff --git a/src/main/runtime/stream-playback-metadata.test.ts b/src/main/runtime/stream-playback-metadata.test.ts index eeb60b10..86dc71ac 100644 --- a/src/main/runtime/stream-playback-metadata.test.ts +++ b/src/main/runtime/stream-playback-metadata.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createStreamPlaybackMetadataStore, matchRequestedStreamPlaybackMetadata, + toAnimeBrowserPlaybackState, toAnilistMediaGuess, toJimakuMediaInfo, } from './stream-playback-metadata'; @@ -10,6 +11,9 @@ import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata'; function metadata(overrides: Partial = {}): AnimeStreamMetadata { return { + sourceId: '9001', + animeUrl: '/anime/mushoku', + episodeUrl: '/watch/ep-4', mediaPath: 'http://127.0.0.1:41234/video/abc.m3u8', statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4', seriesTitle: 'Mushoku Tensei: Jobless Reincarnation', @@ -21,6 +25,15 @@ function metadata(overrides: Partial = {}): 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', () => { const store = createStreamPlaybackMetadataStore(); const current = metadata(); diff --git a/src/main/runtime/stream-playback-metadata.ts b/src/main/runtime/stream-playback-metadata.ts index 7b6b57c0..23beb062 100644 --- a/src/main/runtime/stream-playback-metadata.ts +++ b/src/main/runtime/stream-playback-metadata.ts @@ -1,6 +1,7 @@ import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata'; import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; import type { JimakuMediaInfo } from '../../types'; +import type { AnimeBrowserPlaybackState } from '../../types/anime-browser'; /** * Holds what the anime browser resolved for active and queued streams. @@ -53,6 +54,17 @@ export function matchRequestedStreamPlaybackMetadata( 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. */ function wholeEpisode(episode: number | null): number | null { return typeof episode === 'number' && Number.isInteger(episode) && episode > 0 ? episode : null; diff --git a/src/preload-anime-browser-api.test.ts b/src/preload-anime-browser-api.test.ts index 5f2aacd0..55c0f825 100644 --- a/src/preload-anime-browser-api.test.ts +++ b/src/preload-anime-browser-api.test.ts @@ -29,6 +29,7 @@ test('anime browser API keeps one opaque session per renderer bridge', async () await firstApi.search('frieren', 2); await firstApi.getDetails('/frieren'); await firstApi.getEpisodes('/frieren', 'source.one'); + await firstApi.getPlaybackState(); await secondApi.getPopular(3); 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, args: [firstSessionId, '/frieren', 'source.one'], }, + { + channel: IPC_CHANNELS.request.animeBrowserGetPlaybackState, + args: [], + }, ]); assert.deepEqual(second.calls, [ { diff --git a/src/preload-anime-browser-api.ts b/src/preload-anime-browser-api.ts index 379d89b6..74fd1fef 100644 --- a/src/preload-anime-browser-api.ts +++ b/src/preload-anime-browser-api.ts @@ -8,6 +8,7 @@ import type { AnimeBrowserEpisodeWatchState, AnimeBrowserPlayRequest, AnimeBrowserPlayResult, + AnimeBrowserPlaybackState, AnimeBrowserQueueState, AnimeBrowserSearchResult, AnimeBrowserSearchUpdate, @@ -67,6 +68,8 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani ipcRenderer.invoke(request.animeBrowserClearQueue), getQueue: (): Promise => ipcRenderer.invoke(request.animeBrowserGetQueue), + getPlaybackState: (): Promise => + ipcRenderer.invoke(request.animeBrowserGetPlaybackState), isPlaying: (): Promise => ipcRenderer.invoke(request.animeBrowserIsPlaying), getPreferences: (sourceId: string): Promise => ipcRenderer.invoke(request.animeBrowserGetPreferences, sourceId), @@ -101,5 +104,14 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani ipcRenderer.on(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); + }, }; } diff --git a/src/preload.ts b/src/preload.ts index 02f83c49..86be1fa6 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -170,6 +170,7 @@ const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.run const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen); const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen); const onOpenAnimeBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.animeBrowserOpen); +const onCloseAnimeBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.animeBrowserClose); const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener( IPC_CHANNELS.event.characterDictionaryManagerOpen, ); @@ -454,6 +455,7 @@ const electronAPI: ElectronAPI = { onOpenSessionHelp: onOpenSessionHelpEvent, onOpenChangelog: onOpenChangelogEvent, onOpenAnimeBrowser: onOpenAnimeBrowserEvent, + onCloseAnimeBrowser: onCloseAnimeBrowserEvent, getChangelogSnapshot: (options?: { refresh?: boolean }): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options), onOpenControllerSelect: onOpenControllerSelectEvent, diff --git a/src/renderer/modal-registry.test.ts b/src/renderer/modal-registry.test.ts index 022cc270..349510c9 100644 --- a/src/renderer/modal-registry.test.ts +++ b/src/renderer/modal-registry.test.ts @@ -53,3 +53,25 @@ test('modal registry dismisses every open descriptor and skips closed descriptor 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[] = ['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); +}); diff --git a/src/renderer/modal-registry.ts b/src/renderer/modal-registry.ts index c29a6e8a..f68f1c98 100644 --- a/src/renderer/modal-registry.ts +++ b/src/renderer/modal-registry.ts @@ -20,5 +20,12 @@ export function createModalRegistry( } } }, + dismissOpenExcept: (id: TId): void => { + for (const descriptor of descriptors) { + if (descriptor.id !== id && descriptor.isOpen()) { + descriptor.close(); + } + } + }, }; } diff --git a/src/renderer/modals/anime-browser.test.ts b/src/renderer/modals/anime-browser.test.ts index 4882aad1..0b5104ab 100644 --- a/src/renderer/modals/anime-browser.test.ts +++ b/src/renderer/modals/anime-browser.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { createAnimeBrowserKeydownMessage } from '../../shared/anime-browser-embed'; import { createAnimeBrowserModal } from './anime-browser'; 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 messages: Array<(event: MessageEvent) => void> = []; const notifications: string[] = []; + let staleModalOpen = true; + let dismissOtherModalCalls = 0; const frameWindow = {}; const closeListeners: 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}`), notifyOverlayModalClosed: (name: string) => notifications.push(`close:${name}`), }, + location: { origin: 'file://', protocol: 'file:' }, addEventListener: (name: string, listener: (event: MessageEvent) => void) => { if (name === 'message') messages.push(listener); }, @@ -47,7 +51,22 @@ test('embedded Anime Browser closes only for its own close message', () => { }); 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( { state, @@ -64,7 +83,13 @@ test('embedded Anime Browser closes only for its own close message', () => { }, } as never, { - modalStateReader: { isAnyModalOpen: () => state.animeBrowserModalOpen }, + modalStateReader: { + isAnyModalOpen: () => state.animeBrowserModalOpen || staleModalOpen, + }, + dismissOtherModals: () => { + dismissOtherModalCalls += 1; + staleModalOpen = false; + }, syncSettingsModalSubtitleSuppression: () => undefined, }, ); @@ -72,17 +97,93 @@ test('embedded Anime Browser closes only for its own close message', () => { modal.wireDomEvents(); modal.openAnimeBrowserModal(); assert.equal(state.animeBrowserModalOpen, true); + assert.equal(dismissOtherModalCalls, 1, 'open clears stale retained-window modal state'); assert.equal(frame.src, frame.dataset.src); assert.deepEqual(notifications, ['open:anime-browser']); - messages[0]?.({ source: {}, data: 'subminer:anime-browser-close' } as MessageEvent); - messages[0]?.({ source: frameWindow, data: 'not-the-close-message' } as MessageEvent); + messages[0]?.({ + 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); - 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(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(); assert.deepEqual(removedCloseListeners, closeListeners); } finally { diff --git a/src/renderer/modals/anime-browser.ts b/src/renderer/modals/anime-browser.ts index 4055d772..b0d094d8 100644 --- a/src/renderer/modals/anime-browser.ts +++ b/src/renderer/modals/anime-browser.ts @@ -1,11 +1,14 @@ import type { ModalStateReader, RendererContext } from '../context'; - -const CLOSE_MESSAGE = 'subminer:anime-browser-close'; +import { + ANIME_BROWSER_CLOSE_MESSAGE, + isAnimeBrowserKeydownMessage, +} from '../../shared/anime-browser-embed'; export function createAnimeBrowserModal( ctx: RendererContext, options: { modalStateReader: Pick; + dismissOtherModals: () => void; syncSettingsModalSubtitleSuppression: () => void; }, ) { @@ -16,8 +19,11 @@ export function createAnimeBrowserModal( ctx.dom.animeBrowserFrame.src = source; } - function openAnimeBrowserModal(): void { - if (ctx.state.animeBrowserModalOpen || options.modalStateReader.isAnyModalOpen()) return; + function openAnimeBrowserModal(): boolean { + if (!ctx.state.animeBrowserModalOpen) { + options.dismissOtherModals(); + if (options.modalStateReader.isAnyModalOpen()) return false; + } ctx.state.animeBrowserModalOpen = true; options.syncSettingsModalSubtitleSuppression(); @@ -26,6 +32,7 @@ export function createAnimeBrowserModal( ctx.dom.animeBrowserModal.setAttribute('aria-hidden', 'false'); ensureFrameLoaded(); window.electronAPI.notifyOverlayModalOpened('anime-browser'); + return true; } function closeAnimeBrowserModal(): void { @@ -48,7 +55,22 @@ export function createAnimeBrowserModal( } 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(); } } diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index ecb05a51..3fc1634f 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -106,7 +106,7 @@ function describeCommand(command: (string | number)[]): string { if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku'; if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN) return 'Open TsukiHime'; 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.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { @@ -232,7 +232,7 @@ function describeSessionAction( case 'openPlaylistBrowser': return 'Open playlist browser'; case 'openAnimeBrowser': - return 'Open Anime Browser'; + return 'Toggle Anime Browser'; case 'replayCurrentSubtitle': return 'Replay current subtitle'; case 'playNextSubtitle': diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index d9ce817f..cc99bf2e 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -274,6 +274,7 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, { }); const animeBrowserModal = createAnimeBrowserModal(ctx, { modalStateReader: { isAnyModalOpen }, + dismissOtherModals: () => modalRegistry.dismissOpenExcept('anime-browser'), syncSettingsModalSubtitleSuppression, }); const keyboardHandlers = createKeyboardHandlers(ctx, { @@ -596,6 +597,11 @@ function registerModalOpenHandlers(): void { animeBrowserModal.openAnimeBrowserModal(); }); }); + window.electronAPI.onCloseAnimeBrowser(() => { + runGuarded('anime-browser:close', () => { + animeBrowserModal.closeAnimeBrowserModal(); + }); + }); window.electronAPI.onCancelYoutubeTrackPicker(() => { runGuarded('youtube:picker-cancel', () => { youtubePickerModal.closeYoutubePickerModal(); diff --git a/src/shared/anime-browser-embed.ts b/src/shared/anime-browser-embed.ts new file mode 100644 index 00000000..2a0a1160 --- /dev/null +++ b/src/shared/anime-browser-embed.ts @@ -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, +): 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; + return ( + message.type === ANIME_BROWSER_KEYDOWN_MESSAGE && + typeof message.bindingKey === 'string' && + message.bindingKey.length > 0 && + typeof message.repeat === 'boolean' + ); +} diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 7435d41a..cde4f03e 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -140,6 +140,7 @@ export const IPC_CHANNELS = { animeBrowserDequeueEpisode: 'anime-browser:dequeue-episode', animeBrowserClearQueue: 'anime-browser:clear-queue', animeBrowserGetQueue: 'anime-browser:get-queue', + animeBrowserGetPlaybackState: 'anime-browser:get-playback-state', animeBrowserIsPlaying: 'anime-browser:is-playing', animeBrowserGetPreferences: 'anime-browser:get-preferences', animeBrowserSetPreference: 'anime-browser:set-preference', @@ -174,6 +175,7 @@ export const IPC_CHANNELS = { sessionHelpOpen: 'session-help:open', changelogOpen: 'changelog:open', animeBrowserOpen: 'anime-browser:open', + animeBrowserClose: 'anime-browser:close', characterDictionaryManagerOpen: 'character-dictionary:manager-open', controllerSelectOpen: 'controller-select:open', controllerDebugOpen: 'controller-debug:open', @@ -187,6 +189,7 @@ export const IPC_CHANNELS = { animeBrowserBridgeState: 'anime-browser:bridge-state', animeBrowserSearchUpdate: 'anime-browser:search-update', animeBrowserQueueState: 'anime-browser:queue-state', + animeBrowserPlaybackState: 'anime-browser:playback-state', }, } as const; diff --git a/src/types/anime-browser.ts b/src/types/anime-browser.ts index 3832243c..8621cb64 100644 --- a/src/types/anime-browser.ts +++ b/src/types/anime-browser.ts @@ -222,6 +222,13 @@ export interface AnimeBrowserPlayResult { 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. */ export type AnimeBrowserQueueEntry = AnimeBrowserPlayRequest; @@ -267,6 +274,7 @@ export interface AnimeBrowserAPI { dequeueEpisode: (sourceId: string, episodeUrl: string) => Promise; clearQueue: () => Promise; getQueue: () => Promise; + getPlaybackState: () => Promise; /** * 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. @@ -288,6 +296,8 @@ export interface AnimeBrowserAPI { onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void) => () => void; /** Pushed whenever the queue changes, including when it advances by itself. */ 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'; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index cb45c509..72981533 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -512,6 +512,7 @@ export interface ElectronAPI { onOpenSessionHelp: (callback: () => void) => void; onOpenChangelog: (callback: () => void) => void; onOpenAnimeBrowser: (callback: () => void) => void; + onCloseAnimeBrowser: (callback: () => void) => void; getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise; onOpenControllerSelect: (callback: () => void) => void; onOpenControllerDebug: (callback: () => void) => void;