diff --git a/changes/overlay-notifications.md b/changes/overlay-notifications.md index 1217c955..78b5c0d5 100644 --- a/changes/overlay-notifications.md +++ b/changes/overlay-notifications.md @@ -5,9 +5,10 @@ breaking: true - Added overlay notifications with a Catppuccin Macchiato stack, a 3-second transient timeout, and persistent long-running job notifications for character dictionary sync. - Added `notifications.overlayPosition` to place overlay notifications at the top left, top center, or top right; top right remains the default. - Added a notification history panel (default `Ctrl+N`, configurable via `shortcuts.toggleNotificationHistory`) that logs every notification shown during the session; the toggle works whether the overlay or mpv has focus, the panel slides in from the same edge as notifications (right when centered), and entries can be removed individually or cleared. -- Made the overlay error/recovery toast follow the configured `notifications.overlayPosition` instead of always pinning to the top-right corner, and seeded the notification stack and history panel side from that position at startup. -- Routed startup tokenization and subtitle annotation status through the configured notification surfaces; the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`. -- Initialized the tray and visible overlay shell before deferred tokenization warmups finish on cold managed background startup, while keeping playback paused until SubMiner reports autoplay readiness. +- Made the overlay error/recovery toast follow the configured `notifications.overlayPosition` instead of always pinning to the top-right corner, and kept the notification stack and history panel side synced from that position before first open so left-side history panels slide in from the left. +- Routed startup tokenization, subtitle annotation, and character dictionary status through queued overlay notifications for `overlay`/`both` instead of falling back to mpv OSD while the overlay loads; queued loading cards are shown before their ready update when both happen before the overlay is ready, and the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`. +- Preserved character dictionary checking/building/importing/ready phases in overlay notification history and sent those phases to system notifications when `notificationType` is `both`. +- Initialized the tray and visible overlay shell before deferred tokenization warmups finish on visible-overlay startup, while keeping playback paused until SubMiner reports autoplay readiness. - Kept playback feedback such as subtitle visibility, subtitle track, and subtitle delay text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates. - Reused the active primary/secondary subtitle mode overlay notification while cycling modes so rapid toggles update one card instead of stacking duplicate feedback. - Fixed mined-card overlay notifications so `overlay` and `both` modes show the same generated card thumbnail as system notifications. diff --git a/changes/subtitle-overlay-hover-ready.md b/changes/subtitle-overlay-hover-ready.md new file mode 100644 index 00000000..3467c29f --- /dev/null +++ b/changes/subtitle-overlay-hover-ready.md @@ -0,0 +1,9 @@ +type: fixed +area: overlay + +- Fixed visible overlay startup/resume so subtitle bars can be hovered and clicked as soon as the first subtitle line appears, without waiting for the next subtitle update. +- Released playback after the first overlay measurement instead of waiting for cold subtitle annotation warmup, so overlay notifications and subtitle controls do not freeze during visible-overlay startup. +- Primed Linux overlay input from the first measured subtitle/notification surface before playback resumes, so first-line subtitles and startup notifications are clickable immediately. +- Restored visible-overlay loading feedback as an mpv OSD spinner that stops once the overlay is content-ready and visible. +- Starts that OSD spinner when mpv connects, opens media, or the visible overlay is requested, so cold startup shows feedback before the overlay is almost ready. +- Shows an immediate plugin-side mpv OSD on `start-file` for visible overlay startup, even when normal plugin status OSD messages are disabled or the launcher owns the overlay start, and keeps it spinning until Electron reports the visible overlay is content-ready. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index 8dc20574..8b0f7fb5 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -158,6 +158,8 @@ The three collapsible sections can be configured to start open or closed: When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes. +These phases are emitted through the configured notification surface. Some phases are skipped when unnecessary: `generating` only appears on a cache miss, `building` only appears when the merged ZIP must be rebuilt, and `importing` only appears when Yomitan needs a new dictionary import. + **Phases:** 1. **checking** - Is there already a cached snapshot for this media ID? diff --git a/docs-site/configuration.md b/docs-site/configuration.md index f8aaddd5..62b7c8e6 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -232,9 +232,9 @@ Configure where overlay notification cards appear: #### Notification history panel -Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use — left when `overlayPosition` is `"top-left"`, and right for `"top-right"` or `"top"` (centered). Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts. +Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use — left when `overlayPosition` is `"top-left"`, and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts. -Startup tokenization and subtitle annotation status follows the configured notification surface. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`. +Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`. ### Auto-Start Overlay @@ -250,7 +250,7 @@ Control whether the overlay automatically becomes visible when it connects to mp | -------------------- | --------------- | ----------------------------------------------------- | | `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) | -When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness. On cold managed background startup, SubMiner brings up the tray and visible overlay shell before tokenization and annotation warmups finish, then releases playback only after autoplay readiness. +When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness. On visible-overlay startup, SubMiner brings up the tray and visible overlay shell before tokenization and annotation warmups finish, then releases playback only after autoplay readiness. On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index a28154da..07b67272 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -68,9 +68,13 @@ prefetch work and re-centers prefetch around the live playback time. - `mpv.pauseUntilOverlayReady` waits for tokenization warmup plus visible-overlay readiness before releasing the mpv startup gate. -- Cold `--start --background --managed-playback` launches handle initial args before the deferred - Yomitan wait, so the tray and visible overlay shell can receive startup notifications while - tokenization and annotation warmups continue. +- Visible-overlay startup creates the tray and visible overlay shell before tokenization and + annotation warmups continue. Cold `--start --background --managed-playback` launches still handle + initial args before the deferred Yomitan wait. +- Overlay-routed startup notifications are queued in the main process until an overlay window has + finished loading. Progress notifications with the same id are upserted so spinner ticks do not + flood a cold-start overlay, while events with distinct history ids are retained for phase-level + history such as character dictionary checking/building/importing. - The mpv plugin has a 30-second fallback for cold starts; app-side retry/release budgets match that window so readiness can still arrive before fallback resumes playback. - If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index 9a0919db..c30e5506 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -351,6 +351,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app }; let availabilityConfigDir: string | undefined; let overlayConfigDir: string | undefined; + let overlayLoadingOsd: boolean | undefined; try { process.env.XDG_CONFIG_HOME = xdgConfigHome; @@ -361,7 +362,19 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), checkDependencies: () => {}, registerCleanup: () => {}, - startMpv: async () => {}, + startMpv: async ( + _target, + _targetKind, + _args, + _socketPath, + _appPath, + _preloadedSubtitles, + options, + ) => { + overlayLoadingOsd = ( + options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined + )?.overlayLoadingOsd; + }, waitForUnixSocketReady: async () => true, startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => { overlayConfigDir = configDir; @@ -378,6 +391,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app assert.equal(availabilityConfigDir, expectedConfigDir); assert.equal(overlayConfigDir, expectedConfigDir); + assert.equal(overlayLoadingOsd, true); } finally { if (originalXdgConfigHome === undefined) { delete process.env.XDG_CONFIG_HOME; diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 2b546fa3..93567b4a 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -232,6 +232,14 @@ export async function runPlaybackCommandWithDeps( ? { ...pluginRuntimeConfig, autoStart: false } : pluginRuntimeConfig; + const shouldShowOverlayLoadingOsd = + !isAppOwnedYoutubeFlow && + (pluginRuntimeConfig.autoStartVisibleOverlay || args.startOverlay || args.autoStartOverlay) && + (pluginRuntimeConfig.autoStart || + args.startOverlay || + args.autoStartOverlay || + shouldLauncherAttachRunningApp); + const shouldPauseUntilOverlayReady = pluginRuntimeConfig.autoStart && pluginRuntimeConfig.autoStartVisibleOverlay && @@ -266,6 +274,7 @@ export async function runPlaybackCommandWithDeps( } : {}), backend: args.backend, + overlayLoadingOsd: shouldShowOverlayLoadingOsd, texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled, }, }, diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index a513e1f7..61684983 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -207,6 +207,7 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin 'subminer-backend=x11', 'subminer-auto_start=yes', 'subminer-auto_start_visible_overlay=no', + 'subminer-overlay_loading_osd=no', 'subminer-auto_start_pause_until_ready=yes', 'subminer-auto_start_pause_until_ready_timeout_seconds=30', 'subminer-osd_messages=yes', @@ -240,6 +241,7 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri 'subminer-backend=x11', 'subminer-auto_start=yes', 'subminer-auto_start_visible_overlay=no', + 'subminer-overlay_loading_osd=no', 'subminer-auto_start_pause_until_ready=yes', 'subminer-auto_start_pause_until_ready_timeout_seconds=30', 'subminer-osd_messages=no', diff --git a/launcher/types.ts b/launcher/types.ts index b8e172b8..de0a3a00 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -209,6 +209,7 @@ export interface PluginRuntimeConfig { autoStart: boolean; autoStartVisibleOverlay: boolean; autoStartPauseUntilReady: boolean; + overlayLoadingOsd?: boolean; osdMessages: boolean; texthookerEnabled: boolean; aniskipEnabled: boolean; diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 8714cc88..e1ac2492 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -112,6 +112,14 @@ function M.create(ctx) return options_helper.coerce_bool(raw_visible_overlay, false) end + local function resolve_overlay_loading_osd_enabled() + local raw_overlay_loading_osd = opts.overlay_loading_osd + if raw_overlay_loading_osd == nil then + raw_overlay_loading_osd = opts["overlay-loading-osd"] + end + return options_helper.coerce_bool(raw_overlay_loading_osd, false) + end + local function next_auto_start_retry_generation() state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1 return state.auto_start_retry_generation @@ -151,6 +159,14 @@ function M.create(ctx) and not (state.overlay_running and state.auto_play_ready_signal_seen == true) end + local function should_show_overlay_loading_osd() + return ( + resolve_overlay_loading_osd_enabled() + or (resolve_auto_start_enabled() and resolve_auto_start_visible_overlay_enabled()) + ) + and not state.suppress_ready_overlay_restore + end + local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt) if generation ~= state.auto_start_retry_generation then return @@ -178,6 +194,7 @@ function M.create(ctx) .. process.describe_mpv_ipc_socket_match(opts.socket_path) .. ")" ) + process.stop_overlay_loading_osd() schedule_aniskip_fetch("file-loaded", 0) return end @@ -192,6 +209,9 @@ function M.create(ctx) end local function on_start_file() + if should_show_overlay_loading_osd() then + process.start_overlay_loading_osd() + end 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 @@ -245,6 +265,7 @@ function M.create(ctx) end if same_media_reload then + process.stop_overlay_loading_osd() subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") if state.app_managed_playback_active then return @@ -273,6 +294,7 @@ function M.create(ctx) end if state.app_managed_playback_active then + process.stop_overlay_loading_osd() subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload") return end @@ -291,6 +313,7 @@ function M.create(ctx) aniskip.clear_aniskip_state() hover.clear_hover_overlay() process.disarm_auto_play_ready_gate() + process.stop_overlay_loading_osd() clear_pending_visible_overlay_hide() state.auto_play_ready_signal_seen = false state.current_media_identity = nil @@ -310,6 +333,7 @@ function M.create(ctx) hover.clear_hover_overlay() end) mp.register_event("end-file", function(event) + process.stop_overlay_loading_osd() process.disarm_auto_play_ready_gate() hover.clear_hover_overlay() local reason = type(event) == "table" and event.reason or nil diff --git a/plugin/subminer/log.lua b/plugin/subminer/log.lua index a6542acd..b72ff315 100644 --- a/plugin/subminer/log.lua +++ b/plugin/subminer/log.lua @@ -43,8 +43,8 @@ function M.create(ctx) end end - local function show_osd(message) - if opts.osd_messages then + local function show_osd(message, options) + if opts.osd_messages or (options and options.force == true) then local payload = "SubMiner: " .. message local sent = false if type(mp.osd_message) == "function" then diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index 084c6bcf..289ca9c8 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -44,6 +44,9 @@ function M.create(ctx) mp.register_script_message("subminer-autoplay-ready", function() process.notify_auto_play_ready() end) + mp.register_script_message("subminer-overlay-loading-ready", function() + process.stop_overlay_loading_osd() + end) mp.register_script_message("subminer-aniskip-refresh", function() aniskip.fetch_aniskip_for_current_media("script-message") end) diff --git a/plugin/subminer/options.lua b/plugin/subminer/options.lua index 7f8520bc..cc42de61 100644 --- a/plugin/subminer/options.lua +++ b/plugin/subminer/options.lua @@ -32,6 +32,7 @@ function M.load(options_lib, default_socket_path) backend = "auto", auto_start = false, auto_start_visible_overlay = false, + overlay_loading_osd = false, auto_start_pause_until_ready = true, auto_start_pause_until_ready_owns_initial_pause = false, auto_start_pause_until_ready_timeout_seconds = 30, diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 7b3b2806..af05dbee 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -4,6 +4,9 @@ local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_START_MAX_ATTEMPTS = 6 local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20 +local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading " +local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" } +local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18 local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30 @@ -53,6 +56,14 @@ function M.create(ctx) return options_helper.coerce_bool(raw_pause_until_ready, false) end + local function resolve_osd_messages_enabled() + local raw_osd_messages = opts.osd_messages + if raw_osd_messages == nil then + raw_osd_messages = opts["osd-messages"] + end + return options_helper.coerce_bool(raw_osd_messages, false) + end + local function resolve_pause_until_ready_owns_initial_pause() local raw_owns_initial_pause = opts.auto_start_pause_until_ready_owns_initial_pause if raw_owns_initial_pause == nil then @@ -246,6 +257,42 @@ function M.create(ctx) state.auto_play_ready_osd_timer = nil end + local function clear_overlay_loading_osd_timer() + local timer = state.overlay_loading_osd_timer + if timer and timer.kill then + timer:kill() + end + state.overlay_loading_osd_timer = nil + end + + local function stop_overlay_loading_osd() + state.overlay_loading_osd_active = false + state.overlay_loading_osd_frame = 1 + clear_overlay_loading_osd_timer() + end + + local function start_overlay_loading_osd() + if state.overlay_loading_osd_active then + return + end + state.overlay_loading_osd_active = true + state.overlay_loading_osd_frame = 1 + local function show_next_overlay_loading_frame() + local frame_index = state.overlay_loading_osd_frame or 1 + local frame = OVERLAY_LOADING_OSD_FRAMES[frame_index] or OVERLAY_LOADING_OSD_FRAMES[1] + show_osd(OVERLAY_LOADING_OSD_PREFIX .. frame, { force = true }) + state.overlay_loading_osd_frame = (frame_index % #OVERLAY_LOADING_OSD_FRAMES) + 1 + end + show_next_overlay_loading_frame() + if type(mp.add_periodic_timer) == "function" then + state.overlay_loading_osd_timer = mp.add_periodic_timer(OVERLAY_LOADING_OSD_REFRESH_SECONDS, function() + if state.overlay_loading_osd_active then + show_next_overlay_loading_frame() + end + end) + end + end + local function disarm_auto_play_ready_gate(options) local should_resume = options == nil or options.resume_playback ~= false local was_armed = state.auto_play_ready_gate_armed @@ -264,8 +311,11 @@ function M.create(ctx) return false end local should_resume_playback = state.auto_play_ready_should_resume_playback == true + if resolve_osd_messages_enabled() then + stop_overlay_loading_osd() + show_osd(AUTO_PLAY_READY_READY_OSD) + end disarm_auto_play_ready_gate({ resume_playback = false }) - show_osd(AUTO_PLAY_READY_READY_OSD) if should_resume_playback then mp.set_property_native("pause", false) subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) @@ -287,8 +337,11 @@ function M.create(ctx) end state.auto_play_ready_gate_armed = true mp.set_property_native("pause", true) - show_osd(AUTO_PLAY_READY_LOADING_OSD) - if type(mp.add_periodic_timer) == "function" then + if resolve_osd_messages_enabled() then + stop_overlay_loading_osd() + show_osd(AUTO_PLAY_READY_LOADING_OSD) + end + if resolve_osd_messages_enabled() and type(mp.add_periodic_timer) == "function" then state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function() if state.auto_play_ready_gate_armed then show_osd(AUTO_PLAY_READY_LOADING_OSD) @@ -543,6 +596,7 @@ function M.create(ctx) if not binary.ensure_binary_available() then subminer_log("error", "binary", "SubMiner binary not found") + stop_overlay_loading_osd() show_osd("Error: binary not found") return end @@ -627,6 +681,7 @@ function M.create(ctx) state.overlay_running = false state.auto_play_ready_signal_seen = false subminer_log("error", "process", "Overlay start failed after retries: " .. reason) + stop_overlay_loading_osd() show_osd("Overlay start failed") release_auto_play_ready_gate("overlay-start-failed") return @@ -679,6 +734,7 @@ function M.create(ctx) state.overlay_running = false state.texthooker_running = false state.auto_play_ready_signal_seen = false + stop_overlay_loading_osd() disarm_auto_play_ready_gate() show_osd("Stopped") end @@ -690,6 +746,7 @@ function M.create(ctx) return end state.suppress_ready_overlay_restore = true + stop_overlay_loading_osd() run_control_command_async("hide-visible-overlay", nil, function(ok, result) if ok then @@ -893,6 +950,8 @@ function M.create(ctx) check_binary_available = check_binary_available, notify_auto_play_ready = notify_auto_play_ready, disarm_auto_play_ready_gate = disarm_auto_play_ready_gate, + start_overlay_loading_osd = start_overlay_loading_osd, + stop_overlay_loading_osd = stop_overlay_loading_osd, } end diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 85e05cec..b215f322 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -35,6 +35,9 @@ function M.new() auto_play_ready_osd_timer = nil, auto_play_ready_signal_seen = false, auto_play_ready_initial_pause_ownership_consumed = false, + overlay_loading_osd_active = false, + overlay_loading_osd_timer = nil, + overlay_loading_osd_frame = 1, pending_visible_overlay_hide_timer = nil, pending_visible_overlay_hide_generation = 0, suppress_ready_overlay_restore = false, diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index ad92ef0f..4c3f13f3 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -979,6 +979,31 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "no", + auto_start_visible_overlay = "yes", + overlay_loading_osd = "yes", + osd_messages = false, + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for explicit early overlay loading OSD scenario: " .. tostring(err)) + fire_event(recorded, "start-file") + assert_true( + has_osd_message(recorded.osd, "SubMiner: Overlay loading |"), + "explicit overlay loading OSD option should show spinner even when plugin auto-start is disabled" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -1695,6 +1720,91 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + osd_messages = false, + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for early overlay loading OSD scenario: " .. tostring(err)) + fire_event(recorded, "start-file") + assert_true( + has_osd_message(recorded.osd, "SubMiner: Overlay loading |"), + "auto-start visible overlay should force overlay loading OSD spinner on start-file" + ) + assert_true( + #recorded.periodic_timers == 1, + "auto-start visible overlay should refresh the early overlay loading OSD" + ) + local overlay_loading_timer = recorded.periodic_timers[1] + recorded.periodic_timers[1].callback() + assert_true( + has_osd_message(recorded.osd, "SubMiner: Overlay loading /"), + "auto-start visible overlay should advance the early overlay loading OSD spinner" + ) + fire_event(recorded, "file-loaded") + assert_true( + overlay_loading_timer.killed ~= true, + "autoplay gate should keep forced overlay loading OSD alive while normal plugin OSD messages are disabled" + ) + assert_true( + #recorded.periodic_timers == 1, + "autoplay gate should not replace forced overlay loading OSD with a suppressed tokenization OSD timer" + ) + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + overlay_loading_timer.killed ~= true, + "autoplay readiness should not stop forced overlay loading OSD before overlay content is ready" + ) + overlay_loading_timer.callback() + assert_true( + has_osd_message(recorded.osd, "SubMiner: Overlay loading -"), + "forced overlay loading OSD should keep spinning during the overlay startup gap" + ) + assert_true( + recorded.script_messages["subminer-overlay-loading-ready"] ~= nil, + "overlay loading ready script message should be registered" + ) + recorded.script_messages["subminer-overlay-loading-ready"]() + assert_true( + recorded.periodic_timers[1].killed == true, + "overlay loading ready should stop the early overlay loading OSD refresher" + ) +end + +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "no", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for hidden overlay loading OSD scenario: " .. tostring(err)) + fire_event(recorded, "start-file") + assert_true( + not has_osd_message(recorded.osd, "SubMiner: Overlay loading |"), + "auto-start hidden visible overlay should not show early overlay loading OSD" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 5ee92d0d..7e91afb1 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -346,20 +346,15 @@ test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomit assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs')); }); -test('runAppReadyRuntime starts background warmups before core runtime services', async () => { - const calls: string[] = []; - const { deps } = makeDeps({ - startBackgroundWarmups: () => { - calls.push('startBackgroundWarmups'); - }, - loadSubtitlePosition: () => calls.push('loadSubtitlePosition'), - createMpvClient: () => calls.push('createMpvClient'), - }); +test('runAppReadyRuntime starts background warmups after overlay startup', async () => { + const { deps, calls } = makeDeps(); await runAppReadyRuntime(deps); - assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition')); - assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient')); + assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups')); + assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups')); + assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups')); + assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs')); }); test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => { diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 6fafe2a5..6c493de0 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -211,7 +211,70 @@ test('macOS dismisses overlay loading OSD when tracker recovers', () => { assert.ok(calls.includes('show-inactive')); }); -test('tracked non-macOS overlay stays hidden while tracker is not ready', () => { +test('tracked non-native overlay shows loading OSD until renderer content is visible', () => { + const { window, calls, setContentReady } = createMainWindowRecorder(); + let loadingShown = false; + const osdMessages: string[] = []; + const dismissedOsds: string[] = []; + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => true, + }; + + const run = () => + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: loadingShown, + setTrackerNotReadyWarningShown: (shown: boolean) => { + loadingShown = shown; + }, + 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, + showOverlayLoadingOsd: (message: string) => { + osdMessages.push(message); + }, + dismissOverlayLoadingOsd: () => { + dismissedOsds.push('dismiss'); + }, + } as never); + + setContentReady(false); + run(); + run(); + + assert.equal(loadingShown, true); + assert.deepEqual(osdMessages, ['Overlay loading...']); + assert.deepEqual(dismissedOsds, []); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('show-inactive')); + + setContentReady(true); + run(); + + assert.equal(loadingShown, false); + assert.deepEqual(dismissedOsds, ['dismiss']); + assert.ok(calls.includes('show-inactive')); +}); + +test('tracked non-macOS overlay stays hidden and emits loading OSD while tracker is not ready', () => { const { window, calls } = createMainWindowRecorder(); let trackerWarning = false; const tracker: WindowTrackerStub = { @@ -254,7 +317,7 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () => assert.ok(!calls.includes('update-bounds')); assert.ok(!calls.includes('show')); assert.ok(!calls.includes('focus')); - assert.ok(!calls.includes('osd')); + assert.ok(calls.includes('osd')); }); test('non-native passive overlay stays click-through after subsequent visibility updates', () => { diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 2fec13e1..6888bb67 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -311,8 +311,18 @@ export function updateVisibleOverlayVisibility(args: { !args.isWindowsPlatform && (!args.forceMousePassthrough || args.isMacOSPlatform === true); + const isWaitingForOverlayContentReady = (): boolean => { + const hasWebContents = + typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object'; + return ( + !mainWindow.isVisible() && + hasWebContents && + !isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow) + ); + }; + const maybeShowOverlayLoadingOsd = (): void => { - if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) { + if (!args.showOverlayLoadingOsd) { return; } if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) { @@ -322,9 +332,6 @@ export function updateVisibleOverlayVisibility(args: { args.markOverlayLoadingOsdShown?.(); }; const maybeDismissOverlayLoadingOsd = (): void => { - if (!args.isMacOSPlatform) { - return; - } args.dismissOverlayLoadingOsd?.(); }; @@ -379,8 +386,15 @@ export function updateVisibleOverlayVisibility(args: { args.syncOverlayShortcuts(); return; } - args.setTrackerNotReadyWarningShown(false); - maybeDismissOverlayLoadingOsd(); + if (isWaitingForOverlayContentReady()) { + if (!args.trackerNotReadyWarningShown) { + args.setTrackerNotReadyWarningShown(true); + maybeShowOverlayLoadingOsd(); + } + } else { + args.setTrackerNotReadyWarningShown(false); + maybeDismissOverlayLoadingOsd(); + } const geometry = args.windowTracker.getGeometry(); if (geometry) { args.updateVisibleOverlayBounds(geometry); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 6124563c..d13f7e77 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -116,6 +116,7 @@ export function createOverlayWindow( linuxX11FullscreenOverlay?: boolean; onVisibleWindowBlurred?: () => void; onVisibleWindowFocused?: () => void; + onWindowDidFinishLoad?: () => void; onWindowContentReady?: () => void; onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void; yomitanSession?: Session | null; @@ -139,6 +140,7 @@ export function createOverlayWindow( window.webContents.on('did-finish-load', () => { window.setTitle(OVERLAY_WINDOW_TITLES[kind]); options.onRuntimeOptionsChanged(); + options.onWindowDidFinishLoad?.(); }); window.webContents.on('page-title-updated', (event) => { diff --git a/src/core/services/startup.test.ts b/src/core/services/startup.test.ts index a349d60f..cfba8def 100644 --- a/src/core/services/startup.test.ts +++ b/src/core/services/startup.test.ts @@ -269,7 +269,7 @@ test('runAppReadyRuntime loads Yomitan before headless overlay fallback initiali ]); }); -test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => { +test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => { const calls: string[] = []; await runAppReadyRuntime({ @@ -354,9 +354,10 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime' shouldSkipHeavyStartup: () => false, }); - assert.ok(calls.indexOf('load-yomitan') !== -1); assert.ok(calls.indexOf('init-overlay') !== -1); - assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay')); + assert.ok(calls.indexOf('warmups') !== -1); + assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups')); + assert.equal(calls.includes('load-yomitan'), false); }); test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => { diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index cf325ed5..3bc4b7ce 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -232,6 +232,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise => { if (firstRunSetupHandled) { return; @@ -246,6 +247,13 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { + if (backgroundWarmupsHandled) { + return; + } + backgroundWarmupsHandled = true; + deps.startBackgroundWarmups(); + }; deps.ensureDefaultConfigBootstrap(); if (deps.shouldRunHeadlessInitialCommand?.()) { @@ -297,8 +305,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise - isTokenizationWarmupReady() && isVisibleOverlayAutoplayTargetReady( { getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), @@ -1943,6 +1946,8 @@ let subtitleSidebarRequestedOpen = false; const SEEK_THRESHOLD_SECONDS = 3; const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2; let autoplaySubtitlePrimedMediaPath: string | null = null; +let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType | null = null; +const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100; function getCurrentAutoplayMediaPath(): string | null { return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null; @@ -2017,6 +2022,7 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise { subtitlePrefetchService?.onSeek(lastObservedTimePos); subtitleProcessingController.refreshCurrentSubtitle(text); }, + deferUncachedRefresh: true, emitSubtitle: (payload) => emitSubtitlePayload(payload), setCurrentSecondarySubText: (text) => { if (appState.mpvClient) { @@ -2032,6 +2038,38 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise { }); } +function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { + if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { + return; + } + clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer); + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; +} + +function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { + if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { + return; + } + if (!overlayManager.getVisibleOverlayVisible() || !appState.currentSubText.trim()) { + return; + } + + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => { + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; + if (!overlayManager.getVisibleOverlayVisible()) { + return; + } + const text = appState.currentSubText; + if (!text.trim()) { + return; + } + subtitlePrefetchService?.pause(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); + subtitleProcessingController.refreshCurrentSubtitle(text); + }, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS); + visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.(); +} + async function primeAutoplaySubtitleFromParsedCues( mediaPath: string, cues: SubtitleCue[], @@ -2663,7 +2701,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( showOverlayLoadingStatusNotification(message); }, dismissOverlayLoadingOsd: () => { - dismissOverlayNotification('overlay-loading-status'); + dismissOverlayLoadingStatusNotification(); }, hideNonNativeOverlayWhenTargetUnfocused: () => shouldRunLinuxOverlayZOrderKeepAlive() && @@ -2692,6 +2730,7 @@ const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200; // subtitle pointer interaction. Right after playback starts the overlay can briefly become the // X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s). const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500; +const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500; const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200; let visibleOverlayBlurRefreshTimeouts: Array> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; @@ -2706,6 +2745,8 @@ const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = }; let visibleOverlayInteractionActive = false; let linuxOverlayInputShapeActive = false; +let linuxVisibleOverlayStartupInputPrimed = false; +let linuxVisibleOverlayStartupInputGraceUntilMs = 0; // Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal // region is interactive, so the cursor poll keeps the overlay interactive even when the cursor // moves off measured subtitle/sidebar rects onto the popup. @@ -2728,6 +2769,7 @@ const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHa function resetVisibleOverlayInputState(): void { visibleOverlayInteractionActive = false; linuxOverlayInputShapeActive = false; + resetLinuxVisibleOverlayStartupInputPrimer(); linuxOverlayInteractiveHint = false; overlayContentMeasurementStore.clear('visible'); const mainWindow = overlayManager.getMainWindow(); @@ -3200,6 +3242,23 @@ function shouldUseLinuxOverlayInputShape(): boolean { return false; } +function hasLinuxVisibleOverlayStartupInputGrace(): boolean { + return ( + process.platform === 'linux' && + linuxVisibleOverlayStartupInputGraceUntilMs > 0 && + Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs + ); +} + +function clearLinuxVisibleOverlayStartupInputGrace(): void { + linuxVisibleOverlayStartupInputGraceUntilMs = 0; +} + +function resetLinuxVisibleOverlayStartupInputPrimer(): void { + linuxVisibleOverlayStartupInputPrimed = false; + clearLinuxVisibleOverlayStartupInputGrace(); +} + function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean { if (!shouldUseLinuxOverlayInputShape()) { linuxOverlayInputShapeActive = false; @@ -3238,6 +3297,28 @@ function updateLinuxOverlayPointerInteractionActive(active: boolean): void { overlayVisibilityRuntime.updateVisibleOverlayVisibility(); } +function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void { + if (process.platform !== 'linux') return; + if (linuxVisibleOverlayStartupInputPrimed) return; + if (shouldUseLinuxOverlayInputShape()) return; + if ( + !shouldPrimeLinuxOverlayInteractionFromMeasurement({ + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, + shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, + shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, + }) + ) { + return; + } + + linuxVisibleOverlayStartupInputPrimed = true; + linuxVisibleOverlayStartupInputGraceUntilMs = + Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS; + updateLinuxOverlayPointerInteractionActive(true); +} + const linuxOverlayZOrderKeepAliveDeps = { getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getMainWindow: () => overlayManager.getMainWindow(), @@ -3298,7 +3379,8 @@ const linuxOverlayPointerInteractionDeps = { getCursorScreenPoint: () => linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()), getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, - getRendererInteractiveHint: () => linuxOverlayInteractiveHint, + getRendererInteractiveHint: () => + linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(), shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, shouldUseInputShape: shouldUseLinuxOverlayInputShape, @@ -3355,8 +3437,38 @@ function getConfiguredStatusNotificationType(): NotificationType { return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady()); } +function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean { + if (window.isDestroyed() || !isOverlayWindowContentReady(window)) { + return false; + } + if (window.webContents.isLoading()) { + return false; + } + const currentURL = window.webContents.getURL(); + return currentURL !== '' && currentURL !== 'about:blank'; +} + +function hasReadyOverlayNotificationWindow(): boolean { + return getOverlayWindows().some((window) => isOverlayWindowReadyForNotification(window)); +} + +const overlayNotificationDelivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => hasReadyOverlayNotificationWindow(), + send: (payload) => { + broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload); + }, + scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs), + clearFlushRetry: (handle) => clearTimeout(handle as ReturnType), +}); +let overlayLoadingOsdController: ReturnType | null = + null; + +function flushQueuedOverlayNotifications(): void { + overlayNotificationDelivery.flush(); +} + function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void { - broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload); + overlayNotificationDelivery.send(payload); } function showOverlayNotification(payload: OverlayNotificationPayload): void { @@ -3429,16 +3541,47 @@ function showYoutubeFlowStatusNotification(message: string): void { }); } -function showOverlayLoadingStatusNotification(message: string): void { - showConfiguredStatusNotification(message, { - id: 'overlay-loading-status', - title: 'SubMiner', - variant: 'progress', - persistent: true, - desktop: false, - }); +function getOverlayLoadingOsdController(): ReturnType { + if (!overlayLoadingOsdController) { + overlayLoadingOsdController = createOverlayLoadingOsdController({ + showOsd: (message) => { + showMpvOsd(message); + }, + clearOsd: () => { + sendMpvCommandRuntime(appState.mpvClient, ['show-text', '', '1']); + }, + setInterval: (callback, delayMs) => { + const timer = setInterval(callback, delayMs); + timer.unref?.(); + return timer; + }, + clearInterval: (timer) => { + clearInterval(timer as ReturnType); + }, + }); + } + return overlayLoadingOsdController; } +function showOverlayLoadingStatusNotification(message: string): void { + void message; + getOverlayLoadingOsdController().start(); +} + +function dismissOverlayLoadingStatusNotification(): void { + getOverlayLoadingOsdController().stop(); + sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-overlay-loading-ready']); + dismissOverlayNotification('overlay-loading-status'); +} + +const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({ + getVisibleOverlayRequested: () => overlayManager.getVisibleOverlayVisible(), + isOverlayContentReady: () => isVisibleOverlayContentReady(), + startOverlayLoadingOsd: () => { + showOverlayLoadingStatusNotification('Overlay loading...'); + }, +}); + const buildBroadcastRuntimeOptionsChangedMainDepsHandler = createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ broadcastRuntimeOptionsChangedRuntime, @@ -5191,6 +5334,7 @@ const { void reportJellyfinRemoteStopped(); }, onMpvConnected: () => { + maybeStartOverlayLoadingOsd(); if (appState.sessionBindingsInitialized) { sendMpvCommandRuntime(appState.mpvClient, [ 'script-message', @@ -5228,6 +5372,7 @@ const { tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, updateCurrentMediaPath: (path) => { const normalizedPath = path.trim(); + maybeStartOverlayLoadingOsd(normalizedPath); const previousPath = appState.currentMediaPath?.trim() || null; const preserveParsedSubtitleCues = isSameYoutubeMediaPath( normalizedPath, @@ -6861,6 +7006,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ currentSubText: appState.currentSubText, currentSubtitleData: appState.currentSubtitleData, withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload), + tokenizeUncached: false, tokenizeSubtitle: tokenizeSubtitleForCurrent ? (text) => tokenizeSubtitleForCurrent(text) : undefined, @@ -7014,7 +7160,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ reportOverlayContentBounds: (payload: unknown) => { if (overlayContentMeasurementStore.report(payload)) { tickLinuxOverlayPointerInteractionNow(); + primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); autoplayReadyGate.flushPendingAutoplayReadySignal(); + scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); } }, getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), @@ -7384,12 +7532,14 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa linuxVisibleOverlayWindowMode === 'fullscreen-override', onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(), + onWindowDidFinishLoad: () => { + flushQueuedOverlayNotifications(); + }, onWindowContentReady: () => { - dismissOverlayNotification('overlay-loading-status'); + dismissOverlayLoadingStatusNotification(); + flushQueuedOverlayNotifications(); overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - if (appState.currentSubText.trim()) { - subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); - } + primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); autoplayReadyGate.flushPendingAutoplayReadySignal(); }, onWindowClosed: (windowKind, window) => { @@ -7669,10 +7819,13 @@ function setVisibleOverlayVisible(visible: boolean): void { ensureOverlayWindowsReadyForVisibilityActions(); if (!visible) { autoplayReadyGate.markCurrentMediaAutoplayReady(); + cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); resetVisibleOverlayInputState(); } if (visible) { + maybeStartOverlayLoadingOsd(); + resetLinuxVisibleOverlayStartupInputPrimer(); restoreVisibleOverlayWindowShapeForShow(); void ensureOverlayMpvSubtitlesHidden(); void primeCurrentSubtitleForVisibleOverlay(); @@ -7687,9 +7840,12 @@ function toggleVisibleOverlay(): void { const nextVisible = !overlayManager.getVisibleOverlayVisible(); if (!nextVisible) { autoplayReadyGate.markCurrentMediaAutoplayReady(); + cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); resetVisibleOverlayInputState(); } else { + maybeStartOverlayLoadingOsd(); + resetLinuxVisibleOverlayStartupInputPrimer(); restoreVisibleOverlayWindowShapeForShow(); void ensureOverlayMpvSubtitlesHidden(); void primeCurrentSubtitleForVisibleOverlay(); @@ -7700,11 +7856,14 @@ function toggleVisibleOverlay(): void { } function setOverlayVisible(visible: boolean): void { if (!visible) { + cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); resetVisibleOverlayInputState(); autoplayReadyGate.markCurrentMediaAutoplayReady(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); } if (visible) { + maybeStartOverlayLoadingOsd(); + resetLinuxVisibleOverlayStartupInputPrimer(); restoreVisibleOverlayWindowShapeForShow(); void ensureOverlayMpvSubtitlesHidden(); void primeCurrentSubtitleForVisibleOverlay(); diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index b4624810..1e26170c 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -59,6 +59,50 @@ test('same media path updates do not reset autoplay ready fallback state', () => ); }); +test('mpv startup signals start overlay loading OSD before readiness work', () => { + const source = readMainSource(); + const connectedBlock = source.match( + /onMpvConnected:\s*\(\)\s*=>\s*\{(?[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/, + )?.groups?.body; + const mediaPathBlock = source.match( + /updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?[\s\S]*?)\n restoreMpvSubVisibility:/, + )?.groups?.body; + const setVisibleBlock = source.match( + /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + + assert.ok(connectedBlock); + assert.ok(mediaPathBlock); + assert.ok(setVisibleBlock); + assert.match(connectedBlock, /maybeStartOverlayLoadingOsd\(\);/); + assert.match( + mediaPathBlock, + /const normalizedPath = path\.trim\(\);\s+maybeStartOverlayLoadingOsd\(normalizedPath\);/, + ); + assert.match(setVisibleBlock, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/); + assert.match( + source, + /function toggleVisibleOverlay\(\): void \{[\s\S]*?else \{\s+maybeStartOverlayLoadingOsd\(\);/, + ); + assert.match( + source, + /function setOverlayVisible\(visible: boolean\): void \{[\s\S]*?if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/, + ); +}); + +test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => { + const source = readMainSource(); + const dismissBlock = source.match( + /function dismissOverlayLoadingStatusNotification\(\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + + assert.ok(dismissBlock); + assert.match( + dismissBlock, + /sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/, + ); +}); + test('manual visible overlay toggles only release current-media autoplay when hiding', () => { const source = readMainSource(); const actionBlock = source.match( @@ -68,7 +112,7 @@ test('manual visible overlay toggles only release current-media autoplay when hi assert.ok(actionBlock); assert.match( actionBlock, - /if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, + /if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, ); }); @@ -89,15 +133,15 @@ test('all visible overlay hide paths clear stale overlay input state', () => { assert.ok(setOverlayBlock); assert.match( setVisibleBlock, - /if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/, + /if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/, ); assert.match( toggleBlock, - /if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/, + /if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/, ); assert.match( setOverlayBlock, - /if \(!visible\) \{\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, + /if \(!visible\) \{\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, ); }); @@ -169,7 +213,7 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o ); }); -test('startup autoplay release is tied to tokenization and visible overlay measurement readiness', () => { +test('startup autoplay release is tied to visible overlay measurement readiness', () => { const source = readMainSource(); const gateBlock = source.match( /const autoplayReadyGate = createAutoplayReadyGate\(\{(?[\s\S]*?)\n\}\);/, @@ -180,7 +224,7 @@ test('startup autoplay release is tied to tokenization and visible overlay measu assert.ok(gateBlock); assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/); - assert.match(gateBlock, /isTokenizationWarmupReady\(\)/); + assert.doesNotMatch(gateBlock, /isTokenizationWarmupReady\(\)/); assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/); assert.match(gateBlock, /getLatestVisibleMeasurement:/); @@ -189,6 +233,37 @@ test('startup autoplay release is tied to tokenization and visible overlay measu assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); }); +test('visible overlay content-ready does not tokenize before first measurement', () => { + const source = readMainSource(); + const contentReadyBlock = source.match( + /onWindowContentReady:\s*\(\)\s*=>\s*\{(?[\s\S]*?)\n \},/, + )?.groups?.body; + const measurementBlock = source.match( + /reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?[\s\S]*?)\n \},/, + )?.groups?.body; + + assert.ok(contentReadyBlock); + assert.doesNotMatch(contentReadyBlock, /subtitleProcessingController\.refreshCurrentSubtitle/); + assert.match(contentReadyBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); + assert.match(contentReadyBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/); + assert.ok( + contentReadyBlock.indexOf('overlayVisibilityRuntime.updateVisibleOverlayVisibility();') < + contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'), + ); + assert.ok( + contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();') < + contentReadyBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();'), + ); + + assert.ok(measurementBlock); + assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); + assert.match(measurementBlock, /scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint\(\)/); + assert.ok( + measurementBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();') < + measurementBlock.indexOf('scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();'), + ); +}); + test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => { const source = readMainSource(); const measurementBlock = source.match( @@ -198,10 +273,15 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i assert.ok(measurementBlock); assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/); assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/); + assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/); assert.ok( measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') < measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'), ); + assert.ok( + measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') < + measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'), + ); }); test('subtitle sidebar open state is restored for replacement visible overlay windows', () => { @@ -351,11 +431,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin assert.ok(toggleBlock); assert.match( setBlock, - /if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, + /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, ); assert.match( toggleBlock, - /else \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, + /else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, ); }); @@ -374,7 +454,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape' assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); assert.match( setBlock, - /if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, + /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, ); }); diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts index 2ef62632..8f97ecdf 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts @@ -71,7 +71,7 @@ test('auto sync notifications send osd updates for progress phases', () => { ]); }); -test('auto sync notifications prefer overlay delivery for both when overlay is available', () => { +test('auto sync notifications send overlay and desktop delivery for both', () => { const calls: string[] = []; notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), { @@ -83,7 +83,7 @@ test('auto sync notifications prefer overlay delivery for both when overlay is a calls.push(`desktop:${title}:${options.body ?? ''}`), showOverlayNotification: (payload) => calls.push( - `overlay:${payload.id}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`, + `overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`, ), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), { @@ -95,13 +95,15 @@ test('auto sync notifications prefer overlay delivery for both when overlay is a calls.push(`desktop:${title}:${options.body ?? ''}`), showOverlayNotification: (payload) => calls.push( - `overlay:${payload.id}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`, + `overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`, ), }); assert.deepEqual(calls, [ - 'overlay:character-dictionary-auto-sync:Character dictionary:syncing:pin', - 'overlay:character-dictionary-auto-sync:Character dictionary:ready:auto', + 'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-syncing:Character dictionary:syncing:pin', + 'desktop:SubMiner:syncing', + 'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-ready:Character dictionary:ready:auto', + 'desktop:SubMiner:ready', ]); }); diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.ts index c9b92f70..5496746b 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.ts @@ -29,25 +29,29 @@ function overlayVariantForPhase( return 'progress'; } +function historyIdForEvent(event: CharacterDictionaryAutoSyncNotificationEvent): string { + const mediaId = typeof event.mediaId === 'number' ? String(event.mediaId) : 'current'; + return `character-dictionary-auto-sync-${mediaId}-${event.phase}`; +} + export function notifyCharacterDictionaryAutoSyncStatus( event: CharacterDictionaryAutoSyncNotificationEvent, deps: CharacterDictionaryAutoSyncNotificationDeps, ): void { const type = deps.getNotificationType() ?? 'overlay'; if (type === 'none') return; - let overlayShown = false; let startupSequencerShown = false; if (shouldShowOverlay(type)) { if (deps.showOverlayNotification) { deps.showOverlayNotification({ id: 'character-dictionary-auto-sync', + historyId: historyIdForEvent(event), title: 'Character dictionary', body: event.message, variant: overlayVariantForPhase(event.phase), persistent: !isTerminalPhase(event.phase), }); - overlayShown = true; } else if (!shouldShowDesktop(type)) { deps.showDesktopNotification('SubMiner', { body: event.message }); } @@ -64,7 +68,7 @@ export function notifyCharacterDictionaryAutoSyncStatus( } } - if (shouldShowDesktop(type) && !overlayShown && !startupSequencerShown) { + if (shouldShowDesktop(type) && !startupSequencerShown) { deps.showDesktopNotification('SubMiner', { body: event.message }); } } diff --git a/src/main/runtime/configured-status-notification.test.ts b/src/main/runtime/configured-status-notification.test.ts index 837ac5e0..a3460ea7 100644 --- a/src/main/runtime/configured-status-notification.test.ts +++ b/src/main/runtime/configured-status-notification.test.ts @@ -4,6 +4,7 @@ import { getPlaybackFeedbackNotificationOptions, notifyConfiguredStatus, } from './configured-status-notification'; +import { createOverlayNotificationDelivery } from './overlay-notification-delivery'; test('notifyConfiguredStatus routes both to overlay and system without osd', () => { const calls: string[] = []; @@ -27,7 +28,7 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', () ]); }); -test('notifyConfiguredStatus routes pre-overlay both status to osd and desktop', () => { +test('notifyConfiguredStatus queues pre-overlay both status through overlay sender and desktop', () => { const calls: string[] = []; notifyConfiguredStatus('Overlay loading...', { @@ -42,7 +43,25 @@ test('notifyConfiguredStatus routes pre-overlay both status to osd and desktop', calls.push(`desktop:${title}:${options.body ?? ''}`), }); - assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']); + assert.deepEqual(calls, ['overlay::Overlay loading...', 'desktop:SubMiner:Overlay loading...']); +}); + +test('notifyConfiguredStatus queues pre-overlay overlay-only status without osd fallback', () => { + const calls: string[] = []; + + notifyConfiguredStatus('Overlay loading...', { + getNotificationType: () => 'overlay', + isOverlayReady: () => false, + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showOverlayNotification: (payload) => + calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`), + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }); + + assert.deepEqual(calls, ['overlay::Overlay loading...']); }); test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => { @@ -190,3 +209,231 @@ test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', ( assert.deepEqual(calls, ['desktop:SubMiner:Overlay unavailable.']); }); + +test('overlay notification delivery queues until an overlay window is ready', () => { + const sent: string[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`), + }); + + delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' }); + delivery.send({ id: 'character-dictionary-auto-sync', title: 'Dictionary', body: 'Building' }); + + assert.equal(delivery.getQueuedCount(), 2); + assert.deepEqual(sent, []); + + ready = true; + delivery.flush(); + + assert.equal(delivery.getQueuedCount(), 0); + assert.deepEqual(sent, [ + 'startup-tokenization:Loading', + 'character-dictionary-auto-sync:Building', + ]); +}); + +test('overlay notification delivery upserts queued progress by notification id', () => { + const sent: string[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`), + }); + + delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '|' }); + delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '/' }); + delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Ready' }); + + ready = true; + delivery.flush(); + + assert.deepEqual(sent, ['startup-subtitle-annotations:/', 'startup-tokenization:Ready']); +}); + +test('overlay notification delivery preserves queued events with distinct history ids', () => { + const sent: string[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => + sent.push( + `${payload.id ?? ''}:${'historyId' in payload ? payload.historyId : ''}:${'body' in payload ? payload.body : ''}`, + ), + }); + + delivery.send({ + id: 'character-dictionary-auto-sync', + historyId: 'character-dictionary-auto-sync-checking', + title: 'Character dictionary', + body: 'Checking character dictionary...', + persistent: true, + }); + delivery.send({ + id: 'character-dictionary-auto-sync', + historyId: 'character-dictionary-auto-sync-building', + title: 'Character dictionary', + body: 'Building character dictionary...', + persistent: true, + }); + + ready = true; + delivery.flush(); + + assert.deepEqual(sent, [ + 'character-dictionary-auto-sync:character-dictionary-auto-sync-checking:Checking character dictionary...', + 'character-dictionary-auto-sync:character-dictionary-auto-sync-building:Building character dictionary...', + ]); +}); + +test('overlay notification delivery preserves queued startup progress before terminal update', () => { + const sent: string[] = []; + const scheduled: Array<() => void> = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => + sent.push( + `${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`, + ), + scheduleFlushRetry: (callback) => { + scheduled.push(callback); + }, + }); + + delivery.send({ + id: 'startup-tokenization', + title: 'Subtitle tokenization', + body: 'Loading subtitle tokenization...', + variant: 'progress', + persistent: true, + }); + delivery.send({ + id: 'startup-tokenization', + title: 'Subtitle tokenization', + body: 'Subtitle tokenization ready', + variant: 'success', + persistent: false, + }); + + ready = true; + delivery.flush(); + scheduled.shift()?.(); + + assert.deepEqual(sent, [ + 'startup-tokenization:Loading subtitle tokenization...:pin', + 'startup-tokenization:Subtitle tokenization ready:auto', + ]); +}); + +test('overlay notification delivery defers terminal update after first queued progress paint', () => { + const sent: string[] = []; + const scheduled: Array<() => void> = []; + const delays: number[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => + sent.push( + `${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`, + ), + scheduleFlushRetry: (callback, delayMs) => { + scheduled.push(callback); + delays.push(delayMs); + }, + terminalUpdateDelayMs: 750, + }); + + delivery.send({ + id: 'startup-subtitle-annotations', + title: 'Subtitle annotations', + body: 'Loading subtitle annotations |', + variant: 'progress', + persistent: true, + }); + delivery.send({ + id: 'startup-subtitle-annotations', + title: 'Subtitle annotations', + body: 'Subtitle annotations loaded', + variant: 'success', + persistent: false, + }); + + ready = true; + delivery.flush(); + + assert.deepEqual(sent, ['startup-subtitle-annotations:Loading subtitle annotations |:pin']); + assert.equal(delivery.getQueuedCount(), 1); + assert.deepEqual(delays, [750]); + + scheduled.shift()?.(); + + assert.equal(delivery.getQueuedCount(), 0); + assert.deepEqual(sent, [ + 'startup-subtitle-annotations:Loading subtitle annotations |:pin', + 'startup-subtitle-annotations:Subtitle annotations loaded:auto', + ]); +}); + +test('overlay notification delivery retries flush when lifecycle fires before window readiness settles', () => { + const sent: string[] = []; + const scheduled: Array<() => void> = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`), + scheduleFlushRetry: (callback) => { + scheduled.push(callback); + }, + }); + + delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' }); + delivery.flush(); + + assert.equal(delivery.getQueuedCount(), 1); + assert.equal(scheduled.length, 1); + assert.deepEqual(sent, []); + + ready = true; + scheduled.shift()?.(); + + assert.equal(delivery.getQueuedCount(), 0); + assert.deepEqual(sent, ['startup-tokenization:Loading']); +}); + +test('overlay notification delivery drops queued notification when dismissed before flush', () => { + const sent: string[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => + sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`), + }); + + delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' }); + delivery.send({ id: 'overlay-loading-status', dismiss: true }); + + ready = true; + delivery.flush(); + + assert.deepEqual(sent, []); +}); + +test('overlay notification delivery removes queued notification when dismissed at readiness', () => { + const sent: string[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => + sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`), + }); + + delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' }); + + ready = true; + delivery.send({ id: 'overlay-loading-status', dismiss: true }); + delivery.flush(); + + assert.deepEqual(sent, ['dismiss:overlay-loading-status']); +}); diff --git a/src/main/runtime/configured-status-notification.ts b/src/main/runtime/configured-status-notification.ts index 12124c9c..f6489d50 100644 --- a/src/main/runtime/configured-status-notification.ts +++ b/src/main/runtime/configured-status-notification.ts @@ -49,9 +49,7 @@ export function notifyConfiguredStatus( return; } - const overlayReady = deps.isOverlayReady?.() !== false; - - if (showOverlay && overlayReady) { + if (showOverlay) { if (deps.showOverlayNotification) { deps.showOverlayNotification({ id: options.id, @@ -63,8 +61,6 @@ export function notifyConfiguredStatus( } else if (desktopEnabled && !shouldShowDesktop(type)) { deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message }); } - } else if (showOverlay && !showOsd) { - deps.showOsd(message); } if (showOsd) { diff --git a/src/main/runtime/current-subtitle-snapshot.test.ts b/src/main/runtime/current-subtitle-snapshot.test.ts index d22ad888..b1c18f5a 100644 --- a/src/main/runtime/current-subtitle-snapshot.test.ts +++ b/src/main/runtime/current-subtitle-snapshot.test.ts @@ -62,6 +62,25 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token assert.deepEqual(payload.tokens, [{ text: '新' }]); }); +test('renderer current subtitle snapshot can skip cold tokenizer for first paint', async () => { + let tokenizerCalled = false; + const payload = await resolveCurrentSubtitleForRenderer({ + currentSubText: 'まだキャッシュされていない字幕', + currentSubtitleData: null, + withCurrentSubtitleTiming: withTiming, + tokenizeUncached: false, + tokenizeSubtitle: async (text) => { + tokenizerCalled = true; + return { text, tokens: [{ text: 'ま' } as never] }; + }, + }); + + assert.equal(tokenizerCalled, false); + assert.equal(payload.text, 'まだキャッシュされていない字幕'); + assert.equal(payload.startTime, 1); + assert.equal(payload.tokens, null); +}); + test('renderer current subtitle snapshot reports resolved payload for startup readiness', async () => { const resolvedPayloads: SubtitleData[] = []; const payload = await resolveCurrentSubtitleForRenderer({ @@ -99,6 +118,29 @@ test('visible overlay subtitle prime refreshes current text from mpv before show assert.deepEqual(calls, ['request:sub-text', 'set:国内外から', 'refresh:国内外から']); }); +test('visible overlay subtitle prime can defer uncached tokenization until after first paint', async () => { + const calls: string[] = []; + + await primeVisibleOverlaySubtitleFromMpv({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + calls.push(`request:${name}`); + return '国内外から'; + }, + }), + setCurrentSubText: (text) => calls.push(`set:${text}`), + getCurrentSubtitleData: () => null, + consumeCachedSubtitle: () => null, + onSubtitleChange: (text) => calls.push(`change:${text}`), + refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`), + emitSubtitle: (payload) => calls.push(`emit:${payload.text}`), + deferUncachedRefresh: true, + }); + + assert.deepEqual(calls, ['request:sub-text', 'set:国内外から']); +}); + test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => { const calls: string[] = []; const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] }; diff --git a/src/main/runtime/current-subtitle-snapshot.ts b/src/main/runtime/current-subtitle-snapshot.ts index 926df1aa..09c3beb3 100644 --- a/src/main/runtime/current-subtitle-snapshot.ts +++ b/src/main/runtime/current-subtitle-snapshot.ts @@ -10,6 +10,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: { currentSubtitleData: SubtitleData | null; withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData; tokenizeSubtitle?: (text: string) => Promise; + tokenizeUncached?: boolean; onResolvedSubtitle?: (payload: SubtitleData) => void; }): Promise { const resolve = (payload: SubtitleData): SubtitleData => { @@ -29,9 +30,11 @@ export async function resolveCurrentSubtitleForRenderer(deps: { }); } - const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText); - if (tokenized) { - return resolve(tokenized); + if (deps.tokenizeUncached !== false) { + const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText); + if (tokenized) { + return resolve(tokenized); + } } return resolve({ @@ -48,6 +51,7 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: { onSubtitleChange: (text: string) => void; refreshCurrentSubtitle: (text: string) => void; emitSubtitle: (payload: SubtitleData) => void; + deferUncachedRefresh?: boolean; setCurrentSecondarySubText?: (text: string) => void; emitSecondarySubtitle?: (text: string) => void; logDebug?: (message: string) => void; @@ -114,6 +118,11 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: { return; } + if (deps.deferUncachedRefresh === true) { + await primeSecondarySubtitle(); + return; + } + deps.refreshCurrentSubtitle(text); await primeSecondarySubtitle(); } diff --git a/src/main/runtime/linux-overlay-pointer-interaction.test.ts b/src/main/runtime/linux-overlay-pointer-interaction.test.ts index 0769972d..04ad8b2f 100644 --- a/src/main/runtime/linux-overlay-pointer-interaction.test.ts +++ b/src/main/runtime/linux-overlay-pointer-interaction.test.ts @@ -10,6 +10,7 @@ import { resolveDesiredOverlayInteractive, resolveForegroundSuppressionWithGrace, shouldSuppressPointerInteractionForForegroundWindow, + shouldPrimeLinuxOverlayInteractionFromMeasurement, tickLinuxOverlayPointerInteraction, } from './linux-overlay-pointer-interaction'; @@ -136,6 +137,59 @@ test('resolveDesiredOverlayInteractive: hit-tests separate subtitle bars without ); }); +test('shouldPrimeLinuxOverlayInteractionFromMeasurement primes input from first measured rect', () => { + assert.equal( + shouldPrimeLinuxOverlayInteractionFromMeasurement({ + getVisibleOverlayVisible: () => true, + getMainWindow: () => ({ + isDestroyed: () => false, + isVisible: () => true, + getBounds: () => BOUNDS, + }), + getSubtitleMeasurement: () => ({ + ...MEASUREMENT, + interactiveRects: [{ x: 900, y: 900, width: 320, height: 80 }], + }), + shouldSuspend: () => false, + shouldSuppressInteraction: () => false, + }), + true, + ); +}); + +test('shouldPrimeLinuxOverlayInteractionFromMeasurement skips hidden or empty startup surfaces', () => { + assert.equal( + shouldPrimeLinuxOverlayInteractionFromMeasurement({ + getVisibleOverlayVisible: () => true, + getMainWindow: () => ({ + isDestroyed: () => false, + isVisible: () => false, + getBounds: () => BOUNDS, + }), + getSubtitleMeasurement: () => MEASUREMENT, + shouldSuspend: () => false, + }), + false, + ); + assert.equal( + shouldPrimeLinuxOverlayInteractionFromMeasurement({ + getVisibleOverlayVisible: () => true, + getMainWindow: () => ({ + isDestroyed: () => false, + isVisible: () => true, + getBounds: () => BOUNDS, + }), + getSubtitleMeasurement: () => ({ + viewport: MEASUREMENT.viewport, + contentRect: null, + interactiveRects: [], + }), + shouldSuspend: () => false, + }), + false, + ); +}); + test('mapOverlayMeasurementForPointerInteraction preserves renderer interactive rects', () => { const mapped = mapOverlayMeasurementForPointerInteraction({ layer: 'visible', diff --git a/src/main/runtime/linux-overlay-pointer-interaction.ts b/src/main/runtime/linux-overlay-pointer-interaction.ts index c78c3b1c..ee44f8c5 100644 --- a/src/main/runtime/linux-overlay-pointer-interaction.ts +++ b/src/main/runtime/linux-overlay-pointer-interaction.ts @@ -146,6 +146,29 @@ function measuredRectsForInput(measurement: OverlayContentMeasurementLike): Poin : []; } +function hasMeasuredInputRects(measurement: OverlayContentMeasurementLike): boolean { + return measuredRectsForInput(measurement).some((rect) => rect.width > 0 && rect.height > 0); +} + +export function shouldPrimeLinuxOverlayInteractionFromMeasurement(deps: { + getVisibleOverlayVisible: () => boolean; + getMainWindow: () => PointerInteractionWindow | null; + getSubtitleMeasurement: () => OverlayContentMeasurementLike; + shouldSuspend: () => boolean; + shouldSuppressInteraction?: () => boolean; +}): boolean { + if (!deps.getVisibleOverlayVisible()) return false; + if (deps.shouldSuspend()) return false; + + const mainWindow = deps.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) { + return false; + } + + if (deps.shouldSuppressInteraction?.()) return false; + return hasMeasuredInputRects(deps.getSubtitleMeasurement()); +} + function clampRectToWindow(rect: PointerRect, bounds: PointerRect): PointerRect | null { const left = Math.max(0, Math.floor(rect.x)); const top = Math.max(0, Math.floor(rect.y)); diff --git a/src/main/runtime/notification-routing.test.ts b/src/main/runtime/notification-routing.test.ts index 1d244b13..736689cc 100644 --- a/src/main/runtime/notification-routing.test.ts +++ b/src/main/runtime/notification-routing.test.ts @@ -11,12 +11,12 @@ test('notification routing preserves system notification while overlay is not re assert.equal(resolveOverlayReadinessNotificationType('system', false), 'system'); }); -test('notification routing preserves both as osd plus system while overlay is not ready', () => { - assert.equal(resolveOverlayReadinessNotificationType('both', false), 'osd-system'); +test('notification routing preserves both while overlay is not ready', () => { + assert.equal(resolveOverlayReadinessNotificationType('both', false), 'both'); }); -test('notification routing falls back overlay-only notification to osd while overlay is not ready', () => { - assert.equal(resolveOverlayReadinessNotificationType('overlay', false), 'osd'); +test('notification routing preserves overlay-only notification while overlay is not ready', () => { + assert.equal(resolveOverlayReadinessNotificationType('overlay', false), 'overlay'); }); test('notification routing predicates classify delivery channels', () => { diff --git a/src/main/runtime/notification-routing.ts b/src/main/runtime/notification-routing.ts index ab5d046d..b6844202 100644 --- a/src/main/runtime/notification-routing.ts +++ b/src/main/runtime/notification-routing.ts @@ -14,16 +14,7 @@ export function shouldShowDesktop(type: NotificationType): boolean { export function resolveOverlayReadinessNotificationType( type: NotificationType, - overlayReady: boolean, + _overlayReady: boolean, ): NotificationType { - if (overlayReady) { - return type; - } - if (type === 'overlay') { - return 'osd'; - } - if (type === 'both') { - return 'osd-system'; - } return type; } diff --git a/src/main/runtime/overlay-loading-osd-start.test.ts b/src/main/runtime/overlay-loading-osd-start.test.ts new file mode 100644 index 00000000..62fe9437 --- /dev/null +++ b/src/main/runtime/overlay-loading-osd-start.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createMaybeStartOverlayLoadingOsdHandler, + shouldStartOverlayLoadingOsd, +} from './overlay-loading-osd-start'; + +test('overlay loading OSD starts for visible overlay before content is ready', () => { + assert.equal( + shouldStartOverlayLoadingOsd({ + visibleOverlayRequested: true, + overlayContentReady: false, + }), + true, + ); +}); + +test('overlay loading OSD does not start when hidden or already ready', () => { + assert.equal( + shouldStartOverlayLoadingOsd({ + visibleOverlayRequested: false, + overlayContentReady: false, + }), + false, + ); + assert.equal( + shouldStartOverlayLoadingOsd({ + visibleOverlayRequested: true, + overlayContentReady: true, + }), + false, + ); +}); + +test('overlay loading OSD media-path trigger ignores empty paths', () => { + assert.equal( + shouldStartOverlayLoadingOsd({ + visibleOverlayRequested: true, + overlayContentReady: false, + mediaPath: ' ', + }), + false, + ); +}); + +test('overlay loading OSD handler starts idempotent status through injected deps', () => { + const calls: string[] = []; + const maybeStart = createMaybeStartOverlayLoadingOsdHandler({ + getVisibleOverlayRequested: () => true, + isOverlayContentReady: () => false, + startOverlayLoadingOsd: () => { + calls.push('start'); + }, + }); + + maybeStart(); + maybeStart('/tmp/video.mkv'); + maybeStart(' '); + + assert.deepEqual(calls, ['start', 'start']); +}); diff --git a/src/main/runtime/overlay-loading-osd-start.ts b/src/main/runtime/overlay-loading-osd-start.ts new file mode 100644 index 00000000..bfc0f928 --- /dev/null +++ b/src/main/runtime/overlay-loading-osd-start.ts @@ -0,0 +1,32 @@ +export function shouldStartOverlayLoadingOsd(args: { + visibleOverlayRequested: boolean; + overlayContentReady: boolean; + mediaPath?: string | null; +}): boolean { + if (!args.visibleOverlayRequested || args.overlayContentReady) { + return false; + } + if (args.mediaPath !== undefined && (args.mediaPath ?? '').trim().length === 0) { + return false; + } + return true; +} + +export function createMaybeStartOverlayLoadingOsdHandler(deps: { + getVisibleOverlayRequested: () => boolean; + isOverlayContentReady: () => boolean; + startOverlayLoadingOsd: () => void; +}) { + return (mediaPath?: string | null): void => { + if ( + !shouldStartOverlayLoadingOsd({ + visibleOverlayRequested: deps.getVisibleOverlayRequested(), + overlayContentReady: deps.isOverlayContentReady(), + mediaPath, + }) + ) { + return; + } + deps.startOverlayLoadingOsd(); + }; +} diff --git a/src/main/runtime/overlay-loading-osd.test.ts b/src/main/runtime/overlay-loading-osd.test.ts new file mode 100644 index 00000000..291c2ea0 --- /dev/null +++ b/src/main/runtime/overlay-loading-osd.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createOverlayLoadingOsdController } from './overlay-loading-osd'; + +test('overlay loading OSD shows spinner ticks and clears when stopped', () => { + const messages: string[] = []; + const clearedTimers: unknown[] = []; + let tick: (() => void) | null = null; + const controller = createOverlayLoadingOsdController({ + showOsd: (message) => { + messages.push(message); + }, + clearOsd: () => { + messages.push('clear'); + }, + setInterval: (callback) => { + tick = callback; + return 'timer'; + }, + clearInterval: (timer) => { + clearedTimers.push(timer); + }, + }); + + controller.start(); + controller.start(); + + assert.deepEqual(messages, ['Overlay loading |']); + if (!tick) { + assert.fail('expected spinner tick callback'); + } + const tickCallback: () => void = tick; + tickCallback(); + tickCallback(); + + controller.stop(); + controller.stop(); + + assert.deepEqual(messages, ['Overlay loading |', 'Overlay loading /', 'Overlay loading -', 'clear']); + assert.deepEqual(clearedTimers, ['timer']); +}); + diff --git a/src/main/runtime/overlay-loading-osd.ts b/src/main/runtime/overlay-loading-osd.ts new file mode 100644 index 00000000..636bb373 --- /dev/null +++ b/src/main/runtime/overlay-loading-osd.ts @@ -0,0 +1,49 @@ +const DEFAULT_OVERLAY_LOADING_OSD_TICK_MS = 180; +const OVERLAY_LOADING_OSD_FRAMES = ['|', '/', '-', '\\'] as const; + +export function createOverlayLoadingOsdController(deps: { + showOsd: (message: string) => void; + clearOsd: () => void; + setInterval?: (callback: () => void, delayMs: number) => unknown; + clearInterval?: (timer: unknown) => void; +}) { + const setIntervalHandler = + deps.setInterval ?? + ((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs)); + const clearIntervalHandler = + deps.clearInterval ?? + ((timer: unknown): void => clearInterval(timer as ReturnType)); + let active = false; + let frame = 0; + let timer: unknown = null; + + const showNextFrame = (): void => { + deps.showOsd( + `Overlay loading ${OVERLAY_LOADING_OSD_FRAMES[frame % OVERLAY_LOADING_OSD_FRAMES.length]}`, + ); + frame += 1; + }; + + return { + start(): void { + if (active) { + return; + } + active = true; + frame = 0; + showNextFrame(); + timer = setIntervalHandler(showNextFrame, DEFAULT_OVERLAY_LOADING_OSD_TICK_MS); + }, + stop(): void { + if (!active) { + return; + } + active = false; + if (timer !== null) { + clearIntervalHandler(timer); + timer = null; + } + deps.clearOsd(); + }, + }; +} diff --git a/src/main/runtime/overlay-notification-delivery.ts b/src/main/runtime/overlay-notification-delivery.ts new file mode 100644 index 00000000..48cbd1b1 --- /dev/null +++ b/src/main/runtime/overlay-notification-delivery.ts @@ -0,0 +1,158 @@ +import type { OverlayNotificationEventPayload } from '../../types/notification'; + +export interface OverlayNotificationDeliveryDeps { + hasReadyOverlayWindow: () => boolean; + send: (payload: OverlayNotificationEventPayload) => void; + maxQueuedEvents?: number; + flushRetryDelayMs?: number; + terminalUpdateDelayMs?: number; + scheduleFlushRetry?: (callback: () => void, delayMs: number) => unknown; + clearFlushRetry?: (handle: unknown) => void; +} + +function getPayloadId(payload: OverlayNotificationEventPayload): string | null { + return typeof payload.id === 'string' && payload.id.trim().length > 0 ? payload.id : null; +} + +function getPayloadHistoryId(payload: OverlayNotificationEventPayload): string | null { + if ('dismiss' in payload) { + return null; + } + return typeof payload.historyId === 'string' && payload.historyId.trim().length > 0 + ? payload.historyId + : null; +} + +function isDismissPayload( + payload: OverlayNotificationEventPayload, +): payload is Extract { + return 'dismiss' in payload && payload.dismiss === true; +} + +export function createOverlayNotificationDelivery(deps: OverlayNotificationDeliveryDeps): { + send: (payload: OverlayNotificationEventPayload) => void; + flush: () => void; + getQueuedCount: () => number; +} { + const maxQueuedEvents = Math.max(1, deps.maxQueuedEvents ?? 32); + const flushRetryDelayMs = Math.max(1, deps.flushRetryDelayMs ?? 50); + const terminalUpdateDelayMs = Math.max(1, deps.terminalUpdateDelayMs ?? 750); + const queuedEvents: OverlayNotificationEventPayload[] = []; + let flushRetryHandle: unknown = null; + + const removeQueuedPayloadsById = (id: string): void => { + const nextEvents = queuedEvents.filter((queued) => getPayloadId(queued) !== id); + queuedEvents.splice(0, queuedEvents.length, ...nextEvents); + }; + + const clearFlushRetry = (): void => { + if (flushRetryHandle === null) { + return; + } + deps.clearFlushRetry?.(flushRetryHandle); + flushRetryHandle = null; + }; + + const scheduleFlushRetry = (delayMs = flushRetryDelayMs): void => { + if (!deps.scheduleFlushRetry || flushRetryHandle !== null || queuedEvents.length === 0) { + return; + } + flushRetryHandle = deps.scheduleFlushRetry(() => { + flushRetryHandle = null; + flush(); + }, delayMs); + }; + + const queuePayload = (payload: OverlayNotificationEventPayload): void => { + const id = getPayloadId(payload); + if (isDismissPayload(payload)) { + if (id) { + removeQueuedPayloadsById(id); + } + return; + } + + if (id) { + const payloadPersistent = payload.persistent === true; + const payloadHistoryId = getPayloadHistoryId(payload); + const existingIndex = queuedEvents.findIndex( + (queued) => + getPayloadId(queued) === id && + !isDismissPayload(queued) && + getPayloadHistoryId(queued) === payloadHistoryId && + (queued.persistent === true) === payloadPersistent, + ); + if (existingIndex >= 0) { + queuedEvents[existingIndex] = payload; + return; + } + } + + queuedEvents.push(payload); + while (queuedEvents.length > maxQueuedEvents) { + queuedEvents.shift(); + } + }; + + const flush = (): void => { + if (!deps.hasReadyOverlayWindow()) { + scheduleFlushRetry(); + return; + } + clearFlushRetry(); + const readyEvents = queuedEvents.splice(0, queuedEvents.length); + const sentPersistentIds = new Set(); + const deferredTerminalEvents: OverlayNotificationEventPayload[] = []; + for (const payload of readyEvents) { + const id = getPayloadId(payload); + if ( + id && + !isDismissPayload(payload) && + payload.persistent !== true && + sentPersistentIds.has(id) + ) { + deferredTerminalEvents.push(payload); + continue; + } + deps.send(payload); + if (id && !isDismissPayload(payload) && payload.persistent === true) { + sentPersistentIds.add(id); + } + } + if (deferredTerminalEvents.length > 0) { + if (!deps.scheduleFlushRetry) { + for (const payload of deferredTerminalEvents) { + deps.send(payload); + } + return; + } + queuedEvents.unshift(...deferredTerminalEvents); + scheduleFlushRetry(terminalUpdateDelayMs); + } + }; + + const send = (payload: OverlayNotificationEventPayload): void => { + if (isDismissPayload(payload)) { + const id = getPayloadId(payload); + if (id) { + removeQueuedPayloadsById(id); + } + if (deps.hasReadyOverlayWindow()) { + deps.send(payload); + } + return; + } + + if (!deps.hasReadyOverlayWindow()) { + queuePayload(payload); + return; + } + deps.send(payload); + }; + + return { + send, + flush, + getQueuedCount: () => queuedEvents.length, + }; +} diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts index a63b0b52..8a6a99e6 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -14,6 +14,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { linuxX11FullscreenOverlay?: boolean; onVisibleWindowBlurred?: () => void; onVisibleWindowFocused?: () => void; + onWindowDidFinishLoad?: () => void; onWindowContentReady?: () => void; onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void; yomitanSession?: Session | null; @@ -29,6 +30,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { getLinuxX11FullscreenOverlay?: () => boolean; onVisibleWindowBlurred?: () => void; onVisibleWindowFocused?: () => void; + onWindowDidFinishLoad?: () => void; onWindowContentReady?: () => void; onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void; getYomitanSession?: () => Session | null; @@ -45,6 +47,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay, onVisibleWindowBlurred: deps.onVisibleWindowBlurred, onVisibleWindowFocused: deps.onVisibleWindowFocused, + onWindowDidFinishLoad: deps.onWindowDidFinishLoad, onWindowContentReady: deps.onWindowContentReady, onWindowClosed: deps.onWindowClosed, getYomitanSession: () => deps.getYomitanSession?.() ?? null, diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts index bb778756..b9300b5e 100644 --- a/src/main/runtime/overlay-window-factory.ts +++ b/src/main/runtime/overlay-window-factory.ts @@ -16,6 +16,7 @@ export function createCreateOverlayWindowHandler(deps: { linuxX11FullscreenOverlay?: boolean; onVisibleWindowBlurred?: () => void; onVisibleWindowFocused?: () => void; + onWindowDidFinishLoad?: () => void; onWindowContentReady?: () => void; onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void; yomitanSession?: Session | null; @@ -31,6 +32,7 @@ export function createCreateOverlayWindowHandler(deps: { getLinuxX11FullscreenOverlay?: () => boolean; onVisibleWindowBlurred?: () => void; onVisibleWindowFocused?: () => void; + onWindowDidFinishLoad?: () => void; onWindowContentReady?: () => void; onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void; getYomitanSession?: () => Session | null; @@ -48,6 +50,7 @@ export function createCreateOverlayWindowHandler(deps: { kind === 'visible' ? deps.getLinuxX11FullscreenOverlay?.() : undefined, onVisibleWindowBlurred: deps.onVisibleWindowBlurred, onVisibleWindowFocused: deps.onVisibleWindowFocused, + onWindowDidFinishLoad: deps.onWindowDidFinishLoad, onWindowContentReady: deps.onWindowContentReady, onWindowClosed: deps.onWindowClosed, yomitanSession: deps.getYomitanSession?.() ?? null, diff --git a/src/main/runtime/startup-osd-sequencer.test.ts b/src/main/runtime/startup-osd-sequencer.test.ts index 02cb8ab5..b3d70117 100644 --- a/src/main/runtime/startup-osd-sequencer.test.ts +++ b/src/main/runtime/startup-osd-sequencer.test.ts @@ -161,6 +161,34 @@ test('startup OSD reset keeps tokenization ready after first warmup', () => { assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']); }); +test('startup OSD reset preserves in-flight tokenization loading for ready update', () => { + const calls: string[] = []; + const sequencer = createStartupOsdSequencer({ + getNotificationType: () => 'both', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showOverlayNotification: (payload) => { + calls.push( + `overlay:${payload.id}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`, + ); + }, + showDesktopNotification: (title, options) => { + calls.push(`desktop:${title}:${options.body ?? ''}`); + }, + }); + + sequencer.showTokenizationLoading('Loading subtitle tokenization...'); + sequencer.reset(); + sequencer.markTokenizationReady(); + + assert.deepEqual(calls, [ + 'overlay:startup-tokenization:Loading subtitle tokenization...:progress:pin', + 'overlay:startup-tokenization:Subtitle tokenization ready:success:auto', + 'desktop:SubMiner:Subtitle tokenization ready', + ]); +}); + test('startup OSD shows later dictionary progress immediately once tokenization is ready', () => { const osdMessages: string[] = []; const sequencer = createStartupOsdSequencer({ diff --git a/src/main/runtime/startup-osd-sequencer.ts b/src/main/runtime/startup-osd-sequencer.ts index 13ff62c9..4dd35f66 100644 --- a/src/main/runtime/startup-osd-sequencer.ts +++ b/src/main/runtime/startup-osd-sequencer.ts @@ -135,7 +135,9 @@ export function createStartupOsdSequencer(deps: StartupOsdSequencerDeps): { return { reset: () => { tokenizationReady = tokenizationWarmupCompleted; - tokenizationLoadingShown = false; + if (tokenizationWarmupCompleted) { + tokenizationLoadingShown = false; + } annotationLoadingMessage = null; pendingDictionaryProgress = null; pendingDictionaryFailure = null; diff --git a/src/renderer/overlay-notification-history.test.ts b/src/renderer/overlay-notification-history.test.ts index c41ee866..df8aa347 100644 --- a/src/renderer/overlay-notification-history.test.ts +++ b/src/renderer/overlay-notification-history.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import type { OverlayNotificationEntry } from './overlay-notifications'; import { + createOverlayNotificationHistoryPanel, createOverlayNotificationHistoryStore, resolveHistorySideFromStack, } from './overlay-notification-history'; @@ -54,6 +55,46 @@ test('history store updates an entry in place without reordering or duplicating' assert.equal(job?.updatedAt, 200); }); +test('history store keeps same live notification id when history ids differ', () => { + const store = createOverlayNotificationHistoryStore(); + store.record( + entry({ + id: 'character-dictionary-auto-sync', + title: 'Character dictionary', + body: 'Checking character dictionary...', + variant: 'progress', + historyId: 'character-dictionary-auto-sync-checking', + }), + ); + store.record( + entry({ + id: 'character-dictionary-auto-sync', + title: 'Character dictionary', + body: 'Building character dictionary...', + variant: 'progress', + historyId: 'character-dictionary-auto-sync-building', + }), + ); + store.record( + entry({ + id: 'character-dictionary-auto-sync', + title: 'Character dictionary', + body: 'Character dictionary ready', + variant: 'success', + historyId: 'character-dictionary-auto-sync-ready', + }), + ); + + assert.deepEqual( + store.list().map((item) => `${item.id}:${item.body}`), + [ + 'character-dictionary-auto-sync-ready:Character dictionary ready', + 'character-dictionary-auto-sync-building:Building character dictionary...', + 'character-dictionary-auto-sync-checking:Checking character dictionary...', + ], + ); +}); + test('history store removes and clears entries', () => { const store = createOverlayNotificationHistoryStore(); store.record(entry({ id: 'a' })); @@ -98,3 +139,99 @@ test('panel side mirrors the notification stack position', () => { // Center notifications open the panel from the right. assert.equal(resolveHistorySideFromStack(stackWith('position-top')), 'right'); }); + +function createClassList(initialTokens: string[] = []) { + const tokens = new Set(initialTokens); + return { + add: (...entries: string[]) => { + for (const entry of entries) tokens.add(entry); + }, + remove: (...entries: string[]) => { + for (const entry of entries) tokens.delete(entry); + }, + contains: (entry: string) => tokens.has(entry), + toggle: (entry: string, force?: boolean) => { + if (force === true) tokens.add(entry); + else if (force === false) tokens.delete(entry); + else if (tokens.has(entry)) tokens.delete(entry); + else tokens.add(entry); + }, + }; +} + +function createPanelHarness(stackPositionClass: string) { + const stack = { + classList: createClassList([stackPositionClass]), + }; + const clearButton = { + disabled: false, + addEventListener: () => undefined, + }; + const closeButton = { + addEventListener: () => undefined, + }; + const list = { + replaceChildren: () => undefined, + }; + const empty = { + classList: createClassList(), + }; + const panel = { + classList: createClassList(['notification-history', 'side-right']), + setAttribute: () => undefined, + addEventListener: () => undefined, + querySelector: (selector: string) => { + switch (selector) { + case '.notification-history-list': + return list; + case '.notification-history-empty': + return empty; + case '.notification-history-clear': + return clearButton; + case '.notification-history-close': + return closeButton; + default: + return null; + } + }, + }; + + const controller = createOverlayNotificationHistoryPanel({ + dom: { + overlayNotificationHistory: panel, + overlayNotificationStack: stack, + }, + state: { + isOverNotificationHistory: false, + notificationHistoryOpen: false, + }, + platform: { + shouldToggleMouseIgnore: false, + }, + } as never); + + return { controller, panel, stack }; +} + +test('history panel applies the initial stack side while still closed', () => { + const { panel } = createPanelHarness('position-top-left'); + + assert.equal(panel.classList.contains('side-left'), true); + assert.equal(panel.classList.contains('side-right'), false); + assert.equal(panel.classList.contains('open'), false); +}); + +test('history panel resyncs the closed side before first open', () => { + const { controller, panel, stack } = createPanelHarness('position-top-right'); + + stack.classList.remove('position-top-right'); + stack.classList.add('position-top-left'); + + const syncable = controller as unknown as { syncSide?: () => void }; + assert.equal(typeof syncable.syncSide, 'function'); + syncable.syncSide?.(); + + assert.equal(panel.classList.contains('side-left'), true); + assert.equal(panel.classList.contains('side-right'), false); + assert.equal(panel.classList.contains('open'), false); +}); diff --git a/src/renderer/overlay-notification-history.ts b/src/renderer/overlay-notification-history.ts index 04c65c8c..100f8529 100644 --- a/src/renderer/overlay-notification-history.ts +++ b/src/renderer/overlay-notification-history.ts @@ -35,9 +35,10 @@ function normalizeVariant( } /** - * Session-scoped log of every overlay notification that was shown. Entries are keyed by id so a - * progress notification that updates in place (same id, new body) overwrites its record rather than - * piling up duplicates. Ordering is by first-seen so the panel can render newest-first. + * Session-scoped log of every overlay notification that was shown. Entries are keyed by historyId + * when provided, otherwise by live notification id. Reusing a key updates the record in place; + * distinct history keys preserve separate visible events. Ordering is by first-seen so the panel can + * render newest-first. */ export function createOverlayNotificationHistoryStore( options: OverlayNotificationHistoryStoreOptions = {}, @@ -48,9 +49,10 @@ export function createOverlayNotificationHistoryStore( function record(entry: OverlayNotificationEntry): OverlayNotificationHistoryEntry { const timestamp = now(); - const existing = entries.get(entry.id); + const historyId = entry.historyId?.trim() || entry.id; + const existing = entries.get(historyId); const next: OverlayNotificationHistoryEntry = { - id: entry.id, + id: historyId, title: entry.title, body: entry.body, image: entry.image, @@ -60,7 +62,7 @@ export function createOverlayNotificationHistoryStore( }; // Setting an existing key keeps its original insertion slot, so an in-place update (same id, // new body) refreshes content without jumping the entry to the top of the panel. - entries.set(entry.id, next); + entries.set(historyId, next); while (entries.size > max) { const oldest = entries.keys().next().value; if (oldest === undefined) break; @@ -221,6 +223,7 @@ export function createOverlayNotificationHistoryPanel( if (open) setInteractive(true); }); panel.addEventListener('mouseleave', () => setInteractive(false)); + applySide(); function record(entry: OverlayNotificationEntry): void { store.record(entry); @@ -237,5 +240,6 @@ export function createOverlayNotificationHistoryPanel( open: () => setOpen(true), close: () => setOpen(false), isOpen: () => open, + syncSide: applySide, }; } diff --git a/src/renderer/renderer-init-order.test.ts b/src/renderer/renderer-init-order.test.ts index 1ebe3b3e..38e7dbe8 100644 --- a/src/renderer/renderer-init-order.test.ts +++ b/src/renderer/renderer-init-order.test.ts @@ -58,6 +58,22 @@ test('renderer reports subtitle bounds immediately after initial subtitle layout assert.ok(immediateMeasurementIndex < listenerIndex); }); +test('renderer wires subtitle pointer handlers before first subtitle paint', () => { + const primaryMouseEnterIndex = indexOfRequired( + "ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);", + ); + const pointerTrackingIndex = indexOfRequired('mouseHandlers.setupPointerTracking();'); + const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);'); + const initialMeasurementIndex = indexOfRequired( + 'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();', + ); + + assert.ok(primaryMouseEnterIndex < initialRenderIndex); + assert.ok(pointerTrackingIndex < initialRenderIndex); + assert.ok(primaryMouseEnterIndex < initialMeasurementIndex); + assert.ok(pointerTrackingIndex < initialMeasurementIndex); +}); + test('renderer reports subtitle bounds immediately after live subtitle layout', () => { const liveRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(data);'); const liveLayoutIndex = indexOfRequired( diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 5c29ebd3..0d88561c 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -122,7 +122,10 @@ const notificationHistory = createOverlayNotificationHistoryPanel(ctx, { onChanged: () => measurementReporter.schedule(), }); const overlayNotifications = createOverlayNotificationRenderer(ctx, { - onChanged: () => measurementReporter.schedule(), + onChanged: () => { + notificationHistory.syncSide(); + measurementReporter.schedule(); + }, onShow: (entry) => notificationHistory.record(entry), }); const positioning = createPositioningController(ctx); @@ -632,6 +635,19 @@ async function init(): Promise { syncOverlayMouseIgnoreState(ctx); } + // Seed the notification stack position from config before subscribing to history toggles, so the + // closed history panel starts on the same side it will slide in from. + try { + const overlayNotificationPosition = await window.electronAPI.getOverlayNotificationPosition(); + ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES); + ctx.dom.overlayNotificationStack.classList.add( + overlayNotificationPositionClass(overlayNotificationPosition), + ); + notificationHistory.syncSide(); + } catch { + // Non-fatal: keep the default position class from index.html. + } + window.electronAPI.onOverlayPointerRecoveryRequested(() => { runGuarded('overlay:pointer-recovery', () => { if (!ctx.platform.isMacOSPlatform || !ctx.platform.shouldToggleMouseIgnore) { @@ -656,18 +672,6 @@ async function init(): Promise { await keyboardHandlers.setupMpvInputForwarding(); - // Seed the notification stack position from config so the stack, error/status toasts, and the - // notification history panel side are correct before the first notification arrives. - try { - const overlayNotificationPosition = await window.electronAPI.getOverlayNotificationPosition(); - ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES); - ctx.dom.overlayNotificationStack.classList.add( - overlayNotificationPositionClass(overlayNotificationPosition), - ); - } catch { - // Non-fatal: keep the default position class from index.html. - } - const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle(); subtitleRenderer.applySubtitleStyle(initialSubtitleStyle); subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible'); @@ -677,6 +681,22 @@ async function init(): Promise { ); measurementReporter.schedule(); + ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter); + ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave); + ctx.dom.secondarySubContainer.addEventListener( + 'mouseenter', + mouseHandlers.handleSecondaryMouseEnter, + ); + ctx.dom.secondarySubContainer.addEventListener( + 'mouseleave', + mouseHandlers.handleSecondaryMouseLeave, + ); + + mouseHandlers.setupResizeHandler(); + mouseHandlers.setupPointerTracking(); + mouseHandlers.setupSelectionObserver(); + mouseHandlers.setupYomitanObserver(); + window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => { runGuarded('subtitle-position:update', () => { positioning.applyStoredSubtitlePosition(position, 'media-change'); @@ -731,21 +751,6 @@ async function init(): Promise { subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub()); measurementReporter.schedule(); - ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter); - ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave); - ctx.dom.secondarySubContainer.addEventListener( - 'mouseenter', - mouseHandlers.handleSecondaryMouseEnter, - ); - ctx.dom.secondarySubContainer.addEventListener( - 'mouseleave', - mouseHandlers.handleSecondaryMouseLeave, - ); - - mouseHandlers.setupResizeHandler(); - mouseHandlers.setupPointerTracking(); - mouseHandlers.setupSelectionObserver(); - mouseHandlers.setupYomitanObserver(); setupDragDropToMpvQueue(); window.addEventListener('resize', () => { measurementReporter.schedule(); diff --git a/src/shared/subminer-plugin-script-opts.ts b/src/shared/subminer-plugin-script-opts.ts index fa62ace1..93b8905f 100644 --- a/src/shared/subminer-plugin-script-opts.ts +++ b/src/shared/subminer-plugin-script-opts.ts @@ -10,6 +10,7 @@ export interface SubminerPluginRuntimeScriptOptConfig { autoStart: boolean; autoStartVisibleOverlay: boolean; autoStartPauseUntilReady: boolean; + overlayLoadingOsd?: boolean; osdMessages: boolean; texthookerEnabled: boolean; aniskipEnabled: boolean; @@ -38,12 +39,16 @@ export function buildSubminerPluginRuntimeScriptOptParts( const socketPath = sanitizeScriptOptValue(runtimeConfig.socketPath); const backend = sanitizeScriptOptValue(runtimeConfig.backend); const aniskipButtonKey = sanitizeScriptOptValue(runtimeConfig.aniskipButtonKey); + const overlayLoadingOsd = + runtimeConfig.overlayLoadingOsd ?? + (runtimeConfig.autoStart && runtimeConfig.autoStartVisibleOverlay); return [ `subminer-binary_path=${binaryPath}`, `subminer-socket_path=${socketPath}`, `subminer-backend=${backend}`, `subminer-auto_start=${boolScriptOpt(runtimeConfig.autoStart)}`, `subminer-auto_start_visible_overlay=${boolScriptOpt(runtimeConfig.autoStartVisibleOverlay)}`, + `subminer-overlay_loading_osd=${boolScriptOpt(overlayLoadingOsd)}`, `subminer-auto_start_pause_until_ready=${boolScriptOpt( runtimeConfig.autoStartPauseUntilReady, )}`, diff --git a/src/types/notification.ts b/src/types/notification.ts index a72cff17..acd7d513 100644 --- a/src/types/notification.ts +++ b/src/types/notification.ts @@ -21,6 +21,7 @@ export interface OverlayNotificationAction { export interface OverlayNotificationPayload { id?: string; + historyId?: string; title: string; body?: string; image?: string;