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
This commit is contained in:
sudacode authored and GitHub committed 2026-11-02 21:47:32 -08:00
1 parent b99409bbcd
commit 848a6c4021
13 files changed
+309 -9

No files matched your search

@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+4
View File
@@ -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
+6 -1
View File
@@ -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
+19
View File
@@ -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
+3
View File
@@ -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,
+170
View File
@@ -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,
+2
View File
@@ -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(),
+7 -2
View File
@@ -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'),
);
+2
View File
@@ -205,6 +205,7 @@ export interface PlaybackAssemblyDeps {
) => SubtitlePrefetchService;
requestAppQuit: () => void;
maybeStartOverlayLoadingOsd: OverlayNotificationsRuntime['maybeStartOverlayLoadingOsd'];
reportOverlayContentReadyToPlugin: OverlayNotificationsRuntime['reportOverlayContentReadyToPlugin'];
flushQueuedMpvOsdNotifications: OverlayNotificationsRuntime['flushQueuedMpvOsdNotifications'];
refreshMpvSessionBindings: () => Promise<void>;
ensureImmersionTrackerStarted: () => void;
@@ -589,6 +590,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
},
onMpvConnected: () => {
deps.maybeStartOverlayLoadingOsd();
deps.reportOverlayContentReadyToPlugin();
deps.flushQueuedMpvOsdNotifications();
secondarySubtitleTrackController.scheduleRefresh(0);
void deps.refreshMpvSessionBindings();
@@ -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(), []);
});
@@ -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,
};
}