fix: managed playback overlay lifecycle for launcher-owned sessions

- Remove --background from launcher-owned mpv starts; quit only non-tray/non-background managed sessions
- Defer autoplay-ready signal until overlay window content is loaded; retry after flush
- Retry socket availability before auto-starting overlay (up to 25 attempts, 200ms apart)
- Extract warm tokenization signal into autoplay-tokenization-warm-release with stale-media guard
- Queue second-instance commands until app ready runtime completes
- Guard globalShortcut cleanup with isAppReady check to avoid pre-ready crash
- Recognize "osx" as a macOS platform alias in Lua environment detection
This commit is contained in:
2026-05-19 20:56:17 -07:00
parent e4165a418c
commit 403ee32579
24 changed files with 606 additions and 52 deletions
+4 -1
View File
@@ -147,7 +147,10 @@ test('AnkiConnectClient treats negative deck note sample sizes as empty samples'
},
};
assert.deepEqual(await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining', -1), []);
assert.deepEqual(
await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining', -1),
[],
);
assert.deepEqual(
calls.map((call) => call.action),
['findNotes'],
+4 -1
View File
@@ -188,7 +188,10 @@ export class AnkiConnectClient {
}
const finiteSampleSize = Number.isFinite(sampleSize) ? sampleSize : 0;
const normalizedSampleSize = Math.min(noteIds.length, Math.max(0, Math.floor(finiteSampleSize)));
const normalizedSampleSize = Math.min(
noteIds.length,
Math.max(0, Math.floor(finiteSampleSize)),
);
if (normalizedSampleSize === 0) {
return [];
}
+55
View File
@@ -168,3 +168,58 @@ test('startAppLifecycle app ping exits zero immediately when another instance ow
assert.equal(lockCalls, 1);
assert.deepEqual(calls, ['exit:0']);
});
test('startAppLifecycle queues second-instance commands until app ready runtime completes', async () => {
const handled: string[] = [];
let secondInstanceHandler: ((_event: unknown, argv: string[]) => void) | null = null;
let readyHandler: (() => Promise<void>) | null = null;
let releaseReady: (() => void) | null = null;
const readyFinished = new Promise<void>((resolve) => {
releaseReady = resolve;
});
const { deps } = createDeps({
shouldStartApp: () => true,
onSecondInstance: (handler) => {
secondInstanceHandler = handler;
},
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
handleCliCommand: (args, source) => {
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
},
whenReady: (handler) => {
readyHandler = handler;
},
onReady: async () => {
await readyFinished;
handled.push('ready');
},
});
startAppLifecycle(makeArgs({ background: true }), deps);
const runSecondInstance = (argv: string[]) => {
assert.ok(secondInstanceHandler);
(secondInstanceHandler as (_event: unknown, argv: string[]) => void)({}, argv);
};
const runReady = () => {
assert.ok(readyHandler);
return (readyHandler as () => Promise<void>)();
};
runSecondInstance(['SubMiner', '--start']);
assert.deepEqual(handled, []);
const readyRun = runReady();
await Promise.resolve();
assert.deepEqual(handled, []);
assert.ok(releaseReady);
(releaseReady as () => void)();
await readyRun;
assert.deepEqual(handled, ['ready', 'second-instance:start']);
runSecondInstance(['SubMiner', '--start']);
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
});
+28 -1
View File
@@ -114,9 +114,34 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
return;
}
let appReadyRuntimeComplete = false;
const pendingSecondInstanceCommands: CliArgs[] = [];
const handleSecondInstanceCommand = (args: CliArgs): void => {
try {
deps.handleCliCommand(args, 'second-instance');
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
};
const flushPendingSecondInstanceCommands = (): void => {
while (pendingSecondInstanceCommands.length > 0) {
const nextArgs = pendingSecondInstanceCommands.shift();
if (nextArgs) {
handleSecondInstanceCommand(nextArgs);
}
}
};
deps.onSecondInstance((_event, argv) => {
try {
deps.handleCliCommand(deps.parseArgs(argv), 'second-instance');
const nextArgs = deps.parseArgs(argv);
if (!appReadyRuntimeComplete) {
pendingSecondInstanceCommands.push(nextArgs);
return;
}
handleSecondInstanceCommand(nextArgs);
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
@@ -134,6 +159,8 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
deps.whenReady(async () => {
await deps.onReady();
appReadyRuntimeComplete = true;
flushPendingSecondInstanceCommands();
});
deps.onWindowAllClosed(() => {
+39 -15
View File
@@ -311,6 +311,7 @@ import {
importYomitanDictionaryFromZip,
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
initializeOverlayRuntime as initializeOverlayRuntimeCore,
isOverlayWindowContentReady,
jellyfinTicksToSecondsRuntime,
listJellyfinItemsRuntime,
listJellyfinLibrariesRuntime,
@@ -362,6 +363,7 @@ import {
createYoutubePrimarySubtitleNotificationRuntime,
} from './main/runtime/youtube-primary-subtitle-notification';
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release';
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
import {
buildFirstRunSetupHtml,
@@ -401,6 +403,7 @@ import {
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import {
shouldEnsureTrayOnStartupForInitialArgs,
shouldQuitOnMpvShutdownForTrayState,
shouldQuitOnWindowAllClosedForTrayState,
} from './main/runtime/startup-tray-policy';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
@@ -1102,6 +1105,13 @@ const autoplayReadyGate = createAutoplayReadyGate({
signalPluginAutoplayReady: () => {
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => {
if (!overlayManager.getVisibleOverlayVisible()) {
return true;
}
const overlayWindow = overlayManager.getMainWindow();
return Boolean(overlayWindow && isOverlayWindowContentReady(overlayWindow));
},
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
logDebug: (message) => logger.debug(message),
});
@@ -1591,6 +1601,7 @@ function emitSubtitlePayload(payload: SubtitleData): void {
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
});
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
subtitlePrefetchService?.resume();
}
const buildSubtitleProcessingControllerMainDepsHandler =
@@ -3445,6 +3456,7 @@ const {
restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles({ force: true });
},
isAppReady: () => app.isReady(),
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
stopSubtitleWebsocket: () => {
subtitleWsService.stop();
@@ -4074,6 +4086,9 @@ async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
ensureOverlayWindowsReadyForVisibilityActions();
}
let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined) => void) | null =
null;
const {
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
@@ -4168,15 +4183,7 @@ const {
syncImmersionMediaState: () => {
immersionMediaRuntime.syncFromCurrentMediaState();
},
signalAutoplayReadyIfWarm: () => {
if (!isTokenizationWarmupReady()) {
return;
}
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
},
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
return;
@@ -4240,7 +4247,12 @@ const {
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer;
},
shouldQuitOnMpvShutdown: () => appState.initialArgs?.managedPlayback === true,
shouldQuitOnMpvShutdown: () =>
shouldQuitOnMpvShutdownForTrayState({
managedPlayback: appState.initialArgs?.managedPlayback === true,
backgroundMode: appState.backgroundMode,
hasTray: Boolean(appTray),
}),
requestAppQuit: () => requestAppQuit(),
},
updateMpvSubtitleRenderMetricsMainDeps: {
@@ -4310,15 +4322,11 @@ const {
getFrequencyRank: (text) => appState.frequencyRankLookup(text),
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
getMecabTokenizer: () => appState.mecabTokenizer,
onTokenizationReady: (text) => {
onTokenizationReady: () => {
currentMediaTokenizationGate.markReady(
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
);
startupOsdSequencer.markTokenizationReady();
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text, tokens: null },
{ forceWhilePaused: true },
);
},
},
createTokenizerRuntimeDeps: (deps) =>
@@ -4395,6 +4403,21 @@ const {
},
},
});
signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => isTokenizationWarmupReady(),
startTokenizationWarmups: async () => {
await startTokenizationWarmups();
},
getCurrentMediaPath: () =>
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
signalAutoplayReady: () => {
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
},
warn: (message, error) => logger.warn(message, error),
});
tokenizeSubtitleDeferred = tokenizeSubtitle;
function createMpvClientRuntimeService(): MpvIpcClient {
@@ -5737,6 +5760,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
autoplayReadyGate.flushPendingAutoplayReadySignal();
},
onWindowClosed: (windowKind) => {
if (windowKind === 'visible') {
@@ -15,6 +15,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
@@ -102,6 +103,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
@@ -148,3 +150,51 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
assert.deepEqual(calls, []);
});
test('cleanup deps builder skips global shortcut cleanup before app ready', () => {
const calls: string[] = [];
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
isAppReady: () => false,
unregisterAllGlobalShortcuts: () => {
throw new Error('globalShortcut cannot be used before the app is ready');
},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
clearMainOverlayWindow: () => {},
getModalOverlayWindow: () => null,
clearModalOverlayWindow: () => {},
getYomitanParserWindow: () => null,
clearYomitanParserState: () => {},
getWindowTracker: () => null,
flushMpvLog: () => {},
getMpvSocket: () => null,
getReconnectTimer: () => null,
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
getFirstRunSetupWindow: () => null,
clearFirstRunSetupWindow: () => {},
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
stopDiscordPresenceService: () => {},
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
cleanup();
assert.deepEqual(calls, ['destroy-tray']);
});
@@ -22,6 +22,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void;
restoreMpvSubVisibility: () => void;
isAppReady: () => boolean;
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
@@ -63,7 +64,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopConfigHotReload: () => deps.stopConfigHotReload(),
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
unregisterAllGlobalShortcuts: () => {
if (!deps.isAppReady()) return;
deps.unregisterAllGlobalShortcuts();
},
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
@@ -143,3 +143,52 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
1,
);
});
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
const commands: Array<Array<string | boolean>> = [];
let targetReady = false;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => targetReady,
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
targetReady = true;
gate.flushPendingAutoplayReadySignal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
true,
);
});
+28
View File
@@ -14,6 +14,7 @@ export type AutoplayReadyGateDeps = {
getPlaybackPaused: () => boolean | null;
getMpvClient: () => MpvClientLike | null;
signalPluginAutoplayReady: () => void;
isSignalTargetReady?: () => boolean;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logDebug: (message: string) => void;
};
@@ -21,12 +22,19 @@ export type AutoplayReadyGateDeps = {
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0;
let pendingAutoplayReadySignal: {
payload: SubtitleData;
options?: { forceWhilePaused?: boolean };
} | null = null;
const invalidatePendingAutoplayReadyFallbacks = (): void => {
autoPlayReadySignalMediaPath = null;
pendingAutoplayReadySignal = null;
autoPlayReadySignalGeneration += 1;
};
const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true;
const maybeSignalPluginAutoplayReady = (
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
@@ -104,16 +112,36 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
};
if (duplicateMediaSignal) {
pendingAutoplayReadySignal = null;
return;
}
if (!isSignalTargetReady()) {
pendingAutoplayReadySignal = { payload, options };
deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`,
);
return;
}
pendingAutoplayReadySignal = null;
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
attemptRelease(playbackGeneration, 0);
};
const flushPendingAutoplayReadySignal = (): void => {
if (!pendingAutoplayReadySignal || !isSignalTargetReady()) {
return;
}
const pendingSignal = pendingAutoplayReadySignal;
pendingAutoplayReadySignal = null;
maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options);
};
return {
flushPendingAutoplayReadySignal,
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
invalidatePendingAutoplayReadyFallbacks,
maybeSignalPluginAutoplayReady,
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAutoplayTokenizationWarmRelease } from './autoplay-tokenization-warm-release';
test('autoplay tokenization warm release signals immediately when warmups are ready', () => {
const calls: string[] = [];
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => true,
startTokenizationWarmups: async () => {
calls.push('warmup');
},
getCurrentMediaPath: () => '/tmp/video.mkv',
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video.mkv');
assert.deepEqual(calls, ['signal']);
});
test('autoplay tokenization warm release waits for warmups before signaling current media', async () => {
const calls: string[] = [];
let resolveWarmup!: () => void;
const warmup = new Promise<void>((resolve) => {
resolveWarmup = resolve;
});
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => false,
startTokenizationWarmups: async () => {
calls.push('warmup');
await warmup;
},
getCurrentMediaPath: () => '/tmp/video.mkv',
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video.mkv');
await Promise.resolve();
assert.deepEqual(calls, ['warmup']);
resolveWarmup();
await warmup;
await Promise.resolve();
assert.deepEqual(calls, ['warmup', 'signal']);
});
test('autoplay tokenization warm release skips stale media after warmup resolves', async () => {
const calls: string[] = [];
let currentMediaPath = '/tmp/video-2.mkv';
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => false,
startTokenizationWarmups: async () => {
calls.push('warmup');
},
getCurrentMediaPath: () => currentMediaPath,
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video-1.mkv');
await Promise.resolve();
currentMediaPath = '/tmp/video-3.mkv';
await Promise.resolve();
assert.deepEqual(calls, ['warmup']);
});
@@ -0,0 +1,42 @@
function normalizeMediaPath(mediaPath: string | null | undefined): string | null {
if (typeof mediaPath !== 'string') {
return null;
}
const trimmed = mediaPath.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function createAutoplayTokenizationWarmRelease(deps: {
isTokenizationWarmupReady: () => boolean;
startTokenizationWarmups: () => Promise<void>;
getCurrentMediaPath: () => string | null | undefined;
signalAutoplayReady: () => void;
warn: (message: string, error: unknown) => void;
}): (mediaPath: string | null | undefined) => void {
const signalIfCurrent = (mediaPath: string): void => {
const currentMediaPath = normalizeMediaPath(deps.getCurrentMediaPath());
if (currentMediaPath && currentMediaPath !== mediaPath) {
return;
}
deps.signalAutoplayReady();
};
return (mediaPath) => {
const normalizedPath = normalizeMediaPath(mediaPath);
if (!normalizedPath) {
return;
}
if (deps.isTokenizationWarmupReady()) {
signalIfCurrent(normalizedPath);
return;
}
void deps
.startTokenizationWarmups()
.then(() => {
signalIfCurrent(normalizedPath);
})
.catch((error) => {
deps.warn('Startup tokenization warmup failed before autoplay readiness release:', error);
});
};
}
@@ -18,6 +18,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
@@ -41,18 +41,16 @@ test('current media tokenization gate returns immediately for ready media', asyn
await gate.waitUntilReady('/tmp/video-1.mkv');
});
test('current media tokenization gate stays ready for later media after first warmup', async () => {
test('current media tokenization gate treats later media as ready after warmup completes', async () => {
const gate = createCurrentMediaTokenizationGate();
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
gate.markReady('/tmp/video-1.mkv');
gate.updateCurrentMediaPath('/tmp/video-2.mkv');
let resolved = false;
const waitPromise = gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
await gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
resolved = true;
});
await Promise.resolve();
assert.equal(resolved, true);
await waitPromise;
});
@@ -119,7 +119,6 @@ test('media path change handler reports stop for empty path and probes media key
syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'),
});
@@ -138,7 +137,7 @@ test('media path change handler reports stop for empty path and probes media key
]);
});
test('media path change handler signals autoplay-ready fast path for warm non-empty media', () => {
test('media path change handler signals autoplay readiness from warm media path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
@@ -45,6 +45,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`),
@@ -72,6 +73,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('subtitle-track-change')?.({ sid: 3 });
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
handlers.get('media-path-change')?.({ path: '/tmp/video.mkv' });
handlers.get('media-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
@@ -85,7 +87,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.ok(calls.includes('subtitle-track-change'));
assert.ok(calls.includes('subtitle-track-list-change'));
assert.ok(calls.includes('media-title:Episode 1'));
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('media-path:/tmp/video.mkv'));
assert.ok(calls.includes('autoplay:/tmp/video.mkv'));
assert.ok(calls.includes('reset-guess-state'));
assert.ok(calls.includes('notify-title:Episode 1'));
assert.ok(calls.includes('post-watch:901'));
@@ -92,7 +92,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.maybeProbeAnilistDuration('media-key');
deps.ensureAnilistMediaGuess('media-key');
deps.syncImmersionMediaState();
deps.signalAutoplayReadyIfWarm('/tmp/video');
deps.signalAutoplayReadyIfWarm?.('/tmp/video');
deps.updateCurrentMediaTitle('title');
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate('title');
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
shouldEnsureTrayOnStartupForInitialArgs,
shouldQuitOnMpvShutdownForTrayState,
shouldQuitOnWindowAllClosedForTrayState,
} from './startup-tray-policy';
@@ -42,3 +43,25 @@ test('window-all-closed keeps background app alive without tray', () => {
false,
);
});
test('mpv shutdown keeps managed background tray app alive', () => {
assert.equal(
shouldQuitOnMpvShutdownForTrayState({
managedPlayback: true,
backgroundMode: true,
hasTray: true,
}),
false,
);
});
test('mpv shutdown quits standalone managed playback without tray residency', () => {
assert.equal(
shouldQuitOnMpvShutdownForTrayState({
managedPlayback: true,
backgroundMode: false,
hasTray: false,
}),
true,
);
});
+11
View File
@@ -21,3 +21,14 @@ export function shouldQuitOnWindowAllClosedForTrayState(options: {
if (options.hasTray) return false;
return true;
}
export function shouldQuitOnMpvShutdownForTrayState(options: {
managedPlayback: boolean;
backgroundMode: boolean;
hasTray: boolean;
}): boolean {
if (!options.managedPlayback) return false;
if (options.backgroundMode) return false;
if (options.hasTray) return false;
return true;
}