From 848a6c40214ddd6c9d67cfdddbd0d7ce5e2de00b Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 2 Nov 2026 21:47:32 -0800 Subject: [PATCH] fix(plugin): keep loading OSD off on warm playlist advance (#317) * fix(plugin): keep loading OSD off on warm playlist advance - Track overlay_content_ready_seen when the app sends subminer-overlay-loading-ready - Skip restarting the "Overlay loading" spinner for an overlay that already reported ready, so it no longer times out into "Overlay did not become ready" (#315) - Reset the flag on stop, restart, start failure and shutdown - Add start-gate tests for warm and not-yet-ready playlist advances * fix(plugin): only report overlay ready once content has loaded Hide paths dismissed the loading OSD with subminer-overlay-loading-ready, which the plugin now remembers to skip the spinner on later playlist files. A hide before the overlay had content would wrongly mark it ready. The app now sends ready only when the overlay window content is ready, and a new stop-only subminer-overlay-loading-dismissed message otherwise. * fix(plugin): resync overlay readiness on dismissal and mpv connect A dismissal now clears the plugin's readiness flag, since the app sends it only while its overlay window has no content (e.g. a replaced window). On mpv (re)connect the app resends subminer-overlay-loading-ready when the overlay already has content, so a ready signal lost while mpv was away, or sent before this mpv's plugin existed, still reaches the plugin. * refactor(main): pass overlay readiness handler directly --- changes/plugin-loading-osd-warm-playlist.md | 4 + docs-site/mpv-plugin.md | 2 +- docs-site/websocket-texthooker-api.md | 2 +- plugin/subminer/lifecycle.lua | 4 + plugin/subminer/messages.lua | 7 +- plugin/subminer/process.lua | 19 ++ plugin/subminer/state.lua | 3 + scripts/test-plugin-start-gate.lua | 170 ++++++++++++++++++ src/main.ts | 2 + src/main/assemblies/playback.test.ts | 9 +- src/main/assemblies/playback.ts | 2 + .../overlay-notifications-runtime.test.ts | 60 +++++++ .../runtime/overlay-notifications-runtime.ts | 34 +++- 13 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 changes/plugin-loading-osd-warm-playlist.md create mode 100644 src/main/runtime/overlay-notifications-runtime.test.ts diff --git a/changes/plugin-loading-osd-warm-playlist.md b/changes/plugin-loading-osd-warm-playlist.md new file mode 100644 index 00000000..58b49542 --- /dev/null +++ b/changes/plugin-loading-osd-warm-playlist.md @@ -0,0 +1,4 @@ +type: fixed +area: mpv-plugin + +- Fixed the "Overlay loading" message staying on screen when mpv moves to the next file in a playlist, then turning into "Overlay did not become ready; check SubMiner logs" after 30 seconds even though the overlay was working. The overlay window is reused between files, so the plugin no longer restarts the loading message once the overlay has reported ready. diff --git a/docs-site/mpv-plugin.md b/docs-site/mpv-plugin.md index 40732c9e..d6e6a1ea 100644 --- a/docs-site/mpv-plugin.md +++ b/docs-site/mpv-plugin.md @@ -124,7 +124,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no `log-level` sets SubMiner's log verbosity. Do not use `--debug` for this; it turns on the app's dev mode. -The plugin also handles messages the SubMiner app sends it (`subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-reload-session-bindings`). You do not need to send these yourself. The AniSkip messages are listed on the [AniSkip page](/aniskip-integration#triggering-from-mpv). +The plugin also handles messages the SubMiner app sends it (`subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-overlay-loading-dismissed`, `subminer-reload-session-bindings`). You do not need to send these yourself. The AniSkip messages are listed on the [AniSkip page](/aniskip-integration#triggering-from-mpv). ## Auto-start behavior diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md index f8cc01af..42fe78a3 100644 --- a/docs-site/websocket-texthooker-api.md +++ b/docs-site/websocket-texthooker-api.md @@ -227,7 +227,7 @@ The mpv plugin accepts these script messages: script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug ``` -The plugin also registers `subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts. +The plugin also registers `subminer-autoplay-ready`, `subminer-autoplay-hold`, `subminer-visible-overlay-shown`, `subminer-visible-overlay-hidden`, `subminer-managed-subtitles-loading`, `subminer-overlay-loading-ready`, `subminer-overlay-loading-dismissed`, and `subminer-reload-session-bindings`. The SubMiner app sends these to keep the plugin in sync, so do not send them from your own scripts. While the app is connected to mpv, it also handles two AniSkip messages over the mpv IPC socket: `subminer-skip-intro` skips the intro, and `subminer-aniskip-refresh` reloads intro data, for example after your script changes title or episode metadata. diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 9c3cd60d..976eb758 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -162,12 +162,15 @@ function M.create(ctx) and not (state.overlay_running and state.auto_play_ready_signal_seen == true) end + -- A warm playlist advance keeps the already-ready overlay window, and the app only reports + -- readiness when content first loads, so a restarted spinner would never clear (#315). 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 + and not (state.overlay_running and state.overlay_content_ready_seen == true) end local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt) @@ -311,6 +314,7 @@ function M.create(ctx) process.stop_overlay_loading_osd() clear_pending_visible_overlay_hide() state.auto_play_ready_signal_seen = false + state.overlay_content_ready_seen = false state.current_media_identity = nil state.current_media_title = nil state.pending_reload_media_identity = nil diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index 0d79d4bd..8d1c0ac1 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -46,7 +46,12 @@ function M.create(ctx) process.hold_auto_play_ready_gate() end) mp.register_script_message("subminer-overlay-loading-ready", function() - process.stop_overlay_loading_osd() + process.mark_overlay_content_ready() + end) + -- Sent when the app dismisses loading while its overlay window has no content (hidden mid-load, + -- or a replaced window). + mp.register_script_message("subminer-overlay-loading-dismissed", function() + process.mark_overlay_content_not_ready() end) mp.register_script_message("subminer-stats-toggle", function() if opts.osd_messages then diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 5d8b6045..f005bc8e 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -282,6 +282,19 @@ function M.create(ctx) clear_overlay_loading_osd_deadline() end + -- Handles the app's subminer-overlay-loading-ready message. + local function mark_overlay_content_ready() + state.overlay_content_ready_seen = true + stop_overlay_loading_osd() + end + + -- Handles subminer-overlay-loading-dismissed: the app's current overlay window has no content + -- (hidden mid-load, or a replaced window), so earlier readiness no longer applies. + local function mark_overlay_content_not_ready() + state.overlay_content_ready_seen = false + stop_overlay_loading_osd() + end + local function start_overlay_loading_osd() if state.overlay_loading_osd_active then return @@ -741,6 +754,7 @@ function M.create(ctx) state.overlay_running = false state.auto_play_ready_signal_seen = false + state.overlay_content_ready_seen = false subminer_log("error", "process", "Overlay start failed after retries: " .. reason) stop_overlay_loading_osd() show_osd("Overlay start failed") @@ -795,6 +809,7 @@ function M.create(ctx) state.overlay_running = false state.texthooker_running = false state.auto_play_ready_signal_seen = false + state.overlay_content_ready_seen = false stop_overlay_loading_osd() disarm_auto_play_ready_gate() show_osd("Stopped") @@ -934,6 +949,7 @@ function M.create(ctx) state.overlay_running = false state.texthooker_running = false state.auto_play_ready_signal_seen = false + state.overlay_content_ready_seen = false state.suppress_ready_overlay_restore = false state.force_ready_overlay_restore = true disarm_auto_play_ready_gate({ resume_playback = false }) @@ -957,6 +973,7 @@ function M.create(ctx) if not success or (result and result.status ~= 0) then state.overlay_running = false state.auto_play_ready_signal_seen = false + state.overlay_content_ready_seen = false subminer_log( "error", "process", @@ -1035,6 +1052,8 @@ function M.create(ctx) 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, + mark_overlay_content_ready = mark_overlay_content_ready, + mark_overlay_content_not_ready = mark_overlay_content_not_ready, } end diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index e2ec942e..d6066778 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -14,6 +14,9 @@ function M.new() auto_play_ready_osd_timer = nil, auto_play_ready_signal_seen = false, auto_play_ready_initial_pause_ownership_consumed = false, + -- The running overlay has reported its content ready at least once; warm playlist advances + -- reuse that window, so they must not restart the loading spinner (#315). + overlay_content_ready_seen = false, overlay_loading_osd_active = false, overlay_loading_osd_timer = nil, overlay_loading_osd_deadline = nil, diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 76132c48..4b6202eb 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -1687,6 +1687,176 @@ do ) end +local function count_live_timeouts(recorded, seconds) + local count = 0 + for _, timeout in ipairs(recorded.timeout_handles) do + if timeout.seconds == seconds and not timeout.killed then + count = count + 1 + end + end + return count +end + +-- Fire only the short plugin timers (socket retries, deferred hides), leaving the 30 s loading deadline armed. +local function fire_short_timeouts(recorded) + for _, timeout in ipairs(recorded.timeout_handles) do + if not timeout.killed and timeout.callback and timeout.seconds < 5 then + timeout.killed = true + timeout.callback() + end + end +end + +local function count_live_periodic_timers(recorded) + local count = 0 + for _, timer in ipairs(recorded.periodic_timers) do + if not timer.killed then + count = count + 1 + end + end + return count +end + +do + -- Issue #315: a warm playlist advance reuses an overlay whose content is already ready, so the + -- app never sends another overlay-loading-ready and a restarted spinner would hit its deadline. + local scenario = { + process_list = "", + defer_timeouts = true, + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + path = "/media/warm-episode-01.mkv", + playlist_count = 2, + playlist_pos = 0, + media_title = "Warm Episode 1", + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for warm playlist loading OSD scenario: " .. tostring(err)) + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + fire_short_timeouts(recorded) + recorded.script_messages["subminer-overlay-loading-ready"]() + recorded.script_messages["subminer-autoplay-ready"]() + fire_event(recorded, "end-file", { reason = "eof" }) + scenario.path = "/media/warm-episode-02.mkv" + scenario.media_title = "Warm Episode 2" + scenario.playlist_pos = 1 + recorded.osd = {} + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + fire_short_timeouts(recorded) + assert_true( + not has_osd_message(recorded.osd, "SubMiner: Overlay loading |"), + "warm playlist advance should not restart the overlay loading OSD for an already-ready overlay" + ) + assert_true( + count_live_periodic_timers(recorded) == 0 and count_live_timeouts(recorded, 30) == 0, + "warm playlist advance should not leave an overlay loading spinner or deadline running" + ) +end + +do + -- A stop-only dismissal (overlay hidden before content loaded) clears the spinner without marking + -- the overlay ready, so the next file still shows the spinner. + local scenario = { + process_list = "", + defer_timeouts = true, + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "no", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + path = "/media/slow-overlay-01.mkv", + playlist_count = 2, + playlist_pos = 0, + media_title = "Slow Overlay 1", + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for not-ready playlist loading OSD scenario: " .. tostring(err)) + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + fire_short_timeouts(recorded) + assert_true( + count_live_periodic_timers(recorded) == 1 and count_live_timeouts(recorded, 30) == 1, + "overlay loading OSD should still be running before the app dismisses it" + ) + assert_true( + recorded.script_messages["subminer-overlay-loading-dismissed"] ~= nil, + "overlay loading dismissed script message should be registered" + ) + recorded.script_messages["subminer-overlay-loading-dismissed"]() + assert_true( + count_live_periodic_timers(recorded) == 0 and count_live_timeouts(recorded, 30) == 0, + "overlay loading dismissed should stop the spinner and its deadline" + ) + fire_event(recorded, "end-file", { reason = "eof" }) + scenario.path = "/media/slow-overlay-02.mkv" + scenario.media_title = "Slow Overlay 2" + scenario.playlist_pos = 1 + recorded.osd = {} + fire_event(recorded, "start-file") + assert_true( + has_osd_message(recorded.osd, "SubMiner: Overlay loading |"), + "a stop-only dismissal should not mark the overlay ready for the next playlist file" + ) +end + +do + -- A dismissal after readiness means the app's overlay window was replaced and has no content yet, + -- so the plugin must forget the old readiness and show the spinner on the next file. + local scenario = { + process_list = "", + defer_timeouts = true, + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "no", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + path = "/media/replaced-overlay-01.mkv", + playlist_count = 2, + playlist_pos = 0, + media_title = "Replaced Overlay 1", + files = { + [binary_path] = true, + }, + } + local recorded, err = run_plugin_scenario(scenario) + assert_true(recorded ~= nil, "plugin failed to load for replaced overlay loading OSD scenario: " .. tostring(err)) + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + fire_short_timeouts(recorded) + recorded.script_messages["subminer-overlay-loading-ready"]() + recorded.script_messages["subminer-overlay-loading-dismissed"]() + fire_event(recorded, "end-file", { reason = "eof" }) + scenario.path = "/media/replaced-overlay-02.mkv" + scenario.media_title = "Replaced Overlay 2" + scenario.playlist_pos = 1 + recorded.osd = {} + fire_event(recorded, "start-file") + assert_true( + has_osd_message(recorded.osd, "SubMiner: Overlay loading |"), + "a dismissal after readiness should make the next playlist file show the loading OSD again" + ) +end + do local recorded, err = run_plugin_scenario({ defer_timeouts = true, diff --git a/src/main.ts b/src/main.ts index 37df2529..b23f6194 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1330,6 +1330,7 @@ const { toggleNotificationHistoryPanel, showConfiguredPlaybackFeedback, maybeStartOverlayLoadingOsd, + reportOverlayContentReadyToPlugin, } = overlayNotificationsRuntime; const overlayVisibilityComposer = composeOverlayVisibilityRuntime({ @@ -1777,6 +1778,7 @@ const playback = createPlaybackAssembly({ createSubtitlePrefetchService, requestAppQuit: () => requestAppQuit(), maybeStartOverlayLoadingOsd: (mediaPath) => maybeStartOverlayLoadingOsd(mediaPath), + reportOverlayContentReadyToPlugin, flushQueuedMpvOsdNotifications: () => flushQueuedMpvOsdNotifications(), refreshMpvSessionBindings: () => shortcuts.refreshMpvSessionBindings(), ensureImmersionTrackerStarted: () => stats.ensureImmersionTrackerStarted(), diff --git a/src/main/assemblies/playback.test.ts b/src/main/assemblies/playback.test.ts index f39fb25e..9287da3b 100644 --- a/src/main/assemblies/playback.test.ts +++ b/src/main/assemblies/playback.test.ts @@ -153,6 +153,7 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps { ), requestAppQuit: spy('requestAppQuit', noop), maybeStartOverlayLoadingOsd: spy('maybeStartOverlayLoadingOsd', noop), + reportOverlayContentReadyToPlugin: spy('reportOverlayContentReadyToPlugin', noop), flushQueuedMpvOsdNotifications: spy('flushQueuedMpvOsdNotifications', noop), refreshMpvSessionBindings: spy('refreshMpvSessionBindings', resolved), ensureImmersionTrackerStarted: spy('ensureImmersionTrackerStarted', noop), @@ -212,7 +213,7 @@ test('createPlaybackAssembly does not call its deps while constructing', () => { assert.deepEqual(recorder.names(), []); }); -test('mpv connection starts the loading OSD and flushes queued OSD notifications before session work', () => { +test('mpv connection starts the loading OSD, resends overlay readiness, and flushes queued OSD notifications before session work', () => { const { recorder, emit } = setup(); emit('connection-change', { connected: true }); @@ -221,7 +222,11 @@ test('mpv connection starts the loading OSD and flushes queued OSD notifications const osdIndex = names.indexOf('maybeStartOverlayLoadingOsd'); assert.ok(osdIndex >= 0); assert.deepEqual(recorder.calls[osdIndex]?.args, []); - assert.ok(osdIndex < names.indexOf('flushQueuedMpvOsdNotifications')); + assert.ok(osdIndex < names.indexOf('reportOverlayContentReadyToPlugin')); + assert.ok( + names.indexOf('reportOverlayContentReadyToPlugin') < + names.indexOf('flushQueuedMpvOsdNotifications'), + ); assert.ok( names.indexOf('flushQueuedMpvOsdNotifications') < names.indexOf('refreshMpvSessionBindings'), ); diff --git a/src/main/assemblies/playback.ts b/src/main/assemblies/playback.ts index 1b739efc..00cfd7ca 100644 --- a/src/main/assemblies/playback.ts +++ b/src/main/assemblies/playback.ts @@ -205,6 +205,7 @@ export interface PlaybackAssemblyDeps { ) => SubtitlePrefetchService; requestAppQuit: () => void; maybeStartOverlayLoadingOsd: OverlayNotificationsRuntime['maybeStartOverlayLoadingOsd']; + reportOverlayContentReadyToPlugin: OverlayNotificationsRuntime['reportOverlayContentReadyToPlugin']; flushQueuedMpvOsdNotifications: OverlayNotificationsRuntime['flushQueuedMpvOsdNotifications']; refreshMpvSessionBindings: () => Promise; ensureImmersionTrackerStarted: () => void; @@ -589,6 +590,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) { }, onMpvConnected: () => { deps.maybeStartOverlayLoadingOsd(); + deps.reportOverlayContentReadyToPlugin(); deps.flushQueuedMpvOsdNotifications(); secondarySubtitleTrackController.scheduleRefresh(0); void deps.refreshMpvSessionBindings(); diff --git a/src/main/runtime/overlay-notifications-runtime.test.ts b/src/main/runtime/overlay-notifications-runtime.test.ts new file mode 100644 index 00000000..30e9fdcc --- /dev/null +++ b/src/main/runtime/overlay-notifications-runtime.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { BrowserWindow } from 'electron'; + +import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../../core/services/overlay-window-flags'; +import type { MpvIpcClient } from '../../core/services'; +import type { ResolvedConfig } from '../../types'; +import { createOverlayNotificationsRuntime } from './overlay-notifications-runtime'; + +function createRuntimeHarness(contentReady: boolean) { + const sent: unknown[][] = []; + const overlayWindow = { + isDestroyed: () => false, + [OVERLAY_WINDOW_CONTENT_READY_FLAG]: contentReady, + } as unknown as BrowserWindow; + const mpvClient = { + send: ({ command }: { command: unknown[] }) => { + sent.push(command); + }, + } as unknown as MpvIpcClient; + const runtime = createOverlayNotificationsRuntime({ + getResolvedConfig: () => ({}) as ResolvedConfig, + getMainOverlayWindow: () => overlayWindow, + getVisibleOverlayVisible: () => false, + broadcastToOverlayWindows: () => {}, + showMpvOsd: () => {}, + getMpvClient: () => mpvClient, + getAnkiIntegration: () => null, + getRuntimeOptionsManager: () => null, + }); + const scriptMessages = () => + sent.filter((command) => command[0] === 'script-message').map((command) => command[1]); + return { runtime, scriptMessages }; +} + +test('dismissing the loading OSD before overlay content is ready only stops the plugin spinner', () => { + const { runtime, scriptMessages } = createRuntimeHarness(false); + + runtime.dismissOverlayLoadingStatusNotification(); + + assert.deepEqual(scriptMessages(), ['subminer-overlay-loading-dismissed']); +}); + +test('dismissing the loading OSD once overlay content is ready reports readiness to the plugin', () => { + const { runtime, scriptMessages } = createRuntimeHarness(true); + + runtime.dismissOverlayLoadingStatusNotification(); + + assert.deepEqual(scriptMessages(), ['subminer-overlay-loading-ready']); +}); + +test('reporting overlay readiness after an mpv connect resends ready only for loaded content', () => { + const ready = createRuntimeHarness(true); + ready.runtime.reportOverlayContentReadyToPlugin(); + assert.deepEqual(ready.scriptMessages(), ['subminer-overlay-loading-ready']); + + const loading = createRuntimeHarness(false); + loading.runtime.reportOverlayContentReadyToPlugin(); + assert.deepEqual(loading.scriptMessages(), []); +}); diff --git a/src/main/runtime/overlay-notifications-runtime.ts b/src/main/runtime/overlay-notifications-runtime.ts index 1dc01a97..e2da7390 100644 --- a/src/main/runtime/overlay-notifications-runtime.ts +++ b/src/main/runtime/overlay-notifications-runtime.ts @@ -61,6 +61,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt showYoutubeFlowStatusNotification: (message: string) => void; showOverlayLoadingStatusNotification: () => void; dismissOverlayLoadingStatusNotification: () => void; + reportOverlayContentReadyToPlugin: () => void; maybeStartOverlayLoadingOsd: (mediaPath?: string | null) => void; } { function isVisibleOverlayContentReady(): boolean { @@ -229,15 +230,39 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt getOverlayLoadingOsdController().start(); } + function isMainOverlayContentReady(): boolean { + const overlayWindow = deps.getMainOverlayWindow(); + return Boolean(overlayWindow && isOverlayWindowContentReady(overlayWindow)); + } + + function sendPluginOverlayLoadingMessage( + message: 'subminer-overlay-loading-ready' | 'subminer-overlay-loading-dismissed', + ): void { + sendMpvCommandRuntime(deps.getMpvClient(), ['script-message', message]); + } + + // Hide paths also dismiss, often before the overlay has content. Only a content-ready window + // reports `subminer-overlay-loading-ready`: the plugin remembers it and skips the spinner on + // later playlist files, so other dismissals send the stop-only message, which also makes the + // plugin forget readiness from a replaced window. function dismissOverlayLoadingStatusNotification(): void { getOverlayLoadingOsdController().stop(); - sendMpvCommandRuntime(deps.getMpvClient(), [ - 'script-message', - 'subminer-overlay-loading-ready', - ]); + sendPluginOverlayLoadingMessage( + isMainOverlayContentReady() + ? 'subminer-overlay-loading-ready' + : 'subminer-overlay-loading-dismissed', + ); dismissOverlayNotification('overlay-loading-status'); } + // Called when mpv (re)connects: a ready message sent while mpv was away, or before this mpv's + // plugin existed, was lost. Never sends the dismissal, since nothing is being hidden. + function reportOverlayContentReadyToPlugin(): void { + if (isMainOverlayContentReady()) { + sendPluginOverlayLoadingMessage('subminer-overlay-loading-ready'); + } + } + const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({ getVisibleOverlayRequested: () => deps.getVisibleOverlayVisible(), isOverlayContentReady: () => isVisibleOverlayContentReady(), @@ -261,6 +286,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt showYoutubeFlowStatusNotification, showOverlayLoadingStatusNotification, dismissOverlayLoadingStatusNotification, + reportOverlayContentReadyToPlugin, maybeStartOverlayLoadingOsd, }; }