diff --git a/changes/fix-managed-playback-overlay-lifecycle.md b/changes/fix-managed-playback-overlay-lifecycle.md new file mode 100644 index 00000000..d88dab2e --- /dev/null +++ b/changes/fix-managed-playback-overlay-lifecycle.md @@ -0,0 +1,4 @@ +type: fixed +area: playback + +- Fixed managed mpv startup so launcher-owned videos quit SubMiner when playback ends, background/tray sessions stay alive, and pause-until-ready waits for the overlay and tokenization readiness before playback resumes. diff --git a/plugin/subminer/environment.lua b/plugin/subminer/environment.lua index df5a1f3e..3b6b338d 100644 --- a/plugin/subminer/environment.lua +++ b/plugin/subminer/environment.lua @@ -18,8 +18,14 @@ function M.create(ctx) local function is_macos() local platform = mp.get_property("platform") or "" - if platform == "macos" or platform == "darwin" then - return true + if platform ~= "" then + local normalized = platform:lower() + if normalized == "macos" or normalized == "darwin" or normalized == "osx" then + return true + end + if normalized == "windows" or normalized == "win32" or normalized == "linux" then + return false + end end local ostype = os.getenv("OSTYPE") or "" return ostype:find("darwin") ~= nil diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index fcfedb93..bb17b36d 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -1,5 +1,8 @@ local M = {} +local AUTO_START_SOCKET_RETRY_DELAY_SECONDS = 0.2 +local AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS = 25 + function M.create(ctx) local mp = ctx.mp local opts = ctx.opts @@ -52,6 +55,11 @@ function M.create(ctx) return options_helper.coerce_bool(raw_auto_start, false) end + local function next_auto_start_retry_generation() + state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1 + return state.auto_start_retry_generation + end + local function rearm_managed_subtitle_defaults() if not process.has_matching_mpv_ipc_socket(opts.socket_path) then return false @@ -63,13 +71,58 @@ function M.create(ctx) return true end + local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt) + if generation ~= state.auto_start_retry_generation then + return + end + if media_identity ~= nil and state.current_media_identity ~= media_identity then + return + end + if not resolve_auto_start_enabled() then + schedule_aniskip_fetch("file-loaded", 0) + return + end + + local has_matching_socket = rearm_managed_subtitle_defaults() + if not has_matching_socket then + if attempt < AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS then + mp.add_timeout(AUTO_START_SOCKET_RETRY_DELAY_SECONDS, function() + start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt + 1) + end) + return + end + subminer_log( + "info", + "lifecycle", + "Skipping auto-start: input-ipc-server does not match configured socket_path" + ) + schedule_aniskip_fetch("file-loaded", 0) + return + end + + process.start_overlay({ + auto_start_trigger = true, + socket_path = opts.socket_path, + rearm_pause_until_ready = not same_media_loaded, + }) + -- Give the overlay process a moment to initialize before querying AniSkip. + schedule_aniskip_fetch("overlay-start", 0.8) + end + local function on_file_loaded() local media_identity = resolve_media_identity() + local retry_generation = next_auto_start_retry_generation() + local previous_media_identity = state.current_media_identity local same_media_reload = ( media_identity ~= nil and state.pending_reload_media_identity ~= nil and media_identity == state.pending_reload_media_identity ) + local same_media_loaded = ( + media_identity ~= nil + and previous_media_identity ~= nil + and media_identity == previous_media_identity + ) state.pending_reload_media_identity = nil state.current_media_identity = media_identity @@ -92,32 +145,18 @@ function M.create(ctx) if not preserve_active_auto_start_gate then process.disarm_auto_play_ready_gate() end - has_matching_socket = rearm_managed_subtitle_defaults() if should_auto_start then - if not has_matching_socket then - subminer_log( - "info", - "lifecycle", - "Skipping auto-start: input-ipc-server does not match configured socket_path" - ) - schedule_aniskip_fetch("file-loaded", 0) - return - end - - process.start_overlay({ - auto_start_trigger = true, - socket_path = opts.socket_path, - }) - -- Give the overlay process a moment to initialize before querying AniSkip. - schedule_aniskip_fetch("overlay-start", 0.8) + start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1) return end + rearm_managed_subtitle_defaults() schedule_aniskip_fetch("file-loaded", 0) end local function on_shutdown() + next_auto_start_retry_generation() aniskip.clear_aniskip_state() hover.clear_hover_overlay() process.disarm_auto_play_ready_gate() @@ -139,6 +178,8 @@ function M.create(ctx) state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity() return end + next_auto_start_retry_generation() + state.current_media_identity = nil state.pending_reload_media_identity = nil if state.overlay_running and reason ~= "quit" then process.hide_visible_overlay() diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 58fdf504..1306ffbe 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -207,7 +207,6 @@ function M.create(ctx) end if action == "start" then - table.insert(args, "--background") table.insert(args, "--managed-playback") local backend = resolve_backend(overrides.backend) @@ -411,7 +410,15 @@ function M.create(ctx) if overrides.auto_start_trigger == true then subminer_log("debug", "process", "Auto-start ignored because overlay is already running") local socket_path = overrides.socket_path or opts.socket_path - if not state.auto_play_ready_gate_armed then + local should_pause_until_ready = ( + overrides.rearm_pause_until_ready == true + and resolve_visible_overlay_startup() + and resolve_pause_until_ready() + and has_matching_mpv_ipc_socket(socket_path) + ) + if should_pause_until_ready then + arm_auto_play_ready_gate() + elseif not state.auto_play_ready_gate_armed then disarm_auto_play_ready_gate() end local visibility_action = resolve_visible_overlay_startup() diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 76e4e607..7444e5ad 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -37,6 +37,7 @@ function M.new() force_ready_overlay_restore = false, current_media_identity = nil, pending_reload_media_identity = nil, + auto_start_retry_generation = 0, session_binding_generation = 0, session_binding_names = {}, session_numeric_binding_names = {}, diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 02da0d8e..adb1511b 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -23,6 +23,11 @@ local function run_plugin_scenario(config) return config.platform or "linux" end if name == "input-ipc-server" then + if config.input_ipc_server_sequence then + config.input_ipc_server_sequence_index = (config.input_ipc_server_sequence_index or 0) + 1 + local index = config.input_ipc_server_sequence_index + return config.input_ipc_server_sequence[index] or config.input_ipc_server_sequence[#config.input_ipc_server_sequence] or "" + end return config.input_ipc_server or "" end if name == "filename/no-ext" then @@ -638,6 +643,97 @@ do ) end +do + local scenario = { + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + path = "/media/episode-01.mkv", + media_title = "Episode 1", + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for new-media rearm scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-autoplay-ready"]() + fire_event(recorded, "end-file", { reason = "eof" }) + scenario.path = "/media/episode-02.mkv" + scenario.media_title = "Episode 2" + fire_event(recorded, "file-loaded") + assert_true( + count_start_calls(recorded.async_calls) == 1, + "new media after prior playback should reuse the running overlay" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", true) == 2, + "new media after prior playback should re-arm pause-until-ready" + ) + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 2, + "new media after prior playback should resume only after readiness" + ) +end + +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server_sequence = { "", "", "/tmp/subminer-socket" }, + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for delayed socket auto-start scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true(find_start_call(recorded.async_calls) ~= nil, "delayed socket auto-start should eventually issue --start") + assert_true( + has_property_set(recorded.property_sets, "pause", true), + "delayed socket auto-start should arm pause-until-ready once the socket is available" + ) +end + +do + local recorded, err = run_plugin_scenario({ + process_list = "", + platform = "osx", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for macOS platform alias scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + local start_call = find_start_call(recorded.async_calls) + assert_true(start_call ~= nil, "macOS platform alias auto-start should issue --start") + assert_true( + call_has_arg(start_call, "macos"), + "macOS platform alias auto-start should pass macos backend instead of falling back to x11" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -661,10 +757,17 @@ do assert_true(call ~= nil, "AppImage start should issue an async subprocess") assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags") assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment") - assert_true(env_has(call, "SUBMINER_APP_ARGC=8"), "AppImage subprocess should transport app arg count") + assert_true(env_has(call, "SUBMINER_APP_ARGC=7"), "AppImage subprocess should transport app arg count") assert_true(env_has(call, "SUBMINER_APP_ARG_0=--start"), "AppImage subprocess should transport --start") - assert_true(env_has(call, "SUBMINER_APP_ARG_1=--background"), "AppImage subprocess should transport --background") - assert_true(env_has(call, "SUBMINER_APP_ARG_7=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag") + assert_true( + env_has(call, "SUBMINER_APP_ARG_1=--managed-playback"), + "AppImage subprocess should transport --managed-playback" + ) + assert_true( + not env_has(call, "SUBMINER_APP_ARG_1=--background"), + "AppImage subprocess should not transport --background for video-owned playback" + ) + assert_true(env_has(call, "SUBMINER_APP_ARG_6=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag") assert_true(env_has_prefix(call, "SUBMINER_APP_LOG="), "AppImage subprocess should include app log env") assert_true(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env") assert_true( @@ -1170,7 +1273,10 @@ do fire_event(recorded, "file-loaded") local start_call = find_start_call(recorded.async_calls) assert_true(start_call ~= nil, "auto-start should issue --start command") - assert_true(call_has_arg(start_call, "--background"), "auto-start should launch SubMiner in background mode") + assert_true( + not call_has_arg(start_call, "--background"), + "auto-start should not mark video-owned playback as background/tray mode" + ) assert_true( call_has_arg(start_call, "--managed-playback"), "auto-start should mark SubMiner as launcher-managed playback" diff --git a/src/anki-connect.test.ts b/src/anki-connect.test.ts index a31ea362..c3988746 100644 --- a/src/anki-connect.test.ts +++ b/src/anki-connect.test.ts @@ -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'], diff --git a/src/anki-connect.ts b/src/anki-connect.ts index 6d5f1f52..53c29252 100644 --- a/src/anki-connect.ts +++ b/src/anki-connect.ts @@ -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 []; } diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index e7aca271..b050414c 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -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) | null = null; + let releaseReady: (() => void) | null = null; + const readyFinished = new Promise((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)(); + }; + + 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']); +}); diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index 830ade75..74028d39 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -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(() => { diff --git a/src/main.ts b/src/main.ts index 98aa6a00..cbd8a68d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { 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 | 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') { diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index e9bd557a..102fd992 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -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']); +}); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index 4ab2bd70..83943633 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -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: () => diff --git a/src/main/runtime/autoplay-ready-gate.test.ts b/src/main/runtime/autoplay-ready-gate.test.ts index 17c053a9..07e0a381 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -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> = []; + 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 }) => { + 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, + ); +}); diff --git a/src/main/runtime/autoplay-ready-gate.ts b/src/main/runtime/autoplay-ready-gate.ts index 89c7d2ed..56fe369e 100644 --- a/src/main/runtime/autoplay-ready-gate.ts +++ b/src/main/runtime/autoplay-ready-gate.ts @@ -14,6 +14,7 @@ export type AutoplayReadyGateDeps = { getPlaybackPaused: () => boolean | null; getMpvClient: () => MpvClientLike | null; signalPluginAutoplayReady: () => void; + isSignalTargetReady?: () => boolean; schedule: (callback: () => void, delayMs: number) => ReturnType; 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, diff --git a/src/main/runtime/autoplay-tokenization-warm-release.test.ts b/src/main/runtime/autoplay-tokenization-warm-release.test.ts new file mode 100644 index 00000000..be5faa5a --- /dev/null +++ b/src/main/runtime/autoplay-tokenization-warm-release.test.ts @@ -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((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']); +}); diff --git a/src/main/runtime/autoplay-tokenization-warm-release.ts b/src/main/runtime/autoplay-tokenization-warm-release.ts new file mode 100644 index 00000000..13c1bd9b --- /dev/null +++ b/src/main/runtime/autoplay-tokenization-warm-release.ts @@ -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; + 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); + }); + }; +} diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index 96fbb369..fbb026ca 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -18,6 +18,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler stopConfigHotReload: () => {}, restorePreviousSecondarySubVisibility: () => {}, restoreMpvSubVisibility: () => {}, + isAppReady: () => true, unregisterAllGlobalShortcuts: () => {}, stopSubtitleWebsocket: () => {}, stopTexthookerService: () => {}, diff --git a/src/main/runtime/current-media-tokenization-gate.test.ts b/src/main/runtime/current-media-tokenization-gate.test.ts index 20039f60..66ba5b31 100644 --- a/src/main/runtime/current-media-tokenization-gate.test.ts +++ b/src/main/runtime/current-media-tokenization-gate.test.ts @@ -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; }); diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index 604301c0..9066bedf 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -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}`), diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index b35c3d02..7b0ce30f 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -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')); diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 10569e21..e1f33c9a 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -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'); diff --git a/src/main/runtime/startup-tray-policy.test.ts b/src/main/runtime/startup-tray-policy.test.ts index 59f20c67..2234ea04 100644 --- a/src/main/runtime/startup-tray-policy.test.ts +++ b/src/main/runtime/startup-tray-policy.test.ts @@ -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, + ); +}); diff --git a/src/main/runtime/startup-tray-policy.ts b/src/main/runtime/startup-tray-policy.ts index 64e4f8b1..97d8f0ef 100644 --- a/src/main/runtime/startup-tray-policy.ts +++ b/src/main/runtime/startup-tray-policy.ts @@ -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; +}