fix(overlay): cancel pending window transitions and timing reviews (#262)

This commit is contained in:
2026-09-20 23:35:48 -07:00
committed by GitHub
parent 383aed8bad
commit 2f21582666
17 changed files with 668 additions and 199 deletions
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Closing the overlay while media timing review is still loading now cancels setup and modal retries, restores playback if the review paused it, and cleans up the hidden preview player.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Cancel pending Linux overlay window replacements during teardown so a delayed close callback cannot reopen the overlay.
+2
View File
@@ -214,6 +214,8 @@ Confirming writes the combined lines to the sentence field. Reset drops the adde
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session. Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens.
### Screenshots (static) ### Screenshots (static)
A single frame is captured at the current playback position. A single frame is captured at the current playback position.
+1
View File
@@ -30,6 +30,7 @@ Update checks and startup launcher migration share a serialized update-state sto
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters. - `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection. - `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic. - `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative. - `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments. - Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
+37 -101
View File
@@ -49,10 +49,7 @@ import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts, clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst, updateLinuxMpvFullscreenOverlayRefreshBurst,
} from './main/runtime/linux-mpv-fullscreen-overlay-refresh'; } from './main/runtime/linux-mpv-fullscreen-overlay-refresh';
import { import { createLinuxOverlayModeRuntime } from './main/runtime/linux-overlay-mode-runtime';
resolveLinuxVisibleOverlayWindowModeAction,
type LinuxVisibleOverlayWindowMode,
} from './main/runtime/linux-visible-overlay-window-mode';
import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive'; import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive';
import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus'; import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus';
import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff'; import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff';
@@ -1982,11 +1979,27 @@ let lastObservedTimePos = 0;
let lastObservedPrimarySubtitleTrackId: number | null = null; let lastObservedPrimarySubtitleTrackId: number | null = null;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null = let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
null; null;
let linuxVisibleOverlayWindowMode: LinuxVisibleOverlayWindowMode = 'managed'; const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({
let linuxTrackedMpvFullscreen = false; isEnabled: shouldRunLinuxOverlayZOrderKeepAlive,
let linuxTrackedMpvFullscreenChangedAtMs = 0; isVisible: () => overlayManager.getVisibleOverlayVisible(),
let linuxVisibleOverlayOwnerBindingKey: string | null = null; getWindow: () => overlayManager.getMainWindow(),
let linuxVisibleOverlayWindowModeSwitchToken = 0; clearWindow: () => overlayManager.setMainWindow(null),
createWindow: () => {
visibleOverlayInteractionRuntime.resetVisibleOverlayInputState();
createMainWindow();
},
refreshWindow: () => {
const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry();
if (trackedGeometry) overlayManager.setOverlayWindowBounds(trackedGeometry);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
void ensureOverlayMpvSubtitlesHidden();
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
},
now: Date.now,
logDebug: (message) => logger.debug(message),
});
let subtitleSidebarRequestedOpen = false; let subtitleSidebarRequestedOpen = false;
const SEEK_THRESHOLD_SECONDS = 3; const SEEK_THRESHOLD_SECONDS = 3;
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000; const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
@@ -2746,7 +2759,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
}, },
hideNonNativeOverlayWhenTargetUnfocused: () => hideNonNativeOverlayWhenTargetUnfocused: () =>
shouldRunLinuxOverlayZOrderKeepAlive() && shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxVisibleOverlayWindowMode === 'fullscreen-override', linuxOverlayModeRuntime.mode === 'fullscreen-override',
resolveFallbackBounds: () => { resolveFallbackBounds: () => {
const cursorPoint = screen.getCursorScreenPoint(); const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint); const display = screen.getDisplayNearestPoint(cursorPoint);
@@ -2792,9 +2805,9 @@ const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime(
getBackendOverride: () => appState.backendOverride, getBackendOverride: () => appState.backendOverride,
getInitialArgs: () => appState.initialArgs, getInitialArgs: () => appState.initialArgs,
getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode, getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
setLinuxVisibleOverlayOwnerBindingKey: (key) => { setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxVisibleOverlayOwnerBindingKey = key; linuxOverlayModeRuntime.ownerBindingKey = key;
}, },
bindVisibleOverlayToTrackedX11Window: (window) => bindVisibleOverlayToTrackedX11Window: (window) =>
overlayGeometryRuntime.bindVisibleOverlayToTrackedX11Window(window), overlayGeometryRuntime.bindVisibleOverlayToTrackedX11Window(window),
@@ -2952,7 +2965,8 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
startTime: range.startTime, startTime: range.startTime,
endTime: range.endTime, endTime: range.endTime,
}), }),
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload), openModal: (payload, signal) =>
openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload, signal),
onPreviewEnded: (reviewId) => { onPreviewEnded: (reviewId) => {
// The review may live in either overlay window; the renderer ignores foreign review ids. // The review may live in either overlay window; the renderer ignores foreign review ids.
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) { for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
@@ -3989,6 +4003,7 @@ const {
clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop: () =>
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(), visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => { clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
linuxOverlayModeRuntime.cancelPendingTransition();
cancelLinuxMpvFullscreenOverlayRefreshBurst = null; cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
clearLinuxMpvFullscreenOverlayRefreshTimeouts(); clearLinuxMpvFullscreenOverlayRefreshTimeouts();
}, },
@@ -4709,7 +4724,7 @@ const {
}, },
overlayVisibilityRuntime, overlayVisibilityRuntime,
syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) => syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) =>
syncLinuxVisibleOverlayMpvFullscreenMode(nextFullscreen), linuxOverlayModeRuntime.sync(nextFullscreen),
getOverlayInteractionActive: () => getOverlayInteractionActive: () =>
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() || visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() ||
visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(), visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(),
@@ -5021,14 +5036,14 @@ const overlayGeometryRuntime = createOverlayGeometryRuntime({
getTrackedWindowNativeId: () => appState.windowTracker?.getTargetWindowNativeId?.(), getTrackedWindowNativeId: () => appState.windowTracker?.getTargetWindowNativeId?.(),
getStatsOverlayVisible: () => appState.statsOverlayVisible, getStatsOverlayVisible: () => appState.statsOverlayVisible,
getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(), getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(),
getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode, getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
getLinuxTrackedMpvFullscreen: () => linuxTrackedMpvFullscreen, getLinuxTrackedMpvFullscreen: () => linuxOverlayModeRuntime.fullscreen,
getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxTrackedMpvFullscreenChangedAtMs, getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxOverlayModeRuntime.fullscreenChangedAtMs,
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen) => syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen) =>
syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen), linuxOverlayModeRuntime.sync(fullscreen),
getLinuxVisibleOverlayOwnerBindingKey: () => linuxVisibleOverlayOwnerBindingKey, getLinuxVisibleOverlayOwnerBindingKey: () => linuxOverlayModeRuntime.ownerBindingKey,
setLinuxVisibleOverlayOwnerBindingKey: (key) => { setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxVisibleOverlayOwnerBindingKey = key; linuxOverlayModeRuntime.ownerBindingKey = key;
}, },
clearVisibleOverlayX11OwnerBinding: (window) => clearVisibleOverlayX11OwnerBinding: (window) =>
visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window), visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window),
@@ -5113,85 +5128,6 @@ function createMainWindow(): BrowserWindow {
return window; return window;
} }
function createLinuxVisibleOverlayWindowForCurrentMode(token: number, fullscreen: boolean): void {
if (token !== linuxVisibleOverlayWindowModeSwitchToken) {
return;
}
if (!overlayManager.getVisibleOverlayVisible()) {
return;
}
const existingWindow = overlayManager.getMainWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
return;
}
visibleOverlayInteractionRuntime.resetVisibleOverlayInputState();
createMainWindow();
const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry();
if (trackedGeometry) {
overlayManager.setOverlayWindowBounds(trackedGeometry);
}
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
void ensureOverlayMpvSubtitlesHidden();
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
logger.debug(
`Switched Linux visible overlay window mode to ${linuxVisibleOverlayWindowMode} for mpv fullscreen=${fullscreen}`,
);
}
function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void {
if (!shouldRunLinuxOverlayZOrderKeepAlive()) {
return;
}
if (linuxTrackedMpvFullscreen !== fullscreen) {
linuxTrackedMpvFullscreenChangedAtMs = Date.now();
}
linuxTrackedMpvFullscreen = fullscreen;
const currentWindow = overlayManager.getMainWindow();
const hasLiveWindow = Boolean(currentWindow && !currentWindow.isDestroyed());
const action = resolveLinuxVisibleOverlayWindowModeAction({
currentMode: linuxVisibleOverlayWindowMode,
fullscreen,
hasLiveWindow,
visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(),
});
linuxVisibleOverlayWindowMode = action.nextMode;
linuxVisibleOverlayOwnerBindingKey = null;
linuxVisibleOverlayWindowModeSwitchToken += 1;
const token = linuxVisibleOverlayWindowModeSwitchToken;
if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) {
return;
}
const previousWindow = currentWindow;
if (action.shouldDestroyCurrentWindow && previousWindow && !previousWindow.isDestroyed()) {
previousWindow.once('closed', () => {
if (overlayManager.getMainWindow() === previousWindow) {
overlayManager.setMainWindow(null);
}
if (action.createWindowTiming === 'after-current-destroyed') {
createLinuxVisibleOverlayWindowForCurrentMode(token, fullscreen);
}
});
previousWindow.hide();
previousWindow.destroy();
}
if (!action.shouldCreateWindow) {
logger.debug(
`Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`,
);
return;
}
if (action.createWindowTiming === 'now') {
createLinuxVisibleOverlayWindowForCurrentMode(token, fullscreen);
}
}
function initializeOverlayRuntime(): void { function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler(); initializeOverlayRuntimeHandler();
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) { if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
@@ -6358,8 +6294,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
getLinuxX11FullscreenOverlay: () => getLinuxX11FullscreenOverlay: () =>
shouldRunLinuxOverlayZOrderKeepAlive() && shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxTrackedMpvFullscreen && linuxOverlayModeRuntime.fullscreen &&
linuxVisibleOverlayWindowMode === 'fullscreen-override', linuxOverlayModeRuntime.mode === 'fullscreen-override',
onVisibleWindowBlurred: () => onVisibleWindowBlurred: () =>
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(), visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(),
onVisibleWindowFocused: () => onVisibleWindowFocused: () =>
+6 -3
View File
@@ -453,7 +453,7 @@ test('Linux visible overlay recreation clears stale input state before creating
const source = readMainSource(); const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts'); const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const actionBlock = source.match( const actionBlock = source.match(
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/, /const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?createWindow: \(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body; )?.groups?.body;
const resetBlock = runtimeSource.match( const resetBlock = runtimeSource.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/, /function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
@@ -472,7 +472,7 @@ test('Linux visible overlay recreation clears stale input state before creating
test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => { test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/, /const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?refreshWindow: \(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body; )?.groups?.body;
assert.ok(actionBlock); assert.ok(actionBlock);
@@ -480,7 +480,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
actionBlock, actionBlock,
/const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/, /const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/,
); );
assert.match(actionBlock, /if \(trackedGeometry\) \{/); assert.match(
actionBlock,
/if \(trackedGeometry\) overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/,
);
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/); assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/); assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
}); });
@@ -8,13 +8,10 @@ import {
createBuildRestoreWindowsOnActivateMainDepsHandler, createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler, createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from '../app-lifecycle-main-activate'; } from '../app-lifecycle-main-activate';
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps';
import { registerProtocolUrlHandlers } from '../protocol-url-handlers'; import { registerProtocolUrlHandlers } from '../protocol-url-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts'; import type { ComposerInputs, ComposerOutputs } from './contracts';
type RegisterProtocolUrlHandlersMainDeps = Parameters< type RegisterProtocolUrlHandlersMainDeps = Parameters<typeof registerProtocolUrlHandlers>[0];
typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler
>[0];
type OnWillQuitCleanupDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0]; type OnWillQuitCleanupDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
type ShouldRestoreWindowsOnActivateMainDeps = Parameters< type ShouldRestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler
@@ -40,10 +37,6 @@ export type StartupLifecycleComposerResult = ComposerOutputs<{
export function composeStartupLifecycleHandlers( export function composeStartupLifecycleHandlers(
options: StartupLifecycleComposerOptions, options: StartupLifecycleComposerOptions,
): StartupLifecycleComposerResult { ): StartupLifecycleComposerResult {
const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler(
options.registerProtocolUrlHandlersMainDeps,
)();
const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler( const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler(
createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(), createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(),
); );
@@ -58,9 +51,9 @@ export function composeStartupLifecycleHandlers(
return { return {
registerProtocolUrlHandlers: () => registerProtocolUrlHandlers: () =>
registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps), registerProtocolUrlHandlers(options.registerProtocolUrlHandlersMainDeps),
onWillQuitCleanup: () => onWillQuitCleanupHandler(), onWillQuitCleanup: onWillQuitCleanupHandler,
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler,
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), restoreWindowsOnActivate: restoreWindowsOnActivateHandler,
}; };
} }
-1
View File
@@ -13,4 +13,3 @@ export * from '../anilist-state';
export * from '../anilist-token-refresh'; export * from '../anilist-token-refresh';
export * from '../anilist-token-refresh-main-deps'; export * from '../anilist-token-refresh-main-deps';
export * from '../protocol-url-handlers'; export * from '../protocol-url-handlers';
export * from '../protocol-url-handlers-main-deps';
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { test } from 'node:test';
import { createLinuxOverlayModeRuntime } from './linux-overlay-mode-runtime';
class TestWindow extends EventEmitter {
destroyed = false;
hidden = false;
isDestroyed() {
return this.destroyed;
}
hide() {
this.hidden = true;
}
destroy() {
this.destroyed = true;
}
finishClose() {
this.emit('closed');
}
}
function fixture() {
const initial = new TestWindow();
const state: { window: TestWindow | null; visible: boolean; creates: number; refreshes: number } =
{
window: initial,
visible: true,
creates: 0,
refreshes: 0,
};
const runtime = createLinuxOverlayModeRuntime({
isEnabled: () => true,
isVisible: () => state.visible,
getWindow: () => state.window,
clearWindow: () => {
state.window = null;
},
createWindow: () => {
state.creates += 1;
state.window = new TestWindow();
},
refreshWindow: () => {
state.refreshes += 1;
},
now: () => 42,
logDebug: () => {},
});
return { initial, state, runtime };
}
test('Linux mode transition waits for close before replacing and refreshing the window', () => {
const { initial, state, runtime } = fixture();
runtime.ownerBindingKey = 'old-owner';
runtime.sync(true);
assert.equal(runtime.mode, 'fullscreen-override');
assert.equal(runtime.fullscreenChangedAtMs, 42);
assert.equal(runtime.ownerBindingKey, null);
assert.equal(initial.hidden, true);
assert.equal(state.creates, 0);
initial.finishClose();
assert.equal(state.creates, 1);
assert.equal(state.refreshes, 1);
runtime.sync(true);
assert.equal(state.creates, 1);
});
test('an older close callback cannot clear or replace a newer overlay', () => {
const { initial, state, runtime } = fixture();
runtime.sync(true);
runtime.sync(false);
const replacement = state.window;
assert.equal(state.creates, 1);
initial.finishClose();
assert.equal(state.window, replacement);
assert.equal(state.creates, 1);
assert.equal(runtime.mode, 'managed');
});
test('hiding or cancelling a transition prevents delayed window creation', () => {
for (const cancel of [false, true]) {
const { initial, state, runtime } = fixture();
runtime.sync(true);
if (cancel) runtime.cancelPendingTransition();
else state.visible = false;
initial.finishClose();
assert.equal(state.creates, 0);
assert.equal(state.window, null);
state.visible = true;
runtime.sync(true);
assert.equal(state.creates, 1);
}
});
@@ -0,0 +1,94 @@
import type { BrowserWindow } from 'electron';
import {
resolveLinuxVisibleOverlayWindowModeAction,
type LinuxVisibleOverlayWindowMode,
} from './linux-visible-overlay-window-mode';
type OverlayWindow = Pick<BrowserWindow, 'isDestroyed' | 'hide' | 'destroy'> & {
once: (event: 'closed', listener: () => void) => unknown;
};
export function createLinuxOverlayModeRuntime<Window extends OverlayWindow>(deps: {
isEnabled: () => boolean;
isVisible: () => boolean;
getWindow: () => Window | null;
clearWindow: () => void;
createWindow: () => void;
refreshWindow: () => void;
now: () => number;
logDebug: (message: string) => void;
}) {
let mode: LinuxVisibleOverlayWindowMode = 'managed';
let fullscreen = false;
let fullscreenChangedAtMs = 0;
let ownerBindingKey: string | null = null;
let generation = 0;
function createWindowForMode(token: number, nextFullscreen: boolean): void {
if (token !== generation || !deps.isVisible()) return;
const existing = deps.getWindow();
if (existing && !existing.isDestroyed()) return;
deps.createWindow();
deps.refreshWindow();
deps.logDebug(
`Switched Linux visible overlay window mode to ${mode} for mpv fullscreen=${nextFullscreen}`,
);
}
function sync(nextFullscreen: boolean): void {
if (!deps.isEnabled()) return;
if (fullscreen !== nextFullscreen) fullscreenChangedAtMs = deps.now();
fullscreen = nextFullscreen;
const current = deps.getWindow();
const action = resolveLinuxVisibleOverlayWindowModeAction({
currentMode: mode,
fullscreen,
hasLiveWindow: Boolean(current && !current.isDestroyed()),
visibleOverlayVisible: deps.isVisible(),
});
mode = action.nextMode;
ownerBindingKey = null;
const token = ++generation;
if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) return;
if (action.shouldDestroyCurrentWindow && current && !current.isDestroyed()) {
current.once('closed', () => {
if (deps.getWindow() === current) deps.clearWindow();
if (action.createWindowTiming === 'after-current-destroyed') {
createWindowForMode(token, nextFullscreen);
}
});
current.hide();
current.destroy();
}
if (!action.shouldCreateWindow) {
deps.logDebug(
`Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`,
);
return;
}
if (action.createWindowTiming === 'now') createWindowForMode(token, nextFullscreen);
}
return {
get mode() {
return mode;
},
get fullscreen() {
return fullscreen;
},
get fullscreenChangedAtMs() {
return fullscreenChangedAtMs;
},
get ownerBindingKey() {
return ownerBindingKey;
},
set ownerBindingKey(key: string | null) {
ownerBindingKey = key;
},
sync,
cancelPendingTransition: () => {
generation += 1;
},
};
}
@@ -20,11 +20,13 @@ export async function openMediaTimingReviewModal(
logWarn: (message: string) => void; logWarn: (message: string) => void;
}, },
payload: MediaTimingReviewOpenPayload, payload: MediaTimingReviewOpenPayload,
signal?: AbortSignal,
): Promise<boolean> { ): Promise<boolean> {
return await retryOverlayModalOpen( return await retryOverlayModalOpen(
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn }, { waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
{ {
modal: MODAL, modal: MODAL,
signal,
// The review renderer regularly needs more than the 1.5 s the other modals allow; a // The review renderer regularly needs more than the 1.5 s the other modals allow; a
// premature retry re-sends the payload and reloads the waveform for nothing. // premature retry re-sends the payload and reloads the waveform for nothing.
timeoutMs: 4_000, timeoutMs: 4_000,
@@ -8,6 +8,7 @@ import type {
RemoteMediaWindowSource, RemoteMediaWindowSource,
} from '../../core/services/remote-media-window-cache'; } from '../../core/services/remote-media-window-cache';
import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview'; import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
import { openMediaTimingReviewModal } from './media-timing-review-open';
type MediaTimingPreviewSessionLike = Pick<MediaTimingPreviewSession, 'start'>; type MediaTimingPreviewSessionLike = Pick<MediaTimingPreviewSession, 'start'>;
import { import {
@@ -16,6 +17,20 @@ import {
createMediaTimingReviewRuntime, createMediaTimingReviewRuntime,
} from './media-timing-review'; } from './media-timing-review';
function createDeferred<T>() {
let settle: ((value: T) => void) | null = null;
const promise = new Promise<T>((resolve) => {
settle = resolve;
});
return {
promise,
resolve(value: T): void {
if (!settle) throw new Error('deferred promise is unavailable');
settle(value);
},
};
}
describe('buildMediaTimingReviewPayload', () => { describe('buildMediaTimingReviewPayload', () => {
test('starts from the padded range and leaves two seconds to drag on each side', () => { test('starts from the padded range and leaves two seconds to drag on each side', () => {
const payload = buildMediaTimingReviewPayload( const payload = buildMediaTimingReviewPayload(
@@ -764,6 +779,206 @@ test('disposing an open review settles it with original timing and restores play
]); ]);
}); });
for (const pendingSetup of ['properties', 'video-source'] as const) {
test(`disposing pending ${pendingSetup} cancels side effects and permits a fresh review`, async () => {
const setupGate = createDeferred<void>();
const commands: Array<Array<string | number>> = [];
let blockSetup = true;
let modalOpenCalls = 0;
let previewCreateCalls = 0;
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => {
if (blockSetup && pendingSetup === 'properties') await setupGate.promise;
return name === 'pause' ? false : name === 'duration' ? 100 : null;
},
send: ({ command }) => commands.push(command),
}),
resolveVideoSource: async () => {
if (blockSetup && pendingSetup === 'video-source') await setupGate.promise;
return { path: '/video/show.mkv' };
},
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => {
previewCreateCalls += 1;
return {
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
};
},
openModal: async (payload) => {
modalOpenCalls += 1;
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
return true;
},
showStatus: () => undefined,
});
const request = {
kind: 'word' as const,
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
screenshotEnabled: true,
};
const pending = runtime.requestReview(request);
let pendingSettled = false;
void pending.finally(() => {
pendingSettled = true;
});
await Promise.resolve();
await runtime.dispose();
assert.equal(pendingSettled, true);
assert.deepEqual(await pending, { action: 'use-original' });
assert.deepEqual(commands, []);
assert.equal(modalOpenCalls, 0);
assert.equal(previewCreateCalls, 0);
setupGate.resolve();
await Promise.resolve();
blockSetup = false;
assert.deepEqual(await runtime.requestReview(request), { action: 'use-original' });
assert.equal(modalOpenCalls, 1);
assert.equal(previewCreateCalls, 1);
});
}
test('disposing during modal acknowledgement prevents the real opener from retrying', async () => {
const waiting = createDeferred<void>();
const acknowledgement = createDeferred<boolean>();
const commands: Array<Array<string | number>> = [];
let sendCalls = 0;
let previewDisposeCalls = 0;
let opening: Promise<boolean> | undefined;
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'pause' ? false : null),
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => {
previewDisposeCalls += 1;
},
}),
openModal: (payload, signal) => {
opening = openMediaTimingReviewModal(
{
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: () => {
sendCalls += 1;
return true;
},
waitForModalOpen: () => {
waiting.resolve();
return acknowledgement.promise;
},
logWarn: () => {},
},
payload,
signal,
);
return opening;
},
showStatus: () => {},
});
const pending = runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
});
await waiting.promise;
await runtime.dispose();
assert.deepEqual(await pending, { action: 'use-original' });
acknowledgement.resolve(false);
assert.equal(await opening, false);
assert.equal(sendCalls, 1);
assert.equal(previewDisposeCalls, 1);
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
});
test('disposing owns a preview session whose startup is still pending', async () => {
const openedPayload = createDeferred<MediaTimingReviewOpenPayload>();
const previewStarted = createDeferred<void>();
const previewStartGate = createDeferred<void>();
let previewDisposeCalls = 0;
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : null),
send: () => undefined,
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => {
previewStarted.resolve();
await previewStartGate.promise;
},
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => {
previewDisposeCalls += 1;
},
}),
openModal: async (payload) => {
openedPayload.resolve(payload);
return true;
},
showStatus: () => undefined,
});
const pending = runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
});
await openedPayload.promise;
await previewStarted.promise;
await runtime.dispose();
assert.deepEqual(await pending, { action: 'use-original' });
assert.equal(previewDisposeCalls, 0);
previewStartGate.resolve();
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(previewDisposeCalls, 1);
});
test('media timing review forwards the hidden player finishing a preview to the modal', async () => { test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
const endedReviewIds: string[] = []; const endedReviewIds: string[] = [];
const playback: { ended?: () => void } = {}; const playback: { ended?: () => void } = {};
+125 -32
View File
@@ -78,6 +78,15 @@ interface ActiveReview {
resolve: (decision: MediaTimingReviewDecision) => void; resolve: (decision: MediaTimingReviewDecision) => void;
} }
interface ReviewRequestLifecycle {
signal: AbortSignal;
cancelled: Promise<void>;
settled: Promise<void>;
isCancelled(): boolean;
cancel(): void;
markSettled(): void;
}
export interface MediaTimingReviewRuntimeDeps { export interface MediaTimingReviewRuntimeDeps {
getMpvClient: () => ReviewMpvClient | null; getMpvClient: () => ReviewMpvClient | null;
getCurrentMediaPath: () => string | null; getCurrentMediaPath: () => string | null;
@@ -101,7 +110,7 @@ export interface MediaTimingReviewRuntimeDeps {
next: MediaTimingReviewContextLine[]; next: MediaTimingReviewContextLine[];
}; };
decisionTimeoutMs?: number; decisionTimeoutMs?: number;
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>; openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise<boolean>;
/** Tells the modal that the hidden player finished the previewed clip. */ /** Tells the modal that the hidden player finished the previewed clip. */
onPreviewEnded?: (reviewId: string) => void; onPreviewEnded?: (reviewId: string) => void;
showStatus: (message: string) => void; showStatus: (message: string) => void;
@@ -118,6 +127,33 @@ function booleanProperty(value: unknown): boolean | null {
return null; return null;
} }
function createReviewRequestLifecycle(): ReviewRequestLifecycle {
const controller = new AbortController();
let resolveCancellation: (() => void) | null = null;
let resolveSettled: (() => void) | null = null;
const cancellation = new Promise<void>((resolve) => {
resolveCancellation = resolve;
});
const settled = new Promise<void>((resolve) => {
resolveSettled = resolve;
});
return {
signal: controller.signal,
cancelled: cancellation,
settled,
isCancelled: () => controller.signal.aborted,
cancel: () => {
if (controller.signal.aborted) return;
controller.abort();
resolveCancellation?.();
},
markSettled: () => {
resolveSettled?.();
resolveSettled = null;
},
};
}
/** /**
* Picks the subtitle lines adjacent to the mined range that the review modal can pull * Picks the subtitle lines adjacent to the mined range that the review modal can pull
* onto the card. Parsed cues cover both directions; when none are loaded (e.g. the * onto the card. Parsed cues cover both directions; when none are loaded (e.g. the
@@ -245,7 +281,7 @@ export function buildMediaTimingReviewPayload(
export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) { export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) {
let active: ActiveReview | null = null; let active: ActiveReview | null = null;
let reviewInProgress = false; let currentRequest: ReviewRequestLifecycle | null = null;
let pendingPauseRestore: ReviewMpvClient | null = null; let pendingPauseRestore: ReviewMpvClient | null = null;
function restorePendingPlayback(): void { function restorePendingPlayback(): void {
@@ -311,14 +347,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
const previous = review.preview; const previous = review.preview;
const session = deps.createPreviewSession(); const session = deps.createPreviewSession();
session.onPlaybackEnded(() => {
if (active === review && review.preview?.session === started) {
deps.onPreviewEnded?.(review.payload.reviewId);
}
});
const { audioTrackId, ...previewOptions } = review.previewOptions; const { audioTrackId, ...previewOptions } = review.previewOptions;
const started = session const startSession = async (): Promise<PreviewSession> => {
.start({ if (active !== review) {
throw new Error('This timing review is no longer active.');
}
await session.start({
mediaPath, mediaPath,
...previewOptions, ...previewOptions,
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies. // A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
@@ -327,19 +361,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
: audioTrackId !== undefined : audioTrackId !== undefined
? { audioTrackId } ? { audioTrackId }
: {}), : {}),
}) });
.then(() => session) return session;
.catch((error) => { };
const started = Promise.resolve()
.then(startSession)
.catch((error: unknown) => {
session.dispose(); session.dispose();
throw error; throw error;
}); });
review.preview = { path: mediaPath, session: started }; review.preview = { path: mediaPath, session: started };
session.onPlaybackEnded(() => {
if (active === review && review.preview?.session === started) {
deps.onPreviewEnded?.(review.payload.reviewId);
}
});
void started.catch(() => {}); void started.catch(() => {});
if (previous) void previous.session.then((old) => old.dispose()).catch(() => {}); if (previous) void previous.session.then((old) => old.dispose()).catch(() => {});
return started; return started;
} }
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> { async function runReview(
request: MediaTimingReviewRequest,
lifecycle: ReviewRequestLifecycle,
): Promise<MediaTimingReviewDecision> {
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
const mediaPath = const mediaPath =
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || ''; deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
@@ -348,18 +393,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return { action: 'use-original' }; return { action: 'use-original' };
} }
const setupPromise = Promise.all([
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
]);
const setup = await Promise.race([
setupPromise.then((values) => ({ kind: 'ready' as const, values })),
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
]);
if (setup.kind === 'cancelled' || lifecycle.isCancelled()) {
return { action: 'use-original' };
}
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] = const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
await Promise.all([ setup.values;
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
deps.resolveMediaSource?.().catch(() => null) ?? null,
request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
]);
const pauseState = booleanProperty(pauseRaw); const pauseState = booleanProperty(pauseRaw);
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
pendingPauseRestore = pauseState === false ? mpvClient : null; pendingPauseRestore = pauseState === false ? mpvClient : null;
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
if (lifecycle.isCancelled()) {
restorePendingPlayback();
return { action: 'use-original' };
}
let contextLines: ReturnType<NonNullable<typeof deps.getSubtitleContextLines>> | undefined; let contextLines: ReturnType<NonNullable<typeof deps.getSubtitleContextLines>> | undefined;
try { try {
@@ -423,9 +480,22 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
endTime: payload.timelineEndTime, endTime: payload.timelineEndTime,
}).catch(() => {}); }).catch(() => {});
const opened = await deps.openModal(payload).catch(() => false); if (lifecycle.isCancelled() || active !== review) {
await cleanupActiveReview(review);
return { action: 'use-original' };
}
const openModal = deps.openModal(payload, lifecycle.signal).catch(() => false);
const openResult = await Promise.race([
openModal.then((opened) => ({ kind: 'opened' as const, opened })),
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
]);
if (openResult.kind === 'cancelled' || lifecycle.isCancelled() || active !== review) {
await cleanupActiveReview(review);
return { action: 'use-original' };
}
const { opened } = openResult;
if (!opened) { if (!opened) {
await cleanupActiveReview(); await cleanupActiveReview(review);
deps.showStatus('Timing review could not open. Using the original subtitle timing.'); deps.showStatus('Timing review could not open. Using the original subtitle timing.');
return { action: 'use-original' }; return { action: 'use-original' };
} }
@@ -434,33 +504,43 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
() => resolveDecision({ action: 'use-original' }), () => resolveDecision({ action: 'use-original' }),
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS), Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
); );
let decision: MediaTimingReviewDecision; let decision: MediaTimingReviewDecision = { action: 'use-original' };
try { try {
decision = await decisionPromise; const decisionResult = await Promise.race([
decisionPromise.then((value) => ({ kind: 'decided' as const, value })),
lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
]);
if (decisionResult.kind === 'decided') {
decision = decisionResult.value;
}
} finally { } finally {
clearTimeout(decisionWatchdog); clearTimeout(decisionWatchdog);
} }
await cleanupActiveReview(); await cleanupActiveReview(review);
return decision; return decision;
} }
async function requestReview( async function requestReview(
request: MediaTimingReviewRequest, request: MediaTimingReviewRequest,
): Promise<MediaTimingReviewDecision> { ): Promise<MediaTimingReviewDecision> {
if (active || reviewInProgress) { if (active || currentRequest) {
deps.showStatus('Finish the current timing review before mining another card.'); deps.showStatus('Finish the current timing review before mining another card.');
return { action: 'use-original' }; return { action: 'use-original' };
} }
reviewInProgress = true; const lifecycle = createReviewRequestLifecycle();
currentRequest = lifecycle;
try { try {
return await runReview(request); return await runReview(request, lifecycle);
} catch { } catch {
await cleanupActiveReview(); await cleanupActiveReview();
restorePendingPlayback(); restorePendingPlayback();
deps.showStatus('Timing review failed. Using the original subtitle timing.'); deps.showStatus('Timing review failed. Using the original subtitle timing.');
return { action: 'use-original' }; return { action: 'use-original' };
} finally { } finally {
reviewInProgress = false; if (currentRequest === lifecycle) {
currentRequest = null;
}
lifecycle.markSettled();
} }
} }
@@ -603,9 +683,18 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
} }
try { try {
const previewSession = current.preview ? await current.preview.session : null; const previewSession = current.preview ? await current.preview.session : null;
if (active !== current) {
return staleReviewResult();
}
await previewSession?.stop(); await previewSession?.stop();
if (active !== current) {
return staleReviewResult();
}
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
if (active !== current) {
return staleReviewResult();
}
return { return {
ok: false, ok: false,
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`, message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
@@ -641,8 +730,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return { ok: true }; return { ok: true };
} }
async function cleanupActiveReview(): Promise<void> { async function cleanupActiveReview(expected?: ActiveReview): Promise<void> {
const current = active; const current = active;
if (expected && current !== expected) return;
active = null; active = null;
if (!current) return; if (!current) return;
deps.clearFrameCache?.(); deps.clearFrameCache?.();
@@ -653,9 +743,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
} }
async function dispose(): Promise<void> { async function dispose(): Promise<void> {
const request = currentRequest;
request?.cancel();
active?.resolve({ action: 'use-original' }); active?.resolve({ action: 'use-original' });
await cleanupActiveReview(); await cleanupActiveReview();
restorePendingPlayback(); restorePendingPlayback();
await request?.settled;
} }
return { return {
@@ -1,6 +1,77 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { openOverlayHostedModal } from './overlay-hosted-modal-open'; import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
test('retryOverlayModalOpen skips the first send when already aborted', async () => {
const controller = new AbortController();
controller.abort();
const unexpectedCall = () => assert.fail('aborted open must not send or wait');
assert.equal(
await retryOverlayModalOpen(
{ waitForModalOpen: unexpectedCall, logWarn: unexpectedCall },
{
modal: 'media-timing-review',
timeoutMs: 4_000,
retryWarning: 'retry',
sendOpen: unexpectedCall,
signal: controller.signal,
},
),
false,
);
});
for (const abortOnWait of [1, 2]) {
test(`retryOverlayModalOpen rejects an acknowledgement aborted during wait ${abortOnWait}`, async () => {
const controller = new AbortController();
let waitCalls = 0;
let sendCalls = 0;
const opened = await retryOverlayModalOpen(
{
waitForModalOpen: async () => {
waitCalls += 1;
if (waitCalls === abortOnWait) {
controller.abort();
return true;
}
return false;
},
logWarn: () => {},
},
{
modal: 'media-timing-review',
timeoutMs: 4_000,
retryWarning: 'retry',
sendOpen: () => {
sendCalls += 1;
return true;
},
signal: controller.signal,
},
);
assert.equal(opened, false);
assert.equal(sendCalls, abortOnWait);
assert.equal(waitCalls, abortOnWait);
});
}
test('retryOverlayModalOpen still retries other modals without a signal', async () => {
let sendCalls = 0;
const opened = await retryOverlayModalOpen(
{ waitForModalOpen: async () => sendCalls === 2, logWarn: () => {} },
{
modal: 'runtime-options',
timeoutMs: 1_500,
retryWarning: 'retry',
sendOpen: () => {
sendCalls += 1;
return true;
},
},
);
assert.equal(opened, true);
assert.equal(sendCalls, 2);
});
test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => { test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => {
const calls: string[] = []; const calls: string[] = [];
@@ -38,20 +38,24 @@ export async function retryOverlayModalOpen(
timeoutMs: number; timeoutMs: number;
retryWarning: string; retryWarning: string;
sendOpen: () => boolean; sendOpen: () => boolean;
signal?: AbortSignal;
}, },
): Promise<boolean> { ): Promise<boolean> {
if (!input.sendOpen()) { if (input.signal?.aborted || !input.sendOpen()) {
return false; return false;
} }
if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) { const opened = await deps.waitForModalOpen(input.modal, input.timeoutMs);
if (input.signal?.aborted) return false;
if (opened) {
return true; return true;
} }
deps.logWarn(input.retryWarning); deps.logWarn(input.retryWarning);
if (!input.sendOpen()) { if (input.signal?.aborted || !input.sendOpen()) {
return false; return false;
} }
return await deps.waitForModalOpen(input.modal, input.timeoutMs); const retryOpened = await deps.waitForModalOpen(input.modal, input.timeoutMs);
return !input.signal?.aborted && retryOpened;
} }
@@ -1,29 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from './protocol-url-handlers-main-deps';
test('protocol url handlers main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildRegisterProtocolUrlHandlersMainDepsHandler({
registerOpenUrl: () => calls.push('open-register'),
registerSecondInstance: () => calls.push('second-register'),
handleAnilistSetupProtocolUrl: () => true,
findAnilistSetupDeepLinkArgvUrl: () => 'subminer://anilist-setup',
logUnhandledOpenUrl: (rawUrl) => calls.push(`open:${rawUrl}`),
logUnhandledSecondInstanceUrl: (rawUrl) => calls.push(`second:${rawUrl}`),
})();
deps.registerOpenUrl(() => {});
deps.registerSecondInstance(() => {});
assert.equal(deps.handleAnilistSetupProtocolUrl('subminer://anilist-setup'), true);
assert.equal(deps.findAnilistSetupDeepLinkArgvUrl(['x']), 'subminer://anilist-setup');
deps.logUnhandledOpenUrl('subminer://noop');
deps.logUnhandledSecondInstanceUrl('subminer://noop');
assert.deepEqual(calls, [
'open-register',
'second-register',
'open:subminer://noop',
'second:subminer://noop',
]);
});
@@ -1,16 +0,0 @@
import type { registerProtocolUrlHandlers } from './protocol-url-handlers';
type RegisterProtocolUrlHandlersMainDeps = Parameters<typeof registerProtocolUrlHandlers>[0];
export function createBuildRegisterProtocolUrlHandlersMainDepsHandler(
deps: RegisterProtocolUrlHandlersMainDeps,
) {
return (): RegisterProtocolUrlHandlersMainDeps => ({
registerOpenUrl: (listener) => deps.registerOpenUrl(listener),
registerSecondInstance: (listener) => deps.registerSecondInstance(listener),
handleAnilistSetupProtocolUrl: (rawUrl: string) => deps.handleAnilistSetupProtocolUrl(rawUrl),
findAnilistSetupDeepLinkArgvUrl: (argv: string[]) => deps.findAnilistSetupDeepLinkArgvUrl(argv),
logUnhandledOpenUrl: (rawUrl: string) => deps.logUnhandledOpenUrl(rawUrl),
logUnhandledSecondInstanceUrl: (rawUrl: string) => deps.logUnhandledSecondInstanceUrl(rawUrl),
});
}