From a34ec049a2713172a36bdf89e10ce4e72cbe1f85 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 6 Jun 2026 01:55:12 -0700 Subject: [PATCH] fix(startup): release autoplay gate before first subtitle line - Send synthetic `__warm__` payload when no current subtitle exists so the gate can release without waiting for a subtitle event that can't fire while paused - Visible-overlay readiness accepts `__warm__` once the overlay is content-ready, rejects it otherwise - Autoplay gate self-retries via scheduled polling when signal target isn't ready, removing reliance on an external flush event - Skip duplicate desktop notification when overlay or startup sequencer already delivered it --- changes/startup-autoplay-ready.md | 4 ++ changes/startup-overlay-ready-snapshot.md | 4 -- docs/architecture/subtitle-overlay-priming.md | 10 ++++ src/main.ts | 10 +++- src/main/main-wiring.test.ts | 12 ++++- src/main/runtime/autoplay-ready-gate.test.ts | 51 ++++++++++++++++++ src/main/runtime/autoplay-ready-gate.ts | 54 ++++++++++++++++--- ...dictionary-auto-sync-notifications.test.ts | 31 +++++++++-- ...cter-dictionary-auto-sync-notifications.ts | 7 ++- ...visible-overlay-autoplay-readiness.test.ts | 38 ++++++++++++- .../visible-overlay-autoplay-readiness.ts | 6 ++- 11 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 changes/startup-autoplay-ready.md delete mode 100644 changes/startup-overlay-ready-snapshot.md diff --git a/changes/startup-autoplay-ready.md b/changes/startup-autoplay-ready.md new file mode 100644 index 00000000..577beeca --- /dev/null +++ b/changes/startup-autoplay-ready.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed startup pause-until-ready so SubMiner releases playback after tokenization and overlay content are ready even when playback starts before the first subtitle line. diff --git a/changes/startup-overlay-ready-snapshot.md b/changes/startup-overlay-ready-snapshot.md deleted file mode 100644 index 79eb187a..00000000 --- a/changes/startup-overlay-ready-snapshot.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: overlay - -- Fixed pause-until-overlay-ready startup on macOS so the initial renderer subtitle snapshot can release the mpv startup gate after the overlay paints annotations. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index b58d59f3..b3c317fe 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -64,6 +64,16 @@ prefetch work and re-centers prefetch around the live playback time. - If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is written. +## Startup Ready Release + +- `mpv.pauseUntilOverlayReady` waits for tokenization warmup plus visible-overlay readiness before + releasing the mpv startup gate. +- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and + waits for a fresh measured subtitle rectangle before signaling readiness. +- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after + tokenization warmup and visible overlay content-ready. This releases playback without waiting for + a later subtitle event that cannot happen while mpv is paused. + ## Linux/X11 Window Shape - `restoreLinuxOverlayWindowShape()` reads `BrowserWindow.getBounds()` and calls `setShape()` with diff --git a/src/main.ts b/src/main.ts index e377988e..c34b3153 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1896,10 +1896,16 @@ async function resolveSentenceSearchHeadwords(term: string): Promise { function signalCurrentSubtitleAutoplayReady(): void { autoplayReadyGate.flushPendingAutoplayReadySignal(); const payload = getCurrentAutoplaySubtitlePayload(); - if (!payload) { + if (payload) { + autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true }); return; } - autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true }); + if (!appState.currentSubText.trim()) { + autoplayReadyGate.maybeSignalPluginAutoplayReady( + { text: '__warm__', tokens: null }, + { forceWhilePaused: true }, + ); + } } const buildSubtitleProcessingControllerMainDepsHandler = createBuildSubtitleProcessingControllerMainDepsHandler({ diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 1c3160d8..2cefac31 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -216,11 +216,14 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi assert.match(depsBlock, /subtitleSidebarRequestedOpen/); }); -test('warm tokenization release reuses current subtitle payload instead of synthetic readiness', () => { +test('warm tokenization release can signal readiness before the first subtitle appears', () => { const source = readMainSource(); const warmReleaseBlock = source.match( /signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?[\s\S]*?)\n\}\);/, )?.groups?.body; + const signalBlock = source.match( + /function signalCurrentSubtitleAutoplayReady\(\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; const currentPayloadBlock = source.match( /function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?[\s\S]*?)\n\}/, )?.groups?.body; @@ -230,7 +233,12 @@ test('warm tokenization release reuses current subtitle payload instead of synth warmReleaseBlock, /signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/, ); - assert.doesNotMatch(warmReleaseBlock, /__warm__/); + + assert.ok(signalBlock); + assert.match(signalBlock, /const payload = getCurrentAutoplaySubtitlePayload\(\);/); + assert.match(signalBlock, /if \(payload\) \{/); + assert.match(signalBlock, /if \(!appState\.currentSubText\.trim\(\)\) \{/); + assert.match(signalBlock, /text: '__warm__'/); assert.ok(currentPayloadBlock); assert.match(currentPayloadBlock, /appState\.currentSubtitleData/); diff --git a/src/main/runtime/autoplay-ready-gate.test.ts b/src/main/runtime/autoplay-ready-gate.test.ts index af25162c..a712ade2 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -314,6 +314,57 @@ test('autoplay ready gate defers plugin readiness until the signal target is rea ); }); +test('autoplay ready gate retries deferred readiness without an external flush event', async () => { + const commands: Array> = []; + const scheduled: Array<() => void> = []; + let targetReady = false; + + const gate = createAutoplayReadyGate({ + isAppOwnedFlowInFlight: () => false, + getCurrentMediaPath: () => '/media/video.mkv', + getCurrentVideoPath: () => null, + getPlaybackPaused: () => true, + getMpvClient: () => + ({ + connected: true, + requestProperty: async () => true, + send: ({ command }: { command: Array }) => { + commands.push(command); + }, + }) as never, + signalPluginAutoplayReady: () => { + commands.push(['script-message', 'subminer-autoplay-ready']); + }, + isSignalTargetReady: () => targetReady, + schedule: (callback) => { + scheduled.push(callback); + return 1 as never; + }, + logDebug: () => {}, + }); + + gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(commands, []); + assert.equal(scheduled.length, 1); + + targetReady = true; + scheduled.shift()?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual( + commands.filter((command) => command[0] === 'script-message'), + [['script-message', 'subminer-autoplay-ready']], + ); + assert.equal( + commands.some( + (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, + ), + true, + ); +}); + test('autoplay ready gate drops deferred readiness after media changes before flush', async () => { const commands: Array> = []; let targetReady = false; diff --git a/src/main/runtime/autoplay-ready-gate.ts b/src/main/runtime/autoplay-ready-gate.ts index f9c49bf8..2bf83926 100644 --- a/src/main/runtime/autoplay-ready-gate.ts +++ b/src/main/runtime/autoplay-ready-gate.ts @@ -1,6 +1,9 @@ import type { SubtitleData } from '../../types'; import { resolveAutoplayReadyMaxReleaseAttempts } from './startup-autoplay-release-policy'; +const PENDING_AUTOPLAY_READY_RETRY_DELAY_MS = 200; +const MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS = 75; + type MpvClientLike = { connected?: boolean; requestProperty: (property: string) => Promise; @@ -34,12 +37,22 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { let autoPlayReadySignalMediaPath: string | null = null; let autoPlayReadySignalGeneration = 0; let pendingAutoplayReadySignal: AutoplayReadySignal | null = null; + let pendingAutoplayReadyRetryToken = 0; + let pendingAutoplayReadyRetryAttempts = 0; + let scheduledPendingAutoplayReadyRetryToken: number | null = null; const now = deps.now ?? (() => Date.now()); + const invalidatePendingAutoplayReadyRetry = (): void => { + pendingAutoplayReadyRetryToken += 1; + pendingAutoplayReadyRetryAttempts = 0; + scheduledPendingAutoplayReadyRetryToken = null; + }; + const invalidatePendingAutoplayReadyFallbacks = (): void => { autoPlayReadySignalMediaPath = null; pendingAutoplayReadySignal = null; autoPlayReadySignalGeneration += 1; + invalidatePendingAutoplayReadyRetry(); }; const isSignalTargetReady = (signal: AutoplayReadySignal): boolean => @@ -52,18 +65,43 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { pendingAutoplayReadySignal = null; autoPlayReadySignalMediaPath = getSignalMediaPath(); autoPlayReadySignalGeneration += 1; + invalidatePendingAutoplayReadyRetry(); }; - const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): void => { + const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): boolean => { if ( pendingAutoplayReadySignal && pendingAutoplayReadySignal.mediaPath === signal.mediaPath && pendingAutoplayReadySignal.payload.text === signal.payload.text && pendingAutoplayReadySignal.requestedAtMs <= signal.requestedAtMs ) { - return; + return false; } pendingAutoplayReadySignal = signal; + pendingAutoplayReadyRetryAttempts = 0; + return true; + }; + + const schedulePendingAutoplayReadyRetry = (): void => { + if (scheduledPendingAutoplayReadyRetryToken === pendingAutoplayReadyRetryToken) { + return; + } + if (pendingAutoplayReadyRetryAttempts >= MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS) { + return; + } + + const retryToken = pendingAutoplayReadyRetryToken; + pendingAutoplayReadyRetryAttempts += 1; + scheduledPendingAutoplayReadyRetryToken = retryToken; + deps.schedule(() => { + if (scheduledPendingAutoplayReadyRetryToken === retryToken) { + scheduledPendingAutoplayReadyRetryToken = null; + } + if (retryToken !== pendingAutoplayReadyRetryToken || !pendingAutoplayReadySignal) { + return; + } + flushPendingAutoplayReadySignal(); + }, PENDING_AUTOPLAY_READY_RETRY_DELAY_MS); }; const releaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => { @@ -139,6 +177,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { }; pendingAutoplayReadySignal = null; + invalidatePendingAutoplayReadyRetry(); autoPlayReadySignalMediaPath = mediaPath; const playbackGeneration = ++autoPlayReadySignalGeneration; deps.signalPluginAutoplayReady(); @@ -152,10 +191,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { return; } if (!isSignalTargetReady(signal)) { - setPendingAutoplayReadySignal(signal); - deps.logDebug( - `[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`, - ); + const pendingSignalChanged = setPendingAutoplayReadySignal(signal); + schedulePendingAutoplayReadyRetry(); + if (pendingSignalChanged) { + deps.logDebug( + `[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`, + ); + } return; } diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts index 4475b5f0..2ef62632 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts @@ -4,6 +4,7 @@ import { notifyCharacterDictionaryAutoSyncStatus, type CharacterDictionaryAutoSyncNotificationEvent, } from './character-dictionary-auto-sync-notifications'; +import { createStartupOsdSequencer } from './startup-osd-sequencer'; function makeEvent( phase: CharacterDictionaryAutoSyncNotificationEvent['phase'], @@ -70,7 +71,7 @@ test('auto sync notifications send osd updates for progress phases', () => { ]); }); -test('auto sync notifications route both to overlay and system only', () => { +test('auto sync notifications prefer overlay delivery for both when overlay is available', () => { const calls: string[] = []; notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), { @@ -100,9 +101,7 @@ test('auto sync notifications route both to overlay and system only', () => { assert.deepEqual(calls, [ 'overlay:character-dictionary-auto-sync:Character dictionary:syncing:pin', - 'desktop:SubMiner:syncing', 'overlay:character-dictionary-auto-sync:Character dictionary:ready:auto', - 'desktop:SubMiner:ready', ]); }); @@ -187,3 +186,29 @@ test('auto sync notifications send osd-system desktop updates with startup seque assert.deepEqual(calls, ['sequencer:importing:importing', 'desktop:SubMiner:importing']); }); + +test('auto sync notifications let startup sequencer own osd-system desktop delivery', () => { + const calls: string[] = []; + const startupOsdSequencer = createStartupOsdSequencer({ + getNotificationType: () => 'osd-system', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showDesktopNotification: (title, options) => { + calls.push(`desktop:${title}:${options.body ?? ''}`); + }, + }); + startupOsdSequencer.markTokenizationReady(); + + notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), { + getNotificationType: () => 'osd-system', + showOsd: (message) => { + calls.push(`direct-osd:${message}`); + }, + showDesktopNotification: (title, options) => + calls.push(`direct-desktop:${title}:${options.body ?? ''}`), + startupOsdSequencer, + }); + + assert.deepEqual(calls, ['osd:importing', 'desktop:SubMiner:importing']); +}); diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.ts index 47b80a91..c9b92f70 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.ts @@ -35,6 +35,8 @@ export function notifyCharacterDictionaryAutoSyncStatus( ): void { const type = deps.getNotificationType() ?? 'overlay'; if (type === 'none') return; + let overlayShown = false; + let startupSequencerShown = false; if (shouldShowOverlay(type)) { if (deps.showOverlayNotification) { @@ -45,6 +47,7 @@ export function notifyCharacterDictionaryAutoSyncStatus( variant: overlayVariantForPhase(event.phase), persistent: !isTerminalPhase(event.phase), }); + overlayShown = true; } else if (!shouldShowDesktop(type)) { deps.showDesktopNotification('SubMiner', { body: event.message }); } @@ -52,7 +55,7 @@ export function notifyCharacterDictionaryAutoSyncStatus( if (shouldShowOsd(type)) { if (deps.startupOsdSequencer) { - deps.startupOsdSequencer.notifyCharacterDictionaryStatus({ + startupSequencerShown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({ phase: event.phase, message: event.message, }); @@ -61,7 +64,7 @@ export function notifyCharacterDictionaryAutoSyncStatus( } } - if (shouldShowDesktop(type)) { + if (shouldShowDesktop(type) && !overlayShown && !startupSequencerShown) { deps.showDesktopNotification('SubMiner', { body: event.message }); } } diff --git a/src/main/runtime/visible-overlay-autoplay-readiness.test.ts b/src/main/runtime/visible-overlay-autoplay-readiness.test.ts index 80269df0..c301c651 100644 --- a/src/main/runtime/visible-overlay-autoplay-readiness.test.ts +++ b/src/main/runtime/visible-overlay-autoplay-readiness.test.ts @@ -62,7 +62,41 @@ test('visible overlay autoplay target falls back when interactive rects have no assert.equal(ready, true); }); -test('visible overlay autoplay target rejects synthetic warmup readiness', () => { +test('visible overlay autoplay target accepts synthetic warmup readiness after content-ready', () => { + const ready = isVisibleOverlayAutoplayTargetReady( + { + getVisibleOverlayVisible: () => true, + isOverlayWindowReady: () => true, + getLatestVisibleMeasurement: () => null, + }, + { + mediaPath: '/media/video.mkv', + payload: { text: '__warm__', tokens: null }, + requestedAtMs: 1_000, + }, + ); + + assert.equal(ready, true); +}); + +test('visible overlay autoplay target waits for content-ready before synthetic warmup readiness', () => { + const ready = isVisibleOverlayAutoplayTargetReady( + { + getVisibleOverlayVisible: () => true, + isOverlayWindowReady: () => false, + getLatestVisibleMeasurement: () => visibleMeasurement(2_000), + }, + { + mediaPath: '/media/video.mkv', + payload: { text: '__warm__', tokens: null }, + requestedAtMs: 1_000, + }, + ); + + assert.equal(ready, false); +}); + +test('visible overlay autoplay target rejects empty readiness payloads', () => { const ready = isVisibleOverlayAutoplayTargetReady( { getVisibleOverlayVisible: () => true, @@ -71,7 +105,7 @@ test('visible overlay autoplay target rejects synthetic warmup readiness', () => }, { mediaPath: '/media/video.mkv', - payload: { text: '__warm__', tokens: null }, + payload: { text: '', tokens: null }, requestedAtMs: 1_000, }, ); diff --git a/src/main/runtime/visible-overlay-autoplay-readiness.ts b/src/main/runtime/visible-overlay-autoplay-readiness.ts index 4ee4ac66..fc5eb7ca 100644 --- a/src/main/runtime/visible-overlay-autoplay-readiness.ts +++ b/src/main/runtime/visible-overlay-autoplay-readiness.ts @@ -31,7 +31,7 @@ export function isVisibleOverlayAutoplayTargetReady( } const subtitleText = signal.payload.text.trim(); - if (!subtitleText || subtitleText === '__warm__') { + if (!subtitleText) { return false; } @@ -39,6 +39,10 @@ export function isVisibleOverlayAutoplayTargetReady( return false; } + if (subtitleText === '__warm__') { + return true; + } + const measurement = deps.getLatestVisibleMeasurement(); if (!measurement || measurement.measuredAtMs < signal.requestedAtMs) { return false;