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