From 12670853065200ffecdf5947ddb1b90b072d1ebc Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 20 Mar 2026 00:30:50 -0700 Subject: [PATCH] fix: flush playback position before media path clear --- .../runtime/mpv-main-event-actions.test.ts | 32 ++++++++++++++++ src/main/runtime/mpv-main-event-actions.ts | 4 ++ .../runtime/mpv-main-event-bindings.test.ts | 3 ++ src/main/runtime/mpv-main-event-bindings.ts | 3 ++ .../runtime/mpv-main-event-main-deps.test.ts | 10 ++++- src/main/runtime/mpv-main-event-main-deps.ts | 38 ++++++++++++++++++- 6 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index 0f406a8..eb0b4a7 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -87,6 +87,7 @@ test('media path change handler reports stop for empty path and probes media key maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), syncImmersionMediaState: () => calls.push('sync'), + flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'), scheduleCharacterDictionarySync: () => calls.push('dict-sync'), signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`), refreshDiscordPresence: () => calls.push('presence'), @@ -94,6 +95,7 @@ test('media path change handler reports stop for empty path and probes media key handler({ path: '' }); assert.deepEqual(calls, [ + 'flush-playback', 'path:', 'stopped', 'restore-mpv-sub', @@ -116,6 +118,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), syncImmersionMediaState: () => calls.push('sync'), + flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'), scheduleCharacterDictionarySync: () => calls.push('dict-sync'), signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`), refreshDiscordPresence: () => calls.push('presence'), @@ -133,6 +136,35 @@ test('media path change handler signals autoplay-ready fast path for warm non-em ]); }); +test('media path change handler ignores playback flush for non-empty path', () => { + const calls: string[] = []; + const handler = createHandleMpvMediaPathChangeHandler({ + updateCurrentMediaPath: (path) => calls.push(`path:${path}`), + reportJellyfinRemoteStopped: () => calls.push('stopped'), + restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`), + maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), + ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), + syncImmersionMediaState: () => calls.push('sync'), + flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'), + scheduleCharacterDictionarySync: () => calls.push('dict-sync'), + signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`), + refreshDiscordPresence: () => calls.push('presence'), + }); + + handler({ path: '/tmp/video.mkv' }); + assert.ok(!calls.includes('flush-playback')); + assert.deepEqual(calls, [ + 'path:/tmp/video.mkv', + 'reset:null', + 'sync', + 'dict-sync', + 'autoplay:/tmp/video.mkv', + 'presence', + ]); +}); + test('media title change handler clears guess state without re-scheduling character dictionary sync', () => { const calls: string[] = []; const deps: Parameters[0] & { diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index 0d098c1..77f9daa 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -53,10 +53,14 @@ export function createHandleMpvMediaPathChangeHandler(deps: { syncImmersionMediaState: () => void; scheduleCharacterDictionarySync?: () => void; signalAutoplayReadyIfWarm?: (path: string) => void; + flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void; refreshDiscordPresence: () => void; }) { return ({ path }: { path: string | null }): void => { const normalizedPath = typeof path === 'string' ? path : ''; + if (!normalizedPath) { + deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath); + } deps.updateCurrentMediaPath(normalizedPath); if (!normalizedPath) { deps.reportJellyfinRemoteStopped(); diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index 44c3552..fd4c9f5 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -44,6 +44,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), syncImmersionMediaState: () => calls.push('sync-immersion'), + flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'), updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`), resetAnilistMediaGuessState: () => calls.push('reset-guess-state'), @@ -86,4 +87,6 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { assert.ok(calls.includes('progress:normal')); assert.ok(calls.includes('progress:force')); assert.ok(calls.includes('presence-refresh')); + assert.ok(calls.includes('sync-immersion')); + assert.ok(calls.includes('flush-playback')); }); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 88dc7d7..14266c6 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -56,6 +56,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { ensureAnilistMediaGuess: (mediaKey: string) => void; syncImmersionMediaState: () => void; signalAutoplayReadyIfWarm?: (path: string) => void; + flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void; updateCurrentMediaTitle: (title: string) => void; resetAnilistMediaGuessState: () => void; @@ -114,6 +115,8 @@ export function createBindMpvMainEventHandlersHandler(deps: { maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey), syncImmersionMediaState: () => deps.syncImmersionMediaState(), + flushPlaybackPositionOnMediaPathClear: (mediaPath) => + deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath), signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path), scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(), refreshDiscordPresence: () => deps.refreshDiscordPresence(), diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 0ed1108..5b8b77d 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -7,7 +7,11 @@ test('mpv main event main deps map app state updates and delegate callbacks', as const appState = { initialArgs: { jellyfinPlay: true }, overlayRuntimeInitialized: true, - mpvClient: { connected: true }, + mpvClient: { + connected: true, + currentTimePos: 12.25, + requestProperty: async () => 18.75, + }, immersionTracker: { recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`), handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`), @@ -92,6 +96,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as deps.recordPauseState(true); deps.updateSubtitleRenderMetrics({}); deps.setPreviousSecondarySubVisibility(true); + deps.flushPlaybackPositionOnMediaPathClear?.(''); + await Promise.resolve(); assert.equal(appState.currentSubText, 'sub'); assert.equal(appState.currentSubAssText, 'ass'); @@ -106,4 +112,6 @@ test('mpv main event main deps map app state updates and delegate callbacks', as assert.ok(calls.includes('metrics')); assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('restore-mpv-sub')); + assert.ok(calls.includes('immersion-time:12.25')); + assert.ok(calls.includes('immersion-time:18.75')); }); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index ac1393e..5d4ac65 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -4,7 +4,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { appState: { initialArgs?: { jellyfinPlay?: unknown } | null; overlayRuntimeInitialized: boolean; - mpvClient: { connected?: boolean; currentSecondarySubText?: string } | null; + mpvClient: + | { + connected?: boolean; + currentSecondarySubText?: string; + currentTimePos?: number; + requestProperty?: (name: string) => Promise; + } + | null; immersionTracker: { recordSubtitleLine?: ( text: string, @@ -21,6 +28,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { subtitleTimingTracker: { recordSubtitle?: (text: string, start: number, end: number) => void; } | null; + currentMediaPath?: string | null; currentSubText: string; currentSubAssText: string; currentSubtitleData?: SubtitleData | null; @@ -58,6 +66,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { ensureImmersionTrackerInitialized: () => void; tokenizeSubtitleForImmersion?: (text: string) => Promise; }) { + const writePlaybackPositionFromMpv = (timeSec: unknown): void => { + const normalizedTimeSec = Number(timeSec); + if (!Number.isFinite(normalizedTimeSec)) { + return; + } + deps.ensureImmersionTrackerInitialized(); + deps.appState.immersionTracker?.recordPlaybackPosition?.(normalizedTimeSec); + }; + return () => ({ reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), @@ -161,6 +178,25 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { deps.ensureImmersionTrackerInitialized(); deps.appState.immersionTracker?.recordPauseState?.(paused); }, + flushPlaybackPositionOnMediaPathClear: (mediaPath: string) => { + const mpvClient = deps.appState.mpvClient; + const currentKnownTime = Number(mpvClient?.currentTimePos); + writePlaybackPositionFromMpv(currentKnownTime); + if (!mpvClient?.requestProperty) { + return; + } + void mpvClient.requestProperty('time-pos').then((timePos) => { + const currentPath = (deps.appState.currentMediaPath ?? '').trim(); + if (currentPath.length > 0 && currentPath !== mediaPath) { + return; + } + const resolvedTime = Number(timePos); + if (Number.isFinite(currentKnownTime) && Number.isFinite(resolvedTime) && currentKnownTime === resolvedTime) { + return; + } + writePlaybackPositionFromMpv(resolvedTime); + }); + }, updateSubtitleRenderMetrics: (patch: Record) => deps.updateSubtitleRenderMetrics(patch), setPreviousSecondarySubVisibility: (visible: boolean) => {