mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 12:55:20 -07:00
fix: prime startup subtitle before autoplay resumes
- Add `selectAutoplayStartupCue` to pick active or imminent cue at startup - Call `primeCurrentSubtitle` in warm-release before signaling autoplay ready - Reset primed state on media path change to avoid stale cue leaks
This commit is contained in:
+105
-1
@@ -104,6 +104,7 @@ import type {
|
||||
RuntimeOptionState,
|
||||
SessionActionDispatchRequest,
|
||||
SecondarySubMode,
|
||||
SubtitleCue,
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
UpdateChannel,
|
||||
@@ -363,6 +364,7 @@ import {
|
||||
createYoutubePrimarySubtitleNotificationRuntime,
|
||||
} from './main/runtime/youtube-primary-subtitle-notification';
|
||||
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
|
||||
import { selectAutoplayStartupCue } from './main/runtime/autoplay-subtitle-primer';
|
||||
import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release';
|
||||
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
|
||||
import {
|
||||
@@ -1625,6 +1627,88 @@ let lastObservedTimePos = 0;
|
||||
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
|
||||
null;
|
||||
const SEEK_THRESHOLD_SECONDS = 3;
|
||||
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
||||
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
||||
|
||||
function getCurrentAutoplayMediaPath(): string | null {
|
||||
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
|
||||
}
|
||||
|
||||
function isCurrentAutoplayMediaPath(mediaPath: string): boolean {
|
||||
return getCurrentAutoplayMediaPath() === mediaPath;
|
||||
}
|
||||
|
||||
function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean {
|
||||
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
|
||||
return false;
|
||||
}
|
||||
autoplaySubtitlePrimedMediaPath = mediaPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
|
||||
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||
return false;
|
||||
}
|
||||
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
appState.currentSubText = text;
|
||||
const rawPayload = withCurrentSubtitleTiming({ text, tokens: null });
|
||||
appState.currentSubtitleData = rawPayload;
|
||||
broadcastToOverlayWindows('subtitle:set', rawPayload);
|
||||
subtitlePrefetchService?.pause();
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
|
||||
const client = appState.mpvClient;
|
||||
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subTextRaw = await client.requestProperty('sub-text').catch((error) => {
|
||||
logger.debug(
|
||||
`[autoplay-subtitle-prime] failed to read sub-text: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
emitAutoplayPrimedSubtitle(mediaPath, text);
|
||||
}
|
||||
|
||||
async function primeAutoplaySubtitleFromParsedCues(
|
||||
mediaPath: string,
|
||||
cues: SubtitleCue[],
|
||||
): Promise<void> {
|
||||
if (
|
||||
cues.length === 0 ||
|
||||
autoplaySubtitlePrimedMediaPath === mediaPath ||
|
||||
!isCurrentAutoplayMediaPath(mediaPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = appState.mpvClient;
|
||||
const timePosRaw = await client?.requestProperty('time-pos').catch(() => null);
|
||||
const currentTimeSeconds = Number(
|
||||
timePosRaw ?? client?.currentTimePos ?? lastObservedTimePos ?? 0,
|
||||
);
|
||||
const cue = selectAutoplayStartupCue(
|
||||
cues,
|
||||
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
||||
);
|
||||
if (!cue) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
|
||||
}
|
||||
|
||||
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||
if (subtitlePrefetchRefreshTimer) {
|
||||
@@ -1657,6 +1741,16 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||
appState.activeParsedSubtitleCues = cues ?? [];
|
||||
appState.activeParsedSubtitleSource = sourceKey;
|
||||
const mediaPath = getCurrentAutoplayMediaPath();
|
||||
if (mediaPath && cues?.length) {
|
||||
void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => {
|
||||
logger.debug(
|
||||
`[autoplay-subtitle-prime] failed to prime from parsed cues: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
@@ -4150,6 +4244,14 @@ const {
|
||||
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
|
||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
|
||||
updateCurrentMediaPath: (path) => {
|
||||
const normalizedPath = path.trim();
|
||||
const previousPath = appState.currentMediaPath?.trim() || null;
|
||||
if ((normalizedPath || null) !== previousPath) {
|
||||
autoplaySubtitlePrimedMediaPath = null;
|
||||
appState.currentSubText = '';
|
||||
appState.currentSubAssText = '';
|
||||
appState.currentSubtitleData = null;
|
||||
}
|
||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||
@@ -4159,7 +4261,8 @@ const {
|
||||
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
// Delay slightly to allow MPV's track-list to be populated.
|
||||
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
|
||||
// Retry after a short delay because MPV can populate track-list after path.
|
||||
subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500);
|
||||
}
|
||||
mediaRuntime.updateCurrentMediaPath(path);
|
||||
@@ -4410,6 +4513,7 @@ signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease(
|
||||
},
|
||||
getCurrentMediaPath: () =>
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||
primeCurrentSubtitle: (mediaPath) => primeCurrentSubtitleForAutoplay(mediaPath),
|
||||
signalAutoplayReady: () => {
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(
|
||||
{ text: '__warm__', tokens: null },
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||
|
||||
test('selectAutoplayStartupCue returns the active cue at the current time', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue(
|
||||
[
|
||||
{ startTime: 1, endTime: 3, text: 'first' },
|
||||
{ startTime: 4, endTime: 5, text: 'second' },
|
||||
],
|
||||
2,
|
||||
1,
|
||||
),
|
||||
{ startTime: 1, endTime: 3, text: 'first' },
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue returns the next imminent cue before playback starts', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue(
|
||||
[
|
||||
{ startTime: 1.2, endTime: 3, text: 'first' },
|
||||
{ startTime: 4, endTime: 5, text: 'second' },
|
||||
],
|
||||
0,
|
||||
2,
|
||||
),
|
||||
{ startTime: 1.2, endTime: 3, text: 'first' },
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue does not reveal far future subtitle text', () => {
|
||||
assert.equal(
|
||||
selectAutoplayStartupCue([{ startTime: 12, endTime: 15, text: 'later' }], 0, 2),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('selectAutoplayStartupCue skips blank cues', () => {
|
||||
assert.deepEqual(
|
||||
selectAutoplayStartupCue(
|
||||
[
|
||||
{ startTime: 0, endTime: 1, text: ' ' },
|
||||
{ startTime: 0.5, endTime: 2, text: 'visible' },
|
||||
],
|
||||
0.75,
|
||||
1,
|
||||
),
|
||||
{ startTime: 0.5, endTime: 2, text: 'visible' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { SubtitleCue } from '../../types';
|
||||
|
||||
export function selectAutoplayStartupCue(
|
||||
cues: SubtitleCue[],
|
||||
currentTimeSeconds: number,
|
||||
lookaheadSeconds: number,
|
||||
): SubtitleCue | null {
|
||||
const currentTime = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
|
||||
const lookahead = Math.max(0, Number.isFinite(lookaheadSeconds) ? lookaheadSeconds : 0);
|
||||
const latestStartTime = currentTime + lookahead;
|
||||
|
||||
for (const cue of cues) {
|
||||
if (!cue.text.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (cue.startTime <= currentTime && cue.endTime > currentTime) {
|
||||
return cue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const cue of cues) {
|
||||
if (!cue.text.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (cue.startTime >= currentTime && cue.startTime <= latestStartTime) {
|
||||
return cue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -19,6 +19,60 @@ test('autoplay tokenization warm release signals immediately when warmups are re
|
||||
assert.deepEqual(calls, ['signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release primes subtitles before waiting for warmups', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveWarmup!: () => void;
|
||||
const warmup = new Promise<void>((resolve) => {
|
||||
resolveWarmup = resolve;
|
||||
});
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => false,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
await warmup;
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
primeCurrentSubtitle: () => {
|
||||
calls.push('prime');
|
||||
},
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(calls, ['prime', 'warmup']);
|
||||
|
||||
resolveWarmup();
|
||||
await warmup;
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['prime', 'warmup', 'signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release does not await subtitle priming before signaling ready media', async () => {
|
||||
const calls: string[] = [];
|
||||
const never = new Promise<void>(() => {});
|
||||
const release = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => true,
|
||||
startTokenizationWarmups: async () => {
|
||||
calls.push('warmup');
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
primeCurrentSubtitle: () => {
|
||||
calls.push('prime');
|
||||
return never;
|
||||
},
|
||||
signalAutoplayReady: () => calls.push('signal'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
release('/tmp/video.mkv');
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, ['prime', 'signal']);
|
||||
});
|
||||
|
||||
test('autoplay tokenization warm release waits for warmups before signaling current media', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveWarmup!: () => void;
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createAutoplayTokenizationWarmRelease(deps: {
|
||||
isTokenizationWarmupReady: () => boolean;
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
getCurrentMediaPath: () => string | null | undefined;
|
||||
primeCurrentSubtitle?: (mediaPath: string) => void | Promise<void>;
|
||||
signalAutoplayReady: () => void;
|
||||
warn: (message: string, error: unknown) => void;
|
||||
}): (mediaPath: string | null | undefined) => void {
|
||||
@@ -26,6 +27,13 @@ export function createAutoplayTokenizationWarmRelease(deps: {
|
||||
if (!normalizedPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
void Promise.resolve(deps.primeCurrentSubtitle?.(normalizedPath)).catch((error) => {
|
||||
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
|
||||
}
|
||||
if (deps.isTokenizationWarmupReady()) {
|
||||
signalIfCurrent(normalizedPath);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user