mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-25 12:55:18 -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:
@@ -272,6 +272,7 @@ function M.create(ctx)
|
||||
state.pending_reload_media_identity = nil
|
||||
state.pending_reload_media_title = nil
|
||||
state.pending_reload_reason = nil
|
||||
state.app_managed_playback_pending = false
|
||||
state.app_managed_playback_active = false
|
||||
if state.overlay_running and reason ~= "quit" then
|
||||
process.hide_visible_overlay()
|
||||
|
||||
@@ -401,7 +401,6 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
run_control_command_async = function(action, overrides, callback)
|
||||
record_visible_overlay_action(action)
|
||||
local args = build_command_args(action, overrides)
|
||||
local command = build_subprocess_command(args)
|
||||
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||
@@ -414,6 +413,9 @@ function M.create(ctx)
|
||||
capture_stderr = true,
|
||||
}, function(success, result, error)
|
||||
local ok = success and (result == nil or result.status == 0)
|
||||
if ok then
|
||||
record_visible_overlay_action(action)
|
||||
end
|
||||
if callback then
|
||||
callback(ok, result, error)
|
||||
end
|
||||
|
||||
@@ -83,10 +83,16 @@ local process = process_module.create({
|
||||
return true
|
||||
end,
|
||||
},
|
||||
environment = {
|
||||
detect_backend = function()
|
||||
return "x11"
|
||||
end,
|
||||
environment = {
|
||||
detect_backend = function()
|
||||
return "x11"
|
||||
end,
|
||||
is_linux = function()
|
||||
return false
|
||||
end,
|
||||
is_subminer_app_running_async = function(callback)
|
||||
callback(false)
|
||||
end,
|
||||
},
|
||||
options_helper = {
|
||||
coerce_bool = function(value, default_value)
|
||||
@@ -125,4 +131,79 @@ for _, timeout_seconds in ipairs(recorded.timeouts) do
|
||||
end
|
||||
assert_true(retry_timeout_seen, "expected shorter bounded retry timeout")
|
||||
|
||||
do
|
||||
local visibility_state = {
|
||||
binary_path = "/tmp/subminer",
|
||||
overlay_running = true,
|
||||
texthooker_running = false,
|
||||
visible_overlay_requested = false,
|
||||
}
|
||||
local visibility_calls = {}
|
||||
local visibility_mp = {}
|
||||
|
||||
function visibility_mp.command_native_async(command, callback)
|
||||
visibility_calls[#visibility_calls + 1] = command
|
||||
if callback then
|
||||
callback(false, { status = 1, stdout = "", stderr = "failed" }, "failed")
|
||||
end
|
||||
end
|
||||
|
||||
local visibility_process = process_module.create({
|
||||
mp = visibility_mp,
|
||||
opts = {
|
||||
backend = "x11",
|
||||
socket_path = "/tmp/subminer.sock",
|
||||
log_level = "debug",
|
||||
texthooker_enabled = true,
|
||||
texthooker_port = 5174,
|
||||
auto_start_visible_overlay = false,
|
||||
},
|
||||
state = visibility_state,
|
||||
binary = {
|
||||
ensure_binary_available = function()
|
||||
return true
|
||||
end,
|
||||
},
|
||||
environment = {
|
||||
detect_backend = function()
|
||||
return "x11"
|
||||
end,
|
||||
is_linux = function()
|
||||
return false
|
||||
end,
|
||||
is_subminer_app_running_async = function(callback)
|
||||
callback(true)
|
||||
end,
|
||||
},
|
||||
options_helper = {
|
||||
coerce_bool = function(value, default_value)
|
||||
if value == true or value == "yes" or value == "true" then
|
||||
return true
|
||||
end
|
||||
if value == false or value == "no" or value == "false" then
|
||||
return false
|
||||
end
|
||||
return default_value
|
||||
end,
|
||||
},
|
||||
log = {
|
||||
subminer_log = function(_level, _scope, line)
|
||||
recorded.logs[#recorded.logs + 1] = line
|
||||
end,
|
||||
show_osd = function(_) end,
|
||||
normalize_log_level = function(value)
|
||||
return value or "info"
|
||||
end,
|
||||
},
|
||||
})
|
||||
|
||||
visibility_process.run_control_command_async("show-visible-overlay")
|
||||
|
||||
assert_true(#visibility_calls == 1, "expected visible overlay command to run")
|
||||
assert_true(
|
||||
visibility_state.visible_overlay_requested == false,
|
||||
"failed visible-overlay command should not update requested visibility state"
|
||||
)
|
||||
end
|
||||
|
||||
print("plugin process retry regression tests: OK")
|
||||
|
||||
@@ -645,6 +645,36 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local scenario = {
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
path = "/media/aborted-app-managed.m3u8",
|
||||
media_title = "Aborted App Managed",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
}
|
||||
local recorded, err = run_plugin_scenario(scenario)
|
||||
assert_true(recorded ~= nil, "plugin failed to load for aborted app-managed scenario: " .. tostring(err))
|
||||
recorded.script_messages["subminer-managed-subtitles-loading"]()
|
||||
fire_event(recorded, "end-file", { reason = "error" })
|
||||
scenario.path = "/media/next-normal.mkv"
|
||||
scenario.media_title = "Next Normal"
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
count_start_calls(recorded.async_calls) == 1,
|
||||
"aborted app-managed playback should not leak pending state into the next item"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local scenario = {
|
||||
process_list = "",
|
||||
|
||||
@@ -197,6 +197,68 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
|
||||
assert.ok(!calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
overlayInteractionActive: false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||
} as never);
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
overlayInteractionActive: false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||
} as never);
|
||||
|
||||
assert.equal(calls.includes('mouse-ignore:false:plain'), false);
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
});
|
||||
|
||||
test('suspended visible overlay hides without refreshing bounds or z-order', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
|
||||
@@ -181,12 +181,13 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
!isTrackedWindowsTargetMinimized &&
|
||||
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
|
||||
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
|
||||
const isNonNativePassiveOverlay =
|
||||
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
||||
const shouldIgnoreMouseEvents =
|
||||
shouldUseMacOSMousePassthrough ||
|
||||
forceMousePassthrough ||
|
||||
isNonNativePassiveOverlay ||
|
||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
||||
const isNonNativePassiveOverlay =
|
||||
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||
!args.isWindowsPlatform ||
|
||||
|
||||
+3
-2
@@ -1830,12 +1830,13 @@ async function refreshSubtitleSidebarFromSource(
|
||||
if (!normalizedSourcePath) {
|
||||
return;
|
||||
}
|
||||
appState.activeParsedSubtitleMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||
const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||
normalizedSourcePath,
|
||||
lastObservedTimePos,
|
||||
normalizedSourcePath,
|
||||
);
|
||||
appState.activeParsedSubtitleMediaPath = nextMediaPath;
|
||||
}
|
||||
const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
@@ -6476,8 +6477,8 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
if (!nextVisible) {
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
} else {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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