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
This commit is contained in:
sudacode authored and GitHub committed 2026-11-03 21:40:14 -08:00
1 parent 8e29cb3c55
commit 1c50d249e0
4 files changed
+167 -11

No files matched your search

+106
View File
@@ -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<MpvProtocolHandleMessageDeps> = {}): {
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<Record<string, unknown>>) {
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);
});