mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
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:
@@ -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
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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
@@ -310,6 +310,7 @@ import {
|
||||
importYomitanDictionaryFromZip,
|
||||
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
|
||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||
isOverlayWindowContentReady,
|
||||
jellyfinTicksToSecondsRuntime,
|
||||
listJellyfinItemsRuntime,
|
||||
listJellyfinLibrariesRuntime,
|
||||
@@ -361,6 +362,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,
|
||||
@@ -400,6 +402,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';
|
||||
@@ -1091,6 +1094,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),
|
||||
});
|
||||
@@ -1580,6 +1590,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 =
|
||||
@@ -3415,6 +3426,7 @@ const {
|
||||
restoreMpvSubVisibility: () => {
|
||||
restoreOverlayMpvSubtitles({ force: true });
|
||||
},
|
||||
isAppReady: () => app.isReady(),
|
||||
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
|
||||
stopSubtitleWebsocket: () => {
|
||||
subtitleWsService.stop();
|
||||
@@ -4030,6 +4042,9 @@ async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
}
|
||||
|
||||
let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined) => void) | null =
|
||||
null;
|
||||
|
||||
const {
|
||||
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
|
||||
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
|
||||
@@ -4124,15 +4139,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;
|
||||
@@ -4196,7 +4203,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: {
|
||||
@@ -4266,15 +4278,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) =>
|
||||
@@ -4351,6 +4359,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 {
|
||||
@@ -5677,6 +5700,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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user