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
This commit is contained in:
2026-05-23 01:45:09 -07:00
parent 80d05aef27
commit 81b941fe8c
46 changed files with 1472 additions and 79 deletions
+7
View File
@@ -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.
+1 -1
View File
@@ -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.
+4
View File
@@ -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.
+7
View File
@@ -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
+40
View File
@@ -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
+6
View File
@@ -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)
+35
View File
@@ -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,
+5
View File
@@ -312,6 +312,11 @@ function M.create(ctx)
return
end
if binding.actionId == "toggleVisibleOverlay" and type(process.toggle_overlay) == "function" then
process.toggle_overlay()
return
end
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers)
return
+4
View File
@@ -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 = {},
+25
View File
@@ -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,
+17
View File
@@ -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)
+171 -3
View File
@@ -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 = "",
+43 -5
View File
@@ -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'],
);
});
+12 -2
View File
@@ -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();
}
+49 -1
View File
@@ -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<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type VisibleStatsWindowLevelController = StatsWindowLevelController &
Pick<BrowserWindow, 'isDestroyed' | 'isVisible'>;
type VisibleStatsWindowDialogLayerController = Pick<
BrowserWindow,
'isDestroyed' | 'isVisible' | 'setAlwaysOnTop'
>;
type StatsNativeConfirmDialogWindow = Pick<BrowserWindow, 'isDestroyed'>;
type StatsNativeConfirmDialogPresenter<WindowT> = {
showWithParent: (window: WindowT, options: MessageBoxSyncOptions) => number;
showWithoutParent: (options: MessageBoxSyncOptions) => number;
};
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
@@ -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<WindowT extends StatsNativeConfirmDialogWindow>(
window: WindowT | null,
message: string,
presenter: StatsNativeConfirmDialogPresenter<WindowT>,
): 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,
+87
View File
@@ -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[] = [];
+75 -1
View File
@@ -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<T>(
showDialog: () => Promise<T>,
): Promise<T> {
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, () => {
+61
View File
@@ -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);
+72 -1
View File
@@ -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) {
+51 -12
View File
@@ -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<boolean> {
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);
}
}
+15 -1
View File
@@ -356,6 +356,7 @@ import {
promoteStatsOverlayAbovePlayback,
registerStatsOverlayToggle,
toggleStatsOverlay as toggleStatsOverlayWindow,
withStatsWindowLayerSuspendedForNativeDialog,
} from './core/services/stats-window.js';
import {
createFirstRunSetupService,
@@ -5123,6 +5124,8 @@ function getUpdateService() {
});
app.focus({ steal: true });
},
withStatsWindowLayerSuspended: (showDialog) =>
withStatsWindowLayerSuspendedForNativeDialog(showDialog),
showMessageBox: (options) => dialog.showMessageBox(options),
});
updateService = createUpdateService({
@@ -6325,6 +6328,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) {
@@ -6335,18 +6345,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 {
@@ -6358,6 +6371,7 @@ function setOverlayVisible(visible: boolean): void {
void ensureOverlayMpvSubtitlesHidden();
}
setOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
syncOverlayMpvSubtitleSuppression();
}
function handleOverlayModalClosed(modal: OverlayHostedModal): void {
+16
View File
@@ -70,6 +70,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 \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\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\(\)/);
+13 -1
View File
@@ -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']]);
});
+1 -1
View File
@@ -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();
};
@@ -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;
+4 -2
View File
@@ -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;
}
@@ -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<Array<string | number>> = [];
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<Array<string | number>> = [];
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<Array<string | number>> = [];
let requestCount = 0;
+18 -6
View File
@@ -151,18 +151,16 @@ function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] {
? trackListRaw
.filter(
(track): track is Record<string, unknown> =>
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<MpvSubtitleTrack[] | null> {
@@ -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);
}
@@ -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',
]);
@@ -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();
@@ -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) => {
+13 -6
View File
@@ -19,6 +19,7 @@ export interface UpdateDialogPresenterDeps {
showMessageBox: ShowMessageBox;
focusApp?: () => void | Promise<void>;
yieldToRunLoop?: () => Promise<void>;
withStatsWindowLayerSuspended?: <T>(showDialog: () => Promise<T>) => Promise<T>;
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<MessageBoxResultLike> => {
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 {
+12
View File
@@ -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 });
+32
View File
@@ -993,6 +993,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();
+5
View File
@@ -204,6 +204,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;
+3
View File
@@ -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',
+2
View File
@@ -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() {
/>
</Suspense>
) : null}
<DeleteConfirmDialog />
</div>
);
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
@@ -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<PendingDeleteConfirm | null>(null);
const pendingRef = useRef<PendingDeleteConfirm | null>(null);
const cancelButtonRef = useRef<HTMLButtonElement>(null);
const finish = useCallback((confirmed: boolean) => {
const pending = pendingRef.current;
pendingRef.current = null;
setPendingConfirm(null);
pending?.resolve(confirmed);
}, []);
useEffect(() => {
return setDeleteConfirmPresenter(
(message) =>
new Promise<boolean>((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 (
<div className="fixed inset-0 z-[2147483647] flex items-center justify-center bg-ctp-crust/55 p-4 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-confirm-title"
className="w-full max-w-md rounded-lg border border-ctp-surface1 bg-ctp-mantle shadow-2xl"
>
<div className="border-b border-ctp-surface1 px-4 py-3">
<h2 id="delete-confirm-title" className="text-sm font-semibold text-ctp-text">
Delete?
</h2>
</div>
<div className="px-4 py-4 text-sm leading-6 text-ctp-subtext0">
{pendingConfirm.message}
</div>
<div className="grid grid-cols-2 border-t border-ctp-surface1">
<button
ref={cancelButtonRef}
type="button"
onClick={() => finish(false)}
className="border-r border-ctp-surface1 px-4 py-3 text-sm text-ctp-subtext0 transition-colors hover:bg-ctp-surface0 hover:text-ctp-text focus:outline-none focus:bg-ctp-surface0 focus:text-ctp-text"
>
Cancel
</button>
<button
type="button"
onClick={() => finish(true)}
className="px-4 py-3 text-sm font-semibold text-ctp-red transition-colors hover:bg-ctp-surface0 focus:outline-none focus:bg-ctp-surface0"
>
Delete
</button>
</div>
</div>
</div>
);
}
@@ -11,7 +11,7 @@ interface DeleteEpisodeHandlerOptions {
videoId: number;
title: string;
apiClient: { deleteVideo: (id: number) => Promise<void> };
confirmFn: (title: string) => boolean;
confirmFn: (title: string) => boolean | Promise<boolean>;
onBack: () => void;
setDeleteError: (msg: string | null) => void;
/**
@@ -27,7 +27,7 @@ interface DeleteEpisodeHandlerOptions {
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
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);
@@ -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) => {
@@ -27,7 +27,7 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
export interface BucketDeleteDeps {
bucket: SessionBucket;
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
confirm: (title: string, count: number) => boolean;
confirm: (title: string, count: number) => boolean | Promise<boolean>;
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);
+182 -12
View File
@@ -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;
+52 -11
View File
@@ -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<boolean>;
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<boolean> {
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<boolean> {
return confirmWithStatsNativeDialogLayer('Delete this session and all associated data?');
}
export function confirmDayGroupDelete(dayLabel: string, count: number): Promise<boolean> {
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<boolean> {
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<boolean> {
return confirmWithStatsNativeDialogLayer(`Delete "${title}" and all its sessions?`);
}
export function confirmBucketDelete(title: string, count: number): boolean {
export function confirmBucketDelete(title: string, count: number): Promise<boolean> {
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?`,
);
}
+3
View File
@@ -62,6 +62,9 @@ interface StatsElectronAPI {
ankiBrowse: (noteId: number) => Promise<void>;
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
hideOverlay: () => void;
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
}