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