From 1c50d249e0d1f81a6ffde43b28599ffe56eb2dd2 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 3 Nov 2026 21:40:14 -0800 Subject: [PATCH] fix(mpv): reset per-file state when a same-file reload skips the path clear (#323) mpv only sends the latest value of an observed property, and only after its IPC thread has handled queued commands and events. A same-file `loadfile replace` can therefore arrive with no `path=null`, so path comparisons see no change, the autoplay gate stays released for that path and drops every ready signal, and playback stays paused. Track an `end-file` that mpv has not followed with a different path. If it is still pending at `file-loaded`, replay the clear and read the path back, so consumers see the same sequence as any other load. `file-loaded` waits for the reply so plugins can read their media snapshot during the event. Fixes #320 --- changes/same-file-reload-autoplay.md | 4 + src/core/services/mpv-protocol.test.ts | 106 +++++++++++++++++++++++++ src/core/services/mpv-protocol.ts | 62 ++++++++++++--- src/core/services/mpv.ts | 6 ++ 4 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 changes/same-file-reload-autoplay.md diff --git a/changes/same-file-reload-autoplay.md b/changes/same-file-reload-autoplay.md new file mode 100644 index 00000000..3b70fe50 --- /dev/null +++ b/changes/same-file-reload-autoplay.md @@ -0,0 +1,4 @@ +type: fixed +area: playback + +- Reloading the same file in mpv (`loadfile replace`) could intermittently leave playback paused forever. SubMiner now treats that reload like any other newly loaded file, so it resets the subtitle state and resumes once the file is ready. diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 2cd47155..566e801b 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import type { MpvSubtitleRenderMetrics } from '../../types'; import { dispatchMpvProtocolMessage, + MPV_REQUEST_ID_RELOADED_PATH, MPV_REQUEST_ID_TRACK_LIST_SECONDARY, MpvProtocolHandleMessageDeps, splitMpvMessagesFromBuffer, @@ -99,7 +100,10 @@ function createDeps(overrides: Partial = {}): { emitPauseChange: () => {}, emitFullscreenChange: (payload) => state.events.push(payload), autoLoadSecondarySubTrack: () => {}, + getCurrentVideoPath: () => '', setCurrentVideoPath: () => {}, + getAwaitingPathChangeAfterEndFile: () => false, + setAwaitingPathChangeAfterEndFile: () => {}, emitSecondarySubtitleVisibility: (payload) => state.events.push(payload), setCurrentAudioStreamIndex: () => {}, sendCommand: (payload) => { @@ -502,3 +506,105 @@ test('dispatchMpvProtocolMessage emits end-file with reason, entry id, and error { reason: 'unknown', playlistEntryId: null, error: null }, ]); }); + +const CLIP = '/media/clip.mkv'; + +// Plays a reload's message sequence against real path state. The timeline holds each path change +// plus 'file-loaded' with the path listeners can read at that moment. +async function playFileTransition(messages: Array>) { + const timeline: string[] = []; + let currentPath = CLIP; + let awaitingPathChange = false; + const { deps, state } = createDeps({ + getCurrentVideoPath: () => currentPath, + setCurrentVideoPath: (value) => { + currentPath = value; + }, + getAwaitingPathChangeAfterEndFile: () => awaitingPathChange, + setAwaitingPathChangeAfterEndFile: (value) => { + awaitingPathChange = value; + }, + emitMediaPathChange: ({ path }) => timeline.push(`path:${path}`), + emitFileLoaded: () => timeline.push(`file-loaded:${currentPath}`), + }); + for (const message of messages) { + await dispatchMpvProtocolMessage(message, deps); + } + const pathReads = state.commands.filter( + (payload) => (payload as { request_id?: number }).request_id === MPV_REQUEST_ID_RELOADED_PATH, + ); + return { timeline, pathReads, currentPath }; +} + +const endFile = { event: 'end-file', reason: 'stop' }; +const fileLoaded = { event: 'file-loaded' }; +const pathEvent = (data: string | null) => ({ event: 'property-change', name: 'path', data }); + +test('a reload whose path clear arrives needs no recovery', async () => { + const { timeline, pathReads } = await playFileTransition([ + endFile, + pathEvent(null), + pathEvent(CLIP), + fileLoaded, + ]); + + assert.deepEqual(timeline, ['path:', `path:${CLIP}`, `file-loaded:${CLIP}`]); + assert.equal(pathReads.length, 0); +}); + +test('a dropped clear is replayed and file-loaded waits for the restored path', async () => { + // With commands queued or a stalled reader, every queued event comes first, then the latest path. + const { timeline, pathReads, currentPath } = await playFileTransition([ + endFile, + fileLoaded, + pathEvent(CLIP), + { request_id: MPV_REQUEST_ID_RELOADED_PATH, data: CLIP }, + ]); + + assert.deepEqual(timeline, ['path:', `path:${CLIP}`, `file-loaded:${CLIP}`]); + assert.equal(pathReads.length, 1); + assert.equal(currentPath, CLIP); +}); + +test('an unchanged path event before file-loaded does not hide a dropped clear', async () => { + const { timeline } = await playFileTransition([ + endFile, + pathEvent(CLIP), + fileLoaded, + { request_id: MPV_REQUEST_ID_RELOADED_PATH, data: CLIP }, + ]); + + assert.deepEqual(timeline, [`path:${CLIP}`, 'path:', `path:${CLIP}`, `file-loaded:${CLIP}`]); +}); + +test('a reload with no path event at all restores the path from the read back', async () => { + const { timeline, currentPath } = await playFileTransition([ + endFile, + fileLoaded, + { request_id: MPV_REQUEST_ID_RELOADED_PATH, data: CLIP }, + ]); + + assert.deepEqual(timeline, ['path:', `path:${CLIP}`, `file-loaded:${CLIP}`]); + assert.equal(currentPath, CLIP); +}); + +test('a failed path read back still delivers the deferred file-loaded', async () => { + const { timeline } = await playFileTransition([ + endFile, + fileLoaded, + { request_id: MPV_REQUEST_ID_RELOADED_PATH, error: 'property unavailable' }, + ]); + + assert.deepEqual(timeline, ['path:', 'file-loaded:']); +}); + +test('switching files with a dropped clear stays a plain path change', async () => { + const { timeline, pathReads } = await playFileTransition([ + endFile, + pathEvent('/media/next.mkv'), + fileLoaded, + ]); + + assert.deepEqual(timeline, ['path:/media/next.mkv', 'file-loaded:/media/next.mkv']); + assert.equal(pathReads.length, 0); +}); diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 494260a0..f73ae408 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -55,6 +55,7 @@ export const MPV_REQUEST_ID_SUB_SHADOW_OFFSET = 120; export const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121; export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122; export const MPV_REQUEST_ID_PAUSE = 123; +export const MPV_REQUEST_ID_RELOADED_PATH = 124; export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200; export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; export const MPV_REQUEST_ID_MEDIA_TITLE = 202; @@ -109,7 +110,11 @@ export interface MpvProtocolHandleMessageDeps { getPauseAtTime: () => number | null; setPauseAtTime: (value: number | null) => void; autoLoadSecondarySubTrack: (path: string) => void; + getCurrentVideoPath: () => string; setCurrentVideoPath: (value: string) => void; + /** True from `end-file` until mpv reports a different path or the next `file-loaded`. */ + getAwaitingPathChangeAfterEndFile: () => boolean; + setAwaitingPathChangeAfterEndFile: (value: boolean) => void; emitSecondarySubtitleVisibility: (payload: { visible: boolean }) => void; setPreviousSecondarySubVisibility: (visible: boolean) => void; setCurrentAudioStreamIndex: ( @@ -380,18 +385,10 @@ export async function dispatchMpvProtocolMessage( applyMediaTitle(deps, msg.data); } else if (msg.name === 'path') { const path = (msg.data as string) || ''; - deps.setCurrentVideoPath(path); - // A forced title set before loadfile arrives ahead of the path change that clears the - // cached title and never fires again, so read it back once the new path is known. - if (path) { - deps.sendCommand({ - command: ['get_property', 'media-title'], - request_id: MPV_REQUEST_ID_MEDIA_TITLE, - }); + if (path !== deps.getCurrentVideoPath()) { + deps.setAwaitingPathChangeAfterEndFile(false); } - deps.emitMediaPathChange({ path }); - deps.autoLoadSecondarySubTrack(path); - deps.syncCurrentAudioStreamIndex(); + applyMediaPath(deps, path); } else if (msg.name === 'sub-pos') { deps.emitSubtitleMetricsChange({ subPos: msg.data as number }); } else if (msg.name === 'sub-font-size') { @@ -464,9 +461,26 @@ export async function dispatchMpvProtocolMessage( deps.emitClientMessage?.({ args }); } } else if (msg.event === 'file-loaded') { + if (deps.getAwaitingPathChangeAfterEndFile()) { + // mpv only sends the latest value of an observed property, once its IPC thread gets to it, so + // a same-file reload can skip the path clear. The reload then looks like nothing happened and + // per-file state (autoplay gate, subtitles) never resets. Replay the clear so consumers see + // the same sequence as any other load. Read the path back because the unchanged value may + // never be reported, and hold file-loaded until the reply so listeners can read the path. + deps.setAwaitingPathChangeAfterEndFile(false); + applyMediaPath(deps, ''); + deps.sendCommand({ + command: ['get_property', 'path'], + request_id: MPV_REQUEST_ID_RELOADED_PATH, + }); + return; + } deps.emitFileLoaded?.(); } else if (msg.event === 'end-file') { deps.emitEndFile?.(parseEndFileEvent(msg)); + if (deps.getCurrentVideoPath()) { + deps.setAwaitingPathChangeAfterEndFile(true); + } } else if (msg.event === 'shutdown') { deps.restorePreviousSecondarySubVisibility(); if (deps.shouldQuitOnMpvShutdown()) { @@ -477,6 +491,17 @@ export async function dispatchMpvProtocolMessage( return; } + // Before the no-data check: an error reply still owes the file-loaded held for this read. + if (msg.request_id === MPV_REQUEST_ID_RELOADED_PATH) { + // The path property event may have restored the path before this reply arrived. + const path = (msg.data as string) || ''; + if (path && !deps.getCurrentVideoPath()) { + applyMediaPath(deps, path); + } + deps.emitFileLoaded?.(); + return; + } + if (msg.data === undefined) { return; } @@ -618,6 +643,21 @@ function applyMediaTitle( deps.emitMediaTitleChange({ title }); } +function applyMediaPath(deps: MpvProtocolHandleMessageDeps, path: string): void { + deps.setCurrentVideoPath(path); + // A forced title set before loadfile arrives ahead of the path change that clears the + // cached title and never fires again, so read it back once the new path is known. + if (path) { + deps.sendCommand({ + command: ['get_property', 'media-title'], + request_id: MPV_REQUEST_ID_MEDIA_TITLE, + }); + } + deps.emitMediaPathChange({ path }); + deps.autoLoadSecondarySubTrack(path); + deps.syncCurrentAudioStreamIndex(); +} + export function parseVisibilityProperty(value: unknown): boolean | null { if (typeof value === 'boolean') return value; if (typeof value !== 'string') return null; diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 4e5d53a4..db763e58 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -142,6 +142,7 @@ export class MpvIpcClient implements MpvClient { private hasConnectedOnce = false; private socketErrorWarnedForDisconnect = false; public currentVideoPath = ''; + private awaitingPathChangeAfterEndFile = false; public currentMediaTitle: string | null = null; public currentTimePos = 0; public currentSubStart = 0; @@ -489,10 +490,15 @@ export class MpvIpcClient implements MpvClient { autoLoadSecondarySubTrack: (path: string) => { this.autoLoadSecondarySubTrack(path); }, + getCurrentVideoPath: () => this.currentVideoPath, setCurrentVideoPath: (value: string) => { this.currentVideoPath = value; this.currentMediaTitle = null; }, + getAwaitingPathChangeAfterEndFile: () => this.awaitingPathChangeAfterEndFile, + setAwaitingPathChangeAfterEndFile: (value: boolean) => { + this.awaitingPathChangeAfterEndFile = value; + }, emitSecondarySubtitleVisibility: (payload) => { this.emit('secondary-subtitle-visibility', payload); },