feat(overlay): add loading OSD spinner and queue notifications until ren

- Show mpv OSD spinner from start-file until subminer-overlay-loading-ready; force-shown for visible-overlay startup regardless of osd_messages setting
- Gate non-macOS overlay visibility on content-ready so first subtitle line is immediately hoverable and clickable
- Queue startup notifications in main process until overlay window finishes loading; upsert progress cards by id to avoid cold-start floods
- Defer background warmups until after overlay runtime init so queued notifications can deliver promptly
- Preserve character dictionary checking/building/importing/ready phases as distinct history entries; route building and importing to system notifications when notificationType is both
This commit is contained in:
2026-06-07 23:13:51 -07:00
parent d033884b09
commit 9d77907877
49 changed files with 1613 additions and 132 deletions
+4 -3
View File
@@ -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 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 `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. - 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. - 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 and subtitle annotation status through the configured notification surfaces; the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`. - 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`.
- 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. - 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. - 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. - 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. - Fixed mined-card overlay notifications so `overlay` and `both` modes show the same generated card thumbnail as system notifications.
+9
View File
@@ -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.
+2
View File
@@ -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. 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:** **Phases:**
1. **checking** - Is there already a cached snapshot for this media ID? 1. **checking** - Is there already a cached snapshot for this media ID?
+3 -3
View File
@@ -232,9 +232,9 @@ Configure where overlay notification cards appear:
#### Notification history panel #### 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 ### 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`) | | `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`. On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`.
@@ -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 - `mpv.pauseUntilOverlayReady` waits for tokenization warmup plus visible-overlay readiness before
releasing the mpv startup gate. releasing the mpv startup gate.
- Cold `--start --background --managed-playback` launches handle initial args before the deferred - Visible-overlay startup creates the tray and visible overlay shell before tokenization and
Yomitan wait, so the tray and visible overlay shell can receive startup notifications while annotation warmups continue. Cold `--start --background --managed-playback` launches still handle
tokenization and annotation warmups continue. 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 - 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. 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 - If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
+15 -1
View File
@@ -351,6 +351,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
}; };
let availabilityConfigDir: string | undefined; let availabilityConfigDir: string | undefined;
let overlayConfigDir: string | undefined; let overlayConfigDir: string | undefined;
let overlayLoadingOsd: boolean | undefined;
try { try {
process.env.XDG_CONFIG_HOME = xdgConfigHome; 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' }), chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {}, checkDependencies: () => {},
registerCleanup: () => {}, registerCleanup: () => {},
startMpv: async () => {}, startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
overlayLoadingOsd = (
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
)?.overlayLoadingOsd;
},
waitForUnixSocketReady: async () => true, waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => { startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
overlayConfigDir = 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(availabilityConfigDir, expectedConfigDir);
assert.equal(overlayConfigDir, expectedConfigDir); assert.equal(overlayConfigDir, expectedConfigDir);
assert.equal(overlayLoadingOsd, true);
} finally { } finally {
if (originalXdgConfigHome === undefined) { if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME; delete process.env.XDG_CONFIG_HOME;
+9
View File
@@ -232,6 +232,14 @@ export async function runPlaybackCommandWithDeps(
? { ...pluginRuntimeConfig, autoStart: false } ? { ...pluginRuntimeConfig, autoStart: false }
: pluginRuntimeConfig; : pluginRuntimeConfig;
const shouldShowOverlayLoadingOsd =
!isAppOwnedYoutubeFlow &&
(pluginRuntimeConfig.autoStartVisibleOverlay || args.startOverlay || args.autoStartOverlay) &&
(pluginRuntimeConfig.autoStart ||
args.startOverlay ||
args.autoStartOverlay ||
shouldLauncherAttachRunningApp);
const shouldPauseUntilOverlayReady = const shouldPauseUntilOverlayReady =
pluginRuntimeConfig.autoStart && pluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay && pluginRuntimeConfig.autoStartVisibleOverlay &&
@@ -266,6 +274,7 @@ export async function runPlaybackCommandWithDeps(
} }
: {}), : {}),
backend: args.backend, backend: args.backend,
overlayLoadingOsd: shouldShowOverlayLoadingOsd,
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled, texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
}, },
}, },
+2
View File
@@ -207,6 +207,7 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
'subminer-backend=x11', 'subminer-backend=x11',
'subminer-auto_start=yes', 'subminer-auto_start=yes',
'subminer-auto_start_visible_overlay=no', 'subminer-auto_start_visible_overlay=no',
'subminer-overlay_loading_osd=no',
'subminer-auto_start_pause_until_ready=yes', 'subminer-auto_start_pause_until_ready=yes',
'subminer-auto_start_pause_until_ready_timeout_seconds=30', 'subminer-auto_start_pause_until_ready_timeout_seconds=30',
'subminer-osd_messages=yes', 'subminer-osd_messages=yes',
@@ -240,6 +241,7 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
'subminer-backend=x11', 'subminer-backend=x11',
'subminer-auto_start=yes', 'subminer-auto_start=yes',
'subminer-auto_start_visible_overlay=no', 'subminer-auto_start_visible_overlay=no',
'subminer-overlay_loading_osd=no',
'subminer-auto_start_pause_until_ready=yes', 'subminer-auto_start_pause_until_ready=yes',
'subminer-auto_start_pause_until_ready_timeout_seconds=30', 'subminer-auto_start_pause_until_ready_timeout_seconds=30',
'subminer-osd_messages=no', 'subminer-osd_messages=no',
+1
View File
@@ -209,6 +209,7 @@ export interface PluginRuntimeConfig {
autoStart: boolean; autoStart: boolean;
autoStartVisibleOverlay: boolean; autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean; autoStartPauseUntilReady: boolean;
overlayLoadingOsd?: boolean;
osdMessages: boolean; osdMessages: boolean;
texthookerEnabled: boolean; texthookerEnabled: boolean;
aniskipEnabled: boolean; aniskipEnabled: boolean;
+24
View File
@@ -112,6 +112,14 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_visible_overlay, false) return options_helper.coerce_bool(raw_visible_overlay, false)
end 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() local function next_auto_start_retry_generation()
state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1 state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1
return state.auto_start_retry_generation 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) and not (state.overlay_running and state.auto_play_ready_signal_seen == true)
end 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) local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
if generation ~= state.auto_start_retry_generation then if generation ~= state.auto_start_retry_generation then
return return
@@ -178,6 +194,7 @@ function M.create(ctx)
.. process.describe_mpv_ipc_socket_match(opts.socket_path) .. process.describe_mpv_ipc_socket_match(opts.socket_path)
.. ")" .. ")"
) )
process.stop_overlay_loading_osd()
schedule_aniskip_fetch("file-loaded", 0) schedule_aniskip_fetch("file-loaded", 0)
return return
end end
@@ -192,6 +209,9 @@ function M.create(ctx)
end end
local function on_start_file() 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 if state.pending_reload_media_identity ~= nil then
local media_identity = resolve_media_identity() local media_identity = resolve_media_identity()
if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then
@@ -245,6 +265,7 @@ function M.create(ctx)
end end
if same_media_reload then if same_media_reload then
process.stop_overlay_loading_osd()
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
if state.app_managed_playback_active then if state.app_managed_playback_active then
return return
@@ -273,6 +294,7 @@ function M.create(ctx)
end end
if state.app_managed_playback_active then 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") subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload")
return return
end end
@@ -291,6 +313,7 @@ function M.create(ctx)
aniskip.clear_aniskip_state() aniskip.clear_aniskip_state()
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
process.stop_overlay_loading_osd()
clear_pending_visible_overlay_hide() clear_pending_visible_overlay_hide()
state.auto_play_ready_signal_seen = false state.auto_play_ready_signal_seen = false
state.current_media_identity = nil state.current_media_identity = nil
@@ -310,6 +333,7 @@ function M.create(ctx)
hover.clear_hover_overlay() hover.clear_hover_overlay()
end) end)
mp.register_event("end-file", function(event) mp.register_event("end-file", function(event)
process.stop_overlay_loading_osd()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
hover.clear_hover_overlay() hover.clear_hover_overlay()
local reason = type(event) == "table" and event.reason or nil local reason = type(event) == "table" and event.reason or nil
+2 -2
View File
@@ -43,8 +43,8 @@ function M.create(ctx)
end end
end end
local function show_osd(message) local function show_osd(message, options)
if opts.osd_messages then if opts.osd_messages or (options and options.force == true) then
local payload = "SubMiner: " .. message local payload = "SubMiner: " .. message
local sent = false local sent = false
if type(mp.osd_message) == "function" then if type(mp.osd_message) == "function" then
+3
View File
@@ -44,6 +44,9 @@ function M.create(ctx)
mp.register_script_message("subminer-autoplay-ready", function() mp.register_script_message("subminer-autoplay-ready", function()
process.notify_auto_play_ready() process.notify_auto_play_ready()
end) end)
mp.register_script_message("subminer-overlay-loading-ready", function()
process.stop_overlay_loading_osd()
end)
mp.register_script_message("subminer-aniskip-refresh", function() mp.register_script_message("subminer-aniskip-refresh", function()
aniskip.fetch_aniskip_for_current_media("script-message") aniskip.fetch_aniskip_for_current_media("script-message")
end) end)
+1
View File
@@ -32,6 +32,7 @@ function M.load(options_lib, default_socket_path)
backend = "auto", backend = "auto",
auto_start = false, auto_start = false,
auto_start_visible_overlay = false, auto_start_visible_overlay = false,
overlay_loading_osd = false,
auto_start_pause_until_ready = true, auto_start_pause_until_ready = true,
auto_start_pause_until_ready_owns_initial_pause = false, auto_start_pause_until_ready_owns_initial_pause = false,
auto_start_pause_until_ready_timeout_seconds = 30, auto_start_pause_until_ready_timeout_seconds = 30,
+61 -2
View File
@@ -4,6 +4,9 @@ local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_START_MAX_ATTEMPTS = 6 local OVERLAY_START_MAX_ATTEMPTS = 6
local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20 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_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30 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) return options_helper.coerce_bool(raw_pause_until_ready, false)
end 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 function resolve_pause_until_ready_owns_initial_pause()
local raw_owns_initial_pause = opts.auto_start_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 if raw_owns_initial_pause == nil then
@@ -246,6 +257,42 @@ function M.create(ctx)
state.auto_play_ready_osd_timer = nil state.auto_play_ready_osd_timer = nil
end 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 function disarm_auto_play_ready_gate(options)
local should_resume = options == nil or options.resume_playback ~= false local should_resume = options == nil or options.resume_playback ~= false
local was_armed = state.auto_play_ready_gate_armed local was_armed = state.auto_play_ready_gate_armed
@@ -264,8 +311,11 @@ function M.create(ctx)
return false return false
end end
local should_resume_playback = state.auto_play_ready_should_resume_playback == true local should_resume_playback = state.auto_play_ready_should_resume_playback == true
disarm_auto_play_ready_gate({ resume_playback = false }) if resolve_osd_messages_enabled() then
stop_overlay_loading_osd()
show_osd(AUTO_PLAY_READY_READY_OSD) show_osd(AUTO_PLAY_READY_READY_OSD)
end
disarm_auto_play_ready_gate({ resume_playback = false })
if should_resume_playback then if should_resume_playback then
mp.set_property_native("pause", false) mp.set_property_native("pause", false)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
@@ -287,8 +337,11 @@ function M.create(ctx)
end end
state.auto_play_ready_gate_armed = true state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true) mp.set_property_native("pause", true)
if resolve_osd_messages_enabled() then
stop_overlay_loading_osd()
show_osd(AUTO_PLAY_READY_LOADING_OSD) show_osd(AUTO_PLAY_READY_LOADING_OSD)
if type(mp.add_periodic_timer) == "function" then 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() state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
if state.auto_play_ready_gate_armed then if state.auto_play_ready_gate_armed then
show_osd(AUTO_PLAY_READY_LOADING_OSD) show_osd(AUTO_PLAY_READY_LOADING_OSD)
@@ -543,6 +596,7 @@ function M.create(ctx)
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
stop_overlay_loading_osd()
show_osd("Error: binary not found") show_osd("Error: binary not found")
return return
end end
@@ -627,6 +681,7 @@ function M.create(ctx)
state.overlay_running = false state.overlay_running = false
state.auto_play_ready_signal_seen = false state.auto_play_ready_signal_seen = false
subminer_log("error", "process", "Overlay start failed after retries: " .. reason) subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
stop_overlay_loading_osd()
show_osd("Overlay start failed") show_osd("Overlay start failed")
release_auto_play_ready_gate("overlay-start-failed") release_auto_play_ready_gate("overlay-start-failed")
return return
@@ -679,6 +734,7 @@ function M.create(ctx)
state.overlay_running = false state.overlay_running = false
state.texthooker_running = false state.texthooker_running = false
state.auto_play_ready_signal_seen = false state.auto_play_ready_signal_seen = false
stop_overlay_loading_osd()
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate()
show_osd("Stopped") show_osd("Stopped")
end end
@@ -690,6 +746,7 @@ function M.create(ctx)
return return
end end
state.suppress_ready_overlay_restore = true state.suppress_ready_overlay_restore = true
stop_overlay_loading_osd()
run_control_command_async("hide-visible-overlay", nil, function(ok, result) run_control_command_async("hide-visible-overlay", nil, function(ok, result)
if ok then if ok then
@@ -893,6 +950,8 @@ function M.create(ctx)
check_binary_available = check_binary_available, check_binary_available = check_binary_available,
notify_auto_play_ready = notify_auto_play_ready, notify_auto_play_ready = notify_auto_play_ready,
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate, 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 end
+3
View File
@@ -35,6 +35,9 @@ function M.new()
auto_play_ready_osd_timer = nil, auto_play_ready_osd_timer = nil,
auto_play_ready_signal_seen = false, auto_play_ready_signal_seen = false,
auto_play_ready_initial_pause_ownership_consumed = 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_timer = nil,
pending_visible_overlay_hide_generation = 0, pending_visible_overlay_hide_generation = 0,
suppress_ready_overlay_restore = false, suppress_ready_overlay_restore = false,
+110
View File
@@ -979,6 +979,31 @@ do
) )
end 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 do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1695,6 +1720,91 @@ do
) )
end 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 do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
+6 -11
View File
@@ -346,20 +346,15 @@ test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomit
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs')); assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
}); });
test('runAppReadyRuntime starts background warmups before core runtime services', async () => { test('runAppReadyRuntime starts background warmups after overlay startup', async () => {
const calls: string[] = []; const { deps, calls } = makeDeps();
const { deps } = makeDeps({
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
},
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
createMpvClient: () => calls.push('createMpvClient'),
});
await runAppReadyRuntime(deps); await runAppReadyRuntime(deps);
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition')); assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient')); 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 () => { test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
+65 -2
View File
@@ -211,7 +211,70 @@ test('macOS dismisses overlay loading OSD when tracker recovers', () => {
assert.ok(calls.includes('show-inactive')); 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(); const { window, calls } = createMainWindowRecorder();
let trackerWarning = false; let trackerWarning = false;
const tracker: WindowTrackerStub = { 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('update-bounds'));
assert.ok(!calls.includes('show')); assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus')); 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', () => { test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
+18 -4
View File
@@ -311,8 +311,18 @@ export function updateVisibleOverlayVisibility(args: {
!args.isWindowsPlatform && !args.isWindowsPlatform &&
(!args.forceMousePassthrough || args.isMacOSPlatform === true); (!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 => { const maybeShowOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) { if (!args.showOverlayLoadingOsd) {
return; return;
} }
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) { if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
@@ -322,9 +332,6 @@ export function updateVisibleOverlayVisibility(args: {
args.markOverlayLoadingOsdShown?.(); args.markOverlayLoadingOsdShown?.();
}; };
const maybeDismissOverlayLoadingOsd = (): void => { const maybeDismissOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform) {
return;
}
args.dismissOverlayLoadingOsd?.(); args.dismissOverlayLoadingOsd?.();
}; };
@@ -379,8 +386,15 @@ export function updateVisibleOverlayVisibility(args: {
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
return; return;
} }
if (isWaitingForOverlayContentReady()) {
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd();
}
} else {
args.setTrackerNotReadyWarningShown(false); args.setTrackerNotReadyWarningShown(false);
maybeDismissOverlayLoadingOsd(); maybeDismissOverlayLoadingOsd();
}
const geometry = args.windowTracker.getGeometry(); const geometry = args.windowTracker.getGeometry();
if (geometry) { if (geometry) {
args.updateVisibleOverlayBounds(geometry); args.updateVisibleOverlayBounds(geometry);
+2
View File
@@ -116,6 +116,7 @@ export function createOverlayWindow(
linuxX11FullscreenOverlay?: boolean; linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void; onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void; onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void; onWindowContentReady?: () => void;
onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void; onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void;
yomitanSession?: Session | null; yomitanSession?: Session | null;
@@ -139,6 +140,7 @@ export function createOverlayWindow(
window.webContents.on('did-finish-load', () => { window.webContents.on('did-finish-load', () => {
window.setTitle(OVERLAY_WINDOW_TITLES[kind]); window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
options.onRuntimeOptionsChanged(); options.onRuntimeOptionsChanged();
options.onWindowDidFinishLoad?.();
}); });
window.webContents.on('page-title-updated', (event) => { window.webContents.on('page-title-updated', (event) => {
+4 -3
View File
@@ -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[] = []; const calls: string[] = [];
await runAppReadyRuntime({ await runAppReadyRuntime({
@@ -354,9 +354,10 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
shouldSkipHeavyStartup: () => false, shouldSkipHeavyStartup: () => false,
}); });
assert.ok(calls.indexOf('load-yomitan') !== -1);
assert.ok(calls.indexOf('init-overlay') !== -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 () => { test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
+12 -3
View File
@@ -232,6 +232,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension; deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
let firstRunSetupHandled = false; let firstRunSetupHandled = false;
let initialArgsHandled = false; let initialArgsHandled = false;
let backgroundWarmupsHandled = false;
const handleFirstRunSetupOnce = async (): Promise<void> => { const handleFirstRunSetupOnce = async (): Promise<void> => {
if (firstRunSetupHandled) { if (firstRunSetupHandled) {
return; return;
@@ -246,6 +247,13 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
initialArgsHandled = true; initialArgsHandled = true;
deps.handleInitialArgs(); deps.handleInitialArgs();
}; };
const startBackgroundWarmupsOnce = (): void => {
if (backgroundWarmupsHandled) {
return;
}
backgroundWarmupsHandled = true;
deps.startBackgroundWarmups();
};
deps.ensureDefaultConfigBootstrap(); deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) { if (deps.shouldRunHeadlessInitialCommand?.()) {
@@ -297,8 +305,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
for (const warning of deps.getConfigWarnings()) { for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning); deps.logConfigWarning(warning);
} }
deps.startBackgroundWarmups();
deps.loadSubtitlePosition(); deps.loadSubtitlePosition();
deps.resolveKeybindings(); deps.resolveKeybindings();
deps.createMpvClient(); deps.createMpvClient();
@@ -344,16 +350,19 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.texthookerOnlyMode) { if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.'); deps.log('Texthooker-only mode enabled; skipping overlay window.');
startBackgroundWarmupsOnce();
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) { } else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
await ensureYomitanExtensionReady();
deps.setVisibleOverlayVisible(true); deps.setVisibleOverlayVisible(true);
deps.initializeOverlayRuntime(); deps.initializeOverlayRuntime();
startBackgroundWarmupsOnce();
} else { } else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.'); deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) { if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) {
await handleFirstRunSetupOnce(); await handleFirstRunSetupOnce();
handleInitialArgsOnce(); handleInitialArgsOnce();
startBackgroundWarmupsOnce();
} else { } else {
startBackgroundWarmupsOnce();
await ensureYomitanExtensionReady(); await ensureYomitanExtensionReady();
} }
} }
+174 -15
View File
@@ -62,6 +62,7 @@ import {
type ForegroundSuppressionGraceState, type ForegroundSuppressionGraceState,
mapOverlayMeasurementForPointerInteraction, mapOverlayMeasurementForPointerInteraction,
resolveForegroundSuppressionWithGrace, resolveForegroundSuppressionWithGrace,
shouldPrimeLinuxOverlayInteractionFromMeasurement,
tickLinuxOverlayPointerInteraction, tickLinuxOverlayPointerInteraction,
} from './main/runtime/linux-overlay-pointer-interaction'; } from './main/runtime/linux-overlay-pointer-interaction';
import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point'; import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point';
@@ -608,7 +609,10 @@ import {
notifyUpdateAvailable, notifyUpdateAvailable,
UPDATE_AVAILABLE_NOTIFICATION_ID, UPDATE_AVAILABLE_NOTIFICATION_ID,
} from './main/runtime/update/update-notifications'; } from './main/runtime/update/update-notifications';
import { createOverlayLoadingOsdController } from './main/runtime/overlay-loading-osd';
import { createMaybeStartOverlayLoadingOsdHandler } from './main/runtime/overlay-loading-osd-start';
import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position'; import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position';
import { createOverlayNotificationDelivery } from './main/runtime/overlay-notification-delivery';
import { import {
getPlaybackFeedbackNotificationOptions, getPlaybackFeedbackNotificationOptions,
notifyConfiguredStatus, notifyConfiguredStatus,
@@ -1310,7 +1314,6 @@ const autoplayReadyGate = createAutoplayReadyGate({
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest); broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
}, },
isSignalTargetReady: (signal) => isSignalTargetReady: (signal) =>
isTokenizationWarmupReady() &&
isVisibleOverlayAutoplayTargetReady( isVisibleOverlayAutoplayTargetReady(
{ {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
@@ -1943,6 +1946,8 @@ let subtitleSidebarRequestedOpen = false;
const SEEK_THRESHOLD_SECONDS = 3; const SEEK_THRESHOLD_SECONDS = 3;
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2; const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
let autoplaySubtitlePrimedMediaPath: string | null = null; let autoplaySubtitlePrimedMediaPath: string | null = null;
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null = null;
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
function getCurrentAutoplayMediaPath(): string | null { function getCurrentAutoplayMediaPath(): string | null {
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null; return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
@@ -2017,6 +2022,7 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
subtitlePrefetchService?.onSeek(lastObservedTimePos); subtitlePrefetchService?.onSeek(lastObservedTimePos);
subtitleProcessingController.refreshCurrentSubtitle(text); subtitleProcessingController.refreshCurrentSubtitle(text);
}, },
deferUncachedRefresh: true,
emitSubtitle: (payload) => emitSubtitlePayload(payload), emitSubtitle: (payload) => emitSubtitlePayload(payload),
setCurrentSecondarySubText: (text) => { setCurrentSecondarySubText: (text) => {
if (appState.mpvClient) { if (appState.mpvClient) {
@@ -2032,6 +2038,38 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
}); });
} }
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( async function primeAutoplaySubtitleFromParsedCues(
mediaPath: string, mediaPath: string,
cues: SubtitleCue[], cues: SubtitleCue[],
@@ -2663,7 +2701,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
showOverlayLoadingStatusNotification(message); showOverlayLoadingStatusNotification(message);
}, },
dismissOverlayLoadingOsd: () => { dismissOverlayLoadingOsd: () => {
dismissOverlayNotification('overlay-loading-status'); dismissOverlayLoadingStatusNotification();
}, },
hideNonNativeOverlayWhenTargetUnfocused: () => hideNonNativeOverlayWhenTargetUnfocused: () =>
shouldRunLinuxOverlayZOrderKeepAlive() && 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 // 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). // X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s).
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500; 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; const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = []; let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
@@ -2706,6 +2745,8 @@ const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState =
}; };
let visibleOverlayInteractionActive = false; let visibleOverlayInteractionActive = false;
let linuxOverlayInputShapeActive = false; let linuxOverlayInputShapeActive = false;
let linuxVisibleOverlayStartupInputPrimed = false;
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal // 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 // region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
// moves off measured subtitle/sidebar rects onto the popup. // moves off measured subtitle/sidebar rects onto the popup.
@@ -2728,6 +2769,7 @@ const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHa
function resetVisibleOverlayInputState(): void { function resetVisibleOverlayInputState(): void {
visibleOverlayInteractionActive = false; visibleOverlayInteractionActive = false;
linuxOverlayInputShapeActive = false; linuxOverlayInputShapeActive = false;
resetLinuxVisibleOverlayStartupInputPrimer();
linuxOverlayInteractiveHint = false; linuxOverlayInteractiveHint = false;
overlayContentMeasurementStore.clear('visible'); overlayContentMeasurementStore.clear('visible');
const mainWindow = overlayManager.getMainWindow(); const mainWindow = overlayManager.getMainWindow();
@@ -3200,6 +3242,23 @@ function shouldUseLinuxOverlayInputShape(): boolean {
return false; 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 { function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
if (!shouldUseLinuxOverlayInputShape()) { if (!shouldUseLinuxOverlayInputShape()) {
linuxOverlayInputShapeActive = false; linuxOverlayInputShapeActive = false;
@@ -3238,6 +3297,28 @@ function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); 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 = { const linuxOverlayZOrderKeepAliveDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
@@ -3298,7 +3379,8 @@ const linuxOverlayPointerInteractionDeps = {
getCursorScreenPoint: () => getCursorScreenPoint: () =>
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()), linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
getRendererInteractiveHint: () => linuxOverlayInteractiveHint, getRendererInteractiveHint: () =>
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
shouldUseInputShape: shouldUseLinuxOverlayInputShape, shouldUseInputShape: shouldUseLinuxOverlayInputShape,
@@ -3355,8 +3437,38 @@ function getConfiguredStatusNotificationType(): NotificationType {
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady()); return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
} }
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void { 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); broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
},
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
});
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
null;
function flushQueuedOverlayNotifications(): void {
overlayNotificationDelivery.flush();
}
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
overlayNotificationDelivery.send(payload);
} }
function showOverlayNotification(payload: OverlayNotificationPayload): void { function showOverlayNotification(payload: OverlayNotificationPayload): void {
@@ -3429,16 +3541,47 @@ function showYoutubeFlowStatusNotification(message: string): void {
}); });
} }
function showOverlayLoadingStatusNotification(message: string): void { function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
showConfiguredStatusNotification(message, { if (!overlayLoadingOsdController) {
id: 'overlay-loading-status', overlayLoadingOsdController = createOverlayLoadingOsdController({
title: 'SubMiner', showOsd: (message) => {
variant: 'progress', showMpvOsd(message);
persistent: true, },
desktop: false, 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<typeof setInterval>);
},
}); });
}
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 = const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
broadcastRuntimeOptionsChangedRuntime, broadcastRuntimeOptionsChangedRuntime,
@@ -5191,6 +5334,7 @@ const {
void reportJellyfinRemoteStopped(); void reportJellyfinRemoteStopped();
}, },
onMpvConnected: () => { onMpvConnected: () => {
maybeStartOverlayLoadingOsd();
if (appState.sessionBindingsInitialized) { if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [ sendMpvCommandRuntime(appState.mpvClient, [
'script-message', 'script-message',
@@ -5228,6 +5372,7 @@ const {
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
updateCurrentMediaPath: (path) => { updateCurrentMediaPath: (path) => {
const normalizedPath = path.trim(); const normalizedPath = path.trim();
maybeStartOverlayLoadingOsd(normalizedPath);
const previousPath = appState.currentMediaPath?.trim() || null; const previousPath = appState.currentMediaPath?.trim() || null;
const preserveParsedSubtitleCues = isSameYoutubeMediaPath( const preserveParsedSubtitleCues = isSameYoutubeMediaPath(
normalizedPath, normalizedPath,
@@ -6861,6 +7006,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
currentSubText: appState.currentSubText, currentSubText: appState.currentSubText,
currentSubtitleData: appState.currentSubtitleData, currentSubtitleData: appState.currentSubtitleData,
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload), withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
tokenizeUncached: false,
tokenizeSubtitle: tokenizeSubtitleForCurrent tokenizeSubtitle: tokenizeSubtitleForCurrent
? (text) => tokenizeSubtitleForCurrent(text) ? (text) => tokenizeSubtitleForCurrent(text)
: undefined, : undefined,
@@ -7014,7 +7160,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
reportOverlayContentBounds: (payload: unknown) => { reportOverlayContentBounds: (payload: unknown) => {
if (overlayContentMeasurementStore.report(payload)) { if (overlayContentMeasurementStore.report(payload)) {
tickLinuxOverlayPointerInteractionNow(); tickLinuxOverlayPointerInteractionNow();
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
autoplayReadyGate.flushPendingAutoplayReadySignal(); autoplayReadyGate.flushPendingAutoplayReadySignal();
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
} }
}, },
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
@@ -7384,12 +7532,14 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
linuxVisibleOverlayWindowMode === 'fullscreen-override', linuxVisibleOverlayWindowMode === 'fullscreen-override',
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(), onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(),
onWindowDidFinishLoad: () => {
flushQueuedOverlayNotifications();
},
onWindowContentReady: () => { onWindowContentReady: () => {
dismissOverlayNotification('overlay-loading-status'); dismissOverlayLoadingStatusNotification();
flushQueuedOverlayNotifications();
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
if (appState.currentSubText.trim()) { primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
autoplayReadyGate.flushPendingAutoplayReadySignal(); autoplayReadyGate.flushPendingAutoplayReadySignal();
}, },
onWindowClosed: (windowKind, window) => { onWindowClosed: (windowKind, window) => {
@@ -7669,10 +7819,13 @@ function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) { if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady(); autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
resetVisibleOverlayInputState(); resetVisibleOverlayInputState();
} }
if (visible) { if (visible) {
maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer();
restoreVisibleOverlayWindowShapeForShow(); restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay(); void primeCurrentSubtitleForVisibleOverlay();
@@ -7687,9 +7840,12 @@ function toggleVisibleOverlay(): void {
const nextVisible = !overlayManager.getVisibleOverlayVisible(); const nextVisible = !overlayManager.getVisibleOverlayVisible();
if (!nextVisible) { if (!nextVisible) {
autoplayReadyGate.markCurrentMediaAutoplayReady(); autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
resetVisibleOverlayInputState(); resetVisibleOverlayInputState();
} else { } else {
maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer();
restoreVisibleOverlayWindowShapeForShow(); restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay(); void primeCurrentSubtitleForVisibleOverlay();
@@ -7700,11 +7856,14 @@ function toggleVisibleOverlay(): void {
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
if (!visible) { if (!visible) {
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
resetVisibleOverlayInputState(); resetVisibleOverlayInputState();
autoplayReadyGate.markCurrentMediaAutoplayReady(); autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} }
if (visible) { if (visible) {
maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer();
restoreVisibleOverlayWindowShapeForShow(); restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay(); void primeCurrentSubtitleForVisibleOverlay();
+89 -9
View File
@@ -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*\{(?<body>[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/,
)?.groups?.body;
const mediaPathBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
)?.groups?.body;
const setVisibleBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\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 \{(?<body>[\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', () => { test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
@@ -68,7 +112,7 @@ test('manual visible overlay toggles only release current-media autoplay when hi
assert.ok(actionBlock); assert.ok(actionBlock);
assert.match( assert.match(
actionBlock, 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.ok(setOverlayBlock);
assert.match( assert.match(
setVisibleBlock, setVisibleBlock,
/if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/, /if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
); );
assert.match( assert.match(
toggleBlock, toggleBlock,
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/, /if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
); );
assert.match( assert.match(
setOverlayBlock, 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 source = readMainSource();
const gateBlock = source.match( const gateBlock = source.match(
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/, /const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
@@ -180,7 +224,7 @@ test('startup autoplay release is tied to tokenization and visible overlay measu
assert.ok(gateBlock); assert.ok(gateBlock);
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/); assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
assert.match(gateBlock, /isTokenizationWarmupReady\(\)/); assert.doesNotMatch(gateBlock, /isTokenizationWarmupReady\(\)/);
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/); assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
assert.match(gateBlock, /getLatestVisibleMeasurement:/); 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\(\)/); 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*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
const measurementBlock = source.match(
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\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', () => { test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
const source = readMainSource(); const source = readMainSource();
const measurementBlock = source.match( const measurementBlock = source.match(
@@ -198,10 +273,15 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
assert.ok(measurementBlock); assert.ok(measurementBlock);
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/); assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/); assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
assert.ok( assert.ok(
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') < measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'), measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'),
); );
assert.ok(
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
);
}); });
test('subtitle sidebar open state is restored for replacement visible overlay windows', () => { 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.ok(toggleBlock);
assert.match( assert.match(
setBlock, 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( assert.match(
toggleBlock, 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.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.match( assert.match(
setBlock, setBlock,
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
); );
}); });
@@ -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[] = []; const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), { 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 ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
showOverlayNotification: (payload) => showOverlayNotification: (payload) =>
calls.push( 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'), { 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 ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
showOverlayNotification: (payload) => showOverlayNotification: (payload) =>
calls.push( 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, [ assert.deepEqual(calls, [
'overlay:character-dictionary-auto-sync:Character dictionary:syncing:pin', 'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-syncing:Character dictionary:syncing:pin',
'overlay:character-dictionary-auto-sync:Character dictionary:ready:auto', 'desktop:SubMiner:syncing',
'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-ready:Character dictionary:ready:auto',
'desktop:SubMiner:ready',
]); ]);
}); });
@@ -29,25 +29,29 @@ function overlayVariantForPhase(
return 'progress'; 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( export function notifyCharacterDictionaryAutoSyncStatus(
event: CharacterDictionaryAutoSyncNotificationEvent, event: CharacterDictionaryAutoSyncNotificationEvent,
deps: CharacterDictionaryAutoSyncNotificationDeps, deps: CharacterDictionaryAutoSyncNotificationDeps,
): void { ): void {
const type = deps.getNotificationType() ?? 'overlay'; const type = deps.getNotificationType() ?? 'overlay';
if (type === 'none') return; if (type === 'none') return;
let overlayShown = false;
let startupSequencerShown = false; let startupSequencerShown = false;
if (shouldShowOverlay(type)) { if (shouldShowOverlay(type)) {
if (deps.showOverlayNotification) { if (deps.showOverlayNotification) {
deps.showOverlayNotification({ deps.showOverlayNotification({
id: 'character-dictionary-auto-sync', id: 'character-dictionary-auto-sync',
historyId: historyIdForEvent(event),
title: 'Character dictionary', title: 'Character dictionary',
body: event.message, body: event.message,
variant: overlayVariantForPhase(event.phase), variant: overlayVariantForPhase(event.phase),
persistent: !isTerminalPhase(event.phase), persistent: !isTerminalPhase(event.phase),
}); });
overlayShown = true;
} else if (!shouldShowDesktop(type)) { } else if (!shouldShowDesktop(type)) {
deps.showDesktopNotification('SubMiner', { body: event.message }); 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 }); deps.showDesktopNotification('SubMiner', { body: event.message });
} }
} }
@@ -4,6 +4,7 @@ import {
getPlaybackFeedbackNotificationOptions, getPlaybackFeedbackNotificationOptions,
notifyConfiguredStatus, notifyConfiguredStatus,
} from './configured-status-notification'; } from './configured-status-notification';
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
test('notifyConfiguredStatus routes both to overlay and system without osd', () => { test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
const calls: string[] = []; 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[] = []; const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', { notifyConfiguredStatus('Overlay loading...', {
@@ -42,7 +43,25 @@ test('notifyConfiguredStatus routes pre-overlay both status to osd and desktop',
calls.push(`desktop:${title}:${options.body ?? ''}`), 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', () => { 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.']); 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']);
});
@@ -49,9 +49,7 @@ export function notifyConfiguredStatus(
return; return;
} }
const overlayReady = deps.isOverlayReady?.() !== false; if (showOverlay) {
if (showOverlay && overlayReady) {
if (deps.showOverlayNotification) { if (deps.showOverlayNotification) {
deps.showOverlayNotification({ deps.showOverlayNotification({
id: options.id, id: options.id,
@@ -63,8 +61,6 @@ export function notifyConfiguredStatus(
} else if (desktopEnabled && !shouldShowDesktop(type)) { } else if (desktopEnabled && !shouldShowDesktop(type)) {
deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message }); deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message });
} }
} else if (showOverlay && !showOsd) {
deps.showOsd(message);
} }
if (showOsd) { if (showOsd) {
@@ -62,6 +62,25 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token
assert.deepEqual(payload.tokens, [{ text: '新' }]); 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 () => { test('renderer current subtitle snapshot reports resolved payload for startup readiness', async () => {
const resolvedPayloads: SubtitleData[] = []; const resolvedPayloads: SubtitleData[] = [];
const payload = await resolveCurrentSubtitleForRenderer({ 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:国内外から']); 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 () => { test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => {
const calls: string[] = []; const calls: string[] = [];
const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] }; const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] };
@@ -10,6 +10,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
currentSubtitleData: SubtitleData | null; currentSubtitleData: SubtitleData | null;
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData; withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
tokenizeUncached?: boolean;
onResolvedSubtitle?: (payload: SubtitleData) => void; onResolvedSubtitle?: (payload: SubtitleData) => void;
}): Promise<SubtitleData> { }): Promise<SubtitleData> {
const resolve = (payload: SubtitleData): SubtitleData => { const resolve = (payload: SubtitleData): SubtitleData => {
@@ -29,10 +30,12 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
}); });
} }
if (deps.tokenizeUncached !== false) {
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText); const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
if (tokenized) { if (tokenized) {
return resolve(tokenized); return resolve(tokenized);
} }
}
return resolve({ return resolve({
text: deps.currentSubText, text: deps.currentSubText,
@@ -48,6 +51,7 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
onSubtitleChange: (text: string) => void; onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: (text: string) => void; refreshCurrentSubtitle: (text: string) => void;
emitSubtitle: (payload: SubtitleData) => void; emitSubtitle: (payload: SubtitleData) => void;
deferUncachedRefresh?: boolean;
setCurrentSecondarySubText?: (text: string) => void; setCurrentSecondarySubText?: (text: string) => void;
emitSecondarySubtitle?: (text: string) => void; emitSecondarySubtitle?: (text: string) => void;
logDebug?: (message: string) => void; logDebug?: (message: string) => void;
@@ -114,6 +118,11 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
return; return;
} }
if (deps.deferUncachedRefresh === true) {
await primeSecondarySubtitle();
return;
}
deps.refreshCurrentSubtitle(text); deps.refreshCurrentSubtitle(text);
await primeSecondarySubtitle(); await primeSecondarySubtitle();
} }
@@ -10,6 +10,7 @@ import {
resolveDesiredOverlayInteractive, resolveDesiredOverlayInteractive,
resolveForegroundSuppressionWithGrace, resolveForegroundSuppressionWithGrace,
shouldSuppressPointerInteractionForForegroundWindow, shouldSuppressPointerInteractionForForegroundWindow,
shouldPrimeLinuxOverlayInteractionFromMeasurement,
tickLinuxOverlayPointerInteraction, tickLinuxOverlayPointerInteraction,
} from './linux-overlay-pointer-interaction'; } 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', () => { test('mapOverlayMeasurementForPointerInteraction preserves renderer interactive rects', () => {
const mapped = mapOverlayMeasurementForPointerInteraction({ const mapped = mapOverlayMeasurementForPointerInteraction({
layer: 'visible', layer: 'visible',
@@ -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 { function clampRectToWindow(rect: PointerRect, bounds: PointerRect): PointerRect | null {
const left = Math.max(0, Math.floor(rect.x)); const left = Math.max(0, Math.floor(rect.x));
const top = Math.max(0, Math.floor(rect.y)); const top = Math.max(0, Math.floor(rect.y));
@@ -11,12 +11,12 @@ test('notification routing preserves system notification while overlay is not re
assert.equal(resolveOverlayReadinessNotificationType('system', false), 'system'); assert.equal(resolveOverlayReadinessNotificationType('system', false), 'system');
}); });
test('notification routing preserves both as osd plus system while overlay is not ready', () => { test('notification routing preserves both while overlay is not ready', () => {
assert.equal(resolveOverlayReadinessNotificationType('both', false), 'osd-system'); assert.equal(resolveOverlayReadinessNotificationType('both', false), 'both');
}); });
test('notification routing falls back overlay-only notification to osd while overlay is not ready', () => { test('notification routing preserves overlay-only notification while overlay is not ready', () => {
assert.equal(resolveOverlayReadinessNotificationType('overlay', false), 'osd'); assert.equal(resolveOverlayReadinessNotificationType('overlay', false), 'overlay');
}); });
test('notification routing predicates classify delivery channels', () => { test('notification routing predicates classify delivery channels', () => {
+1 -10
View File
@@ -14,16 +14,7 @@ export function shouldShowDesktop(type: NotificationType): boolean {
export function resolveOverlayReadinessNotificationType( export function resolveOverlayReadinessNotificationType(
type: NotificationType, type: NotificationType,
overlayReady: boolean, _overlayReady: boolean,
): NotificationType { ): NotificationType {
if (overlayReady) {
return type;
}
if (type === 'overlay') {
return 'osd';
}
if (type === 'both') {
return 'osd-system';
}
return type; return type;
} }
@@ -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']);
});
@@ -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();
};
}
@@ -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']);
});
+49
View File
@@ -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<typeof setInterval>));
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();
},
};
}
@@ -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<OverlayNotificationEventPayload, { dismiss: true }> {
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<string>();
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,
};
}
@@ -14,6 +14,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
linuxX11FullscreenOverlay?: boolean; linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void; onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void; onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void; onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void; onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
yomitanSession?: Session | null; yomitanSession?: Session | null;
@@ -29,6 +30,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
getLinuxX11FullscreenOverlay?: () => boolean; getLinuxX11FullscreenOverlay?: () => boolean;
onVisibleWindowBlurred?: () => void; onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void; onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void; onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void; onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
getYomitanSession?: () => Session | null; getYomitanSession?: () => Session | null;
@@ -45,6 +47,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay, getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred, onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onVisibleWindowFocused: deps.onVisibleWindowFocused, onVisibleWindowFocused: deps.onVisibleWindowFocused,
onWindowDidFinishLoad: deps.onWindowDidFinishLoad,
onWindowContentReady: deps.onWindowContentReady, onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed, onWindowClosed: deps.onWindowClosed,
getYomitanSession: () => deps.getYomitanSession?.() ?? null, getYomitanSession: () => deps.getYomitanSession?.() ?? null,
@@ -16,6 +16,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
linuxX11FullscreenOverlay?: boolean; linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void; onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void; onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void; onWindowContentReady?: () => void;
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void; onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
yomitanSession?: Session | null; yomitanSession?: Session | null;
@@ -31,6 +32,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
getLinuxX11FullscreenOverlay?: () => boolean; getLinuxX11FullscreenOverlay?: () => boolean;
onVisibleWindowBlurred?: () => void; onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void; onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void; onWindowContentReady?: () => void;
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void; onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
getYomitanSession?: () => Session | null; getYomitanSession?: () => Session | null;
@@ -48,6 +50,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
kind === 'visible' ? deps.getLinuxX11FullscreenOverlay?.() : undefined, kind === 'visible' ? deps.getLinuxX11FullscreenOverlay?.() : undefined,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred, onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onVisibleWindowFocused: deps.onVisibleWindowFocused, onVisibleWindowFocused: deps.onVisibleWindowFocused,
onWindowDidFinishLoad: deps.onWindowDidFinishLoad,
onWindowContentReady: deps.onWindowContentReady, onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed, onWindowClosed: deps.onWindowClosed,
yomitanSession: deps.getYomitanSession?.() ?? null, yomitanSession: deps.getYomitanSession?.() ?? null,
@@ -161,6 +161,34 @@ test('startup OSD reset keeps tokenization ready after first warmup', () => {
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']); 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', () => { test('startup OSD shows later dictionary progress immediately once tokenization is ready', () => {
const osdMessages: string[] = []; const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({ const sequencer = createStartupOsdSequencer({
@@ -135,7 +135,9 @@ export function createStartupOsdSequencer(deps: StartupOsdSequencerDeps): {
return { return {
reset: () => { reset: () => {
tokenizationReady = tokenizationWarmupCompleted; tokenizationReady = tokenizationWarmupCompleted;
if (tokenizationWarmupCompleted) {
tokenizationLoadingShown = false; tokenizationLoadingShown = false;
}
annotationLoadingMessage = null; annotationLoadingMessage = null;
pendingDictionaryProgress = null; pendingDictionaryProgress = null;
pendingDictionaryFailure = null; pendingDictionaryFailure = null;
@@ -3,6 +3,7 @@ import test from 'node:test';
import type { OverlayNotificationEntry } from './overlay-notifications'; import type { OverlayNotificationEntry } from './overlay-notifications';
import { import {
createOverlayNotificationHistoryPanel,
createOverlayNotificationHistoryStore, createOverlayNotificationHistoryStore,
resolveHistorySideFromStack, resolveHistorySideFromStack,
} from './overlay-notification-history'; } 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); 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', () => { test('history store removes and clears entries', () => {
const store = createOverlayNotificationHistoryStore(); const store = createOverlayNotificationHistoryStore();
store.record(entry({ id: 'a' })); 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. // Center notifications open the panel from the right.
assert.equal(resolveHistorySideFromStack(stackWith('position-top')), '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);
});
+10 -6
View File
@@ -35,9 +35,10 @@ function normalizeVariant(
} }
/** /**
* Session-scoped log of every overlay notification that was shown. Entries are keyed by id so a * Session-scoped log of every overlay notification that was shown. Entries are keyed by historyId
* progress notification that updates in place (same id, new body) overwrites its record rather than * when provided, otherwise by live notification id. Reusing a key updates the record in place;
* piling up duplicates. Ordering is by first-seen so the panel can render newest-first. * distinct history keys preserve separate visible events. Ordering is by first-seen so the panel can
* render newest-first.
*/ */
export function createOverlayNotificationHistoryStore( export function createOverlayNotificationHistoryStore(
options: OverlayNotificationHistoryStoreOptions = {}, options: OverlayNotificationHistoryStoreOptions = {},
@@ -48,9 +49,10 @@ export function createOverlayNotificationHistoryStore(
function record(entry: OverlayNotificationEntry): OverlayNotificationHistoryEntry { function record(entry: OverlayNotificationEntry): OverlayNotificationHistoryEntry {
const timestamp = now(); const timestamp = now();
const existing = entries.get(entry.id); const historyId = entry.historyId?.trim() || entry.id;
const existing = entries.get(historyId);
const next: OverlayNotificationHistoryEntry = { const next: OverlayNotificationHistoryEntry = {
id: entry.id, id: historyId,
title: entry.title, title: entry.title,
body: entry.body, body: entry.body,
image: entry.image, 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, // 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. // 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) { while (entries.size > max) {
const oldest = entries.keys().next().value; const oldest = entries.keys().next().value;
if (oldest === undefined) break; if (oldest === undefined) break;
@@ -221,6 +223,7 @@ export function createOverlayNotificationHistoryPanel(
if (open) setInteractive(true); if (open) setInteractive(true);
}); });
panel.addEventListener('mouseleave', () => setInteractive(false)); panel.addEventListener('mouseleave', () => setInteractive(false));
applySide();
function record(entry: OverlayNotificationEntry): void { function record(entry: OverlayNotificationEntry): void {
store.record(entry); store.record(entry);
@@ -237,5 +240,6 @@ export function createOverlayNotificationHistoryPanel(
open: () => setOpen(true), open: () => setOpen(true),
close: () => setOpen(false), close: () => setOpen(false),
isOpen: () => open, isOpen: () => open,
syncSide: applySide,
}; };
} }
+16
View File
@@ -58,6 +58,22 @@ test('renderer reports subtitle bounds immediately after initial subtitle layout
assert.ok(immediateMeasurementIndex < listenerIndex); 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', () => { test('renderer reports subtitle bounds immediately after live subtitle layout', () => {
const liveRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(data);'); const liveRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(data);');
const liveLayoutIndex = indexOfRequired( const liveLayoutIndex = indexOfRequired(
+33 -28
View File
@@ -122,7 +122,10 @@ const notificationHistory = createOverlayNotificationHistoryPanel(ctx, {
onChanged: () => measurementReporter.schedule(), onChanged: () => measurementReporter.schedule(),
}); });
const overlayNotifications = createOverlayNotificationRenderer(ctx, { const overlayNotifications = createOverlayNotificationRenderer(ctx, {
onChanged: () => measurementReporter.schedule(), onChanged: () => {
notificationHistory.syncSide();
measurementReporter.schedule();
},
onShow: (entry) => notificationHistory.record(entry), onShow: (entry) => notificationHistory.record(entry),
}); });
const positioning = createPositioningController(ctx); const positioning = createPositioningController(ctx);
@@ -632,6 +635,19 @@ async function init(): Promise<void> {
syncOverlayMouseIgnoreState(ctx); 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(() => { window.electronAPI.onOverlayPointerRecoveryRequested(() => {
runGuarded('overlay:pointer-recovery', () => { runGuarded('overlay:pointer-recovery', () => {
if (!ctx.platform.isMacOSPlatform || !ctx.platform.shouldToggleMouseIgnore) { if (!ctx.platform.isMacOSPlatform || !ctx.platform.shouldToggleMouseIgnore) {
@@ -656,18 +672,6 @@ async function init(): Promise<void> {
await keyboardHandlers.setupMpvInputForwarding(); 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(); const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle); subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible'); subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
@@ -677,6 +681,22 @@ async function init(): Promise<void> {
); );
measurementReporter.schedule(); 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) => { window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
runGuarded('subtitle-position:update', () => { runGuarded('subtitle-position:update', () => {
positioning.applyStoredSubtitlePosition(position, 'media-change'); positioning.applyStoredSubtitlePosition(position, 'media-change');
@@ -731,21 +751,6 @@ async function init(): Promise<void> {
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub()); subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
measurementReporter.schedule(); 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(); setupDragDropToMpvQueue();
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
measurementReporter.schedule(); measurementReporter.schedule();
@@ -10,6 +10,7 @@ export interface SubminerPluginRuntimeScriptOptConfig {
autoStart: boolean; autoStart: boolean;
autoStartVisibleOverlay: boolean; autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean; autoStartPauseUntilReady: boolean;
overlayLoadingOsd?: boolean;
osdMessages: boolean; osdMessages: boolean;
texthookerEnabled: boolean; texthookerEnabled: boolean;
aniskipEnabled: boolean; aniskipEnabled: boolean;
@@ -38,12 +39,16 @@ export function buildSubminerPluginRuntimeScriptOptParts(
const socketPath = sanitizeScriptOptValue(runtimeConfig.socketPath); const socketPath = sanitizeScriptOptValue(runtimeConfig.socketPath);
const backend = sanitizeScriptOptValue(runtimeConfig.backend); const backend = sanitizeScriptOptValue(runtimeConfig.backend);
const aniskipButtonKey = sanitizeScriptOptValue(runtimeConfig.aniskipButtonKey); const aniskipButtonKey = sanitizeScriptOptValue(runtimeConfig.aniskipButtonKey);
const overlayLoadingOsd =
runtimeConfig.overlayLoadingOsd ??
(runtimeConfig.autoStart && runtimeConfig.autoStartVisibleOverlay);
return [ return [
`subminer-binary_path=${binaryPath}`, `subminer-binary_path=${binaryPath}`,
`subminer-socket_path=${socketPath}`, `subminer-socket_path=${socketPath}`,
`subminer-backend=${backend}`, `subminer-backend=${backend}`,
`subminer-auto_start=${boolScriptOpt(runtimeConfig.autoStart)}`, `subminer-auto_start=${boolScriptOpt(runtimeConfig.autoStart)}`,
`subminer-auto_start_visible_overlay=${boolScriptOpt(runtimeConfig.autoStartVisibleOverlay)}`, `subminer-auto_start_visible_overlay=${boolScriptOpt(runtimeConfig.autoStartVisibleOverlay)}`,
`subminer-overlay_loading_osd=${boolScriptOpt(overlayLoadingOsd)}`,
`subminer-auto_start_pause_until_ready=${boolScriptOpt( `subminer-auto_start_pause_until_ready=${boolScriptOpt(
runtimeConfig.autoStartPauseUntilReady, runtimeConfig.autoStartPauseUntilReady,
)}`, )}`,
+1
View File
@@ -21,6 +21,7 @@ export interface OverlayNotificationAction {
export interface OverlayNotificationPayload { export interface OverlayNotificationPayload {
id?: string; id?: string;
historyId?: string;
title: string; title: string;
body?: string; body?: string;
image?: string; image?: string;