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.
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)
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/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/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.
+37 -101
View File
@@ -49,10 +49,7 @@ import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst,
} from './main/runtime/linux-mpv-fullscreen-overlay-refresh';
import {
resolveLinuxVisibleOverlayWindowModeAction,
type LinuxVisibleOverlayWindowMode,
} from './main/runtime/linux-visible-overlay-window-mode';
import { createLinuxOverlayModeRuntime } from './main/runtime/linux-overlay-mode-runtime';
import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive';
import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus';
import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff';
@@ -1982,11 +1979,27 @@ let lastObservedTimePos = 0;
let lastObservedPrimarySubtitleTrackId: number | null = null;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
null;
let linuxVisibleOverlayWindowMode: LinuxVisibleOverlayWindowMode = 'managed';
let linuxTrackedMpvFullscreen = false;
let linuxTrackedMpvFullscreenChangedAtMs = 0;
let linuxVisibleOverlayOwnerBindingKey: string | null = null;
let linuxVisibleOverlayWindowModeSwitchToken = 0;
const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({
isEnabled: shouldRunLinuxOverlayZOrderKeepAlive,
isVisible: () => overlayManager.getVisibleOverlayVisible(),
getWindow: () => overlayManager.getMainWindow(),
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;
const SEEK_THRESHOLD_SECONDS = 3;
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
@@ -2746,7 +2759,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
},
hideNonNativeOverlayWhenTargetUnfocused: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxVisibleOverlayWindowMode === 'fullscreen-override',
linuxOverlayModeRuntime.mode === 'fullscreen-override',
resolveFallbackBounds: () => {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
@@ -2792,9 +2805,9 @@ const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime(
getBackendOverride: () => appState.backendOverride,
getInitialArgs: () => appState.initialArgs,
getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode,
getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxVisibleOverlayOwnerBindingKey = key;
linuxOverlayModeRuntime.ownerBindingKey = key;
},
bindVisibleOverlayToTrackedX11Window: (window) =>
overlayGeometryRuntime.bindVisibleOverlayToTrackedX11Window(window),
@@ -2952,7 +2965,8 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
startTime: range.startTime,
endTime: range.endTime,
}),
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
openModal: (payload, signal) =>
openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload, signal),
onPreviewEnded: (reviewId) => {
// The review may live in either overlay window; the renderer ignores foreign review ids.
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
@@ -3989,6 +4003,7 @@ const {
clearWindowsVisibleOverlayForegroundPollLoop: () =>
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
linuxOverlayModeRuntime.cancelPendingTransition();
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
},
@@ -4709,7 +4724,7 @@ const {
},
overlayVisibilityRuntime,
syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) =>
syncLinuxVisibleOverlayMpvFullscreenMode(nextFullscreen),
linuxOverlayModeRuntime.sync(nextFullscreen),
getOverlayInteractionActive: () =>
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() ||
visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(),
@@ -5021,14 +5036,14 @@ const overlayGeometryRuntime = createOverlayGeometryRuntime({
getTrackedWindowNativeId: () => appState.windowTracker?.getTargetWindowNativeId?.(),
getStatsOverlayVisible: () => appState.statsOverlayVisible,
getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(),
getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode,
getLinuxTrackedMpvFullscreen: () => linuxTrackedMpvFullscreen,
getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxTrackedMpvFullscreenChangedAtMs,
getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
getLinuxTrackedMpvFullscreen: () => linuxOverlayModeRuntime.fullscreen,
getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxOverlayModeRuntime.fullscreenChangedAtMs,
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen) =>
syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen),
getLinuxVisibleOverlayOwnerBindingKey: () => linuxVisibleOverlayOwnerBindingKey,
linuxOverlayModeRuntime.sync(fullscreen),
getLinuxVisibleOverlayOwnerBindingKey: () => linuxOverlayModeRuntime.ownerBindingKey,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
linuxVisibleOverlayOwnerBindingKey = key;
linuxOverlayModeRuntime.ownerBindingKey = key;
},
clearVisibleOverlayX11OwnerBinding: (window) =>
visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window),
@@ -5113,85 +5128,6 @@ function createMainWindow(): BrowserWindow {
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 {
initializeOverlayRuntimeHandler();
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
@@ -6358,8 +6294,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
getLinuxX11FullscreenOverlay: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
linuxTrackedMpvFullscreen &&
linuxVisibleOverlayWindowMode === 'fullscreen-override',
linuxOverlayModeRuntime.fullscreen &&
linuxOverlayModeRuntime.mode === 'fullscreen-override',
onVisibleWindowBlurred: () =>
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(),
onVisibleWindowFocused: () =>
+6 -3
View File
@@ -453,7 +453,7 @@ test('Linux visible overlay recreation clears stale input state before creating
const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
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;
const resetBlock = runtimeSource.match(
/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', () => {
const source = readMainSource();
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;
assert.ok(actionBlock);
@@ -480,7 +480,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
actionBlock,
/const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/,
);
assert.match(actionBlock, /if \(trackedGeometry\) \{/);
assert.match(
actionBlock,
/if \(trackedGeometry\) overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/,
);
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
});
@@ -8,13 +8,10 @@ import {
createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from '../app-lifecycle-main-activate';
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps';
import { registerProtocolUrlHandlers } from '../protocol-url-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type RegisterProtocolUrlHandlersMainDeps = Parameters<
typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler
>[0];
type RegisterProtocolUrlHandlersMainDeps = Parameters<typeof registerProtocolUrlHandlers>[0];
type OnWillQuitCleanupDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
type ShouldRestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler
@@ -40,10 +37,6 @@ export type StartupLifecycleComposerResult = ComposerOutputs<{
export function composeStartupLifecycleHandlers(
options: StartupLifecycleComposerOptions,
): StartupLifecycleComposerResult {
const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler(
options.registerProtocolUrlHandlersMainDeps,
)();
const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler(
createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(),
);
@@ -58,9 +51,9 @@ export function composeStartupLifecycleHandlers(
return {
registerProtocolUrlHandlers: () =>
registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps),
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
registerProtocolUrlHandlers(options.registerProtocolUrlHandlersMainDeps),
onWillQuitCleanup: onWillQuitCleanupHandler,
shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler,
restoreWindowsOnActivate: restoreWindowsOnActivateHandler,
};
}
-1
View File
@@ -13,4 +13,3 @@ export * from '../anilist-state';
export * from '../anilist-token-refresh';
export * from '../anilist-token-refresh-main-deps';
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;
},
payload: MediaTimingReviewOpenPayload,
signal?: AbortSignal,
): Promise<boolean> {
return await retryOverlayModalOpen(
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
{
modal: MODAL,
signal,
// 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.
timeoutMs: 4_000,
@@ -8,6 +8,7 @@ import type {
RemoteMediaWindowSource,
} from '../../core/services/remote-media-window-cache';
import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
import { openMediaTimingReviewModal } from './media-timing-review-open';
type MediaTimingPreviewSessionLike = Pick<MediaTimingPreviewSession, 'start'>;
import {
@@ -16,6 +17,20 @@ import {
createMediaTimingReviewRuntime,
} 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', () => {
test('starts from the padded range and leaves two seconds to drag on each side', () => {
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 () => {
const endedReviewIds: string[] = [];
const playback: { ended?: () => void } = {};
+125 -32
View File
@@ -78,6 +78,15 @@ interface ActiveReview {
resolve: (decision: MediaTimingReviewDecision) => void;
}
interface ReviewRequestLifecycle {
signal: AbortSignal;
cancelled: Promise<void>;
settled: Promise<void>;
isCancelled(): boolean;
cancel(): void;
markSettled(): void;
}
export interface MediaTimingReviewRuntimeDeps {
getMpvClient: () => ReviewMpvClient | null;
getCurrentMediaPath: () => string | null;
@@ -101,7 +110,7 @@ export interface MediaTimingReviewRuntimeDeps {
next: MediaTimingReviewContextLine[];
};
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. */
onPreviewEnded?: (reviewId: string) => void;
showStatus: (message: string) => void;
@@ -118,6 +127,33 @@ function booleanProperty(value: unknown): boolean | 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
* 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) {
let active: ActiveReview | null = null;
let reviewInProgress = false;
let currentRequest: ReviewRequestLifecycle | null = null;
let pendingPauseRestore: ReviewMpvClient | null = null;
function restorePendingPlayback(): void {
@@ -311,14 +347,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
const previous = review.preview;
const session = deps.createPreviewSession();
session.onPlaybackEnded(() => {
if (active === review && review.preview?.session === started) {
deps.onPreviewEnded?.(review.payload.reviewId);
}
});
const { audioTrackId, ...previewOptions } = review.previewOptions;
const started = session
.start({
const startSession = async (): Promise<PreviewSession> => {
if (active !== review) {
throw new Error('This timing review is no longer active.');
}
await session.start({
mediaPath,
...previewOptions,
// 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 }
: {}),
})
.then(() => session)
.catch((error) => {
});
return session;
};
const started = Promise.resolve()
.then(startSession)
.catch((error: unknown) => {
session.dispose();
throw error;
});
review.preview = { path: mediaPath, session: started };
session.onPlaybackEnded(() => {
if (active === review && review.preview?.session === started) {
deps.onPreviewEnded?.(review.payload.reviewId);
}
});
void started.catch(() => {});
if (previous) void previous.session.then((old) => old.dispose()).catch(() => {});
return started;
}
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> {
async function runReview(
request: MediaTimingReviewRequest,
lifecycle: ReviewRequestLifecycle,
): Promise<MediaTimingReviewDecision> {
const mpvClient = deps.getMpvClient();
const mediaPath =
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
@@ -348,18 +393,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
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] =
await 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,
]);
setup.values;
const pauseState = booleanProperty(pauseRaw);
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
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;
try {
@@ -423,9 +480,22 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
endTime: payload.timelineEndTime,
}).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) {
await cleanupActiveReview();
await cleanupActiveReview(review);
deps.showStatus('Timing review could not open. Using the original subtitle timing.');
return { action: 'use-original' };
}
@@ -434,33 +504,43 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
() => resolveDecision({ action: 'use-original' }),
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
);
let decision: MediaTimingReviewDecision;
let decision: MediaTimingReviewDecision = { action: 'use-original' };
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 {
clearTimeout(decisionWatchdog);
}
await cleanupActiveReview();
await cleanupActiveReview(review);
return decision;
}
async function requestReview(
request: MediaTimingReviewRequest,
): Promise<MediaTimingReviewDecision> {
if (active || reviewInProgress) {
if (active || currentRequest) {
deps.showStatus('Finish the current timing review before mining another card.');
return { action: 'use-original' };
}
reviewInProgress = true;
const lifecycle = createReviewRequestLifecycle();
currentRequest = lifecycle;
try {
return await runReview(request);
return await runReview(request, lifecycle);
} catch {
await cleanupActiveReview();
restorePendingPlayback();
deps.showStatus('Timing review failed. Using the original subtitle timing.');
return { action: 'use-original' };
} finally {
reviewInProgress = false;
if (currentRequest === lifecycle) {
currentRequest = null;
}
lifecycle.markSettled();
}
}
@@ -603,9 +683,18 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
try {
const previewSession = current.preview ? await current.preview.session : null;
if (active !== current) {
return staleReviewResult();
}
await previewSession?.stop();
if (active !== current) {
return staleReviewResult();
}
return { ok: true };
} catch (error) {
if (active !== current) {
return staleReviewResult();
}
return {
ok: false,
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
@@ -641,8 +730,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return { ok: true };
}
async function cleanupActiveReview(): Promise<void> {
async function cleanupActiveReview(expected?: ActiveReview): Promise<void> {
const current = active;
if (expected && current !== expected) return;
active = null;
if (!current) return;
deps.clearFrameCache?.();
@@ -653,9 +743,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
async function dispose(): Promise<void> {
const request = currentRequest;
request?.cancel();
active?.resolve({ action: 'use-original' });
await cleanupActiveReview();
restorePendingPlayback();
await request?.settled;
}
return {
@@ -1,6 +1,77 @@
import assert from 'node:assert/strict';
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', () => {
const calls: string[] = [];
@@ -38,20 +38,24 @@ export async function retryOverlayModalOpen(
timeoutMs: number;
retryWarning: string;
sendOpen: () => boolean;
signal?: AbortSignal;
},
): Promise<boolean> {
if (!input.sendOpen()) {
if (input.signal?.aborted || !input.sendOpen()) {
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;
}
deps.logWarn(input.retryWarning);
if (!input.sendOpen()) {
if (input.signal?.aborted || !input.sendOpen()) {
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),
});
}