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
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
test('delete confirmation dialog swallows Escape before closing', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'stats/src/components/layout/DeleteConfirmDialog.tsx'),
'utf8',
);
const handlerBlock = source.match(
/const onKeyDown = \(event: KeyboardEvent\) => \{(?<body>[\s\S]*?)\n \};/,
)?.groups?.body;
assert.ok(handlerBlock);
assert.match(handlerBlock, /event\.preventDefault\(\);/);
assert.match(handlerBlock, /event\.stopPropagation\(\);/);
assert.match(handlerBlock, /event\.stopImmediatePropagation\(\);/);
assert.ok(
handlerBlock.indexOf('event.stopPropagation();') < handlerBlock.indexOf('finish(false);'),
);
});
@@ -40,6 +40,8 @@ export function DeleteConfirmDialog() {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
finish(false);
};
window.addEventListener('keydown', onKeyDown, true);
@@ -125,3 +125,40 @@ test('buildDeleteEpisodeHandler sets error when deleteVideo throws', async () =>
await handler();
assert.equal(capturedError, 'Network failure');
});
test('buildDeleteEpisodeHandler guards duplicate clicks while confirmation is pending', async () => {
const confirmResolvers: Array<(value: boolean) => void> = [];
let confirmCalls = 0;
let deleteCalls = 0;
const isDeletingRef = { current: false };
const handler = buildDeleteEpisodeHandler({
videoId: 42,
title: 'Test Episode',
apiClient: {
deleteVideo: async () => {
deleteCalls += 1;
},
},
confirmFn: () => {
confirmCalls += 1;
return new Promise<boolean>((resolve) => {
confirmResolvers.push(resolve);
});
},
onBack: () => {},
setDeleteError: () => {},
isDeletingRef,
});
const first = handler();
const second = handler();
for (const resolveConfirm of confirmResolvers) {
resolveConfirm(true);
}
await Promise.all([first, second]);
assert.equal(confirmCalls, 1);
assert.equal(deleteCalls, 1);
assert.equal(isDeletingRef.current, false);
});
@@ -27,8 +27,19 @@ interface DeleteEpisodeHandlerOptions {
export function buildDeleteEpisodeHandler(opts: DeleteEpisodeHandlerOptions): () => Promise<void> {
return async () => {
if (opts.isDeletingRef?.current) return;
if (!(await opts.confirmFn(opts.title))) return;
if (opts.isDeletingRef) opts.isDeletingRef.current = true;
let confirmed = false;
try {
confirmed = await opts.confirmFn(opts.title);
} catch (err) {
if (opts.isDeletingRef) opts.isDeletingRef.current = false;
opts.setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
return;
}
if (!confirmed) {
if (opts.isDeletingRef) opts.isDeletingRef.current = false;
return;
}
opts.setIsDeleting?.(true);
opts.setDeleteError(null);
try {
@@ -73,6 +84,7 @@ export function MediaDetailView({
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
const [isDeletingEpisode, setIsDeletingEpisode] = useState(false);
const isDeletingEpisodeRef = useRef(false);
const isDeletingSessionRef = useRef(false);
useEffect(() => {
setLocalSessions(data?.sessions ?? null);
@@ -101,7 +113,20 @@ export function MediaDetailView({
const relatedCollectionLabel = getRelatedCollectionLabel(detail);
const handleDeleteSession = async (session: SessionSummary) => {
if (!(await confirmSessionDelete())) return;
if (isDeletingSessionRef.current) return;
isDeletingSessionRef.current = true;
let confirmed = false;
try {
confirmed = await confirmSessionDelete();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
isDeletingSessionRef.current = false;
return;
}
if (!confirmed) {
isDeletingSessionRef.current = false;
return;
}
setDeleteError(null);
setDeletingSessionId(session.sessionId);
@@ -114,6 +139,7 @@ export function MediaDetailView({
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingSessionId(null);
isDeletingSessionRef.current = false;
}
};
@@ -125,6 +125,34 @@ test('buildBucketDeleteHandler reports errors via onError without calling onSucc
assert.equal(successCalled, false);
});
test('buildBucketDeleteHandler reports confirmation errors via onError', async () => {
let errorMessage: string | null = null;
let deleteCalled = false;
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
const handler = buildBucketDeleteHandler({
bucket,
apiClient: {
deleteSessions: async () => {
deleteCalled = true;
},
},
confirm: async () => {
throw new Error('confirm failed');
},
onSuccess: () => {},
onError: (message) => {
errorMessage = message;
},
});
await handler();
assert.equal(errorMessage, 'confirm failed');
assert.equal(deleteCalled, false);
});
test('buildBucketDeleteHandler falls back to a generic title when canonicalTitle is null', async () => {
let seenTitle: string | null = null;
@@ -43,8 +43,8 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<
return async () => {
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
const ids = bucket.sessions.map((s) => s.sessionId);
if (!(await confirm(title, ids.length))) return;
try {
if (!(await confirm(title, ids.length))) return;
await client.deleteSessions(ids);
onSuccess(ids);
} catch (err) {
@@ -120,7 +120,14 @@ export function SessionsTab({
};
const handleDeleteSession = async (session: SessionSummary) => {
if (!(await confirmSessionDelete())) return;
let confirmed = false;
try {
confirmed = await confirmSessionDelete();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
return;
}
if (!confirmed) return;
setDeleteError(null);
setDeletingSessionId(session.sessionId);