mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user