diff --git a/changes/anime-browser.md b/changes/anime-browser.md index 852b7740..25ac230e 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -11,7 +11,7 @@ area: anime - The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries. - The Extensions tab opens with an Installed section listing every extension on disk with Remove, including ones added by hand or whose repository has since been removed. It compares APK and repository version codes, enables Update only for newer builds, marks current extensions as Up to date, and offers Update all when multiple updates are waiting. - Added `anime.repos`, `anime.extensionsDir`, `anime.preferredQuality`, and `anime.defaultSource` config keys; **Set default** on an Installed row in the Extensions tab makes that source (or All sources) the one the browser opens on, and the current default carries a tag. SubMiner ships no extension repositories and performs no discovery. -- The settings app now groups Anime Browser config under an **Aniyomi** section in Integrations. A new `anime.autoOpenJimaku` option pauses newly loaded Anime Browser episodes, closes the in-player browser, opens Jimaku with the episode details filled in, and resumes after a subtitle loads or the modal closes without overriding playback that was already paused. +- The settings app now groups Anime Browser config under an **Aniyomi** section in Integrations. A new `anime.autoOpenJimaku` option pauses newly loaded Anime Browser episodes, waits for mpv's video window to appear, closes the in-player browser, hides the standalone browser window so it is not pulled in front of mpv when the modals close, opens Jimaku with the episode details filled in, and resumes after a subtitle loads or the modal closes without overriding playback that was already paused. - 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 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. Upstream responses use identity encoding so playlists remain readable for URL rewriting. diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md index 3e767cda..9c31c5da 100644 --- a/docs-site/anime-browser.md +++ b/docs-site/anime-browser.md @@ -220,10 +220,14 @@ there is nowhere to write them and the status bar says so. | `anime.bridgeDir` | A bridge bundle to run instead of the downloaded one. Empty by default. | Enable `anime.autoOpenJimaku` to hand each newly loaded Anime Browser episode -to Jimaku. SubMiner pauses playback, closes the in-player browser if it is open, -and opens Jimaku with the source title, season, and episode already filled in. -Playback resumes after the selected subtitle loads. Closing Jimaku also releases -the automatic pause, while playback that was already paused stays paused. +to Jimaku. SubMiner pauses playback on its first frame, waits for mpv's video +window to appear, closes the in-player browser if it is open, hides the +standalone browser window, and opens Jimaku with the source title, season, and +episode already filled in. Playback resumes after the selected subtitle loads. +Closing Jimaku also releases the automatic pause, while playback that was +already paused stays paused. A stream that never shows a window resumes without +opening Jimaku. Reopen the browser window with `subminer anime`, the tray +entry, or the shortcut to keep browsing. ## Source settings diff --git a/src/main.ts b/src/main.ts index f1180214..8ff9ec43 100644 --- a/src/main.ts +++ b/src/main.ts @@ -545,6 +545,7 @@ import { } from './main/runtime/setup-window-factory'; import { createAnimeBrowserApplicationRuntime } from './main/runtime/anime-browser-application-runtime'; import { createAnimeBrowserJimakuAutoOpen } from './main/runtime/anime-browser-jimaku-auto-open'; +import { waitForPlaybackWindow } from './main/runtime/playback-window-ready'; import { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open'; import { ensureBridgeBinaries, @@ -2526,6 +2527,16 @@ const animeBrowserJimakuAutoOpen = createAnimeBrowserJimakuAutoOpen({ setPlaybackPaused: (paused) => { sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', paused ? 'yes' : 'no']); }, + waitForPlaybackWindow: () => + waitForPlaybackWindow({ + isWindowTracked: () => appState.windowTracker?.isTracking() ?? null, + readProperty: (name) => { + const client = appState.mpvClient; + if (!client) return Promise.reject(new Error('mpv is not connected.')); + return client.requestProperty(name); + }, + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + }), closeAnimeBrowserModal: () => { if (!overlayModalRuntime.isModalOpen('anime-browser')) return; overlayModalRuntime.sendToActiveOverlayWindow(IPC_CHANNELS.event.animeBrowserClose, undefined, { @@ -2533,6 +2544,12 @@ const animeBrowserJimakuAutoOpen = createAnimeBrowserJimakuAutoOpen({ preferModalWindow: true, }); }, + hideAnimeBrowserWindow: () => { + const window = appState.animeBrowserWindow; + if (window && !window.isDestroyed() && window.isVisible()) { + window.hide(); + } + }, openJimakuModal: () => openJimakuOverlay(), logWarn: (message, error) => logger.warn(message, error), }); diff --git a/src/main/runtime/anime-browser-jimaku-auto-open.test.ts b/src/main/runtime/anime-browser-jimaku-auto-open.test.ts index 1cf2e2ea..14e77af7 100644 --- a/src/main/runtime/anime-browser-jimaku-auto-open.test.ts +++ b/src/main/runtime/anime-browser-jimaku-auto-open.test.ts @@ -7,6 +7,7 @@ function createHarness(options?: { animeMedia?: boolean | ((mediaPath: string) => boolean); paused?: boolean | null; opened?: boolean; + windowReady?: () => Promise; }) { const calls: string[] = []; let currentMediaPath: string | null = null; @@ -19,7 +20,12 @@ function createHarness(options?: { getCurrentMediaPath: () => currentMediaPath, getPlaybackPaused: async () => (options?.paused === undefined ? false : options.paused), setPlaybackPaused: (paused) => calls.push(`pause:${paused}`), + waitForPlaybackWindow: () => { + calls.push('wait-window'); + return options?.windowReady?.() ?? Promise.resolve(true); + }, closeAnimeBrowserModal: () => calls.push('close-anime-browser'), + hideAnimeBrowserWindow: () => calls.push('hide-anime-browser-window'), openJimakuModal: async () => { calls.push('open-jimaku'); return options?.opened ?? true; @@ -36,20 +42,23 @@ function createHarness(options?: { }; } -test('anime browser playback pauses, opens Jimaku, and resumes after subtitle load', async () => { +const OPEN_SEQUENCE = [ + 'pause:true', + 'wait-window', + 'close-anime-browser', + 'hide-anime-browser-window', + 'open-jimaku', +]; + +test('anime browser playback pauses, waits for the mpv window, opens Jimaku, and resumes after subtitle load', async () => { const harness = createHarness(); harness.setMediaPath('https://127.0.0.1/stream.m3u8'); await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8'); - assert.deepEqual(harness.calls, ['pause:true', 'close-anime-browser', 'open-jimaku']); + assert.deepEqual(harness.calls, OPEN_SEQUENCE); harness.runtime.handleJimakuSubtitleLoaded(); - assert.deepEqual(harness.calls, [ - 'pause:true', - 'close-anime-browser', - 'open-jimaku', - 'pause:false', - ]); + assert.deepEqual(harness.calls, [...OPEN_SEQUENCE, 'pause:false']); }); test('anime browser playback that was already paused stays paused after subtitle load', async () => { @@ -59,7 +68,12 @@ test('anime browser playback that was already paused stays paused after subtitle await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8'); harness.runtime.handleJimakuSubtitleLoaded(); - assert.deepEqual(harness.calls, ['close-anime-browser', 'open-jimaku']); + assert.deepEqual(harness.calls, [ + 'wait-window', + 'close-anime-browser', + 'hide-anime-browser-window', + 'open-jimaku', + ]); }); test('disabled and non-Anime Browser media do not open Jimaku', async () => { @@ -74,6 +88,41 @@ test('disabled and non-Anime Browser media do not open Jimaku', async () => { assert.deepEqual(unrelated.calls, []); }); +test('a stream that never shows a window releases the pause without opening Jimaku', async () => { + const harness = createHarness({ windowReady: async () => false }); + harness.setMediaPath('https://127.0.0.1/stream.m3u8'); + + await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8'); + + assert.ok(!harness.calls.includes('open-jimaku')); + assert.equal(harness.calls.at(-1), 'pause:false'); +}); + +test('a newer episode during the window wait cancels the stale Jimaku open', async () => { + const firstWait: { resolve: (ready: boolean) => void } = { resolve: () => {} }; + const firstWaitPromise = new Promise((resolve) => { + firstWait.resolve = resolve; + }); + let waits = 0; + const harness = createHarness({ + windowReady: () => { + waits += 1; + return waits === 1 ? firstWaitPromise : Promise.resolve(true); + }, + }); + + harness.setMediaPath('https://127.0.0.1/one.m3u8'); + const first = harness.runtime.handleMediaPathChange('https://127.0.0.1/one.m3u8'); + await Promise.resolve(); + + harness.setMediaPath('https://127.0.0.1/two.m3u8'); + await harness.runtime.handleMediaPathChange('https://127.0.0.1/two.m3u8'); + firstWait.resolve(true); + await first; + + assert.equal(harness.calls.filter((call) => call === 'open-jimaku').length, 1); +}); + test('closing Jimaku or failing to open it releases an owned pause', async () => { const closed = createHarness(); closed.setMediaPath('https://127.0.0.1/one.m3u8'); diff --git a/src/main/runtime/anime-browser-jimaku-auto-open.ts b/src/main/runtime/anime-browser-jimaku-auto-open.ts index efd76c90..74183ae1 100644 --- a/src/main/runtime/anime-browser-jimaku-auto-open.ts +++ b/src/main/runtime/anime-browser-jimaku-auto-open.ts @@ -4,7 +4,10 @@ export interface AnimeBrowserJimakuAutoOpenDeps { getCurrentMediaPath: () => string | null; getPlaybackPaused: () => Promise; setPlaybackPaused: (paused: boolean) => void; + /** Resolves true once mpv shows a video window; false when it never appears. */ + waitForPlaybackWindow: () => Promise; closeAnimeBrowserModal: () => void; + hideAnimeBrowserWindow: () => void; openJimakuModal: () => Promise; logWarn: (message: string, error?: unknown) => void; } @@ -20,7 +23,16 @@ interface ActiveFlow { ownsPause: boolean; } -/** Coordinates the pause owned by the Anime Browser to Jimaku handoff. */ +/** + * Coordinates the pause owned by the Anime Browser to Jimaku handoff. + * + * The pause goes out on the path change so the stream freezes on its first + * frame, but Jimaku waits for mpv's window: the modal takes its geometry from + * that window, and on macOS closing the last modal yields focus back to it. + * Both Anime Browser surfaces get out of the way before Jimaku opens; a + * visible standalone window would otherwise be pulled in front of mpv the + * next time the app activates after that focus handoff. + */ export function createAnimeBrowserJimakuAutoOpen( deps: AnimeBrowserJimakuAutoOpenDeps, ): AnimeBrowserJimakuAutoOpen { @@ -68,7 +80,22 @@ export function createAnimeBrowserJimakuAutoOpen( } if (!isCurrent(flow)) return; + try { + const windowReady = await deps.waitForPlaybackWindow(); + if (!isCurrent(flow)) return; + if (!windowReady) { + deps.logWarn('mpv showed no video window for Anime Browser playback; skipping Jimaku.'); + releaseFlow(flow); + return; + } + } catch (error) { + deps.logWarn('Could not wait for the mpv window before opening Jimaku.', error); + releaseFlow(flow); + return; + } + deps.closeAnimeBrowserModal(); + deps.hideAnimeBrowserWindow(); try { const opened = await deps.openJimakuModal(); diff --git a/src/main/runtime/playback-window-ready.test.ts b/src/main/runtime/playback-window-ready.test.ts new file mode 100644 index 00000000..43d48aa8 --- /dev/null +++ b/src/main/runtime/playback-window-ready.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { waitForPlaybackWindow } from './playback-window-ready'; + +function createClock() { + let time = 0; + return { + now: () => time, + wait: async (ms: number) => { + time += ms; + }, + }; +} + +test('resolves once the video output is configured and the tracker has the window', async () => { + const clock = createClock(); + let probes = 0; + let tracked = false; + const ready = await waitForPlaybackWindow({ + ...clock, + isWindowTracked: () => tracked, + readProperty: async () => { + probes += 1; + if (probes === 1) throw new Error('property unavailable'); + if (probes === 3) tracked = true; + return probes >= 2; + }, + probeIntervalMs: 100, + }); + + assert.equal(ready, true); + assert.equal(probes, 3); + assert.equal(clock.now(), 200); +}); + +test('a configured video output is enough when no window tracker is running', async () => { + const ready = await waitForPlaybackWindow({ + ...createClock(), + isWindowTracked: () => null, + readProperty: async () => true, + }); + + assert.equal(ready, true); +}); + +test('gives up after the wall-clock budget when no window appears', async () => { + const clock = createClock(); + const ready = await waitForPlaybackWindow({ + ...clock, + isWindowTracked: () => false, + readProperty: async () => true, + timeoutMs: 1000, + probeIntervalMs: 300, + }); + + assert.equal(ready, false); + assert.equal(clock.now(), 1000); +}); diff --git a/src/main/runtime/playback-window-ready.ts b/src/main/runtime/playback-window-ready.ts new file mode 100644 index 00000000..a85b7af1 --- /dev/null +++ b/src/main/runtime/playback-window-ready.ts @@ -0,0 +1,45 @@ +/** + * Waits until mpv has a video window the overlay can attach to. + * + * mpv reports a new `path` as soon as loading starts, which for a network + * stream is seconds before any window exists (idle mpv shows none). A modal + * opened on the path change lands on a blank desktop with fallback geometry. + * Ready means the video output is configured (`vo-configured`, a window with a + * frame in it) and, when a window tracker is running, that it has located the + * window so overlay geometry follows it. + */ +export interface WaitForPlaybackWindowDeps { + /** Tracker state, or null when no window tracker is running. */ + isWindowTracked: () => boolean | null; + /** One-shot mpv property read; may reject while the file is still loading. */ + readProperty: (name: string) => Promise; + wait: (ms: number) => Promise; + now?: () => number; + timeoutMs?: number; + probeIntervalMs?: number; +} + +export const DEFAULT_PLAYBACK_WINDOW_TIMEOUT_MS = 20_000; +const DEFAULT_PROBE_INTERVAL_MS = 200; + +/** Resolves true once the window is ready, false when the wall-clock budget runs out. */ +export async function waitForPlaybackWindow(deps: WaitForPlaybackWindowDeps): Promise { + const now = deps.now ?? Date.now; + const timeoutMs = deps.timeoutMs ?? DEFAULT_PLAYBACK_WINDOW_TIMEOUT_MS; + const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS; + const deadline = now() + timeoutMs; + + for (;;) { + let voConfigured = false; + try { + voConfigured = (await deps.readProperty('vo-configured')) === true; + } catch { + // Unreadable between files or before mpv connects; keep polling. + } + if (voConfigured && deps.isWindowTracked() !== false) return true; + + const remaining = deadline - now(); + if (remaining <= 0) return false; + await deps.wait(Math.min(probeIntervalMs, remaining)); + } +}