mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 05:16:20 -07:00
fix(overlay): cancel pending window transitions and timing reviews (#262)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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: () =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } = {};
|
||||
|
||||
@@ -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,8 +393,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
|
||||
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
|
||||
await Promise.all([
|
||||
const setupPromise = Promise.all([
|
||||
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
|
||||
@@ -357,9 +401,22 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
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] =
|
||||
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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user