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
This commit is contained in:
2026-06-06 01:55:12 -07:00
parent ef914a321f
commit 501304e451
11 changed files with 205 additions and 22 deletions
+4
View File
@@ -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.
@@ -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.
@@ -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
+8 -2
View File
@@ -1896,10 +1896,16 @@ async function resolveSentenceSearchHeadwords(term: string): Promise<string[]> {
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({
+10 -2
View File
@@ -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\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
const signalBlock = source.match(
/function signalCurrentSubtitleAutoplayReady\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const currentPayloadBlock = source.match(
/function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?<body>[\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/);
@@ -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<Array<string | boolean>> = [];
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<string | boolean> }) => {
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<Array<string | boolean>> = [];
let targetReady = false;
+45 -3
View File
@@ -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<unknown>;
@@ -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);
const pendingSignalChanged = setPendingAutoplayReadySignal(signal);
schedulePendingAutoplayReadyRetry();
if (pendingSignalChanged) {
deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`,
);
}
return;
}
@@ -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']);
});
@@ -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 });
}
}
@@ -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,
},
);
@@ -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;