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 1105b18a5a
commit 167004b2c9
24 changed files with 606 additions and 52 deletions
@@ -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.
+8 -2
View File
@@ -18,8 +18,14 @@ function M.create(ctx)
local function is_macos() local function is_macos()
local platform = mp.get_property("platform") or "" local platform = mp.get_property("platform") or ""
if platform == "macos" or platform == "darwin" then if platform ~= "" then
return true 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 end
local ostype = os.getenv("OSTYPE") or "" local ostype = os.getenv("OSTYPE") or ""
return ostype:find("darwin") ~= nil return ostype:find("darwin") ~= nil
+58 -17
View File
@@ -1,5 +1,8 @@
local M = {} local M = {}
local AUTO_START_SOCKET_RETRY_DELAY_SECONDS = 0.2
local AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS = 25
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
local opts = ctx.opts local opts = ctx.opts
@@ -52,6 +55,11 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_auto_start, false) return options_helper.coerce_bool(raw_auto_start, false)
end 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() local function rearm_managed_subtitle_defaults()
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
return false return false
@@ -63,13 +71,58 @@ function M.create(ctx)
return true return true
end 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 function on_file_loaded()
local media_identity = resolve_media_identity() 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 = ( local same_media_reload = (
media_identity ~= nil media_identity ~= nil
and state.pending_reload_media_identity ~= nil and state.pending_reload_media_identity ~= nil
and media_identity == state.pending_reload_media_identity 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.pending_reload_media_identity = nil
state.current_media_identity = media_identity state.current_media_identity = media_identity
@@ -92,32 +145,18 @@ function M.create(ctx)
if not preserve_active_auto_start_gate then if not preserve_active_auto_start_gate then
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
end end
has_matching_socket = rearm_managed_subtitle_defaults()
if should_auto_start then if should_auto_start then
if not has_matching_socket then start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1)
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)
return return
end end
rearm_managed_subtitle_defaults()
schedule_aniskip_fetch("file-loaded", 0) schedule_aniskip_fetch("file-loaded", 0)
end end
local function on_shutdown() local function on_shutdown()
next_auto_start_retry_generation()
aniskip.clear_aniskip_state() aniskip.clear_aniskip_state()
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate() 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() state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
return return
end end
next_auto_start_retry_generation()
state.current_media_identity = nil
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
if state.overlay_running and reason ~= "quit" then if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay() process.hide_visible_overlay()
+9 -2
View File
@@ -207,7 +207,6 @@ function M.create(ctx)
end end
if action == "start" then if action == "start" then
table.insert(args, "--background")
table.insert(args, "--managed-playback") table.insert(args, "--managed-playback")
local backend = resolve_backend(overrides.backend) local backend = resolve_backend(overrides.backend)
@@ -411,7 +410,15 @@ function M.create(ctx)
if overrides.auto_start_trigger == true then if overrides.auto_start_trigger == true then
subminer_log("debug", "process", "Auto-start ignored because overlay is already running") subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
local socket_path = overrides.socket_path or opts.socket_path 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() disarm_auto_play_ready_gate()
end end
local visibility_action = resolve_visible_overlay_startup() local visibility_action = resolve_visible_overlay_startup()
+1
View File
@@ -37,6 +37,7 @@ function M.new()
force_ready_overlay_restore = false, force_ready_overlay_restore = false,
current_media_identity = nil, current_media_identity = nil,
pending_reload_media_identity = nil, pending_reload_media_identity = nil,
auto_start_retry_generation = 0,
session_binding_generation = 0, session_binding_generation = 0,
session_binding_names = {}, session_binding_names = {},
session_numeric_binding_names = {}, session_numeric_binding_names = {},
+110 -4
View File
@@ -23,6 +23,11 @@ local function run_plugin_scenario(config)
return config.platform or "linux" return config.platform or "linux"
end end
if name == "input-ipc-server" then 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 "" return config.input_ipc_server or ""
end end
if name == "filename/no-ext" then if name == "filename/no-ext" then
@@ -638,6 +643,97 @@ do
) )
end 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 do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -661,10 +757,17 @@ do
assert_true(call ~= nil, "AppImage start should issue an async subprocess") 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(#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, "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_0=--start"), "AppImage subprocess should transport --start")
assert_true(env_has(call, "SUBMINER_APP_ARG_1=--background"), "AppImage subprocess should transport --background") assert_true(
assert_true(env_has(call, "SUBMINER_APP_ARG_7=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag") 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_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(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env")
assert_true( assert_true(
@@ -1170,7 +1273,10 @@ do
fire_event(recorded, "file-loaded") fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") 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( assert_true(
call_has_arg(start_call, "--managed-playback"), call_has_arg(start_call, "--managed-playback"),
"auto-start should mark SubMiner as launcher-managed playback" "auto-start should mark SubMiner as launcher-managed playback"
+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( assert.deepEqual(
calls.map((call) => call.action), calls.map((call) => call.action),
['findNotes'], ['findNotes'],
+4 -1
View File
@@ -188,7 +188,10 @@ export class AnkiConnectClient {
} }
const finiteSampleSize = Number.isFinite(sampleSize) ? sampleSize : 0; 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) { if (normalizedSampleSize === 0) {
return []; return [];
} }
+55
View File
@@ -168,3 +168,58 @@ test('startAppLifecycle app ping exits zero immediately when another instance ow
assert.equal(lockCalls, 1); assert.equal(lockCalls, 1);
assert.deepEqual(calls, ['exit:0']); 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; 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) => { deps.onSecondInstance((_event, argv) => {
try { try {
deps.handleCliCommand(deps.parseArgs(argv), 'second-instance'); const nextArgs = deps.parseArgs(argv);
if (!appReadyRuntimeComplete) {
pendingSecondInstanceCommands.push(nextArgs);
return;
}
handleSecondInstanceCommand(nextArgs);
} catch (error) { } catch (error) {
logger.error('Failed to handle second-instance CLI command:', 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 () => { deps.whenReady(async () => {
await deps.onReady(); await deps.onReady();
appReadyRuntimeComplete = true;
flushPendingSecondInstanceCommands();
}); });
deps.onWindowAllClosed(() => { deps.onWindowAllClosed(() => {
+39 -15
View File
@@ -310,6 +310,7 @@ import {
importYomitanDictionaryFromZip, importYomitanDictionaryFromZip,
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore, initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
initializeOverlayRuntime as initializeOverlayRuntimeCore, initializeOverlayRuntime as initializeOverlayRuntimeCore,
isOverlayWindowContentReady,
jellyfinTicksToSecondsRuntime, jellyfinTicksToSecondsRuntime,
listJellyfinItemsRuntime, listJellyfinItemsRuntime,
listJellyfinLibrariesRuntime, listJellyfinLibrariesRuntime,
@@ -361,6 +362,7 @@ import {
createYoutubePrimarySubtitleNotificationRuntime, createYoutubePrimarySubtitleNotificationRuntime,
} from './main/runtime/youtube-primary-subtitle-notification'; } from './main/runtime/youtube-primary-subtitle-notification';
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate'; 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 { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
import { import {
buildFirstRunSetupHtml, buildFirstRunSetupHtml,
@@ -400,6 +402,7 @@ import {
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch'; import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import { import {
shouldEnsureTrayOnStartupForInitialArgs, shouldEnsureTrayOnStartupForInitialArgs,
shouldQuitOnMpvShutdownForTrayState,
shouldQuitOnWindowAllClosedForTrayState, shouldQuitOnWindowAllClosedForTrayState,
} from './main/runtime/startup-tray-policy'; } from './main/runtime/startup-tray-policy';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup'; import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
@@ -1091,6 +1094,13 @@ const autoplayReadyGate = createAutoplayReadyGate({
signalPluginAutoplayReady: () => { signalPluginAutoplayReady: () => {
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']); 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), schedule: (callback, delayMs) => setTimeout(callback, delayMs),
logDebug: (message) => logger.debug(message), logDebug: (message) => logger.debug(message),
}); });
@@ -1580,6 +1590,7 @@ function emitSubtitlePayload(payload: SubtitleData): void {
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
}); });
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
subtitlePrefetchService?.resume(); subtitlePrefetchService?.resume();
} }
const buildSubtitleProcessingControllerMainDepsHandler = const buildSubtitleProcessingControllerMainDepsHandler =
@@ -3415,6 +3426,7 @@ const {
restoreMpvSubVisibility: () => { restoreMpvSubVisibility: () => {
restoreOverlayMpvSubtitles({ force: true }); restoreOverlayMpvSubtitles({ force: true });
}, },
isAppReady: () => app.isReady(),
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
stopSubtitleWebsocket: () => { stopSubtitleWebsocket: () => {
subtitleWsService.stop(); subtitleWsService.stop();
@@ -4030,6 +4042,9 @@ async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
} }
let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined) => void) | null =
null;
const { const {
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
@@ -4124,15 +4139,7 @@ const {
syncImmersionMediaState: () => { syncImmersionMediaState: () => {
immersionMediaRuntime.syncFromCurrentMediaState(); immersionMediaRuntime.syncFromCurrentMediaState();
}, },
signalAutoplayReadyIfWarm: () => { signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
if (!isTokenizationWarmupReady()) {
return;
}
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
},
scheduleCharacterDictionarySync: () => { scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
return; return;
@@ -4196,7 +4203,12 @@ const {
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => { setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer; appState.reconnectTimer = timer;
}, },
shouldQuitOnMpvShutdown: () => appState.initialArgs?.managedPlayback === true, shouldQuitOnMpvShutdown: () =>
shouldQuitOnMpvShutdownForTrayState({
managedPlayback: appState.initialArgs?.managedPlayback === true,
backgroundMode: appState.backgroundMode,
hasTray: Boolean(appTray),
}),
requestAppQuit: () => requestAppQuit(), requestAppQuit: () => requestAppQuit(),
}, },
updateMpvSubtitleRenderMetricsMainDeps: { updateMpvSubtitleRenderMetricsMainDeps: {
@@ -4266,15 +4278,11 @@ const {
getFrequencyRank: (text) => appState.frequencyRankLookup(text), getFrequencyRank: (text) => appState.frequencyRankLookup(text),
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
onTokenizationReady: (text) => { onTokenizationReady: () => {
currentMediaTokenizationGate.markReady( currentMediaTokenizationGate.markReady(
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
); );
startupOsdSequencer.markTokenizationReady(); startupOsdSequencer.markTokenizationReady();
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text, tokens: null },
{ forceWhilePaused: true },
);
}, },
}, },
createTokenizerRuntimeDeps: (deps) => 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; tokenizeSubtitleDeferred = tokenizeSubtitle;
function createMpvClientRuntimeService(): MpvIpcClient { function createMpvClientRuntimeService(): MpvIpcClient {
@@ -5677,6 +5700,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
if (appState.currentSubText.trim()) { if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
} }
autoplayReadyGate.flushPendingAutoplayReadySignal();
}, },
onWindowClosed: (windowKind) => { onWindowClosed: (windowKind) => {
if (windowKind === 'visible') { if (windowKind === 'visible') {
@@ -15,6 +15,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
stopConfigHotReload: () => calls.push('stop-config'), stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'), stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'), stopTexthookerService: () => calls.push('stop-texthooker'),
@@ -102,6 +103,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
stopConfigHotReload: () => {}, stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {}, restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {}, restoreMpvSubVisibility: () => {},
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => {}, unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},
@@ -148,3 +150,51 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
assert.deepEqual(calls, []); 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; stopConfigHotReload: () => void;
restorePreviousSecondarySubVisibility: () => void; restorePreviousSecondarySubVisibility: () => void;
restoreMpvSubVisibility: () => void; restoreMpvSubVisibility: () => void;
isAppReady: () => boolean;
unregisterAllGlobalShortcuts: () => void; unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void; stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void; stopTexthookerService: () => void;
@@ -63,7 +64,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopConfigHotReload: () => deps.stopConfigHotReload(), stopConfigHotReload: () => deps.stopConfigHotReload(),
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(), restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(), restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(), unregisterAllGlobalShortcuts: () => {
if (!deps.isAppReady()) return;
deps.unregisterAllGlobalShortcuts();
},
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(), stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(), stopTexthookerService: () => deps.stopTexthookerService(),
clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop: () =>
@@ -143,3 +143,52 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
1, 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; getPlaybackPaused: () => boolean | null;
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
signalPluginAutoplayReady: () => void; signalPluginAutoplayReady: () => void;
isSignalTargetReady?: () => boolean;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>; schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logDebug: (message: string) => void; logDebug: (message: string) => void;
}; };
@@ -21,12 +22,19 @@ export type AutoplayReadyGateDeps = {
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
let autoPlayReadySignalMediaPath: string | null = null; let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0; let autoPlayReadySignalGeneration = 0;
let pendingAutoplayReadySignal: {
payload: SubtitleData;
options?: { forceWhilePaused?: boolean };
} | null = null;
const invalidatePendingAutoplayReadyFallbacks = (): void => { const invalidatePendingAutoplayReadyFallbacks = (): void => {
autoPlayReadySignalMediaPath = null; autoPlayReadySignalMediaPath = null;
pendingAutoplayReadySignal = null;
autoPlayReadySignalGeneration += 1; autoPlayReadySignalGeneration += 1;
}; };
const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true;
const maybeSignalPluginAutoplayReady = ( const maybeSignalPluginAutoplayReady = (
payload: SubtitleData, payload: SubtitleData,
options?: { forceWhilePaused?: boolean }, options?: { forceWhilePaused?: boolean },
@@ -104,16 +112,36 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
}; };
if (duplicateMediaSignal) { if (duplicateMediaSignal) {
pendingAutoplayReadySignal = null;
return;
}
if (!isSignalTargetReady()) {
pendingAutoplayReadySignal = { payload, options };
deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`,
);
return; return;
} }
pendingAutoplayReadySignal = null;
autoPlayReadySignalMediaPath = mediaPath; autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration; const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady(); deps.signalPluginAutoplayReady();
attemptRelease(playbackGeneration, 0); attemptRelease(playbackGeneration, 0);
}; };
const flushPendingAutoplayReadySignal = (): void => {
if (!pendingAutoplayReadySignal || !isSignalTargetReady()) {
return;
}
const pendingSignal = pendingAutoplayReadySignal;
pendingAutoplayReadySignal = null;
maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options);
};
return { return {
flushPendingAutoplayReadySignal,
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath, getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
invalidatePendingAutoplayReadyFallbacks, invalidatePendingAutoplayReadyFallbacks,
maybeSignalPluginAutoplayReady, 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: () => {}, stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {}, restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {}, restoreMpvSubVisibility: () => {},
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => {}, unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},
@@ -41,18 +41,16 @@ test('current media tokenization gate returns immediately for ready media', asyn
await gate.waitUntilReady('/tmp/video-1.mkv'); 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(); const gate = createCurrentMediaTokenizationGate();
gate.updateCurrentMediaPath('/tmp/video-1.mkv'); gate.updateCurrentMediaPath('/tmp/video-1.mkv');
gate.markReady('/tmp/video-1.mkv'); gate.markReady('/tmp/video-1.mkv');
gate.updateCurrentMediaPath('/tmp/video-2.mkv'); gate.updateCurrentMediaPath('/tmp/video-2.mkv');
let resolved = false; let resolved = false;
const waitPromise = gate.waitUntilReady('/tmp/video-2.mkv').then(() => { await gate.waitUntilReady('/tmp/video-2.mkv').then(() => {
resolved = true; resolved = true;
}); });
await Promise.resolve();
assert.equal(resolved, true); 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'), syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'), flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'), scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'), 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 calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({ const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`), 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}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'), syncImmersionMediaState: () => calls.push('sync-immersion'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'), flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`), 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-change')?.({ text: 'line' });
handlers.get('subtitle-track-change')?.({ sid: 3 }); handlers.get('subtitle-track-change')?.({ sid: 3 });
handlers.get('subtitle-track-list-change')?.({ trackList: [] }); 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-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' }); handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 }); 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-change'));
assert.ok(calls.includes('subtitle-track-list-change')); assert.ok(calls.includes('subtitle-track-list-change'));
assert.ok(calls.includes('media-title:Episode 1')); 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('reset-guess-state'));
assert.ok(calls.includes('notify-title:Episode 1')); assert.ok(calls.includes('notify-title:Episode 1'));
assert.ok(calls.includes('post-watch:901')); 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.maybeProbeAnilistDuration('media-key');
deps.ensureAnilistMediaGuess('media-key'); deps.ensureAnilistMediaGuess('media-key');
deps.syncImmersionMediaState(); deps.syncImmersionMediaState();
deps.signalAutoplayReadyIfWarm('/tmp/video'); deps.signalAutoplayReadyIfWarm?.('/tmp/video');
deps.updateCurrentMediaTitle('title'); deps.updateCurrentMediaTitle('title');
deps.resetAnilistMediaGuessState(); deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate('title'); deps.notifyImmersionTitleUpdate('title');
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
shouldEnsureTrayOnStartupForInitialArgs, shouldEnsureTrayOnStartupForInitialArgs,
shouldQuitOnMpvShutdownForTrayState,
shouldQuitOnWindowAllClosedForTrayState, shouldQuitOnWindowAllClosedForTrayState,
} from './startup-tray-policy'; } from './startup-tray-policy';
@@ -42,3 +43,25 @@ test('window-all-closed keeps background app alive without tray', () => {
false, 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; if (options.hasTray) return false;
return true; 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;
}