From afe173151425dcaed6b9993367ad496c0c183f02 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 23 May 2026 01:45:09 -0700 Subject: [PATCH] fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi - Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift - Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress - Preserve manual hide across Jellyfin path-changing redirects even when media-title drops - Rearm managed subtitle defaults on path-changing redirects - Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC - Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus - Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary - Add stats window layer management so delete/update dialogs appear above stats window - Fix Jellyfin remote progress sync during Linux websocket reconnect windows --- changes/fix-jellyfin-overlay-toggle.md | 7 + changes/fix-jellyfin-remote-progress-sync.md | 2 +- changes/fix-stats-dialog-layering.md | 4 + plugin/subminer/binary.lua | 7 + plugin/subminer/lifecycle.lua | 40 ++++ plugin/subminer/messages.lua | 6 + plugin/subminer/process.lua | 35 ++++ plugin/subminer/session_bindings.lua | 5 + plugin/subminer/state.lua | 4 + scripts/test-plugin-binary-windows.lua | 25 +++ scripts/test-plugin-session-bindings.lua | 17 ++ scripts/test-plugin-start-gate.lua | 174 +++++++++++++++- src/core/services/overlay-visibility.test.ts | 48 ++++- src/core/services/overlay-visibility.ts | 14 +- src/core/services/stats-window-runtime.ts | 50 ++++- src/core/services/stats-window.test.ts | 87 ++++++++ src/core/services/stats-window.ts | 76 ++++++- src/main-entry-runtime.test.ts | 61 ++++++ src/main-entry-runtime.ts | 73 ++++++- src/main-entry.ts | 63 ++++-- src/main.ts | 16 +- src/main/main-wiring.test.ts | 16 ++ src/main/overlay-runtime.test.ts | 14 +- src/main/overlay-runtime.ts | 2 +- .../runtime/jellyfin-remote-playback.test.ts | 83 ++++++++ src/main/runtime/jellyfin-remote-playback.ts | 6 +- .../runtime/jellyfin-subtitle-preload.test.ts | 88 ++++++++ src/main/runtime/jellyfin-subtitle-preload.ts | 24 ++- .../runtime/overlay-modal-input-state.test.ts | 2 +- src/main/runtime/overlay-modal-input-state.ts | 2 +- .../runtime/update/update-dialogs.test.ts | 51 +++++ src/main/runtime/update/update-dialogs.ts | 19 +- src/preload-stats.ts | 12 ++ src/renderer/handlers/keyboard.test.ts | 32 +++ src/renderer/handlers/keyboard.ts | 5 + src/shared/ipc/contracts.ts | 3 + stats/src/App.tsx | 2 + stats/src/components/anime/EpisodeDetail.tsx | 2 +- stats/src/components/anime/EpisodeList.tsx | 2 +- .../components/layout/DeleteConfirmDialog.tsx | 94 +++++++++ .../components/library/MediaDetailView.tsx | 6 +- stats/src/components/overview/OverviewTab.tsx | 6 +- stats/src/components/sessions/SessionsTab.tsx | 6 +- stats/src/lib/delete-confirm.test.ts | 194 ++++++++++++++++-- stats/src/lib/delete-confirm.ts | 63 +++++- stats/src/lib/ipc-client.ts | 3 + 46 files changed, 1472 insertions(+), 79 deletions(-) create mode 100644 changes/fix-stats-dialog-layering.md create mode 100644 stats/src/components/layout/DeleteConfirmDialog.tsx diff --git a/changes/fix-jellyfin-overlay-toggle.md b/changes/fix-jellyfin-overlay-toggle.md index 785cb4e3..8c1c97b9 100644 --- a/changes/fix-jellyfin-overlay-toggle.md +++ b/changes/fix-jellyfin-overlay-toggle.md @@ -2,3 +2,10 @@ type: fixed area: jellyfin - Fixed Jellyfin `y-t` overlay hide so the plugin sends an explicit hide command when it knows the overlay is visible, avoiding overlay reloads and paused playback resumes. +- Kept that manual hide sticky across Jellyfin stream redirects that change mpv's path, even when the redirected URL drops mpv's media title. +- Re-armed managed subtitle defaults during those path-changing redirects so Japanese primary subtitles can load on the redirected stream. +- Routed visible-overlay shortcuts and app-side visibility changes back through the mpv plugin so SubMiner overlay toggling stays independent of Jellyfin playback controls. +- Collapsed duplicate visible-overlay toggle events so Hyprland does not process one physical shortcut as hide-then-show. +- Kept passive Linux/Hyprland visible-overlay shows from taking keyboard focus away from mpv/Jellyfin. +- Made Jellyfin external subtitle selection tolerate transient mpv `track-list` read failures and numeric string track IDs so Japanese subtitles are selected after preload on Linux. +- Fixed AppImage-launched Jellyfin playback controls so mpv sends overlay commands to the running SubMiner app-control socket instead of the mounted Electron binary. diff --git a/changes/fix-jellyfin-remote-progress-sync.md b/changes/fix-jellyfin-remote-progress-sync.md index 143c8e39..30b7e211 100644 --- a/changes/fix-jellyfin-remote-progress-sync.md +++ b/changes/fix-jellyfin-remote-progress-sync.md @@ -1,4 +1,4 @@ type: fixed area: jellyfin -- Fixed Jellyfin remote controller visibility and progress syncing for mpv/SubMiner seek jumps, stopped sessions, and startup path changes. +- Fixed Jellyfin remote controller visibility and progress syncing for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows. diff --git a/changes/fix-stats-dialog-layering.md b/changes/fix-stats-dialog-layering.md new file mode 100644 index 00000000..f5bc9677 --- /dev/null +++ b/changes/fix-stats-dialog-layering.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Stats: Fixed in-player stats layering so delete confirmations, overlay modals, and update-check dialogs appear above the stats window. diff --git a/plugin/subminer/binary.lua b/plugin/subminer/binary.lua index 9b231ebe..ce958441 100644 --- a/plugin/subminer/binary.lua +++ b/plugin/subminer/binary.lua @@ -114,6 +114,13 @@ function M.create(ctx) end end + if not environment.is_windows() then + local appimage_path = resolve_binary_candidate(os.getenv("APPIMAGE")) + if appimage_path and appimage_path ~= "" then + return appimage_path + end + end + return nil end diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 31689cd2..8f281a05 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -33,6 +33,20 @@ function M.create(ctx) return nil end + local function resolve_media_title() + local media_title = mp.get_property("media-title") + if type(media_title) == "string" and media_title ~= "" then + return media_title + end + + local filename = mp.get_property("filename") + if type(filename) == "string" and filename ~= "" then + return filename + end + + return nil + end + local function is_reload_end_file(reason) return reason == "reload" or reason == "redirect" end @@ -125,6 +139,10 @@ function M.create(ctx) local function on_start_file() if state.pending_reload_media_identity ~= nil then + local media_identity = resolve_media_identity() + if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then + rearm_managed_subtitle_load_defaults() + end return end rearm_managed_subtitle_load_defaults() @@ -132,12 +150,23 @@ function M.create(ctx) local function on_file_loaded() local media_identity = resolve_media_identity() + local media_title = resolve_media_title() local retry_generation = next_auto_start_retry_generation() local previous_media_identity = state.current_media_identity + local pending_reload_title = state.pending_reload_media_title + local pending_reload_reason = state.pending_reload_reason local same_media_reload = ( media_identity ~= nil and state.pending_reload_media_identity ~= nil and media_identity == state.pending_reload_media_identity + ) or ( + state.pending_reload_media_identity ~= nil + and media_title ~= nil + and pending_reload_title ~= nil + and media_title == pending_reload_title + ) or ( + pending_reload_reason == "redirect" + and state.pending_reload_media_identity ~= nil ) local same_media_loaded = ( media_identity ~= nil @@ -146,7 +175,10 @@ function M.create(ctx) ) local new_media_loaded = media_identity ~= nil and not same_media_reload and not same_media_loaded state.pending_reload_media_identity = nil + state.pending_reload_media_title = nil + state.pending_reload_reason = nil state.current_media_identity = media_identity + state.current_media_title = media_title if new_media_loaded then state.suppress_ready_overlay_restore = false end @@ -191,7 +223,10 @@ function M.create(ctx) hover.clear_hover_overlay() process.disarm_auto_play_ready_gate() state.current_media_identity = nil + state.current_media_title = nil state.pending_reload_media_identity = nil + state.pending_reload_media_title = nil + state.pending_reload_reason = nil end local function register_lifecycle_hooks() @@ -207,11 +242,16 @@ function M.create(ctx) local reason = type(event) == "table" and event.reason or nil if is_reload_end_file(reason) then state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity() + state.pending_reload_media_title = state.current_media_title or resolve_media_title() + state.pending_reload_reason = reason return end next_auto_start_retry_generation() state.current_media_identity = nil + state.current_media_title = nil state.pending_reload_media_identity = nil + state.pending_reload_media_title = nil + state.pending_reload_reason = nil if state.overlay_running and reason ~= "quit" then process.hide_visible_overlay() end diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index a62824da..28a01ab6 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -17,6 +17,12 @@ function M.create(ctx) mp.register_script_message("subminer-toggle", function() process.toggle_overlay() end) + mp.register_script_message("subminer-visible-overlay-hidden", function() + process.record_visible_overlay_visibility(false) + end) + mp.register_script_message("subminer-visible-overlay-shown", function() + process.record_visible_overlay_visibility(true) + end) mp.register_script_message("subminer-menu", function() ui.show_menu() end) diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 18a8dd61..250d3838 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -7,6 +7,7 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20 local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 +local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25 function M.create(ctx) local mp = ctx.mp @@ -91,6 +92,35 @@ function M.create(ctx) end end + local function record_visible_overlay_visibility(visible) + if visible then + state.visible_overlay_requested = true + state.suppress_ready_overlay_restore = false + return + end + state.visible_overlay_requested = false + state.suppress_ready_overlay_restore = true + end + + local function should_ignore_duplicate_visible_overlay_toggle() + if type(mp.get_time) ~= "function" then + return false + end + local now = mp.get_time() + if type(now) ~= "number" then + return false + end + + local previous = state.last_visible_overlay_toggle_time + state.last_visible_overlay_toggle_time = now + if type(previous) ~= "number" then + return false + end + + local elapsed = now - previous + return elapsed >= 0 and elapsed < DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS + end + local function normalize_socket_path(path) if type(path) ~= "string" then return nil @@ -604,6 +634,10 @@ function M.create(ctx) show_osd("Error: binary not found") return end + if should_ignore_duplicate_visible_overlay_toggle() then + subminer_log("debug", "process", "Ignoring duplicate visible overlay toggle") + return + end if state.visible_overlay_requested == true then state.suppress_ready_overlay_restore = true hide_visible_overlay({ resume_playback = false }) @@ -751,6 +785,7 @@ function M.create(ctx) build_command_args = build_command_args, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, run_control_command_async = run_control_command_async, + record_visible_overlay_visibility = record_visible_overlay_visibility, run_binary_command_async = run_binary_command_async, parse_start_script_message_overrides = parse_start_script_message_overrides, ensure_texthooker_running = ensure_texthooker_running, diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index 194aec56..d7fa2c5a 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -251,6 +251,11 @@ function M.create(ctx) return end + if binding.actionId == "toggleVisibleOverlay" and type(process.toggle_overlay) == "function" then + process.toggle_overlay() + return + end + invoke_cli_action(binding.actionId, binding.payload) end diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index fd3b9d50..58fdbdfd 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -36,8 +36,12 @@ function M.new() suppress_ready_overlay_restore = false, force_ready_overlay_restore = false, visible_overlay_requested = nil, + last_visible_overlay_toggle_time = nil, current_media_identity = nil, + current_media_title = nil, pending_reload_media_identity = nil, + pending_reload_media_title = nil, + pending_reload_reason = nil, auto_start_retry_generation = 0, session_binding_generation = 0, session_binding_names = {}, diff --git a/scripts/test-plugin-binary-windows.lua b/scripts/test-plugin-binary-windows.lua index c04e77f4..749aa5f2 100644 --- a/scripts/test-plugin-binary-windows.lua +++ b/scripts/test-plugin-binary-windows.lua @@ -68,6 +68,31 @@ local function create_binary_module(config) return binary end +do + local appimage_path = "/home/tester/.local/bin/SubMiner.AppImage" + local mounted_binary_path = "/tmp/.mount_SubMiner/SubMiner" + local resolved = with_env({ + APPIMAGE = appimage_path, + }, function() + local binary = create_binary_module({ + is_windows = false, + binary_path = mounted_binary_path, + entries = { + [appimage_path] = "file", + [mounted_binary_path] = "file", + }, + }) + + return binary.find_binary() + end) + + assert_equal( + resolved, + appimage_path, + "linux resolver should prefer APPIMAGE over the mounted AppImage inner binary" + ) +end + do local binary = create_binary_module({ is_windows = true, diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index 437cfa6a..88b81e21 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -23,6 +23,7 @@ local recorded = { async_calls = {}, mpv_commands = {}, osd = {}, + overlay_toggles = 0, } local mp = {} @@ -68,6 +69,14 @@ local ctx = { return { numericSelectionTimeoutMs = 3000, bindings = { + { + key = { + code = "KeyO", + modifiers = { "alt", "shift" }, + }, + actionType = "session-action", + actionId = "toggleVisibleOverlay", + }, { key = { code = "KeyS", @@ -253,6 +262,9 @@ local ctx = { run_binary_command_async = function(args) recorded.async_calls[#recorded.async_calls + 1] = args end, + toggle_overlay = function() + recorded.overlay_toggles = recorded.overlay_toggles + 1 + end, }, environment = { resolve_session_bindings_artifact_path = function() @@ -318,6 +330,11 @@ local expected_cli_bindings = { { keys = "w", flag = "--mark-watched" }, } +local visible_overlay_toggle = find_binding("Alt+O") +assert_true(visible_overlay_toggle ~= nil, "visible overlay session binding should register") +visible_overlay_toggle.fn() +assert_true(recorded.overlay_toggles == 1, "visible overlay session binding should use plugin toggle") + for _, expected in ipairs(expected_cli_bindings) do local binding = find_binding(expected.keys) assert_true(binding ~= nil, "default session action should register " .. expected.keys) diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 181d9d09..ca6bfc98 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -201,7 +201,7 @@ local function run_plugin_scenario(config) end function mp.set_osd_ass(...) end function mp.get_time() - return 0 + return config.now or 0 end function mp.commandv(...) end function mp.set_property_native(name, value) @@ -623,16 +623,18 @@ local binary_path = "/tmp/subminer-binary" local appimage_path = "/tmp/SubMiner.AppImage" do - local recorded, err = run_plugin_scenario({ + local scenario = { process_list = "", option_overrides = { binary_path = binary_path, auto_start = "no", }, + now = 20, files = { [binary_path] = true, }, - }) + } + local recorded, err = run_plugin_scenario(scenario) assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err)) assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered") recorded.script_messages["subminer-start"]("texthooker=no") @@ -683,6 +685,125 @@ 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/jellyfin-app-toggle-initial.m3u8", + media_title = "Jellyfin App Toggle", + paused = true, + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for app-side hide Jellyfin redirect: " .. tostring(err)) + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-visible-overlay-hidden"]() + fire_event(recorded, "end-file", { reason = "redirect" }) + scenario.path = "/media/jellyfin-app-toggle-final.m3u8" + scenario.media_title = "" + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "app-side hide sync should suppress path-changing Jellyfin redirect visible overlay reassertion" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "app-side hide sync followed by Jellyfin redirect should keep paused playback paused" + ) +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/jellyfin-duplicate-toggle.m3u8", + media_title = "Jellyfin Duplicate Toggle", + paused = true, + now = 10, + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for duplicate visible overlay toggle: " .. tostring(err)) + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-toggle"]() + recorded.script_messages["subminer-toggle"]() + assert_true( + count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1, + "duplicate same-tick visible overlay toggles should hide once" + ) + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "duplicate same-tick visible overlay toggles should not immediately show the overlay again" + ) + scenario.now = 10.5 + recorded.script_messages["subminer-toggle"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, + "later visible overlay toggle should still show after duplicate suppression window" + ) +end + +do + local scenario = { + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "no", + }, + now = 20, + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for visible overlay state sync scenario: " .. tostring(err)) + assert_true( + recorded.script_messages["subminer-visible-overlay-hidden"] ~= nil, + "hidden visibility sync message should be registered" + ) + assert_true( + recorded.script_messages["subminer-visible-overlay-shown"] ~= nil, + "shown visibility sync message should be registered" + ) + recorded.script_messages["subminer-visible-overlay-hidden"]() + recorded.script_messages["subminer-toggle"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "toggle after app-side hide should explicitly show SubMiner overlay through plugin state" + ) + assert_true( + count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0, + "toggle after app-side hide should avoid app-side visible overlay toggle" + ) + scenario.now = 20.5 + recorded.script_messages["subminer-visible-overlay-shown"]() + recorded.script_messages["subminer-toggle"]() + assert_true( + count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1, + "toggle after app-side show should explicitly hide SubMiner overlay through plugin state" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -1717,6 +1838,53 @@ 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/jellyfin-redirect-initial.m3u8", + media_title = "Jellyfin Redirect", + paused = true, + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for manual hide path-changing Jellyfin redirect: " .. tostring(err)) + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-autoplay-ready"]() + recorded.script_messages["subminer-toggle"]() + fire_event(recorded, "end-file", { reason = "redirect" }) + scenario.path = "/media/jellyfin-redirect-final.m3u8" + scenario.media_title = "" + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, + "manual toggle-off should suppress path-changing Jellyfin redirect visible overlay reassertion even if media-title drops" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", false) == 0, + "manual toggle-off followed by path-changing Jellyfin reload should keep paused playback paused" + ) + assert_true( + count_property_set(recorded.property_sets, "sid", "auto") == 2, + "path-changing Jellyfin redirect should rearm primary subtitle selection before mpv loads tracks" + ) + assert_true( + count_property_set(recorded.property_sets, "secondary-sid", "auto") == 2, + "path-changing Jellyfin redirect should rearm secondary subtitle selection before mpv loads tracks" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 72ba5d78..847867d5 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -244,7 +244,7 @@ test('suspended visible overlay hides without refreshing bounds or z-order', () assert.ok(!calls.includes('focus')); }); -test('untracked non-macOS overlay keeps fallback visible behavior when no tracker exists', () => { +test('untracked non-macOS overlay shows passively when no tracker exists', () => { const { window, calls } = createMainWindowRecorder(); let trackerWarning = false; @@ -279,11 +279,49 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke } as never); assert.equal(trackerWarning, false); - assert.ok(calls.includes('show')); - assert.ok(calls.includes('focus')); + assert.ok(calls.includes('show-inactive')); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('osd')); }); +test('passive Linux visible overlay does not take keyboard focus', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: false, + } as never); + + assert.ok(calls.includes('show-inactive')); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('focus')); +}); + test('tracked non-macOS overlay reapplies bounds after first show', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { @@ -317,8 +355,8 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => { } as never); assert.deepEqual( - calls.filter((call) => call === 'update-bounds' || call === 'show'), - ['update-bounds', 'show', 'update-bounds'], + calls.filter((call) => call === 'update-bounds' || call === 'show-inactive'), + ['update-bounds', 'show-inactive', 'update-bounds'], ); }); diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 8d7a2d40..7e84828d 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -185,6 +185,8 @@ export function updateVisibleOverlayVisibility(args: { shouldUseMacOSMousePassthrough || forceMousePassthrough || (shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow)); + const isNonNativePassiveOverlay = + !args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive; const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker; const shouldKeepTrackedWindowsOverlayTopmost = !args.isWindowsPlatform || @@ -227,7 +229,10 @@ export function updateVisibleOverlayVisibility(args: { // skip — ready-to-show hasn't fired yet; the onWindowContentReady // callback will trigger another visibility update when the renderer // has painted its first frame. - } else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) { + } else if ( + ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) || + isNonNativePassiveOverlay + ) { if (args.isWindowsPlatform) { setOverlayWindowOpacity(mainWindow, 0); } @@ -271,7 +276,12 @@ export function updateVisibleOverlayVisibility(args: { mainWindow.focus(); } - if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { + if ( + !args.isWindowsPlatform && + !args.isMacOSPlatform && + !forceMousePassthrough && + overlayInteractionActive + ) { mainWindow.focus(); } diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index f08760a3..e3195f9e 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -1,4 +1,8 @@ -import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; +import type { + BrowserWindow, + BrowserWindowConstructorOptions, + MessageBoxSyncOptions, +} from 'electron'; import type { WindowGeometry } from '../../types'; const DEFAULT_STATS_WINDOW_WIDTH = 900; @@ -9,6 +13,15 @@ type StatsWindowLevelController = Pick>; type VisibleStatsWindowLevelController = StatsWindowLevelController & Pick; +type VisibleStatsWindowDialogLayerController = Pick< + BrowserWindow, + 'isDestroyed' | 'isVisible' | 'setAlwaysOnTop' +>; +type StatsNativeConfirmDialogWindow = Pick; +type StatsNativeConfirmDialogPresenter = { + showWithParent: (window: WindowT, options: MessageBoxSyncOptions) => number; + showWithoutParent: (options: MessageBoxSyncOptions) => number; +}; type StatsWindowBoundsController = Pick; type StatsWindowPresentationController = Pick & @@ -124,6 +137,41 @@ export function promoteVisibleStatsWindowAboveOverlay( return true; } +export function demoteVisibleStatsWindowBelowDialogs( + window: VisibleStatsWindowDialogLayerController, +): boolean { + if (window.isDestroyed() || !window.isVisible()) { + return false; + } + + window.setAlwaysOnTop(false); + return true; +} + +export function buildStatsNativeConfirmDialogOptions(message: string): MessageBoxSyncOptions { + return { + type: 'warning', + message, + buttons: ['Delete', 'Cancel'], + defaultId: 1, + cancelId: 1, + noLink: true, + }; +} + +export function showStatsNativeConfirmDialog( + window: WindowT | null, + message: string, + presenter: StatsNativeConfirmDialogPresenter, +): boolean { + const options = buildStatsNativeConfirmDialogOptions(message); + const response = + window && !window.isDestroyed() + ? presenter.showWithParent(window, options) + : presenter.showWithoutParent(options); + return response === 0; +} + export function presentStatsWindow( window: StatsWindowPresentationController, platform: NodeJS.Platform = process.platform, diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index befb88a2..be4d3de3 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -3,10 +3,13 @@ import test from 'node:test'; import { buildStatsWindowLoadFileOptions, buildStatsWindowOptions, + buildStatsNativeConfirmDialogOptions, + demoteVisibleStatsWindowBelowDialogs, presentStatsWindow, promoteVisibleStatsWindowAboveOverlay, promoteStatsWindowLevel, resolveStatsWindowOuterBoundsForContent, + showStatsNativeConfirmDialog, shouldHideStatsWindowForInput, } from './stats-window-runtime'; @@ -274,6 +277,90 @@ test('promoteVisibleStatsWindowAboveOverlay skips hidden stats windows', () => { assert.deepEqual(calls, []); }); +test('demoteVisibleStatsWindowBelowDialogs lowers visible stats below native dialogs', () => { + const calls: string[] = []; + const demoted = demoteVisibleStatsWindowBelowDialogs({ + isDestroyed: () => false, + isVisible: () => true, + setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => { + calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`); + }, + } as never); + + assert.equal(demoted, true); + assert.deepEqual(calls, ['always-on-top:false:none:0']); +}); + +test('demoteVisibleStatsWindowBelowDialogs skips hidden stats windows', () => { + const calls: string[] = []; + const demoted = demoteVisibleStatsWindowBelowDialogs({ + isDestroyed: () => false, + isVisible: () => false, + setAlwaysOnTop: () => calls.push('always-on-top'), + } as never); + + assert.equal(demoted, false); + assert.deepEqual(calls, []); +}); + +test('buildStatsNativeConfirmDialogOptions makes delete the explicit destructive action', () => { + assert.deepEqual(buildStatsNativeConfirmDialogOptions('Delete this session?'), { + type: 'warning', + message: 'Delete this session?', + buttons: ['Delete', 'Cancel'], + defaultId: 1, + cancelId: 1, + noLink: true, + }); +}); + +test('showStatsNativeConfirmDialog parents the native dialog to live stats windows', () => { + const calls: string[] = []; + const parent = { isDestroyed: () => false }; + + const confirmed = showStatsNativeConfirmDialog(parent, 'Delete this session?', { + showWithParent: (window, options) => { + assert.equal(window, parent); + calls.push(`${options.message}:${options.defaultId}:${options.cancelId}`); + return 0; + }, + showWithoutParent: () => { + calls.push('unparented'); + return 1; + }, + }); + + assert.equal(confirmed, true); + assert.deepEqual(calls, ['Delete this session?:1:1']); +}); + +test('showStatsNativeConfirmDialog treats cancel as not confirmed', () => { + const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => false }, 'Delete?', { + showWithParent: () => 1, + showWithoutParent: () => 0, + }); + + assert.equal(confirmed, false); +}); + +test('showStatsNativeConfirmDialog falls back to an unparented dialog without a live stats window', () => { + const calls: string[] = []; + + const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => true }, 'Delete?', { + showWithParent: () => { + calls.push('parented'); + return 0; + }, + showWithoutParent: (options) => { + calls.push(options.message); + return 0; + }, + }); + + assert.equal(confirmed, true); + assert.deepEqual(calls, ['Delete?']); +}); + test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => { const calls: string[] = []; diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index 58c17e20..ddb8b2d4 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -1,14 +1,16 @@ -import { BrowserWindow, ipcMain } from 'electron'; +import { BrowserWindow, dialog, ipcMain } from 'electron'; import * as path from 'path'; import type { WindowGeometry } from '../../types.js'; import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; import { buildStatsWindowLoadFileOptions, buildStatsWindowOptions, + demoteVisibleStatsWindowBelowDialogs, presentStatsWindow, promoteStatsWindowLevel, promoteVisibleStatsWindowAboveOverlay, resolveStatsWindowOuterBoundsForContent, + showStatsNativeConfirmDialog, shouldHideStatsWindowForInput, STATS_WINDOW_TITLE, } from './stats-window-runtime.js'; @@ -16,6 +18,8 @@ import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement let statsWindow: BrowserWindow | null = null; let toggleRegistered = false; +let nativeDialogLayerRegistered = false; +let nativeDialogLayerSuspensionCount = 0; export interface StatsWindowOptions { /** Absolute path to stats/dist/ directory */ @@ -63,6 +67,10 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo } export function promoteStatsOverlayAbovePlayback(): boolean { + if (nativeDialogLayerSuspensionCount > 0) { + return false; + } + if (!statsWindow) { return false; } @@ -74,6 +82,71 @@ export function promoteStatsOverlayAbovePlayback(): boolean { }); } +export function demoteStatsOverlayBelowDialogs(): boolean { + if (!statsWindow) { + return false; + } + + return demoteVisibleStatsWindowBelowDialogs(statsWindow); +} + +export function suspendStatsWindowLayerForNativeDialog(): void { + nativeDialogLayerSuspensionCount += 1; + if (nativeDialogLayerSuspensionCount !== 1) { + return; + } + + demoteStatsOverlayBelowDialogs(); +} + +export function restoreStatsWindowLayerAfterNativeDialog(): void { + if (nativeDialogLayerSuspensionCount <= 0) { + return; + } + + nativeDialogLayerSuspensionCount -= 1; + if (nativeDialogLayerSuspensionCount === 0) { + promoteStatsOverlayAbovePlayback(); + } +} + +export async function withStatsWindowLayerSuspendedForNativeDialog( + showDialog: () => Promise, +): Promise { + suspendStatsWindowLayerForNativeDialog(); + try { + return await showDialog(); + } finally { + restoreStatsWindowLayerAfterNativeDialog(); + } +} + +function confirmStatsNativeDialog(message: unknown): boolean { + const dialogMessage = + typeof message === 'string' && message.trim().length > 0 ? message : 'Confirm deletion?'; + + return showStatsNativeConfirmDialog(statsWindow, dialogMessage, { + showWithParent: (parentWindow, options) => dialog.showMessageBoxSync(parentWindow, options), + showWithoutParent: (options) => dialog.showMessageBoxSync(options), + }); +} + +function registerStatsNativeDialogLayerHandlers(): void { + if (nativeDialogLayerRegistered) return; + nativeDialogLayerRegistered = true; + + ipcMain.on(IPC_CHANNELS.command.statsNativeConfirmDialog, (event, message) => { + event.returnValue = confirmStatsNativeDialog(message); + }); + ipcMain.on(IPC_CHANNELS.command.statsNativeDialogOpened, (event) => { + suspendStatsWindowLayerForNativeDialog(); + event.returnValue = true; + }); + ipcMain.on(IPC_CHANNELS.command.statsNativeDialogClosed, () => { + restoreStatsWindowLayerAfterNativeDialog(); + }); +} + /** * Toggle the stats overlay window: create on first call, then show/hide. * The React app stays mounted across toggles — state is preserved. @@ -132,6 +205,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void { * Call this once during app initialization. */ export function registerStatsOverlayToggle(options: StatsWindowOptions): void { + registerStatsNativeDialogLayerHandlers(); if (toggleRegistered) return; toggleRegistered = true; ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => { diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 9bd4b9f2..0aa22286 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -15,6 +15,9 @@ import { shouldHandleLaunchMpvAtEntry, shouldHandleStatsDaemonCommandAtEntry, hasTransportedStartupArgs, + shouldForwardStartupArgvViaAppControl, + applyEarlyLinuxCommandLineSwitches, + resolveLinuxPasswordStoreValue, } from './main-entry-runtime'; test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => { @@ -106,6 +109,64 @@ test('hasTransportedStartupArgs detects env-carried app args', () => { assert.equal(hasTransportedStartupArgs({}), false); }); +test('resolveLinuxPasswordStoreValue defaults Linux safeStorage to gnome-libsecret', () => { + assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.AppImage'], 'linux'), 'gnome-libsecret'); + assert.equal( + resolveLinuxPasswordStoreValue(['SubMiner.AppImage', '--password-store', 'gnome'], 'linux'), + 'gnome-libsecret', + ); + assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.exe'], 'win32'), null); +}); + +test('applyEarlyLinuxCommandLineSwitches appends password store before main startup', () => { + const switches: Array<[string, string | undefined]> = []; + applyEarlyLinuxCommandLineSwitches( + { + appendSwitch: (name, value) => { + switches.push([name, value]); + }, + }, + ['SubMiner.AppImage', '--password-store=kwallet6'], + 'linux', + ); + + assert.deepEqual(switches, [ + ['enable-features', 'GlobalShortcutsPortal'], + ['password-store', 'kwallet6'], + ]); +}); + +test('transported AppImage visibility commands should forward through app control', () => { + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], { + SUBMINER_APP_ARGC: '1', + SUBMINER_APP_ARG_0: '--hide-visible-overlay', + }), + true, + ); +}); + +test('app control forwarding is only for transported runtime commands', () => { + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {}), + false, + ); + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--app-ping'], { + SUBMINER_APP_ARGC: '1', + SUBMINER_APP_ARG_0: '--app-ping', + }), + false, + ); + assert.equal( + shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--launch-mpv'], { + SUBMINER_APP_ARGC: '1', + SUBMINER_APP_ARG_0: '--launch-mpv', + }), + false, + ); +}); + test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => { assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true); assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false); diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 51695af2..744d1fb6 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -1,11 +1,12 @@ import fs from 'node:fs'; import os from 'node:os'; -import { CliArgs, parseArgs, shouldStartApp } from './cli/args'; +import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args'; import { resolveConfigDir } from './config/path-resolution'; const BACKGROUND_ARG = '--background'; const START_ARG = '--start'; const PASSWORD_STORE_ARG = '--password-store'; +const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret'; const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD'; const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; @@ -34,6 +35,10 @@ type EarlyAppLike = { setPath: (name: 'userData', value: string) => void; }; +type CommandLineLike = { + appendSwitch: (name: string, value?: string) => void; +}; + type EarlyAppPathOptions = { platform?: NodeJS.Platform; appDataDir?: string; @@ -73,6 +78,58 @@ function removePassiveStartupArgs(argv: string[]): string[] { return filtered; } +function getPasswordStoreArg(argv: string[]): string | null { + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg?.startsWith(PASSWORD_STORE_ARG)) { + continue; + } + + if (arg === PASSWORD_STORE_ARG) { + const value = argv[i + 1]; + if (value && !value.startsWith('--')) { + return value; + } + return null; + } + + const [prefix, value] = arg.split('=', 2); + if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + +function normalizePasswordStoreArg(value: string): string { + const normalized = value.trim(); + if (normalized.toLowerCase() === 'gnome') { + return DEFAULT_LINUX_PASSWORD_STORE; + } + return normalized; +} + +export function resolveLinuxPasswordStoreValue( + argv: string[], + platform: NodeJS.Platform = process.platform, +): string | null { + if (platform !== 'linux') return null; + return normalizePasswordStoreArg(getPasswordStoreArg(argv) ?? DEFAULT_LINUX_PASSWORD_STORE); +} + +export function applyEarlyLinuxCommandLineSwitches( + commandLine: CommandLineLike, + argv: string[], + platform: NodeJS.Platform = process.platform, +): void { + if (platform !== 'linux') return; + commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); + commandLine.appendSwitch( + 'password-store', + resolveLinuxPasswordStoreValue(argv, platform) ?? DEFAULT_LINUX_PASSWORD_STORE, + ); +} + function consumesLaunchMpvValue(token: string): boolean { return ( token.startsWith('--') && @@ -90,6 +147,20 @@ export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean { return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string'; } +export function shouldForwardStartupArgvViaAppControl( + argv: string[], + env: NodeJS.ProcessEnv, +): boolean { + if (env.ELECTRON_RUN_AS_NODE === '1') return false; + if (!hasTransportedStartupArgs(env)) return false; + + const args = parseCliArgs(argv); + if (args.help || args.appPing || args.launchMpv) return false; + if (resolveStatsDaemonCommandAction(argv) !== null) return false; + + return hasExplicitCommand(args); +} + function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null { const rawCount = env[TRANSPORTED_APP_ARGC_ENV]; if (rawCount === undefined) { diff --git a/src/main-entry.ts b/src/main-entry.ts index 6af7f1e1..76d1f28b 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -9,17 +9,20 @@ import { normalizeLaunchMpvExtraArgs, normalizeLaunchMpvTargets, normalizeStartupArgv, + applyEarlyLinuxCommandLineSwitches, sanitizeStartupEnv, sanitizeBackgroundEnv, sanitizeHelpEnv, sanitizeLaunchMpvEnv, hasTransportedStartupArgs, + shouldForwardStartupArgvViaAppControl, shouldDetachBackgroundLaunch, shouldHandleHelpOnlyAtEntry, shouldHandleLaunchMpvAtEntry, shouldHandleStatsDaemonCommandAtEntry, } from './main-entry-runtime'; import { requestSingleInstanceLockEarly } from './main/early-single-instance'; +import { sendAppControlCommand } from './shared/app-control-client'; import { detectInstalledFirstRunPluginCandidates, detectInstalledMpvPlugin, @@ -173,6 +176,7 @@ function readConfiguredWindowsMpvLaunch(configDir: string): { } process.argv = normalizeStartupArgv(process.argv, process.env); +applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv); applySanitizedEnv(sanitizeStartupEnv(process.env)); const userDataPath = configureEarlyAppPaths(app); const reportFatalError = createFatalErrorReporter({ @@ -184,6 +188,44 @@ registerFatalErrorHandlers({ exit: (code) => app.exit(code), }); +function startMainProcess(): void { + const gotSingleInstanceLock = requestSingleInstanceLockEarly(app); + if (!gotSingleInstanceLock) { + app.exit(0); + return; + } + try { + require('./main.js'); + } catch (error) { + reportFatalError(error, { + title: 'SubMiner startup failed', + context: 'SubMiner failed while loading the main process.', + }); + app.exit(1); + } +} + +async function forwardStartupArgvViaAppControlIfAvailable(): Promise { + if (!shouldForwardStartupArgvViaAppControl(process.argv, process.env)) { + return false; + } + + const result = await sendAppControlCommand(process.argv, { + configDir: userDataPath, + timeoutMs: 500, + }); + if (result.ok) { + app.exit(0); + return true; + } + if (!result.unavailable) { + console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`); + app.exit(1); + return true; + } + return false; +} + if (shouldDetachBackgroundLaunch(process.argv, process.env)) { const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1); const child = spawn(process.execPath, childArgs, { @@ -233,17 +275,14 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { app.exit(exitCode); }); } else { - const gotSingleInstanceLock = requestSingleInstanceLockEarly(app); - if (!gotSingleInstanceLock) { - app.exit(0); - } - try { - require('./main.js'); - } catch (error) { - reportFatalError(error, { - title: 'SubMiner startup failed', - context: 'SubMiner failed while loading the main process.', + void forwardStartupArgvViaAppControlIfAvailable() + .then((forwarded) => { + if (!forwarded) { + startMainProcess(); + } + }) + .catch((error) => { + console.error('SubMiner app-control handoff failed:', error); + startMainProcess(); }); - app.exit(1); - } } diff --git a/src/main.ts b/src/main.ts index 17f2d49b..778968f7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -356,6 +356,7 @@ import { promoteStatsOverlayAbovePlayback, registerStatsOverlayToggle, toggleStatsOverlay as toggleStatsOverlayWindow, + withStatsWindowLayerSuspendedForNativeDialog, } from './core/services/stats-window.js'; import { createFirstRunSetupService, @@ -5184,6 +5185,8 @@ function getUpdateService() { }); app.focus({ steal: true }); }, + withStatsWindowLayerSuspended: (showDialog) => + withStatsWindowLayerSuspendedForNativeDialog(showDialog), showMessageBox: (options) => dialog.showMessageBox(options), }); updateService = createUpdateService({ @@ -6406,6 +6409,13 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void { } } +function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void { + sendMpvCommandRuntime(appState.mpvClient, [ + 'script-message', + visible ? 'subminer-visible-overlay-shown' : 'subminer-visible-overlay-hidden', + ]); +} + function setVisibleOverlayVisible(visible: boolean): void { ensureOverlayWindowsReadyForVisibilityActions(); if (!visible) { @@ -6416,18 +6426,21 @@ function setVisibleOverlayVisible(visible: boolean): void { void ensureOverlayMpvSubtitlesHidden(); } setVisibleOverlayVisibleHandler(visible); + notifyMpvPluginVisibleOverlayVisibility(visible); syncOverlayMpvSubtitleSuppression(); } function toggleVisibleOverlay(): void { ensureOverlayWindowsReadyForVisibilityActions(); + const nextVisible = !overlayManager.getVisibleOverlayVisible(); autoplayReadyGate.markCurrentMediaAutoplayReady(); - if (overlayManager.getVisibleOverlayVisible()) { + if (!nextVisible) { cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); } else { void ensureOverlayMpvSubtitlesHidden(); } toggleVisibleOverlayHandler(); + notifyMpvPluginVisibleOverlayVisibility(nextVisible); syncOverlayMpvSubtitleSuppression(); } function setOverlayVisible(visible: boolean): void { @@ -6439,6 +6452,7 @@ function setOverlayVisible(visible: boolean): void { void ensureOverlayMpvSubtitlesHidden(); } setOverlayVisibleHandler(visible); + notifyMpvPluginVisibleOverlayVisibility(visible); syncOverlayMpvSubtitleSuppression(); } function handleOverlayModalClosed(modal: OverlayHostedModal): void { diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index d3a5b309..8d83cc9c 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -73,6 +73,22 @@ test('manual visible overlay toggles suppress current-media autoplay release', ( ); }); +test('manual visible overlay changes notify mpv plugin visibility state', () => { + const source = readMainSource(); + const setBlock = source.match( + /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + const toggleBlock = source.match( + /function toggleVisibleOverlay\(\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + + assert.ok(setBlock); + assert.ok(toggleBlock); + assert.match(setBlock, /notifyMpvPluginVisibleOverlayVisibility\(visible\);/); + assert.match(toggleBlock, /const nextVisible = !overlayManager\.getVisibleOverlayVisible\(\);/); + assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/); +}); + test('main process uses one shared mpv plugin runtime config helper', () => { const source = readMainSource(); assert.match(source, /function getMpvPluginRuntimeConfig\(\)/); diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index ff10d2ac..5d2f56a0 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -9,6 +9,7 @@ type MockWindow = { ignoreMouseEvents: boolean; forwardedIgnoreMouseEvents: boolean; webContentsFocused: boolean; + alwaysOnTopCalls: string[]; showCount: number; hideCount: number; sent: unknown[][]; @@ -53,6 +54,7 @@ function createMockWindow(): MockWindow & { ignoreMouseEvents: false, forwardedIgnoreMouseEvents: false, webContentsFocused: false, + alwaysOnTopCalls: [], showCount: 0, hideCount: 0, sent: [], @@ -72,7 +74,9 @@ function createMockWindow(): MockWindow & { state.ignoreMouseEvents = ignore; state.forwardedIgnoreMouseEvents = options?.forward === true; }, - setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {}, + setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => { + state.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`); + }, moveTop: () => {}, getShowCount: () => state.showCount, getHideCount: () => state.hideCount, @@ -155,6 +159,13 @@ function createMockWindow(): MockWindow & { }, }); + Object.defineProperty(window, 'alwaysOnTopCalls', { + get: () => state.alwaysOnTopCalls, + set: (value: string[]) => { + state.alwaysOnTopCalls = value; + }, + }); + Object.defineProperty(window, 'url', { get: () => state.url, set: (value: string) => { @@ -219,6 +230,7 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac runtime.notifyOverlayModalOpened('runtime-options'); assert.equal(window.getShowCount(), 1); assert.equal(window.isFocused(), true); + assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']); assert.deepEqual(window.sent, [['runtime-options:open']]); }); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 0bb60978..47f59912 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -138,7 +138,7 @@ export function createOverlayModalRuntimeService( const elevateModalWindow = (window: BrowserWindow): void => { if (window.isDestroyed()) return; - window.setAlwaysOnTop(true, 'screen-saver', 1); + window.setAlwaysOnTop(true, 'screen-saver', 3); window.moveTop(); }; diff --git a/src/main/runtime/jellyfin-remote-playback.test.ts b/src/main/runtime/jellyfin-remote-playback.test.ts index 58c91c0a..0edb9780 100644 --- a/src/main/runtime/jellyfin-remote-playback.test.ts +++ b/src/main/runtime/jellyfin-remote-playback.test.ts @@ -61,6 +61,42 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn assert.equal(lastProgressAtMs, 5000); }); +test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => { + const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = []; + + const reportProgress = createReportJellyfinRemoteProgressHandler({ + getActivePlayback: () => ({ + itemId: 'item-1', + playMethod: 'DirectPlay', + }), + clearActivePlayback: () => {}, + getSession: () => ({ + isConnected: () => false, + reportProgress: async (payload) => { + reportPayloads.push({ + positionTicks: payload.positionTicks, + isPaused: payload.isPaused, + }); + }, + reportStopped: async () => {}, + }), + getMpvClient: () => ({ + currentTimePos: 42, + requestProperty: async (name: string) => (name === 'pause' ? false : 42), + }), + getNow: () => 5000, + getLastProgressAtMs: () => 0, + setLastProgressAtMs: () => {}, + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportProgress(true); + + assert.deepEqual(reportPayloads, [{ positionTicks: 420_000_000, isPaused: false }]); +}); + test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => { const reportPayloads: Array<{ isPaused: boolean }> = []; @@ -219,6 +255,53 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback' assert.equal(cleared, true); }); +test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => { + let cleared = false; + let stoppedPayload: { + itemId: string; + positionTicks?: number; + failed?: boolean; + } | null = null; + const reportStopped = createReportJellyfinRemoteStoppedHandler({ + getActivePlayback: () => ({ + itemId: 'item-2', + mediaSourceId: undefined, + playMethod: 'Transcode', + audioStreamIndex: null, + subtitleStreamIndex: null, + loadedMediaPath: 'https://stream.example/video.m3u8', + }), + clearActivePlayback: () => { + cleared = true; + }, + getSession: () => ({ + isConnected: () => false, + reportProgress: async () => {}, + reportStopped: async (payload) => { + stoppedPayload = { + itemId: payload.itemId, + positionTicks: payload.positionTicks, + failed: payload.failed, + }; + }, + }), + getMpvClient: () => ({ + currentTimePos: 12.5, + }), + ticksPerSecond: 10_000_000, + logDebug: () => {}, + }); + + await reportStopped(); + + assert.deepEqual(stoppedPayload, { + itemId: 'item-2', + positionTicks: 125_000_000, + failed: false, + }); + assert.equal(cleared, true); +}); + test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => { let cleared = false; let stopped = false; diff --git a/src/main/runtime/jellyfin-remote-playback.ts b/src/main/runtime/jellyfin-remote-playback.ts index 6e24b31e..305bdfaf 100644 --- a/src/main/runtime/jellyfin-remote-playback.ts +++ b/src/main/runtime/jellyfin-remote-playback.ts @@ -108,7 +108,8 @@ export function createReportJellyfinRemoteProgressHandler( const playback = deps.getActivePlayback(); if (!playback) return; const session = deps.getSession(); - if (!session || !session.isConnected()) return; + // Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects. + if (!session) return; const now = deps.getNow(); try { const mpvClient = deps.getMpvClient(); @@ -167,7 +168,8 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto return; } const session = deps.getSession(); - if (!session || !session.isConnected()) { + // Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects. + if (!session) { deps.clearActivePlayback(); return; } diff --git a/src/main/runtime/jellyfin-subtitle-preload.test.ts b/src/main/runtime/jellyfin-subtitle-preload.test.ts index df3e8f00..07e5f076 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -201,6 +201,94 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste ); }); +test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => { + const commands: Array> = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => [ + { + type: 'sub', + id: ' ', + lang: 'jpn', + title: 'Invalid empty id', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/invalid.srt', + }, + { + type: 'sub', + id: '10', + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + { + type: 'sub', + id: '11', + lang: 'eng', + title: 'English', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt', + }, + ], + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.deepEqual( + commands.filter((command) => command[0] === 'set_property'), + [ + ['set_property', 'sid', 10], + ['set_property', 'secondary-sid', 11], + ], + ); +}); + +test('preload jellyfin subtitles retries transient mpv track-list read failures', async () => { + const commands: Array> = []; + let requestCount = 0; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + ], + getMpvClient: () => ({ + connected: true, + requestProperty: async () => { + requestCount += 1; + if (requestCount === 1) { + throw new Error('MPV request timed out'); + } + return [ + { + type: 'sub', + id: 10, + lang: 'jpn', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', + }, + ]; + }, + }), + sendMpvCommand: (command) => commands.push(command), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(requestCount, 2); + assert.deepEqual(commands.at(-1), ['set_property', 'sid', 10]); +}); + test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => { const commands: Array> = []; let requestCount = 0; diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts index 933cbec7..ee3f4cbf 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.ts @@ -151,18 +151,16 @@ function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] { ? trackListRaw .filter( (track): track is Record => - Boolean(track) && - typeof track === 'object' && - track.type === 'sub' && - typeof track.id === 'number', + Boolean(track) && typeof track === 'object' && track.type === 'sub', ) .map((track) => ({ - id: track.id as number, + id: parseTrackId(track.id), lang: String(track.lang || ''), title: String(track.title || ''), external: track.external === true, externalFilename: String(track['external-filename'] || ''), })) + .filter((track): track is MpvSubtitleTrack => track.id !== null) : []; } @@ -179,6 +177,15 @@ function hasExpectedExternalSubtitleTracks( return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath)); } +function parseTrackId(value: unknown): number | null { + if (typeof value === 'string' && value.trim() === '') { + return null; + } + const numeric = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN; + return Number.isFinite(numeric) ? numeric : null; +} + async function readMpvSubtitleTracks(deps: { getMpvClient: () => MpvClientLike | null; }): Promise { @@ -186,7 +193,12 @@ async function readMpvSubtitleTracks(deps: { if (!client || client.connected === false) { return null; } - const trackListRaw = await client.requestProperty('track-list'); + let trackListRaw: unknown; + try { + trackListRaw = await client.requestProperty('track-list'); + } catch { + return null; + } return parseMpvSubtitleTracks(trackListRaw); } diff --git a/src/main/runtime/overlay-modal-input-state.test.ts b/src/main/runtime/overlay-modal-input-state.test.ts index 694f7e1c..55bc1f6d 100644 --- a/src/main/runtime/overlay-modal-input-state.test.ts +++ b/src/main/runtime/overlay-modal-input-state.test.ts @@ -63,7 +63,7 @@ test('overlay modal input state activates modal window interactivity and syncs d assert.deepEqual(modalWindow.calls, [ 'focusable:true', 'ignore:false', - 'top:true:screen-saver:1', + 'top:true:screen-saver:3', 'focus', 'web-focus', ]); diff --git a/src/main/runtime/overlay-modal-input-state.ts b/src/main/runtime/overlay-modal-input-state.ts index fd49a952..68c58797 100644 --- a/src/main/runtime/overlay-modal-input-state.ts +++ b/src/main/runtime/overlay-modal-input-state.ts @@ -42,7 +42,7 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) { setWindowFocusable(modalWindow); requestOverlayApplicationFocus(); modalWindow.setIgnoreMouseEvents(false); - modalWindow.setAlwaysOnTop(true, 'screen-saver', 1); + modalWindow.setAlwaysOnTop(true, 'screen-saver', 3); modalWindow.focus(); if (!modalWindow.webContents.isFocused()) { modalWindow.webContents.focus(); diff --git a/src/main/runtime/update/update-dialogs.test.ts b/src/main/runtime/update/update-dialogs.test.ts index 5672a137..49b4a9c8 100644 --- a/src/main/runtime/update/update-dialogs.test.ts +++ b/src/main/runtime/update/update-dialogs.test.ts @@ -28,6 +28,57 @@ test('update dialog presenter focuses app and yields the run loop before showing assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']); }); +test('update dialog presenter suspends stats window layer while showing dialogs', async () => { + const calls: string[] = []; + const showMessageBox: ShowMessageBox = async (options) => { + calls.push(`dialog:${options.message}`); + return { response: 0 }; + }; + const presenter = createUpdateDialogPresenter({ + platform: 'linux', + withStatsWindowLayerSuspended: async (showDialog) => { + calls.push('suspend-stats-window'); + try { + return await showDialog(); + } finally { + calls.push('restore-stats-window'); + } + }, + showMessageBox, + }); + + await presenter.showNoUpdateDialog('0.14.0'); + + assert.deepEqual(calls, [ + 'suspend-stats-window', + 'dialog:SubMiner is up to date (v0.14.0)', + 'restore-stats-window', + ]); +}); + +test('update dialog presenter restores stats window layer when dialog fails', async () => { + const calls: string[] = []; + const presenter = createUpdateDialogPresenter({ + platform: 'linux', + withStatsWindowLayerSuspended: async (showDialog) => { + calls.push('suspend-stats-window'); + try { + return await showDialog(); + } finally { + calls.push('restore-stats-window'); + } + }, + showMessageBox: async () => { + calls.push('dialog'); + throw new Error('dialog failed'); + }, + }); + + await assert.rejects(() => presenter.showNoUpdateDialog('0.14.0'), /dialog failed/); + + assert.deepEqual(calls, ['suspend-stats-window', 'dialog', 'restore-stats-window']); +}); + test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => { const calls: string[] = []; const showMessageBox: ShowMessageBox = async (options) => { diff --git a/src/main/runtime/update/update-dialogs.ts b/src/main/runtime/update/update-dialogs.ts index f5ce4f88..261d3ac4 100644 --- a/src/main/runtime/update/update-dialogs.ts +++ b/src/main/runtime/update/update-dialogs.ts @@ -19,6 +19,7 @@ export interface UpdateDialogPresenterDeps { showMessageBox: ShowMessageBox; focusApp?: () => void | Promise; yieldToRunLoop?: () => Promise; + withStatsWindowLayerSuspended?: (showDialog: () => Promise) => Promise; platform?: NodeJS.Platform; } @@ -46,12 +47,18 @@ async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise< export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) { const showFocusedMessageBox: ShowMessageBox = async (options) => { - try { - await maybeFocusAppForDialog(deps); - } catch { - // Best-effort focus only; never block the dialog itself. - } - return deps.showMessageBox(options); + const showDialog = async (): Promise => { + try { + await maybeFocusAppForDialog(deps); + } catch { + // Best-effort focus only; never block the dialog itself. + } + return deps.showMessageBox(options); + }; + + return deps.withStatsWindowLayerSuspended + ? deps.withStatsWindowLayerSuspended(showDialog) + : showDialog(); }; return { diff --git a/src/preload-stats.ts b/src/preload-stats.ts index 136890ad..8e2b5a31 100644 --- a/src/preload-stats.ts +++ b/src/preload-stats.ts @@ -43,6 +43,18 @@ const statsAPI = { hideOverlay: (): void => { ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay); }, + + confirmNativeDialog: (message: string): boolean => { + return ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeConfirmDialog, message) === true; + }, + + beginNativeDialog: (): void => { + ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeDialogOpened); + }, + + endNativeDialog: (): void => { + ipcRenderer.send(IPC_CHANNELS.command.statsNativeDialogClosed); + }, }; contextBridge.exposeInMainWorld('electronAPI', { stats: statsAPI }); diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index e3b88908..443e2171 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -1008,6 +1008,38 @@ test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus', } }); +test('visible-layer configured overlay toggle dispatches mpv plugin toggle', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.toggleVisibleOverlayGlobal', + originalKey: 'Alt+Shift+O', + key: { code: 'KeyO', modifiers: ['alt', 'shift'] }, + actionType: 'session-action', + actionId: 'toggleVisibleOverlay', + }, + ] as never); + + testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', altKey: true, shiftKey: true }); + + assert.equal( + testGlobals.mpvCommands.some( + (command) => command[0] === 'script-message' && command[1] === 'subminer-toggle', + ), + true, + ); + assert.equal( + testGlobals.sessionActions.some((action) => action.actionId === 'toggleVisibleOverlay'), + false, + ); + } finally { + testGlobals.restore(); + } +}); + test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index cefdd995..0da7cbaf 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -207,6 +207,11 @@ export function createKeyboardHandlers( return; } + if (binding.actionType === 'session-action' && binding.actionId === 'toggleVisibleOverlay') { + window.electronAPI.sendMpvCommand(['script-message', 'subminer-toggle']); + return; + } + if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') { options.openControllerSelectModal?.(); return; diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 3f880cc4..6167eb3e 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -27,6 +27,9 @@ export const IPC_CHANNELS = { toggleDevTools: 'toggle-dev-tools', toggleOverlay: 'toggle-overlay', saveSubtitlePosition: 'save-subtitle-position', + statsNativeConfirmDialog: 'stats:native-confirm-dialog', + statsNativeDialogOpened: 'stats:native-dialog-opened', + statsNativeDialogClosed: 'stats:native-dialog-closed', saveControllerConfig: 'save-controller-config', saveControllerPreference: 'save-controller-preference', setMecabEnabled: 'set-mecab-enabled', diff --git a/stats/src/App.tsx b/stats/src/App.tsx index e70e0968..e8eab628 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -1,4 +1,5 @@ import { Suspense, lazy, useCallback, useState } from 'react'; +import { DeleteConfirmDialog } from './components/layout/DeleteConfirmDialog'; import { TabBar } from './components/layout/TabBar'; import { OverviewTab } from './components/overview/OverviewTab'; import { useExcludedWords } from './hooks/useExcludedWords'; @@ -272,6 +273,7 @@ export function App() { /> ) : null} + ); } diff --git a/stats/src/components/anime/EpisodeDetail.tsx b/stats/src/components/anime/EpisodeDetail.tsx index 408b79d5..01ff837e 100644 --- a/stats/src/components/anime/EpisodeDetail.tsx +++ b/stats/src/components/anime/EpisodeDetail.tsx @@ -85,7 +85,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps) }, [videoId]); const handleDeleteSession = async (sessionId: number) => { - if (!confirmSessionDelete()) return; + if (!(await confirmSessionDelete())) return; await apiClient.deleteSession(sessionId); setData((prev) => { if (!prev) return prev; diff --git a/stats/src/components/anime/EpisodeList.tsx b/stats/src/components/anime/EpisodeList.tsx index 8a2da53c..f9e11ff2 100644 --- a/stats/src/components/anime/EpisodeList.tsx +++ b/stats/src/components/anime/EpisodeList.tsx @@ -44,7 +44,7 @@ export function EpisodeList({ }; const handleDeleteEpisode = async (videoId: number, title: string) => { - if (!confirmEpisodeDelete(title)) return; + if (!(await confirmEpisodeDelete(title))) return; await apiClient.deleteVideo(videoId); setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId)); if (expandedVideoId === videoId) setExpandedVideoId(null); diff --git a/stats/src/components/layout/DeleteConfirmDialog.tsx b/stats/src/components/layout/DeleteConfirmDialog.tsx new file mode 100644 index 00000000..09da6b5a --- /dev/null +++ b/stats/src/components/layout/DeleteConfirmDialog.tsx @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { setDeleteConfirmPresenter } from '../../lib/delete-confirm'; + +interface PendingDeleteConfirm { + message: string; + resolve: (confirmed: boolean) => void; +} + +export function DeleteConfirmDialog() { + const [pendingConfirm, setPendingConfirm] = useState(null); + const pendingRef = useRef(null); + const cancelButtonRef = useRef(null); + + const finish = useCallback((confirmed: boolean) => { + const pending = pendingRef.current; + pendingRef.current = null; + setPendingConfirm(null); + pending?.resolve(confirmed); + }, []); + + useEffect(() => { + return setDeleteConfirmPresenter( + (message) => + new Promise((resolve) => { + pendingRef.current?.resolve(false); + const next = { message, resolve }; + pendingRef.current = next; + setPendingConfirm(next); + }), + ); + }, []); + + useEffect(() => { + if (!pendingConfirm) return; + cancelButtonRef.current?.focus(); + }, [pendingConfirm]); + + useEffect(() => { + if (!pendingConfirm) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + finish(false); + }; + window.addEventListener('keydown', onKeyDown, true); + return () => window.removeEventListener('keydown', onKeyDown, true); + }, [finish, pendingConfirm]); + + useEffect(() => { + return () => { + pendingRef.current?.resolve(false); + pendingRef.current = null; + }; + }, []); + + if (!pendingConfirm) return null; + + return ( +
+
+
+

+ Delete? +

+
+
+ {pendingConfirm.message} +
+
+ + +
+
+
+ ); +} diff --git a/stats/src/components/library/MediaDetailView.tsx b/stats/src/components/library/MediaDetailView.tsx index 8c07a286..d2129f32 100644 --- a/stats/src/components/library/MediaDetailView.tsx +++ b/stats/src/components/library/MediaDetailView.tsx @@ -11,7 +11,7 @@ interface DeleteEpisodeHandlerOptions { videoId: number; title: string; apiClient: { deleteVideo: (id: number) => Promise }; - confirmFn: (title: string) => boolean; + confirmFn: (title: string) => boolean | Promise; onBack: () => void; setDeleteError: (msg: string | null) => void; /** @@ -27,7 +27,7 @@ interface DeleteEpisodeHandlerOptions { export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise { return async () => { if (opts.isDeletingRef?.current) return; - if (!opts.confirmFn(opts.title)) return; + if (!(await opts.confirmFn(opts.title))) return; if (opts.isDeletingRef) opts.isDeletingRef.current = true; opts.setIsDeleting?.(true); opts.setDeleteError(null); @@ -101,7 +101,7 @@ export function MediaDetailView({ const relatedCollectionLabel = getRelatedCollectionLabel(detail); const handleDeleteSession = async (session: SessionSummary) => { - if (!confirmSessionDelete()) return; + if (!(await confirmSessionDelete())) return; setDeleteError(null); setDeletingSessionId(session.sessionId); diff --git a/stats/src/components/overview/OverviewTab.tsx b/stats/src/components/overview/OverviewTab.tsx index 83cf834a..f2b95811 100644 --- a/stats/src/components/overview/OverviewTab.tsx +++ b/stats/src/components/overview/OverviewTab.tsx @@ -47,7 +47,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov }, []); const handleDeleteSession = async (session: SessionSummary) => { - if (!confirmSessionDelete()) return; + if (!(await confirmSessionDelete())) return; setDeleteError(null); setDeletingIds((prev) => new Set(prev).add(session.sessionId)); try { @@ -65,7 +65,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov }; const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => { - if (!confirmDayGroupDelete(dayLabel, daySessions.length)) return; + if (!(await confirmDayGroupDelete(dayLabel, daySessions.length))) return; setDeleteError(null); const ids = daySessions.map((s) => s.sessionId); setDeletingIds((prev) => { @@ -91,7 +91,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => { const title = groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media'; - if (!confirmAnimeGroupDelete(title, groupSessions.length)) return; + if (!(await confirmAnimeGroupDelete(title, groupSessions.length))) return; setDeleteError(null); const ids = groupSessions.map((s) => s.sessionId); setDeletingIds((prev) => { diff --git a/stats/src/components/sessions/SessionsTab.tsx b/stats/src/components/sessions/SessionsTab.tsx index c35e8c98..628359a2 100644 --- a/stats/src/components/sessions/SessionsTab.tsx +++ b/stats/src/components/sessions/SessionsTab.tsx @@ -27,7 +27,7 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map Promise }; - confirm: (title: string, count: number) => boolean; + confirm: (title: string, count: number) => boolean | Promise; onSuccess: (deletedIds: number[]) => void; onError: (message: string) => void; } @@ -43,7 +43,7 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise< return async () => { const title = bucket.representativeSession.canonicalTitle ?? 'this episode'; const ids = bucket.sessions.map((s) => s.sessionId); - if (!confirm(title, ids.length)) return; + if (!(await confirm(title, ids.length))) return; try { await client.deleteSessions(ids); onSuccess(ids); @@ -120,7 +120,7 @@ export function SessionsTab({ }; const handleDeleteSession = async (session: SessionSummary) => { - if (!confirmSessionDelete()) return; + if (!(await confirmSessionDelete())) return; setDeleteError(null); setDeletingSessionId(session.sessionId); diff --git a/stats/src/lib/delete-confirm.test.ts b/stats/src/lib/delete-confirm.test.ts index 585d19db..2b4b6d6a 100644 --- a/stats/src/lib/delete-confirm.test.ts +++ b/stats/src/lib/delete-confirm.test.ts @@ -5,9 +5,10 @@ import { confirmDayGroupDelete, confirmEpisodeDelete, confirmSessionDelete, + setDeleteConfirmPresenter, } from './delete-confirm'; -test('confirmSessionDelete uses the shared session delete warning copy', () => { +test('confirmSessionDelete uses the shared session delete warning copy', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -16,14 +17,183 @@ test('confirmSessionDelete uses the shared session delete warning copy', () => { }) as typeof globalThis.confirm; try { - assert.equal(confirmSessionDelete(), true); + assert.equal(await confirmSessionDelete(), true); assert.deepEqual(calls, ['Delete this session and all associated data?']); } finally { globalThis.confirm = originalConfirm; } }); -test('confirmDayGroupDelete includes the day label and count in the warning copy', () => { +test('confirmSessionDelete suspends stats overlay layering around native confirm', async () => { + const calls: string[] = []; + const originalConfirm = globalThis.confirm; + const originalElectronAPI = ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI = { + stats: { + beginNativeDialog: () => calls.push('begin-native-dialog'), + endNativeDialog: () => calls.push('end-native-dialog'), + }, + }; + globalThis.confirm = ((message?: string) => { + calls.push(`confirm:${message ?? ''}`); + return true; + }) as typeof globalThis.confirm; + + try { + assert.equal(await confirmSessionDelete(), true); + assert.deepEqual(calls, [ + 'begin-native-dialog', + 'confirm:Delete this session and all associated data?', + 'end-native-dialog', + ]); + } finally { + globalThis.confirm = originalConfirm; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI = originalElectronAPI; + } +}); + +test('confirmSessionDelete uses parented Electron confirm when available', async () => { + const calls: string[] = []; + const originalConfirm = globalThis.confirm; + const originalElectronAPI = ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI = { + stats: { + confirmNativeDialog: (message) => { + calls.push(`native-confirm:${message}`); + return false; + }, + beginNativeDialog: () => calls.push('begin-native-dialog'), + endNativeDialog: () => calls.push('end-native-dialog'), + }, + }; + globalThis.confirm = ((message?: string) => { + calls.push(`browser-confirm:${message ?? ''}`); + return true; + }) as typeof globalThis.confirm; + + try { + assert.equal(await confirmSessionDelete(), false); + assert.deepEqual(calls, ['native-confirm:Delete this session and all associated data?']); + } finally { + globalThis.confirm = originalConfirm; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; + } + ).electronAPI = originalElectronAPI; + } +}); + +test('confirmSessionDelete uses the registered stats presenter before native or browser confirm', async () => { + const calls: string[] = []; + const originalConfirm = globalThis.confirm; + const originalElectronAPI = ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + }; + }; + } + ).electronAPI; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + }; + }; + } + ).electronAPI = { + stats: { + confirmNativeDialog: (message) => { + calls.push(`native-confirm:${message}`); + return true; + }, + }, + }; + globalThis.confirm = ((message?: string) => { + calls.push(`browser-confirm:${message ?? ''}`); + return true; + }) as typeof globalThis.confirm; + + const unregister = setDeleteConfirmPresenter(async (message) => { + calls.push(`presenter:${message}`); + return false; + }); + + try { + assert.equal(await confirmSessionDelete(), false); + assert.deepEqual(calls, ['presenter:Delete this session and all associated data?']); + } finally { + unregister(); + globalThis.confirm = originalConfirm; + ( + globalThis as typeof globalThis & { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + }; + }; + } + ).electronAPI = originalElectronAPI; + } +}); + +test('confirmDayGroupDelete includes the day label and count in the warning copy', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -32,14 +202,14 @@ test('confirmDayGroupDelete includes the day label and count in the warning copy }) as typeof globalThis.confirm; try { - assert.equal(confirmDayGroupDelete('Today', 3), true); + assert.equal(await confirmDayGroupDelete('Today', 3), true); assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']); } finally { globalThis.confirm = originalConfirm; } }); -test('confirmDayGroupDelete uses singular for one session', () => { +test('confirmDayGroupDelete uses singular for one session', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -48,14 +218,14 @@ test('confirmDayGroupDelete uses singular for one session', () => { }) as typeof globalThis.confirm; try { - assert.equal(confirmDayGroupDelete('Yesterday', 1), true); + assert.equal(await confirmDayGroupDelete('Yesterday', 1), true); assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']); } finally { globalThis.confirm = originalConfirm; } }); -test('confirmBucketDelete asks about merging multiple sessions of the same episode', () => { +test('confirmBucketDelete asks about merging multiple sessions of the same episode', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -64,7 +234,7 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo }) as typeof globalThis.confirm; try { - assert.equal(confirmBucketDelete('My Episode', 3), true); + assert.equal(await confirmBucketDelete('My Episode', 3), true); assert.deepEqual(calls, [ 'Delete all 3 sessions of "My Episode" from this day and all associated data?', ]); @@ -73,7 +243,7 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo } }); -test('confirmBucketDelete uses a clean singular form for one session', () => { +test('confirmBucketDelete uses a clean singular form for one session', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -82,7 +252,7 @@ test('confirmBucketDelete uses a clean singular form for one session', () => { }) as typeof globalThis.confirm; try { - assert.equal(confirmBucketDelete('Solo Episode', 1), false); + assert.equal(await confirmBucketDelete('Solo Episode', 1), false); assert.deepEqual(calls, [ 'Delete this session of "Solo Episode" from this day and all associated data?', ]); @@ -91,7 +261,7 @@ test('confirmBucketDelete uses a clean singular form for one session', () => { } }); -test('confirmEpisodeDelete includes the episode title in the shared warning copy', () => { +test('confirmEpisodeDelete includes the episode title in the shared warning copy', async () => { const calls: string[] = []; const originalConfirm = globalThis.confirm; globalThis.confirm = ((message?: string) => { @@ -100,7 +270,7 @@ test('confirmEpisodeDelete includes the episode title in the shared warning copy }) as typeof globalThis.confirm; try { - assert.equal(confirmEpisodeDelete('Episode 4'), false); + assert.equal(await confirmEpisodeDelete('Episode 4'), false); assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']); } finally { globalThis.confirm = originalConfirm; diff --git a/stats/src/lib/delete-confirm.ts b/stats/src/lib/delete-confirm.ts index 137e3996..41cbd664 100644 --- a/stats/src/lib/delete-confirm.ts +++ b/stats/src/lib/delete-confirm.ts @@ -1,30 +1,71 @@ -export function confirmSessionDelete(): boolean { - return globalThis.confirm('Delete this session and all associated data?'); +type NativeDialogBridge = { + electronAPI?: { + stats?: { + confirmNativeDialog?: (message: string) => boolean; + beginNativeDialog?: () => void; + endNativeDialog?: () => void; + }; + }; +}; + +type DeleteConfirmPresenter = (message: string) => boolean | Promise; + +let deleteConfirmPresenter: DeleteConfirmPresenter | null = null; + +export function setDeleteConfirmPresenter(presenter: DeleteConfirmPresenter): () => void { + deleteConfirmPresenter = presenter; + return () => { + if (deleteConfirmPresenter === presenter) { + deleteConfirmPresenter = null; + } + }; } -export function confirmDayGroupDelete(dayLabel: string, count: number): boolean { - return globalThis.confirm( +async function confirmWithStatsNativeDialogLayer(message: string): Promise { + if (deleteConfirmPresenter) { + return deleteConfirmPresenter(message); + } + + const statsApi = (globalThis as typeof globalThis & NativeDialogBridge).electronAPI?.stats; + if (statsApi?.confirmNativeDialog) { + return statsApi.confirmNativeDialog(message); + } + + statsApi?.beginNativeDialog?.(); + try { + return globalThis.confirm(message); + } finally { + statsApi?.endNativeDialog?.(); + } +} + +export function confirmSessionDelete(): Promise { + return confirmWithStatsNativeDialogLayer('Delete this session and all associated data?'); +} + +export function confirmDayGroupDelete(dayLabel: string, count: number): Promise { + return confirmWithStatsNativeDialogLayer( `Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`, ); } -export function confirmAnimeGroupDelete(title: string, count: number): boolean { - return globalThis.confirm( +export function confirmAnimeGroupDelete(title: string, count: number): Promise { + return confirmWithStatsNativeDialogLayer( `Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`, ); } -export function confirmEpisodeDelete(title: string): boolean { - return globalThis.confirm(`Delete "${title}" and all its sessions?`); +export function confirmEpisodeDelete(title: string): Promise { + return confirmWithStatsNativeDialogLayer(`Delete "${title}" and all its sessions?`); } -export function confirmBucketDelete(title: string, count: number): boolean { +export function confirmBucketDelete(title: string, count: number): Promise { if (count === 1) { - return globalThis.confirm( + return confirmWithStatsNativeDialogLayer( `Delete this session of "${title}" from this day and all associated data?`, ); } - return globalThis.confirm( + return confirmWithStatsNativeDialogLayer( `Delete all ${count} sessions of "${title}" from this day and all associated data?`, ); } diff --git a/stats/src/lib/ipc-client.ts b/stats/src/lib/ipc-client.ts index 096b02d1..ae141037 100644 --- a/stats/src/lib/ipc-client.ts +++ b/stats/src/lib/ipc-client.ts @@ -62,6 +62,9 @@ interface StatsElectronAPI { ankiBrowse: (noteId: number) => Promise; ankiNotesInfo: (noteIds: number[]) => Promise; hideOverlay: () => void; + confirmNativeDialog?: (message: string) => boolean; + beginNativeDialog?: () => void; + endNativeDialog?: () => void; }; }