fix(anime): wait for mpv window before opening Jimaku

- Hide the standalone browser during the Jimaku handoff
- Skip stale or windowless playback flows safely
This commit is contained in:
2026-09-03 01:03:16 -07:00
parent 89479f7045
commit cd7a65ec80
7 changed files with 215 additions and 15 deletions
@@ -7,6 +7,7 @@ function createHarness(options?: {
animeMedia?: boolean | ((mediaPath: string) => boolean);
paused?: boolean | null;
opened?: boolean;
windowReady?: () => Promise<boolean>;
}) {
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<boolean>((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');
@@ -4,7 +4,10 @@ export interface AnimeBrowserJimakuAutoOpenDeps {
getCurrentMediaPath: () => string | null;
getPlaybackPaused: () => Promise<boolean | null>;
setPlaybackPaused: (paused: boolean) => void;
/** Resolves true once mpv shows a video window; false when it never appears. */
waitForPlaybackWindow: () => Promise<boolean>;
closeAnimeBrowserModal: () => void;
hideAnimeBrowserWindow: () => void;
openJimakuModal: () => Promise<boolean>;
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();
@@ -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);
});
+45
View File
@@ -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<unknown>;
wait: (ms: number) => Promise<void>;
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<boolean> {
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));
}
}