fix: clear aborted playback state, fix overlay passthrough, and guard du

- Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item
- Record visible overlay action only after command succeeds, not before
- Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering)
- Defer activeParsedSubtitleMediaPath assignment until after prefetch completes
- Move autoplay gate release into the hide branch of toggleVisibleOverlay
- Clear active Jellyfin playback when stopping media that never loaded
- Reset managed subtitle delay and delay key when no external tracks are available
- Await async removeDir in subtitle cache cleanup
- Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs
- Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
This commit is contained in:
2026-05-24 03:48:27 -07:00
parent 55eb7068b3
commit a91c3f8beb
20 changed files with 423 additions and 27 deletions
+20 -4
View File
@@ -59,17 +59,33 @@ test('same media path updates do not reset autoplay ready fallback state', () =>
);
});
test('manual visible overlay toggles suppress current-media autoplay release', () => {
test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
const source = readMainSource();
const actionBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);/);
assert.match(
actionBlock,
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
);
});
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
const source = readMainSource();
const actionBlock = source.match(
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
);
assert.ok(
actionBlock.indexOf('autoplayReadyGate.markCurrentMediaAutoplayReady();') <
actionBlock.indexOf('toggleVisibleOverlayHandler();'),
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') <
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'),
);
});
@@ -290,6 +290,37 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
assert.equal(cleared, true);
});
test('createReportJellyfinRemoteStoppedHandler clears aborted playback that never loaded', async () => {
let cleared = false;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () => ({
itemId: 'item-2',
mediaSourceId: undefined,
playMethod: 'Transcode',
audioStreamIndex: null,
subtitleStreamIndex: null,
loadedMediaPath: null,
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async () => {
throw new Error('should not report stopped for unloaded media');
},
}),
getMpvClient: () => null,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportStopped();
assert.equal(cleared, true);
});
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
let cleared = false;
let stoppedPayload: {
@@ -409,7 +440,7 @@ test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback'
await reportStopped();
assert.equal(stopped, false);
assert.equal(cleared, false);
assert.equal(cleared, true);
});
test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => {
+4 -1
View File
@@ -209,7 +209,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
return async (): Promise<void> => {
const playback = deps.getActivePlayback();
if (!playback) return;
if (playback.loadedMediaPath === null) return;
if (playback.loadedMediaPath === null) {
deps.clearActivePlayback();
return;
}
if (
typeof playback.stopReportsAfterMs === 'number' &&
Number.isFinite(playback.stopReportsAfterMs) &&
@@ -67,3 +67,27 @@ test('jellyfin subtitle cache io removes temp dir when download fails', async ()
);
assert.deepEqual(removed, ['/tmp/subminer-jellyfin-subtitles-failed']);
});
test('jellyfin subtitle cache io awaits async temp cleanup when download fails', async () => {
let removed = false;
const cacheIo = createJellyfinSubtitleCacheIo({
tmpDir: () => '/tmp',
makeTempDir: async () => '/tmp/subminer-jellyfin-subtitles-failed',
writeFile: async () => {},
removeDir: async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
removed = true;
},
fetch: async () => ({
ok: false,
status: 500,
arrayBuffer: async () => new ArrayBuffer(0),
}),
});
await assert.rejects(
() => cacheIo.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' }),
/HTTP 500/,
);
assert.equal(removed, true);
});
@@ -20,7 +20,7 @@ type JellyfinSubtitleCacheIoDeps = {
tmpDir: () => string;
makeTempDir: (prefix: string) => Promise<string>;
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
removeDir: (dir: string, options: { recursive: true; force: true }) => void;
removeDir: (dir: string, options: { recursive: true; force: true }) => void | Promise<void>;
fetch: (url: string) => Promise<FetchResponseLike>;
};
@@ -59,14 +59,16 @@ export function createJellyfinSubtitleCacheIo(deps: JellyfinSubtitleCacheIoDeps)
const bytes = new Uint8Array(await response.arrayBuffer());
await deps.writeFile(subtitlePath, bytes);
} catch (error) {
deps.removeDir(cacheDir, { recursive: true, force: true });
try {
await Promise.resolve(deps.removeDir(cacheDir, { recursive: true, force: true }));
} catch {}
throw error;
}
return { path: subtitlePath, cleanupDir: cacheDir };
},
cleanupCachedSubtitles(dirs: string[]): void {
for (const dir of dirs) {
deps.removeDir(dir, { recursive: true, force: true });
void Promise.resolve(deps.removeDir(dir, { recursive: true, force: true })).catch(() => {});
}
},
};
@@ -73,7 +73,8 @@ function withoutTrackAutoSelectionCommands(
(command[1] === 'sid' && command[2] === 'no') ||
(command[1] === 'secondary-sid' && command[2] === 'no') ||
(command[1] === 'sub-visibility' && command[2] === 'no') ||
(command[1] === 'secondary-sub-visibility' && command[2] === 'no'))
(command[1] === 'secondary-sub-visibility' && command[2] === 'no') ||
(command[1] === 'sub-delay' && command[2] === 0))
),
);
}
@@ -284,6 +285,25 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
]);
});
test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => {
const commands: Array<Array<string | number>> = [];
const activeDelayKeys: Array<unknown> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Embedded Japanese' },
],
sendMpvCommand: (command) => commands.push(command),
setActiveSubtitleDelayKey: (key) => activeDelayKeys.push(key),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(activeDelayKeys, [null]);
assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]);
});
test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese sources', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
@@ -953,7 +973,7 @@ test('preload jellyfin subtitles exits quietly when no external tracks', async (
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(waited, false);
assert.deepEqual(commands, []);
assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]);
});
test('preload jellyfin subtitles logs debug on failure', async () => {
@@ -326,9 +326,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
let preloadQueue: Promise<void> = Promise.resolve();
function resetManagedSubtitleDelay(): void {
if (deps.getSavedSubtitleDelay) {
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
}
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
}
function cleanupActiveCache(): void {
@@ -358,6 +356,8 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
);
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
if (externalTracks.length === 0) {
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
return;
}