fix: flush playback position before media path clear

This commit is contained in:
2026-03-20 00:30:50 -07:00
parent 0ee150ed91
commit 1267085306
6 changed files with 88 additions and 2 deletions

View File

@@ -87,6 +87,7 @@ test('media path change handler reports stop for empty path and probes media key
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'), syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'), scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`), signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'), refreshDiscordPresence: () => calls.push('presence'),
@@ -94,6 +95,7 @@ test('media path change handler reports stop for empty path and probes media key
handler({ path: '' }); handler({ path: '' });
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'flush-playback',
'path:', 'path:',
'stopped', 'stopped',
'restore-mpv-sub', '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}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'), syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'), scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`), signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'), 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', () => { test('media title change handler clears guess state without re-scheduling character dictionary sync', () => {
const calls: string[] = []; const calls: string[] = [];
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & { const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {

View File

@@ -53,10 +53,14 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
syncImmersionMediaState: () => void; syncImmersionMediaState: () => void;
scheduleCharacterDictionarySync?: () => void; scheduleCharacterDictionarySync?: () => void;
signalAutoplayReadyIfWarm?: (path: string) => void; signalAutoplayReadyIfWarm?: (path: string) => void;
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
}) { }) {
return ({ path }: { path: string | null }): void => { return ({ path }: { path: string | null }): void => {
const normalizedPath = typeof path === 'string' ? path : ''; const normalizedPath = typeof path === 'string' ? path : '';
if (!normalizedPath) {
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
}
deps.updateCurrentMediaPath(normalizedPath); deps.updateCurrentMediaPath(normalizedPath);
if (!normalizedPath) { if (!normalizedPath) {
deps.reportJellyfinRemoteStopped(); deps.reportJellyfinRemoteStopped();

View File

@@ -44,6 +44,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'), syncImmersionMediaState: () => calls.push('sync-immersion'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`), updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess-state'), 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:normal'));
assert.ok(calls.includes('progress:force')); assert.ok(calls.includes('progress:force'));
assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('flush-playback'));
}); });

View File

@@ -56,6 +56,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
ensureAnilistMediaGuess: (mediaKey: string) => void; ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void; syncImmersionMediaState: () => void;
signalAutoplayReadyIfWarm?: (path: string) => void; signalAutoplayReadyIfWarm?: (path: string) => void;
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
updateCurrentMediaTitle: (title: string) => void; updateCurrentMediaTitle: (title: string) => void;
resetAnilistMediaGuessState: () => void; resetAnilistMediaGuessState: () => void;
@@ -114,6 +115,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey), ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(), syncImmersionMediaState: () => deps.syncImmersionMediaState(),
flushPlaybackPositionOnMediaPathClear: (mediaPath) =>
deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath),
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path), signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(), scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(), refreshDiscordPresence: () => deps.refreshDiscordPresence(),

View File

@@ -7,7 +7,11 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
const appState = { const appState = {
initialArgs: { jellyfinPlay: true }, initialArgs: { jellyfinPlay: true },
overlayRuntimeInitialized: true, overlayRuntimeInitialized: true,
mpvClient: { connected: true }, mpvClient: {
connected: true,
currentTimePos: 12.25,
requestProperty: async () => 18.75,
},
immersionTracker: { immersionTracker: {
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`), recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`), 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.recordPauseState(true);
deps.updateSubtitleRenderMetrics({}); deps.updateSubtitleRenderMetrics({});
deps.setPreviousSecondarySubVisibility(true); deps.setPreviousSecondarySubVisibility(true);
deps.flushPlaybackPositionOnMediaPathClear?.('');
await Promise.resolve();
assert.equal(appState.currentSubText, 'sub'); assert.equal(appState.currentSubText, 'sub');
assert.equal(appState.currentSubAssText, 'ass'); 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('metrics'));
assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('immersion-time:12.25'));
assert.ok(calls.includes('immersion-time:18.75'));
}); });

View File

@@ -4,7 +4,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: { appState: {
initialArgs?: { jellyfinPlay?: unknown } | null; initialArgs?: { jellyfinPlay?: unknown } | null;
overlayRuntimeInitialized: boolean; overlayRuntimeInitialized: boolean;
mpvClient: { connected?: boolean; currentSecondarySubText?: string } | null; mpvClient:
| {
connected?: boolean;
currentSecondarySubText?: string;
currentTimePos?: number;
requestProperty?: (name: string) => Promise<unknown>;
}
| null;
immersionTracker: { immersionTracker: {
recordSubtitleLine?: ( recordSubtitleLine?: (
text: string, text: string,
@@ -21,6 +28,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
subtitleTimingTracker: { subtitleTimingTracker: {
recordSubtitle?: (text: string, start: number, end: number) => void; recordSubtitle?: (text: string, start: number, end: number) => void;
} | null; } | null;
currentMediaPath?: string | null;
currentSubText: string; currentSubText: string;
currentSubAssText: string; currentSubAssText: string;
currentSubtitleData?: SubtitleData | null; currentSubtitleData?: SubtitleData | null;
@@ -58,6 +66,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
ensureImmersionTrackerInitialized: () => void; ensureImmersionTrackerInitialized: () => void;
tokenizeSubtitleForImmersion?: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitleForImmersion?: (text: string) => Promise<SubtitleData | null>;
}) { }) {
const writePlaybackPositionFromMpv = (timeSec: unknown): void => {
const normalizedTimeSec = Number(timeSec);
if (!Number.isFinite(normalizedTimeSec)) {
return;
}
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPlaybackPosition?.(normalizedTimeSec);
};
return () => ({ return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
@@ -161,6 +178,25 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
deps.ensureImmersionTrackerInitialized(); deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPauseState?.(paused); 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<string, unknown>) => updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
deps.updateSubtitleRenderMetrics(patch), deps.updateSubtitleRenderMetrics(patch),
setPreviousSecondarySubVisibility: (visible: boolean) => { setPreviousSecondarySubVisibility: (visible: boolean) => {