From dde19ad0daf4a7a8e7f5929c9494b74b73c6ace4 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 20 May 2026 08:09:49 -0700 Subject: [PATCH] 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 --- changes/fix-autoplay-subtitle-prime.md | 4 + package.json | 4 +- src/main.ts | 106 +++++++++++++++++- .../runtime/autoplay-subtitle-primer.test.ts | 52 +++++++++ src/main/runtime/autoplay-subtitle-primer.ts | 31 +++++ ...autoplay-tokenization-warm-release.test.ts | 54 +++++++++ .../autoplay-tokenization-warm-release.ts | 8 ++ 7 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 changes/fix-autoplay-subtitle-prime.md create mode 100644 src/main/runtime/autoplay-subtitle-primer.test.ts create mode 100644 src/main/runtime/autoplay-subtitle-primer.ts diff --git a/changes/fix-autoplay-subtitle-prime.md b/changes/fix-autoplay-subtitle-prime.md new file mode 100644 index 00000000..8b34d453 --- /dev/null +++ b/changes/fix-autoplay-subtitle-prime.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Primed the first startup subtitle before autoplay resumes so the overlay can render text before video playback begins. diff --git a/package.json b/package.json index 32479bc7..957b6695 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", - "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", + "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", + "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", diff --git a/src/main.ts b/src/main.ts index cbd8a68d..ecf6817c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { + 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 { + 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 => 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 }, diff --git a/src/main/runtime/autoplay-subtitle-primer.test.ts b/src/main/runtime/autoplay-subtitle-primer.test.ts new file mode 100644 index 00000000..dcd706c1 --- /dev/null +++ b/src/main/runtime/autoplay-subtitle-primer.test.ts @@ -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' }, + ); +}); diff --git a/src/main/runtime/autoplay-subtitle-primer.ts b/src/main/runtime/autoplay-subtitle-primer.ts new file mode 100644 index 00000000..fbc93314 --- /dev/null +++ b/src/main/runtime/autoplay-subtitle-primer.ts @@ -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; +} diff --git a/src/main/runtime/autoplay-tokenization-warm-release.test.ts b/src/main/runtime/autoplay-tokenization-warm-release.test.ts index 350401ff..17ff7f92 100644 --- a/src/main/runtime/autoplay-tokenization-warm-release.test.ts +++ b/src/main/runtime/autoplay-tokenization-warm-release.test.ts @@ -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((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(() => {}); + 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; diff --git a/src/main/runtime/autoplay-tokenization-warm-release.ts b/src/main/runtime/autoplay-tokenization-warm-release.ts index ae6112e4..9ed46391 100644 --- a/src/main/runtime/autoplay-tokenization-warm-release.ts +++ b/src/main/runtime/autoplay-tokenization-warm-release.ts @@ -10,6 +10,7 @@ export function createAutoplayTokenizationWarmRelease(deps: { isTokenizationWarmupReady: () => boolean; startTokenizationWarmups: () => Promise; getCurrentMediaPath: () => string | null | undefined; + primeCurrentSubtitle?: (mediaPath: string) => void | Promise; 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;