mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
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:
1 parent
8e29cb3c55
commit
1c50d249e0
4 files changed
+167
-11
No files matched your search
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: playback
|
||||
|
||||
- Reloading the same file in mpv (`loadfile <current path> 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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user