mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
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:
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user