mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-30 06:12:06 -07:00
fix: stabilize macOS visible overlay toggle
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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()) {
|
||||
if (window.isDestroyed()) return;
|
||||
handleOverlayWindowBlurred({
|
||||
kind,
|
||||
windowVisible: window.isVisible(),
|
||||
isOverlayVisible: options.isOverlayVisible,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
if (kind === 'visible' && window.isVisible()) {
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
window.moveTop();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (options.isDev && kind === 'visible') {
|
||||
|
||||
@@ -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<Array<string | boolean>> = [];
|
||||
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<string | boolean> }) => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user