From c939c5804f051b24504f88a0919cb5804aa52f30 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 29 Mar 2026 15:14:10 -0700 Subject: [PATCH] fix: stabilize macOS visible overlay toggle --- plugin/subminer/process.lua | 10 ++- plugin/subminer/state.lua | 1 + scripts/test-plugin-start-gate.lua | 86 ++++++++++++++++++++ src/core/services/cli-command.test.ts | 14 +++- src/core/services/cli-command.ts | 4 +- src/core/services/overlay-window-input.ts | 18 ++++ src/core/services/overlay-window.test.ts | 56 +++++++++++++ src/core/services/overlay-window.ts | 17 ++-- src/main/runtime/autoplay-ready-gate.test.ts | 61 +++++++++++++- src/main/runtime/autoplay-ready-gate.ts | 35 ++++---- 10 files changed, 270 insertions(+), 32 deletions(-) diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 532f65f..a51f53b 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -153,6 +153,9 @@ function M.create(ctx) local function notify_auto_play_ready() release_auto_play_ready_gate("tokenization-ready") + if state.suppress_ready_overlay_restore then + return + end if state.overlay_running and resolve_visible_overlay_startup() then run_control_command_async("show-visible-overlay", { socket_path = opts.socket_path, @@ -287,6 +290,9 @@ function M.create(ctx) local function start_overlay(overrides) overrides = overrides or {} + if overrides.auto_start_trigger == true then + state.suppress_ready_overlay_restore = false + end if not binary.ensure_binary_available() then subminer_log("error", "binary", "SubMiner binary not found") @@ -433,6 +439,7 @@ function M.create(ctx) subminer_log("error", "binary", "SubMiner binary not found") return end + state.suppress_ready_overlay_restore = true run_control_command_async("hide-visible-overlay", nil, function(ok, result) if ok then @@ -456,8 +463,9 @@ function M.create(ctx) show_osd("Error: binary not found") return end + state.suppress_ready_overlay_restore = true - run_control_command_async("toggle", nil, function(ok) + run_control_command_async("toggle-visible-overlay", nil, function(ok) if not ok then subminer_log("warn", "process", "Toggle command failed") show_osd("Toggle failed") diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 732624e..8814b0e 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -32,6 +32,7 @@ function M.new() auto_play_ready_gate_armed = false, auto_play_ready_timeout = nil, auto_play_ready_osd_timer = nil, + suppress_ready_overlay_restore = false, } end diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 5f45f83..59f95cc 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -822,6 +822,92 @@ do ) 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 = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for manual toggle-off ready scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered") + recorded.script_messages["subminer-toggle"]() + assert_true( + count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1, + "manual toggle should use explicit visible-overlay toggle command" + ) + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "manual toggle-off before readiness should suppress ready-time visible overlay restore" + ) +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 = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true( + recorded ~= nil, + "plugin failed to load for repeated ready restore suppression scenario: " .. tostring(err) + ) + fire_event(recorded, "file-loaded") + assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered") + recorded.script_messages["subminer-toggle"]() + recorded.script_messages["subminer-autoplay-ready"]() + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "manual toggle-off should suppress repeated ready-time visible overlay restores for the same session" + ) +end + +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "no", + }, + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for manual toggle command scenario: " .. tostring(err)) + assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered") + recorded.script_messages["subminer-toggle"]() + assert_true( + count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1, + "script-message toggle should issue explicit visible-overlay toggle command" + ) + assert_true( + count_control_calls(recorded.async_calls, "--toggle") == 0, + "script-message toggle should not issue legacy generic toggle command" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 6b370c0..2d4e0aa 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -443,13 +443,23 @@ test('handleCliCommand still runs non-start actions on second-instance', () => { ); }); -test('handleCliCommand connects MPV for toggle on second-instance', () => { +test('handleCliCommand does not connect MPV for pure toggle on second-instance', () => { const { deps, calls } = createDeps(); handleCliCommand(makeArgs({ toggle: true }), 'second-instance', deps); assert.ok(calls.includes('toggleVisibleOverlay')); assert.equal( calls.some((value) => value === 'connectMpvClient'), - true, + false, + ); +}); + +test('handleCliCommand does not connect MPV for explicit visible-overlay toggle', () => { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs({ toggleVisibleOverlay: true }), 'second-instance', deps); + assert.ok(calls.includes('toggleVisibleOverlay')); + assert.equal( + calls.some((value) => value === 'connectMpvClient'), + false, ); }); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 95e32bb..5bca31c 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -271,7 +271,7 @@ export function handleCliCommand( const reuseSecondInstanceStart = source === 'second-instance' && args.start && deps.isOverlayRuntimeInitialized(); - const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay; + const shouldConnectMpv = args.start; const needsOverlayRuntime = commandNeedsOverlayRuntime(args); const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start; @@ -302,7 +302,7 @@ export function handleCliCommand( deps.initializeOverlayRuntime(); } - if (shouldStart && deps.hasMpvClient()) { + if (shouldConnectMpv && deps.hasMpvClient()) { const socketPath = deps.getMpvSocketPath(); deps.setMpvClientSocketPath(socketPath); deps.connectMpvClient(); diff --git a/src/core/services/overlay-window-input.ts b/src/core/services/overlay-window-input.ts index 33f31bb..0ad8be5 100644 --- a/src/core/services/overlay-window-input.ts +++ b/src/core/services/overlay-window-input.ts @@ -59,3 +59,21 @@ export function handleOverlayWindowBeforeInputEvent(options: { options.preventDefault(); return true; } + +export function handleOverlayWindowBlurred(options: { + kind: OverlayWindowKind; + windowVisible: boolean; + isOverlayVisible: (kind: OverlayWindowKind) => boolean; + ensureOverlayWindowLevel: () => void; + moveWindowTop: () => void; +}): boolean { + if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) { + return false; + } + + options.ensureOverlayWindowLevel(); + if (options.kind === 'visible' && options.windowVisible) { + options.moveWindowTop(); + } + return true; +} diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts index 1fa1cfa..42e8a77 100644 --- a/src/core/services/overlay-window.test.ts +++ b/src/core/services/overlay-window.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { handleOverlayWindowBeforeInputEvent, + handleOverlayWindowBlurred, isTabInputForMpvForwarding, } from './overlay-window-input'; @@ -82,3 +83,58 @@ test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () = assert.equal(handled, false); assert.deepEqual(calls, []); }); + +test('handleOverlayWindowBlurred skips visible overlay restacking after manual hide', () => { + const calls: string[] = []; + + const handled = handleOverlayWindowBlurred({ + kind: 'visible', + windowVisible: true, + isOverlayVisible: () => false, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + moveWindowTop: () => { + calls.push('move-top'); + }, + }); + + assert.equal(handled, false); + assert.deepEqual(calls, []); +}); + +test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => { + const calls: string[] = []; + + assert.equal( + handleOverlayWindowBlurred({ + kind: 'visible', + windowVisible: true, + isOverlayVisible: () => true, + ensureOverlayWindowLevel: () => { + calls.push('ensure-visible'); + }, + moveWindowTop: () => { + calls.push('move-visible'); + }, + }), + true, + ); + + assert.equal( + handleOverlayWindowBlurred({ + kind: 'modal', + windowVisible: true, + isOverlayVisible: () => false, + ensureOverlayWindowLevel: () => { + calls.push('ensure-modal'); + }, + moveWindowTop: () => { + calls.push('move-modal'); + }, + }), + true, + ); + + assert.deepEqual(calls, ['ensure-visible', 'move-visible', 'ensure-modal']); +}); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 96393ef..6b7b4c6 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -5,6 +5,7 @@ import { createLogger } from '../../logger'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { handleOverlayWindowBeforeInputEvent, + handleOverlayWindowBlurred, type OverlayWindowKind, } from './overlay-window-input'; import { buildOverlayWindowOptions } from './overlay-window-options'; @@ -124,12 +125,18 @@ export function createOverlayWindow( }); window.on('blur', () => { - if (!window.isDestroyed()) { - options.ensureOverlayWindowLevel(window); - if (kind === 'visible' && window.isVisible()) { + if (window.isDestroyed()) return; + handleOverlayWindowBlurred({ + kind, + windowVisible: window.isVisible(), + isOverlayVisible: options.isOverlayVisible, + ensureOverlayWindowLevel: () => { + options.ensureOverlayWindowLevel(window); + }, + moveWindowTop: () => { window.moveTop(); - } - } + }, + }); }); if (options.isDev && kind === 'visible') { diff --git a/src/main/runtime/autoplay-ready-gate.test.ts b/src/main/runtime/autoplay-ready-gate.test.ts index e9376f3..8ad5313 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -33,13 +33,66 @@ test('autoplay ready gate suppresses duplicate media signals unless forced while gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }); gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const firstScheduled = scheduled.shift(); + firstScheduled?.(); await new Promise((resolve) => setTimeout(resolve, 0)); - assert.deepEqual(commands.slice(0, 3), [ - ['script-message', 'subminer-autoplay-ready'], - ['script-message', 'subminer-autoplay-ready'], + assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [ ['script-message', 'subminer-autoplay-ready'], ]); - assert.ok(commands.some((command) => command[0] === 'set_property' && command[1] === 'pause')); + assert.ok( + commands.some( + (command) => + command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, + ), + ); assert.equal(scheduled.length > 0, true); }); + +test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => { + const commands: Array> = []; + const scheduled: Array<() => void> = []; + + 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']); + }, + schedule: (callback) => { + scheduled.push(callback); + return 1 as never; + }, + logDebug: () => {}, + }); + + gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + for (const callback of scheduled.splice(0, 3)) { + callback(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [ + ['script-message', 'subminer-autoplay-ready'], + ]); + assert.equal( + commands.filter( + (command) => + command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, + ).length > 0, + true, + ); +}); diff --git a/src/main/runtime/autoplay-ready-gate.ts b/src/main/runtime/autoplay-ready-gate.ts index 572e71b..cd916d6 100644 --- a/src/main/runtime/autoplay-ready-gate.ts +++ b/src/main/runtime/autoplay-ready-gate.ts @@ -46,19 +46,6 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath; const allowDuplicateWhilePaused = options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false; - if (duplicateMediaSignal && !allowDuplicateWhilePaused) { - return; - } - - if (duplicateMediaSignal && allowDuplicateWhilePaused) { - deps.signalPluginAutoplayReady(); - return; - } - - autoPlayReadySignalMediaPath = mediaPath; - const playbackGeneration = ++autoPlayReadySignalGeneration; - deps.signalPluginAutoplayReady(); - const releaseRetryDelayMs = 200; const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: options?.forceWhilePaused === true, @@ -88,7 +75,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { return true; }; - const attemptRelease = (attempt: number): void => { + const attemptRelease = (playbackGeneration: number, attempt: number): void => { void (async () => { if ( autoPlayReadySignalMediaPath !== mediaPath || @@ -100,7 +87,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { const mpvClient = deps.getMpvClient(); if (!mpvClient?.connected) { if (attempt < maxReleaseAttempts) { - deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs); + deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs); } return; } @@ -110,15 +97,27 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { return; } - deps.signalPluginAutoplayReady(); mpvClient.send({ command: ['set_property', 'pause', false] }); if (attempt < maxReleaseAttempts) { - deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs); + deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs); } })(); }; - attemptRelease(0); + if (duplicateMediaSignal && !allowDuplicateWhilePaused) { + return; + } + + if (!duplicateMediaSignal) { + autoPlayReadySignalMediaPath = mediaPath; + const playbackGeneration = ++autoPlayReadySignalGeneration; + deps.signalPluginAutoplayReady(); + attemptRelease(playbackGeneration, 0); + return; + } + + const playbackGeneration = ++autoPlayReadySignalGeneration; + attemptRelease(playbackGeneration, 0); }; return {