mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
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:
@@ -2,3 +2,10 @@ type: fixed
|
|||||||
area: jellyfin
|
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.
|
- 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,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: jellyfin
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -114,6 +114,13 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
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
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ function M.create(ctx)
|
|||||||
return nil
|
return nil
|
||||||
end
|
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)
|
local function is_reload_end_file(reason)
|
||||||
return reason == "reload" or reason == "redirect"
|
return reason == "reload" or reason == "redirect"
|
||||||
end
|
end
|
||||||
@@ -125,6 +139,10 @@ function M.create(ctx)
|
|||||||
|
|
||||||
local function on_start_file()
|
local function on_start_file()
|
||||||
if state.pending_reload_media_identity ~= nil then
|
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
|
return
|
||||||
end
|
end
|
||||||
rearm_managed_subtitle_load_defaults()
|
rearm_managed_subtitle_load_defaults()
|
||||||
@@ -132,12 +150,23 @@ function M.create(ctx)
|
|||||||
|
|
||||||
local function on_file_loaded()
|
local function on_file_loaded()
|
||||||
local media_identity = resolve_media_identity()
|
local media_identity = resolve_media_identity()
|
||||||
|
local media_title = resolve_media_title()
|
||||||
local retry_generation = next_auto_start_retry_generation()
|
local retry_generation = next_auto_start_retry_generation()
|
||||||
local previous_media_identity = state.current_media_identity
|
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 = (
|
local same_media_reload = (
|
||||||
media_identity ~= nil
|
media_identity ~= nil
|
||||||
and state.pending_reload_media_identity ~= nil
|
and state.pending_reload_media_identity ~= nil
|
||||||
and media_identity == state.pending_reload_media_identity
|
and media_identity == state.pending_reload_media_identity
|
||||||
|
) 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 = (
|
local same_media_loaded = (
|
||||||
media_identity ~= nil
|
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
|
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_identity = nil
|
||||||
|
state.pending_reload_media_title = nil
|
||||||
|
state.pending_reload_reason = nil
|
||||||
state.current_media_identity = media_identity
|
state.current_media_identity = media_identity
|
||||||
|
state.current_media_title = media_title
|
||||||
if new_media_loaded then
|
if new_media_loaded then
|
||||||
state.suppress_ready_overlay_restore = false
|
state.suppress_ready_overlay_restore = false
|
||||||
end
|
end
|
||||||
@@ -191,7 +223,10 @@ function M.create(ctx)
|
|||||||
hover.clear_hover_overlay()
|
hover.clear_hover_overlay()
|
||||||
process.disarm_auto_play_ready_gate()
|
process.disarm_auto_play_ready_gate()
|
||||||
state.current_media_identity = nil
|
state.current_media_identity = nil
|
||||||
|
state.current_media_title = nil
|
||||||
state.pending_reload_media_identity = nil
|
state.pending_reload_media_identity = nil
|
||||||
|
state.pending_reload_media_title = nil
|
||||||
|
state.pending_reload_reason = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function register_lifecycle_hooks()
|
local function register_lifecycle_hooks()
|
||||||
@@ -207,11 +242,16 @@ function M.create(ctx)
|
|||||||
local reason = type(event) == "table" and event.reason or nil
|
local reason = type(event) == "table" and event.reason or nil
|
||||||
if is_reload_end_file(reason) then
|
if is_reload_end_file(reason) then
|
||||||
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
|
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
|
||||||
|
state.pending_reload_media_title = state.current_media_title or resolve_media_title()
|
||||||
|
state.pending_reload_reason = reason
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
next_auto_start_retry_generation()
|
next_auto_start_retry_generation()
|
||||||
state.current_media_identity = nil
|
state.current_media_identity = nil
|
||||||
|
state.current_media_title = nil
|
||||||
state.pending_reload_media_identity = 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
|
if state.overlay_running and reason ~= "quit" then
|
||||||
process.hide_visible_overlay()
|
process.hide_visible_overlay()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ function M.create(ctx)
|
|||||||
mp.register_script_message("subminer-toggle", function()
|
mp.register_script_message("subminer-toggle", function()
|
||||||
process.toggle_overlay()
|
process.toggle_overlay()
|
||||||
end)
|
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()
|
mp.register_script_message("subminer-menu", function()
|
||||||
ui.show_menu()
|
ui.show_menu()
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
|
|||||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||||
|
local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
@@ -91,6 +92,35 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
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)
|
local function normalize_socket_path(path)
|
||||||
if type(path) ~= "string" then
|
if type(path) ~= "string" then
|
||||||
return nil
|
return nil
|
||||||
@@ -604,6 +634,10 @@ function M.create(ctx)
|
|||||||
show_osd("Error: binary not found")
|
show_osd("Error: binary not found")
|
||||||
return
|
return
|
||||||
end
|
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
|
if state.visible_overlay_requested == true then
|
||||||
state.suppress_ready_overlay_restore = true
|
state.suppress_ready_overlay_restore = true
|
||||||
hide_visible_overlay({ resume_playback = false })
|
hide_visible_overlay({ resume_playback = false })
|
||||||
@@ -751,6 +785,7 @@ function M.create(ctx)
|
|||||||
build_command_args = build_command_args,
|
build_command_args = build_command_args,
|
||||||
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
||||||
run_control_command_async = run_control_command_async,
|
run_control_command_async = run_control_command_async,
|
||||||
|
record_visible_overlay_visibility = record_visible_overlay_visibility,
|
||||||
run_binary_command_async = run_binary_command_async,
|
run_binary_command_async = run_binary_command_async,
|
||||||
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
||||||
ensure_texthooker_running = ensure_texthooker_running,
|
ensure_texthooker_running = ensure_texthooker_running,
|
||||||
|
|||||||
@@ -312,6 +312,11 @@ function M.create(ctx)
|
|||||||
return
|
return
|
||||||
end
|
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
|
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
|
||||||
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers)
|
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ function M.new()
|
|||||||
suppress_ready_overlay_restore = false,
|
suppress_ready_overlay_restore = false,
|
||||||
force_ready_overlay_restore = false,
|
force_ready_overlay_restore = false,
|
||||||
visible_overlay_requested = nil,
|
visible_overlay_requested = nil,
|
||||||
|
last_visible_overlay_toggle_time = nil,
|
||||||
current_media_identity = nil,
|
current_media_identity = nil,
|
||||||
|
current_media_title = nil,
|
||||||
pending_reload_media_identity = nil,
|
pending_reload_media_identity = nil,
|
||||||
|
pending_reload_media_title = nil,
|
||||||
|
pending_reload_reason = nil,
|
||||||
auto_start_retry_generation = 0,
|
auto_start_retry_generation = 0,
|
||||||
session_binding_generation = 0,
|
session_binding_generation = 0,
|
||||||
session_binding_names = {},
|
session_binding_names = {},
|
||||||
|
|||||||
@@ -68,6 +68,31 @@ local function create_binary_module(config)
|
|||||||
return binary
|
return binary
|
||||||
end
|
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
|
do
|
||||||
local binary = create_binary_module({
|
local binary = create_binary_module({
|
||||||
is_windows = true,
|
is_windows = true,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ local recorded = {
|
|||||||
async_calls = {},
|
async_calls = {},
|
||||||
mpv_commands = {},
|
mpv_commands = {},
|
||||||
osd = {},
|
osd = {},
|
||||||
|
overlay_toggles = 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
local mp = {}
|
local mp = {}
|
||||||
@@ -68,6 +69,14 @@ local ctx = {
|
|||||||
return {
|
return {
|
||||||
numericSelectionTimeoutMs = 3000,
|
numericSelectionTimeoutMs = 3000,
|
||||||
bindings = {
|
bindings = {
|
||||||
|
{
|
||||||
|
key = {
|
||||||
|
code = "KeyO",
|
||||||
|
modifiers = { "alt", "shift" },
|
||||||
|
},
|
||||||
|
actionType = "session-action",
|
||||||
|
actionId = "toggleVisibleOverlay",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key = {
|
key = {
|
||||||
code = "KeyS",
|
code = "KeyS",
|
||||||
@@ -253,6 +262,9 @@ local ctx = {
|
|||||||
run_binary_command_async = function(args)
|
run_binary_command_async = function(args)
|
||||||
recorded.async_calls[#recorded.async_calls + 1] = args
|
recorded.async_calls[#recorded.async_calls + 1] = args
|
||||||
end,
|
end,
|
||||||
|
toggle_overlay = function()
|
||||||
|
recorded.overlay_toggles = recorded.overlay_toggles + 1
|
||||||
|
end,
|
||||||
},
|
},
|
||||||
environment = {
|
environment = {
|
||||||
resolve_session_bindings_artifact_path = function()
|
resolve_session_bindings_artifact_path = function()
|
||||||
@@ -318,6 +330,11 @@ local expected_cli_bindings = {
|
|||||||
{ keys = "w", flag = "--mark-watched" },
|
{ 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
|
for _, expected in ipairs(expected_cli_bindings) do
|
||||||
local binding = find_binding(expected.keys)
|
local binding = find_binding(expected.keys)
|
||||||
assert_true(binding ~= nil, "default session action should register " .. expected.keys)
|
assert_true(binding ~= nil, "default session action should register " .. expected.keys)
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ local function run_plugin_scenario(config)
|
|||||||
end
|
end
|
||||||
function mp.set_osd_ass(...) end
|
function mp.set_osd_ass(...) end
|
||||||
function mp.get_time()
|
function mp.get_time()
|
||||||
return 0
|
return config.now or 0
|
||||||
end
|
end
|
||||||
function mp.commandv(...) end
|
function mp.commandv(...) end
|
||||||
function mp.set_property_native(name, value)
|
function mp.set_property_native(name, value)
|
||||||
@@ -623,16 +623,18 @@ local binary_path = "/tmp/subminer-binary"
|
|||||||
local appimage_path = "/tmp/SubMiner.AppImage"
|
local appimage_path = "/tmp/SubMiner.AppImage"
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local scenario = {
|
||||||
process_list = "",
|
process_list = "",
|
||||||
option_overrides = {
|
option_overrides = {
|
||||||
binary_path = binary_path,
|
binary_path = binary_path,
|
||||||
auto_start = "no",
|
auto_start = "no",
|
||||||
},
|
},
|
||||||
|
now = 20,
|
||||||
files = {
|
files = {
|
||||||
[binary_path] = true,
|
[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 ~= 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")
|
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
|
||||||
recorded.script_messages["subminer-start"]("texthooker=no")
|
recorded.script_messages["subminer-start"]("texthooker=no")
|
||||||
@@ -683,6 +685,125 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local scenario = {
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "yes",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
path = "/media/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
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
@@ -1717,6 +1838,53 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local scenario = {
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "yes",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
path = "/media/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
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ test('suspended visible overlay hides without refreshing bounds or z-order', ()
|
|||||||
assert.ok(!calls.includes('focus'));
|
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();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
let trackerWarning = false;
|
let trackerWarning = false;
|
||||||
|
|
||||||
@@ -279,11 +279,49 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.equal(trackerWarning, false);
|
assert.equal(trackerWarning, false);
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('show-inactive'));
|
||||||
assert.ok(calls.includes('focus'));
|
assert.ok(!calls.includes('show'));
|
||||||
|
assert.ok(!calls.includes('focus'));
|
||||||
assert.ok(!calls.includes('osd'));
|
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', () => {
|
test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
@@ -317,8 +355,8 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
calls.filter((call) => call === 'update-bounds' || call === 'show'),
|
calls.filter((call) => call === 'update-bounds' || call === 'show-inactive'),
|
||||||
['update-bounds', 'show', 'update-bounds'],
|
['update-bounds', 'show-inactive', 'update-bounds'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
shouldUseMacOSMousePassthrough ||
|
shouldUseMacOSMousePassthrough ||
|
||||||
forceMousePassthrough ||
|
forceMousePassthrough ||
|
||||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
||||||
|
const isNonNativePassiveOverlay =
|
||||||
|
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
||||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||||
!args.isWindowsPlatform ||
|
!args.isWindowsPlatform ||
|
||||||
@@ -227,7 +229,10 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
||||||
// callback will trigger another visibility update when the renderer
|
// callback will trigger another visibility update when the renderer
|
||||||
// has painted its first frame.
|
// has painted its first frame.
|
||||||
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
|
} else if (
|
||||||
|
((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) ||
|
||||||
|
isNonNativePassiveOverlay
|
||||||
|
) {
|
||||||
if (args.isWindowsPlatform) {
|
if (args.isWindowsPlatform) {
|
||||||
setOverlayWindowOpacity(mainWindow, 0);
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
}
|
}
|
||||||
@@ -271,7 +276,12 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
if (
|
||||||
|
!args.isWindowsPlatform &&
|
||||||
|
!args.isMacOSPlatform &&
|
||||||
|
!forceMousePassthrough &&
|
||||||
|
overlayInteractionActive
|
||||||
|
) {
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
import type {
|
||||||
|
BrowserWindow,
|
||||||
|
BrowserWindowConstructorOptions,
|
||||||
|
MessageBoxSyncOptions,
|
||||||
|
} from 'electron';
|
||||||
import type { WindowGeometry } from '../../types';
|
import type { WindowGeometry } from '../../types';
|
||||||
|
|
||||||
const DEFAULT_STATS_WINDOW_WIDTH = 900;
|
const DEFAULT_STATS_WINDOW_WIDTH = 900;
|
||||||
@@ -9,6 +13,15 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
|
|||||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||||
type VisibleStatsWindowLevelController = StatsWindowLevelController &
|
type VisibleStatsWindowLevelController = StatsWindowLevelController &
|
||||||
Pick<BrowserWindow, 'isDestroyed' | 'isVisible'>;
|
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 StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
||||||
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
||||||
@@ -124,6 +137,41 @@ export function promoteVisibleStatsWindowAboveOverlay(
|
|||||||
return true;
|
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(
|
export function presentStatsWindow(
|
||||||
window: StatsWindowPresentationController,
|
window: StatsWindowPresentationController,
|
||||||
platform: NodeJS.Platform = process.platform,
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
buildStatsWindowLoadFileOptions,
|
buildStatsWindowLoadFileOptions,
|
||||||
buildStatsWindowOptions,
|
buildStatsWindowOptions,
|
||||||
|
buildStatsNativeConfirmDialogOptions,
|
||||||
|
demoteVisibleStatsWindowBelowDialogs,
|
||||||
presentStatsWindow,
|
presentStatsWindow,
|
||||||
promoteVisibleStatsWindowAboveOverlay,
|
promoteVisibleStatsWindowAboveOverlay,
|
||||||
promoteStatsWindowLevel,
|
promoteStatsWindowLevel,
|
||||||
resolveStatsWindowOuterBoundsForContent,
|
resolveStatsWindowOuterBoundsForContent,
|
||||||
|
showStatsNativeConfirmDialog,
|
||||||
shouldHideStatsWindowForInput,
|
shouldHideStatsWindowForInput,
|
||||||
} from './stats-window-runtime';
|
} from './stats-window-runtime';
|
||||||
|
|
||||||
@@ -274,6 +277,90 @@ test('promoteVisibleStatsWindowAboveOverlay skips hidden stats windows', () => {
|
|||||||
assert.deepEqual(calls, []);
|
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', () => {
|
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { BrowserWindow, ipcMain } from 'electron';
|
import { BrowserWindow, dialog, ipcMain } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { WindowGeometry } from '../../types.js';
|
import type { WindowGeometry } from '../../types.js';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
||||||
import {
|
import {
|
||||||
buildStatsWindowLoadFileOptions,
|
buildStatsWindowLoadFileOptions,
|
||||||
buildStatsWindowOptions,
|
buildStatsWindowOptions,
|
||||||
|
demoteVisibleStatsWindowBelowDialogs,
|
||||||
presentStatsWindow,
|
presentStatsWindow,
|
||||||
promoteStatsWindowLevel,
|
promoteStatsWindowLevel,
|
||||||
promoteVisibleStatsWindowAboveOverlay,
|
promoteVisibleStatsWindowAboveOverlay,
|
||||||
resolveStatsWindowOuterBoundsForContent,
|
resolveStatsWindowOuterBoundsForContent,
|
||||||
|
showStatsNativeConfirmDialog,
|
||||||
shouldHideStatsWindowForInput,
|
shouldHideStatsWindowForInput,
|
||||||
STATS_WINDOW_TITLE,
|
STATS_WINDOW_TITLE,
|
||||||
} from './stats-window-runtime.js';
|
} from './stats-window-runtime.js';
|
||||||
@@ -16,6 +18,8 @@ import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement
|
|||||||
|
|
||||||
let statsWindow: BrowserWindow | null = null;
|
let statsWindow: BrowserWindow | null = null;
|
||||||
let toggleRegistered = false;
|
let toggleRegistered = false;
|
||||||
|
let nativeDialogLayerRegistered = false;
|
||||||
|
let nativeDialogLayerSuspensionCount = 0;
|
||||||
|
|
||||||
export interface StatsWindowOptions {
|
export interface StatsWindowOptions {
|
||||||
/** Absolute path to stats/dist/ directory */
|
/** Absolute path to stats/dist/ directory */
|
||||||
@@ -63,6 +67,10 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function promoteStatsOverlayAbovePlayback(): boolean {
|
export function promoteStatsOverlayAbovePlayback(): boolean {
|
||||||
|
if (nativeDialogLayerSuspensionCount > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!statsWindow) {
|
if (!statsWindow) {
|
||||||
return false;
|
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.
|
* Toggle the stats overlay window: create on first call, then show/hide.
|
||||||
* The React app stays mounted across toggles — state is preserved.
|
* 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.
|
* Call this once during app initialization.
|
||||||
*/
|
*/
|
||||||
export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
|
export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
|
||||||
|
registerStatsNativeDialogLayerHandlers();
|
||||||
if (toggleRegistered) return;
|
if (toggleRegistered) return;
|
||||||
toggleRegistered = true;
|
toggleRegistered = true;
|
||||||
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
|
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
shouldHandleLaunchMpvAtEntry,
|
shouldHandleLaunchMpvAtEntry,
|
||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
hasTransportedStartupArgs,
|
hasTransportedStartupArgs,
|
||||||
|
shouldForwardStartupArgvViaAppControl,
|
||||||
|
applyEarlyLinuxCommandLineSwitches,
|
||||||
|
resolveLinuxPasswordStoreValue,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
|
|
||||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
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);
|
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', () => {
|
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
||||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
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';
|
import { resolveConfigDir } from './config/path-resolution';
|
||||||
|
|
||||||
const BACKGROUND_ARG = '--background';
|
const BACKGROUND_ARG = '--background';
|
||||||
const START_ARG = '--start';
|
const START_ARG = '--start';
|
||||||
const PASSWORD_STORE_ARG = '--password-store';
|
const PASSWORD_STORE_ARG = '--password-store';
|
||||||
|
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
|
||||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||||
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||||
@@ -34,6 +35,10 @@ type EarlyAppLike = {
|
|||||||
setPath: (name: 'userData', value: string) => void;
|
setPath: (name: 'userData', value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CommandLineLike = {
|
||||||
|
appendSwitch: (name: string, value?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type EarlyAppPathOptions = {
|
type EarlyAppPathOptions = {
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
appDataDir?: string;
|
appDataDir?: string;
|
||||||
@@ -73,6 +78,58 @@ function removePassiveStartupArgs(argv: string[]): string[] {
|
|||||||
return filtered;
|
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 {
|
function consumesLaunchMpvValue(token: string): boolean {
|
||||||
return (
|
return (
|
||||||
token.startsWith('--') &&
|
token.startsWith('--') &&
|
||||||
@@ -90,6 +147,20 @@ export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
|
|||||||
return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string';
|
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 {
|
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
|
||||||
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
||||||
if (rawCount === undefined) {
|
if (rawCount === undefined) {
|
||||||
|
|||||||
+51
-12
@@ -9,17 +9,20 @@ import {
|
|||||||
normalizeLaunchMpvExtraArgs,
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
|
applyEarlyLinuxCommandLineSwitches,
|
||||||
sanitizeStartupEnv,
|
sanitizeStartupEnv,
|
||||||
sanitizeBackgroundEnv,
|
sanitizeBackgroundEnv,
|
||||||
sanitizeHelpEnv,
|
sanitizeHelpEnv,
|
||||||
sanitizeLaunchMpvEnv,
|
sanitizeLaunchMpvEnv,
|
||||||
hasTransportedStartupArgs,
|
hasTransportedStartupArgs,
|
||||||
|
shouldForwardStartupArgvViaAppControl,
|
||||||
shouldDetachBackgroundLaunch,
|
shouldDetachBackgroundLaunch,
|
||||||
shouldHandleHelpOnlyAtEntry,
|
shouldHandleHelpOnlyAtEntry,
|
||||||
shouldHandleLaunchMpvAtEntry,
|
shouldHandleLaunchMpvAtEntry,
|
||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||||
|
import { sendAppControlCommand } from './shared/app-control-client';
|
||||||
import {
|
import {
|
||||||
detectInstalledFirstRunPluginCandidates,
|
detectInstalledFirstRunPluginCandidates,
|
||||||
detectInstalledMpvPlugin,
|
detectInstalledMpvPlugin,
|
||||||
@@ -173,6 +176,7 @@ function readConfiguredWindowsMpvLaunch(configDir: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||||
|
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
|
||||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||||
const userDataPath = configureEarlyAppPaths(app);
|
const userDataPath = configureEarlyAppPaths(app);
|
||||||
const reportFatalError = createFatalErrorReporter({
|
const reportFatalError = createFatalErrorReporter({
|
||||||
@@ -184,6 +188,44 @@ registerFatalErrorHandlers({
|
|||||||
exit: (code) => app.exit(code),
|
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)) {
|
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||||
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
||||||
const child = spawn(process.execPath, childArgs, {
|
const child = spawn(process.execPath, childArgs, {
|
||||||
@@ -233,17 +275,14 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
|||||||
app.exit(exitCode);
|
app.exit(exitCode);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
void forwardStartupArgvViaAppControlIfAvailable()
|
||||||
if (!gotSingleInstanceLock) {
|
.then((forwarded) => {
|
||||||
app.exit(0);
|
if (!forwarded) {
|
||||||
}
|
startMainProcess();
|
||||||
try {
|
}
|
||||||
require('./main.js');
|
})
|
||||||
} catch (error) {
|
.catch((error) => {
|
||||||
reportFatalError(error, {
|
console.error('SubMiner app-control handoff failed:', error);
|
||||||
title: 'SubMiner startup failed',
|
startMainProcess();
|
||||||
context: 'SubMiner failed while loading the main process.',
|
|
||||||
});
|
});
|
||||||
app.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -356,6 +356,7 @@ import {
|
|||||||
promoteStatsOverlayAbovePlayback,
|
promoteStatsOverlayAbovePlayback,
|
||||||
registerStatsOverlayToggle,
|
registerStatsOverlayToggle,
|
||||||
toggleStatsOverlay as toggleStatsOverlayWindow,
|
toggleStatsOverlay as toggleStatsOverlayWindow,
|
||||||
|
withStatsWindowLayerSuspendedForNativeDialog,
|
||||||
} from './core/services/stats-window.js';
|
} from './core/services/stats-window.js';
|
||||||
import {
|
import {
|
||||||
createFirstRunSetupService,
|
createFirstRunSetupService,
|
||||||
@@ -5123,6 +5124,8 @@ function getUpdateService() {
|
|||||||
});
|
});
|
||||||
app.focus({ steal: true });
|
app.focus({ steal: true });
|
||||||
},
|
},
|
||||||
|
withStatsWindowLayerSuspended: (showDialog) =>
|
||||||
|
withStatsWindowLayerSuspendedForNativeDialog(showDialog),
|
||||||
showMessageBox: (options) => dialog.showMessageBox(options),
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
});
|
});
|
||||||
updateService = createUpdateService({
|
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 {
|
function setVisibleOverlayVisible(visible: boolean): void {
|
||||||
ensureOverlayWindowsReadyForVisibilityActions();
|
ensureOverlayWindowsReadyForVisibilityActions();
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
@@ -6335,18 +6345,21 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
|||||||
void ensureOverlayMpvSubtitlesHidden();
|
void ensureOverlayMpvSubtitlesHidden();
|
||||||
}
|
}
|
||||||
setVisibleOverlayVisibleHandler(visible);
|
setVisibleOverlayVisibleHandler(visible);
|
||||||
|
notifyMpvPluginVisibleOverlayVisibility(visible);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVisibleOverlay(): void {
|
function toggleVisibleOverlay(): void {
|
||||||
ensureOverlayWindowsReadyForVisibilityActions();
|
ensureOverlayWindowsReadyForVisibilityActions();
|
||||||
|
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
||||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||||
if (overlayManager.getVisibleOverlayVisible()) {
|
if (!nextVisible) {
|
||||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||||
} else {
|
} else {
|
||||||
void ensureOverlayMpvSubtitlesHidden();
|
void ensureOverlayMpvSubtitlesHidden();
|
||||||
}
|
}
|
||||||
toggleVisibleOverlayHandler();
|
toggleVisibleOverlayHandler();
|
||||||
|
notifyMpvPluginVisibleOverlayVisibility(nextVisible);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
function setOverlayVisible(visible: boolean): void {
|
function setOverlayVisible(visible: boolean): void {
|
||||||
@@ -6358,6 +6371,7 @@ function setOverlayVisible(visible: boolean): void {
|
|||||||
void ensureOverlayMpvSubtitlesHidden();
|
void ensureOverlayMpvSubtitlesHidden();
|
||||||
}
|
}
|
||||||
setOverlayVisibleHandler(visible);
|
setOverlayVisibleHandler(visible);
|
||||||
|
notifyMpvPluginVisibleOverlayVisibility(visible);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
function handleOverlayModalClosed(modal: OverlayHostedModal): void {
|
function handleOverlayModalClosed(modal: OverlayHostedModal): void {
|
||||||
|
|||||||
@@ -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', () => {
|
test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type MockWindow = {
|
|||||||
ignoreMouseEvents: boolean;
|
ignoreMouseEvents: boolean;
|
||||||
forwardedIgnoreMouseEvents: boolean;
|
forwardedIgnoreMouseEvents: boolean;
|
||||||
webContentsFocused: boolean;
|
webContentsFocused: boolean;
|
||||||
|
alwaysOnTopCalls: string[];
|
||||||
showCount: number;
|
showCount: number;
|
||||||
hideCount: number;
|
hideCount: number;
|
||||||
sent: unknown[][];
|
sent: unknown[][];
|
||||||
@@ -53,6 +54,7 @@ function createMockWindow(): MockWindow & {
|
|||||||
ignoreMouseEvents: false,
|
ignoreMouseEvents: false,
|
||||||
forwardedIgnoreMouseEvents: false,
|
forwardedIgnoreMouseEvents: false,
|
||||||
webContentsFocused: false,
|
webContentsFocused: false,
|
||||||
|
alwaysOnTopCalls: [],
|
||||||
showCount: 0,
|
showCount: 0,
|
||||||
hideCount: 0,
|
hideCount: 0,
|
||||||
sent: [],
|
sent: [],
|
||||||
@@ -72,7 +74,9 @@ function createMockWindow(): MockWindow & {
|
|||||||
state.ignoreMouseEvents = ignore;
|
state.ignoreMouseEvents = ignore;
|
||||||
state.forwardedIgnoreMouseEvents = options?.forward === true;
|
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: () => {},
|
moveTop: () => {},
|
||||||
getShowCount: () => state.showCount,
|
getShowCount: () => state.showCount,
|
||||||
getHideCount: () => state.hideCount,
|
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', {
|
Object.defineProperty(window, 'url', {
|
||||||
get: () => state.url,
|
get: () => state.url,
|
||||||
set: (value: string) => {
|
set: (value: string) => {
|
||||||
@@ -219,6 +230,7 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
|
|||||||
runtime.notifyOverlayModalOpened('runtime-options');
|
runtime.notifyOverlayModalOpened('runtime-options');
|
||||||
assert.equal(window.getShowCount(), 1);
|
assert.equal(window.getShowCount(), 1);
|
||||||
assert.equal(window.isFocused(), true);
|
assert.equal(window.isFocused(), true);
|
||||||
|
assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']);
|
||||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function createOverlayModalRuntimeService(
|
|||||||
|
|
||||||
const elevateModalWindow = (window: BrowserWindow): void => {
|
const elevateModalWindow = (window: BrowserWindow): void => {
|
||||||
if (window.isDestroyed()) return;
|
if (window.isDestroyed()) return;
|
||||||
window.setAlwaysOnTop(true, 'screen-saver', 1);
|
window.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||||
window.moveTop();
|
window.moveTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,42 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
|||||||
assert.equal(lastProgressAtMs, 5000);
|
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 () => {
|
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
|
||||||
const reportPayloads: Array<{ isPaused: boolean }> = [];
|
const reportPayloads: Array<{ isPaused: boolean }> = [];
|
||||||
|
|
||||||
@@ -219,6 +255,53 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
|||||||
assert.equal(cleared, true);
|
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 () => {
|
test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => {
|
||||||
let cleared = false;
|
let cleared = false;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ export function createReportJellyfinRemoteProgressHandler(
|
|||||||
const playback = deps.getActivePlayback();
|
const playback = deps.getActivePlayback();
|
||||||
if (!playback) return;
|
if (!playback) return;
|
||||||
const session = deps.getSession();
|
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();
|
const now = deps.getNow();
|
||||||
try {
|
try {
|
||||||
const mpvClient = deps.getMpvClient();
|
const mpvClient = deps.getMpvClient();
|
||||||
@@ -167,7 +168,8 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const session = deps.getSession();
|
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();
|
deps.clearActivePlayback();
|
||||||
return;
|
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 () => {
|
test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
|
|||||||
@@ -151,18 +151,16 @@ function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] {
|
|||||||
? trackListRaw
|
? trackListRaw
|
||||||
.filter(
|
.filter(
|
||||||
(track): track is Record<string, unknown> =>
|
(track): track is Record<string, unknown> =>
|
||||||
Boolean(track) &&
|
Boolean(track) && typeof track === 'object' && track.type === 'sub',
|
||||||
typeof track === 'object' &&
|
|
||||||
track.type === 'sub' &&
|
|
||||||
typeof track.id === 'number',
|
|
||||||
)
|
)
|
||||||
.map((track) => ({
|
.map((track) => ({
|
||||||
id: track.id as number,
|
id: parseTrackId(track.id),
|
||||||
lang: String(track.lang || ''),
|
lang: String(track.lang || ''),
|
||||||
title: String(track.title || ''),
|
title: String(track.title || ''),
|
||||||
external: track.external === true,
|
external: track.external === true,
|
||||||
externalFilename: String(track['external-filename'] || ''),
|
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));
|
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: {
|
async function readMpvSubtitleTracks(deps: {
|
||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
}): Promise<MpvSubtitleTrack[] | null> {
|
}): Promise<MpvSubtitleTrack[] | null> {
|
||||||
@@ -186,7 +193,12 @@ async function readMpvSubtitleTracks(deps: {
|
|||||||
if (!client || client.connected === false) {
|
if (!client || client.connected === false) {
|
||||||
return null;
|
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);
|
return parseMpvSubtitleTracks(trackListRaw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ test('overlay modal input state activates modal window interactivity and syncs d
|
|||||||
assert.deepEqual(modalWindow.calls, [
|
assert.deepEqual(modalWindow.calls, [
|
||||||
'focusable:true',
|
'focusable:true',
|
||||||
'ignore:false',
|
'ignore:false',
|
||||||
'top:true:screen-saver:1',
|
'top:true:screen-saver:3',
|
||||||
'focus',
|
'focus',
|
||||||
'web-focus',
|
'web-focus',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
|||||||
setWindowFocusable(modalWindow);
|
setWindowFocusable(modalWindow);
|
||||||
requestOverlayApplicationFocus();
|
requestOverlayApplicationFocus();
|
||||||
modalWindow.setIgnoreMouseEvents(false);
|
modalWindow.setIgnoreMouseEvents(false);
|
||||||
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
|
modalWindow.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||||
modalWindow.focus();
|
modalWindow.focus();
|
||||||
if (!modalWindow.webContents.isFocused()) {
|
if (!modalWindow.webContents.isFocused()) {
|
||||||
modalWindow.webContents.focus();
|
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)']);
|
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 () => {
|
test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const showMessageBox: ShowMessageBox = async (options) => {
|
const showMessageBox: ShowMessageBox = async (options) => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface UpdateDialogPresenterDeps {
|
|||||||
showMessageBox: ShowMessageBox;
|
showMessageBox: ShowMessageBox;
|
||||||
focusApp?: () => void | Promise<void>;
|
focusApp?: () => void | Promise<void>;
|
||||||
yieldToRunLoop?: () => Promise<void>;
|
yieldToRunLoop?: () => Promise<void>;
|
||||||
|
withStatsWindowLayerSuspended?: <T>(showDialog: () => Promise<T>) => Promise<T>;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +47,18 @@ async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<
|
|||||||
|
|
||||||
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
||||||
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
||||||
try {
|
const showDialog = async (): Promise<MessageBoxResultLike> => {
|
||||||
await maybeFocusAppForDialog(deps);
|
try {
|
||||||
} catch {
|
await maybeFocusAppForDialog(deps);
|
||||||
// Best-effort focus only; never block the dialog itself.
|
} catch {
|
||||||
}
|
// Best-effort focus only; never block the dialog itself.
|
||||||
return deps.showMessageBox(options);
|
}
|
||||||
|
return deps.showMessageBox(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
return deps.withStatsWindowLayerSuspended
|
||||||
|
? deps.withStatsWindowLayerSuspended(showDialog)
|
||||||
|
: showDialog();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ const statsAPI = {
|
|||||||
hideOverlay: (): void => {
|
hideOverlay: (): void => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay);
|
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 });
|
contextBridge.exposeInMainWorld('electronAPI', { stats: statsAPI });
|
||||||
|
|||||||
@@ -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 () => {
|
test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,11 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
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') {
|
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
|
||||||
options.openControllerSelectModal?.();
|
options.openControllerSelectModal?.();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const IPC_CHANNELS = {
|
|||||||
toggleDevTools: 'toggle-dev-tools',
|
toggleDevTools: 'toggle-dev-tools',
|
||||||
toggleOverlay: 'toggle-overlay',
|
toggleOverlay: 'toggle-overlay',
|
||||||
saveSubtitlePosition: 'save-subtitle-position',
|
saveSubtitlePosition: 'save-subtitle-position',
|
||||||
|
statsNativeConfirmDialog: 'stats:native-confirm-dialog',
|
||||||
|
statsNativeDialogOpened: 'stats:native-dialog-opened',
|
||||||
|
statsNativeDialogClosed: 'stats:native-dialog-closed',
|
||||||
saveControllerConfig: 'save-controller-config',
|
saveControllerConfig: 'save-controller-config',
|
||||||
saveControllerPreference: 'save-controller-preference',
|
saveControllerPreference: 'save-controller-preference',
|
||||||
setMecabEnabled: 'set-mecab-enabled',
|
setMecabEnabled: 'set-mecab-enabled',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Suspense, lazy, useCallback, useState } from 'react';
|
import { Suspense, lazy, useCallback, useState } from 'react';
|
||||||
|
import { DeleteConfirmDialog } from './components/layout/DeleteConfirmDialog';
|
||||||
import { TabBar } from './components/layout/TabBar';
|
import { TabBar } from './components/layout/TabBar';
|
||||||
import { OverviewTab } from './components/overview/OverviewTab';
|
import { OverviewTab } from './components/overview/OverviewTab';
|
||||||
import { useExcludedWords } from './hooks/useExcludedWords';
|
import { useExcludedWords } from './hooks/useExcludedWords';
|
||||||
@@ -272,6 +273,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
|
<DeleteConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function EpisodeDetail({ videoId, onSessionDeleted }: EpisodeDetailProps)
|
|||||||
}, [videoId]);
|
}, [videoId]);
|
||||||
|
|
||||||
const handleDeleteSession = async (sessionId: number) => {
|
const handleDeleteSession = async (sessionId: number) => {
|
||||||
if (!confirmSessionDelete()) return;
|
if (!(await confirmSessionDelete())) return;
|
||||||
await apiClient.deleteSession(sessionId);
|
await apiClient.deleteSession(sessionId);
|
||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function EpisodeList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEpisode = async (videoId: number, title: string) => {
|
const handleDeleteEpisode = async (videoId: number, title: string) => {
|
||||||
if (!confirmEpisodeDelete(title)) return;
|
if (!(await confirmEpisodeDelete(title))) return;
|
||||||
await apiClient.deleteVideo(videoId);
|
await apiClient.deleteVideo(videoId);
|
||||||
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
|
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
|
||||||
if (expandedVideoId === videoId) setExpandedVideoId(null);
|
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;
|
videoId: number;
|
||||||
title: string;
|
title: string;
|
||||||
apiClient: { deleteVideo: (id: number) => Promise<void> };
|
apiClient: { deleteVideo: (id: number) => Promise<void> };
|
||||||
confirmFn: (title: string) => boolean;
|
confirmFn: (title: string) => boolean | Promise<boolean>;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
setDeleteError: (msg: string | null) => void;
|
setDeleteError: (msg: string | null) => void;
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +27,7 @@ interface DeleteEpisodeHandlerOptions {
|
|||||||
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
|
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
|
||||||
return async () => {
|
return async () => {
|
||||||
if (opts.isDeletingRef?.current) return;
|
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;
|
if (opts.isDeletingRef) opts.isDeletingRef.current = true;
|
||||||
opts.setIsDeleting?.(true);
|
opts.setIsDeleting?.(true);
|
||||||
opts.setDeleteError(null);
|
opts.setDeleteError(null);
|
||||||
@@ -101,7 +101,7 @@ export function MediaDetailView({
|
|||||||
const relatedCollectionLabel = getRelatedCollectionLabel(detail);
|
const relatedCollectionLabel = getRelatedCollectionLabel(detail);
|
||||||
|
|
||||||
const handleDeleteSession = async (session: SessionSummary) => {
|
const handleDeleteSession = async (session: SessionSummary) => {
|
||||||
if (!confirmSessionDelete()) return;
|
if (!(await confirmSessionDelete())) return;
|
||||||
|
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
setDeletingSessionId(session.sessionId);
|
setDeletingSessionId(session.sessionId);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteSession = async (session: SessionSummary) => {
|
const handleDeleteSession = async (session: SessionSummary) => {
|
||||||
if (!confirmSessionDelete()) return;
|
if (!(await confirmSessionDelete())) return;
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
setDeletingIds((prev) => new Set(prev).add(session.sessionId));
|
setDeletingIds((prev) => new Set(prev).add(session.sessionId));
|
||||||
try {
|
try {
|
||||||
@@ -65,7 +65,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => {
|
const handleDeleteDayGroup = async (dayLabel: string, daySessions: SessionSummary[]) => {
|
||||||
if (!confirmDayGroupDelete(dayLabel, daySessions.length)) return;
|
if (!(await confirmDayGroupDelete(dayLabel, daySessions.length))) return;
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
const ids = daySessions.map((s) => s.sessionId);
|
const ids = daySessions.map((s) => s.sessionId);
|
||||||
setDeletingIds((prev) => {
|
setDeletingIds((prev) => {
|
||||||
@@ -91,7 +91,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
|||||||
const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => {
|
const handleDeleteAnimeGroup = async (groupSessions: SessionSummary[]) => {
|
||||||
const title =
|
const title =
|
||||||
groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media';
|
groupSessions[0]?.animeTitle ?? groupSessions[0]?.canonicalTitle ?? 'Unknown Media';
|
||||||
if (!confirmAnimeGroupDelete(title, groupSessions.length)) return;
|
if (!(await confirmAnimeGroupDelete(title, groupSessions.length))) return;
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
const ids = groupSessions.map((s) => s.sessionId);
|
const ids = groupSessions.map((s) => s.sessionId);
|
||||||
setDeletingIds((prev) => {
|
setDeletingIds((prev) => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSumm
|
|||||||
export interface BucketDeleteDeps {
|
export interface BucketDeleteDeps {
|
||||||
bucket: SessionBucket;
|
bucket: SessionBucket;
|
||||||
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
||||||
confirm: (title: string, count: number) => boolean;
|
confirm: (title: string, count: number) => boolean | Promise<boolean>;
|
||||||
onSuccess: (deletedIds: number[]) => void;
|
onSuccess: (deletedIds: number[]) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<
|
|||||||
return async () => {
|
return async () => {
|
||||||
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
||||||
const ids = bucket.sessions.map((s) => s.sessionId);
|
const ids = bucket.sessions.map((s) => s.sessionId);
|
||||||
if (!confirm(title, ids.length)) return;
|
if (!(await confirm(title, ids.length))) return;
|
||||||
try {
|
try {
|
||||||
await client.deleteSessions(ids);
|
await client.deleteSessions(ids);
|
||||||
onSuccess(ids);
|
onSuccess(ids);
|
||||||
@@ -120,7 +120,7 @@ export function SessionsTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSession = async (session: SessionSummary) => {
|
const handleDeleteSession = async (session: SessionSummary) => {
|
||||||
if (!confirmSessionDelete()) return;
|
if (!(await confirmSessionDelete())) return;
|
||||||
|
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
setDeletingSessionId(session.sessionId);
|
setDeletingSessionId(session.sessionId);
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
confirmDayGroupDelete,
|
confirmDayGroupDelete,
|
||||||
confirmEpisodeDelete,
|
confirmEpisodeDelete,
|
||||||
confirmSessionDelete,
|
confirmSessionDelete,
|
||||||
|
setDeleteConfirmPresenter,
|
||||||
} from './delete-confirm';
|
} 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 calls: string[] = [];
|
||||||
const originalConfirm = globalThis.confirm;
|
const originalConfirm = globalThis.confirm;
|
||||||
globalThis.confirm = ((message?: string) => {
|
globalThis.confirm = ((message?: string) => {
|
||||||
@@ -16,14 +17,183 @@ test('confirmSessionDelete uses the shared session delete warning copy', () => {
|
|||||||
}) as typeof globalThis.confirm;
|
}) as typeof globalThis.confirm;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assert.equal(confirmSessionDelete(), true);
|
assert.equal(await confirmSessionDelete(), true);
|
||||||
assert.deepEqual(calls, ['Delete this session and all associated data?']);
|
assert.deepEqual(calls, ['Delete this session and all associated data?']);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.confirm = originalConfirm;
|
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 calls: string[] = [];
|
||||||
const originalConfirm = globalThis.confirm;
|
const originalConfirm = globalThis.confirm;
|
||||||
globalThis.confirm = ((message?: string) => {
|
globalThis.confirm = ((message?: string) => {
|
||||||
@@ -32,14 +202,14 @@ test('confirmDayGroupDelete includes the day label and count in the warning copy
|
|||||||
}) as typeof globalThis.confirm;
|
}) as typeof globalThis.confirm;
|
||||||
|
|
||||||
try {
|
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?']);
|
assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.confirm = originalConfirm;
|
globalThis.confirm = originalConfirm;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirmDayGroupDelete uses singular for one session', () => {
|
test('confirmDayGroupDelete uses singular for one session', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const originalConfirm = globalThis.confirm;
|
const originalConfirm = globalThis.confirm;
|
||||||
globalThis.confirm = ((message?: string) => {
|
globalThis.confirm = ((message?: string) => {
|
||||||
@@ -48,14 +218,14 @@ test('confirmDayGroupDelete uses singular for one session', () => {
|
|||||||
}) as typeof globalThis.confirm;
|
}) as typeof globalThis.confirm;
|
||||||
|
|
||||||
try {
|
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?']);
|
assert.deepEqual(calls, ['Delete all 1 session from Yesterday and all associated data?']);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.confirm = originalConfirm;
|
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 calls: string[] = [];
|
||||||
const originalConfirm = globalThis.confirm;
|
const originalConfirm = globalThis.confirm;
|
||||||
globalThis.confirm = ((message?: string) => {
|
globalThis.confirm = ((message?: string) => {
|
||||||
@@ -64,7 +234,7 @@ test('confirmBucketDelete asks about merging multiple sessions of the same episo
|
|||||||
}) as typeof globalThis.confirm;
|
}) as typeof globalThis.confirm;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assert.equal(confirmBucketDelete('My Episode', 3), true);
|
assert.equal(await confirmBucketDelete('My Episode', 3), true);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'Delete all 3 sessions of "My Episode" from this day and all associated data?',
|
'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 calls: string[] = [];
|
||||||
const originalConfirm = globalThis.confirm;
|
const originalConfirm = globalThis.confirm;
|
||||||
globalThis.confirm = ((message?: string) => {
|
globalThis.confirm = ((message?: string) => {
|
||||||
@@ -82,7 +252,7 @@ test('confirmBucketDelete uses a clean singular form for one session', () => {
|
|||||||
}) as typeof globalThis.confirm;
|
}) as typeof globalThis.confirm;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assert.equal(confirmBucketDelete('Solo Episode', 1), false);
|
assert.equal(await confirmBucketDelete('Solo Episode', 1), false);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'Delete this session of "Solo Episode" from this day and all associated data?',
|
'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 calls: string[] = [];
|
||||||
const originalConfirm = globalThis.confirm;
|
const originalConfirm = globalThis.confirm;
|
||||||
globalThis.confirm = ((message?: string) => {
|
globalThis.confirm = ((message?: string) => {
|
||||||
@@ -100,7 +270,7 @@ test('confirmEpisodeDelete includes the episode title in the shared warning copy
|
|||||||
}) as typeof globalThis.confirm;
|
}) as typeof globalThis.confirm;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assert.equal(confirmEpisodeDelete('Episode 4'), false);
|
assert.equal(await confirmEpisodeDelete('Episode 4'), false);
|
||||||
assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']);
|
assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.confirm = originalConfirm;
|
globalThis.confirm = originalConfirm;
|
||||||
|
|||||||
@@ -1,30 +1,71 @@
|
|||||||
export function confirmSessionDelete(): boolean {
|
type NativeDialogBridge = {
|
||||||
return globalThis.confirm('Delete this session and all associated data?');
|
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 {
|
async function confirmWithStatsNativeDialogLayer(message: string): Promise<boolean> {
|
||||||
return globalThis.confirm(
|
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?`,
|
`Delete all ${count} session${count === 1 ? '' : 's'} from ${dayLabel} and all associated data?`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirmAnimeGroupDelete(title: string, count: number): boolean {
|
export function confirmAnimeGroupDelete(title: string, count: number): Promise<boolean> {
|
||||||
return globalThis.confirm(
|
return confirmWithStatsNativeDialogLayer(
|
||||||
`Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`,
|
`Delete all ${count} session${count === 1 ? '' : 's'} for "${title}" and all associated data?`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirmEpisodeDelete(title: string): boolean {
|
export function confirmEpisodeDelete(title: string): Promise<boolean> {
|
||||||
return globalThis.confirm(`Delete "${title}" and all its sessions?`);
|
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) {
|
if (count === 1) {
|
||||||
return globalThis.confirm(
|
return confirmWithStatsNativeDialogLayer(
|
||||||
`Delete this session of "${title}" from this day and all associated data?`,
|
`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?`,
|
`Delete all ${count} sessions of "${title}" from this day and all associated data?`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ interface StatsElectronAPI {
|
|||||||
ankiBrowse: (noteId: number) => Promise<void>;
|
ankiBrowse: (noteId: number) => Promise<void>;
|
||||||
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
|
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
|
||||||
hideOverlay: () => void;
|
hideOverlay: () => void;
|
||||||
|
confirmNativeDialog?: (message: string) => boolean;
|
||||||
|
beginNativeDialog?: () => void;
|
||||||
|
endNativeDialog?: () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user