mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
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:
+17
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user