mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 03:13:32 -07:00
feat(overlay): add loading OSD spinner and queue notifications until ren
- Show mpv OSD spinner from start-file until subminer-overlay-loading-ready; force-shown for visible-overlay startup regardless of osd_messages setting - Gate non-macOS overlay visibility on content-ready so first subtitle line is immediately hoverable and clickable - Queue startup notifications in main process until overlay window finishes loading; upsert progress cards by id to avoid cold-start floods - Defer background warmups until after overlay runtime init so queued notifications can deliver promptly - Preserve character dictionary checking/building/importing/ready phases as distinct history entries; route building and importing to system notifications when notificationType is both
This commit is contained in:
@@ -346,20 +346,15 @@ test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomit
|
||||
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
|
||||
const calls: string[] = [];
|
||||
const { deps } = makeDeps({
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||
createMpvClient: () => calls.push('createMpvClient'),
|
||||
});
|
||||
test('runAppReadyRuntime starts background warmups after overlay startup', async () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient'));
|
||||
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
|
||||
|
||||
@@ -211,7 +211,70 @@ test('macOS dismisses overlay loading OSD when tracker recovers', () => {
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay stays hidden while tracker is not ready', () => {
|
||||
test('tracked non-native overlay shows loading OSD until renderer content is visible', () => {
|
||||
const { window, calls, setContentReady } = createMainWindowRecorder();
|
||||
let loadingShown = false;
|
||||
const osdMessages: string[] = [];
|
||||
const dismissedOsds: string[] = [];
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => true,
|
||||
};
|
||||
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: loadingShown,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
loadingShown = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
dismissOverlayLoadingOsd: () => {
|
||||
dismissedOsds.push('dismiss');
|
||||
},
|
||||
} as never);
|
||||
|
||||
setContentReady(false);
|
||||
run();
|
||||
run();
|
||||
|
||||
assert.equal(loadingShown, true);
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...']);
|
||||
assert.deepEqual(dismissedOsds, []);
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('show-inactive'));
|
||||
|
||||
setContentReady(true);
|
||||
run();
|
||||
|
||||
assert.equal(loadingShown, false);
|
||||
assert.deepEqual(dismissedOsds, ['dismiss']);
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay stays hidden and emits loading OSD while tracker is not ready', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
const tracker: WindowTrackerStub = {
|
||||
@@ -254,7 +317,7 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
assert.ok(!calls.includes('osd'));
|
||||
assert.ok(calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
|
||||
|
||||
@@ -311,8 +311,18 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
!args.isWindowsPlatform &&
|
||||
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
|
||||
|
||||
const isWaitingForOverlayContentReady = (): boolean => {
|
||||
const hasWebContents =
|
||||
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
|
||||
return (
|
||||
!mainWindow.isVisible() &&
|
||||
hasWebContents &&
|
||||
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
|
||||
);
|
||||
};
|
||||
|
||||
const maybeShowOverlayLoadingOsd = (): void => {
|
||||
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
||||
if (!args.showOverlayLoadingOsd) {
|
||||
return;
|
||||
}
|
||||
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
|
||||
@@ -322,9 +332,6 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.markOverlayLoadingOsdShown?.();
|
||||
};
|
||||
const maybeDismissOverlayLoadingOsd = (): void => {
|
||||
if (!args.isMacOSPlatform) {
|
||||
return;
|
||||
}
|
||||
args.dismissOverlayLoadingOsd?.();
|
||||
};
|
||||
|
||||
@@ -379,8 +386,15 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
maybeDismissOverlayLoadingOsd();
|
||||
if (isWaitingForOverlayContentReady()) {
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
} else {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
maybeDismissOverlayLoadingOsd();
|
||||
}
|
||||
const geometry = args.windowTracker.getGeometry();
|
||||
if (geometry) {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
|
||||
@@ -116,6 +116,7 @@ export function createOverlayWindow(
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowDidFinishLoad?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void;
|
||||
yomitanSession?: Session | null;
|
||||
@@ -139,6 +140,7 @@ export function createOverlayWindow(
|
||||
window.webContents.on('did-finish-load', () => {
|
||||
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||
options.onRuntimeOptionsChanged();
|
||||
options.onWindowDidFinishLoad?.();
|
||||
});
|
||||
|
||||
window.webContents.on('page-title-updated', (event) => {
|
||||
|
||||
@@ -269,7 +269,7 @@ test('runAppReadyRuntime loads Yomitan before headless overlay fallback initiali
|
||||
]);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
|
||||
test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
@@ -354,9 +354,10 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.ok(calls.indexOf('load-yomitan') !== -1);
|
||||
assert.ok(calls.indexOf('init-overlay') !== -1);
|
||||
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
|
||||
assert.ok(calls.indexOf('warmups') !== -1);
|
||||
assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups'));
|
||||
assert.equal(calls.includes('load-yomitan'), false);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
|
||||
|
||||
@@ -232,6 +232,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
|
||||
let firstRunSetupHandled = false;
|
||||
let initialArgsHandled = false;
|
||||
let backgroundWarmupsHandled = false;
|
||||
const handleFirstRunSetupOnce = async (): Promise<void> => {
|
||||
if (firstRunSetupHandled) {
|
||||
return;
|
||||
@@ -246,6 +247,13 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
initialArgsHandled = true;
|
||||
deps.handleInitialArgs();
|
||||
};
|
||||
const startBackgroundWarmupsOnce = (): void => {
|
||||
if (backgroundWarmupsHandled) {
|
||||
return;
|
||||
}
|
||||
backgroundWarmupsHandled = true;
|
||||
deps.startBackgroundWarmups();
|
||||
};
|
||||
|
||||
deps.ensureDefaultConfigBootstrap();
|
||||
if (deps.shouldRunHeadlessInitialCommand?.()) {
|
||||
@@ -297,8 +305,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
for (const warning of deps.getConfigWarnings()) {
|
||||
deps.logConfigWarning(warning);
|
||||
}
|
||||
deps.startBackgroundWarmups();
|
||||
|
||||
deps.loadSubtitlePosition();
|
||||
deps.resolveKeybindings();
|
||||
deps.createMpvClient();
|
||||
@@ -344,16 +350,19 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
if (deps.texthookerOnlyMode) {
|
||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||
startBackgroundWarmupsOnce();
|
||||
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
deps.initializeOverlayRuntime();
|
||||
startBackgroundWarmupsOnce();
|
||||
} else {
|
||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||
if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) {
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
startBackgroundWarmupsOnce();
|
||||
} else {
|
||||
startBackgroundWarmupsOnce();
|
||||
await ensureYomitanExtensionReady();
|
||||
}
|
||||
}
|
||||
|
||||
+175
-16
@@ -62,6 +62,7 @@ import {
|
||||
type ForegroundSuppressionGraceState,
|
||||
mapOverlayMeasurementForPointerInteraction,
|
||||
resolveForegroundSuppressionWithGrace,
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement,
|
||||
tickLinuxOverlayPointerInteraction,
|
||||
} from './main/runtime/linux-overlay-pointer-interaction';
|
||||
import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point';
|
||||
@@ -608,7 +609,10 @@ import {
|
||||
notifyUpdateAvailable,
|
||||
UPDATE_AVAILABLE_NOTIFICATION_ID,
|
||||
} from './main/runtime/update/update-notifications';
|
||||
import { createOverlayLoadingOsdController } from './main/runtime/overlay-loading-osd';
|
||||
import { createMaybeStartOverlayLoadingOsdHandler } from './main/runtime/overlay-loading-osd-start';
|
||||
import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position';
|
||||
import { createOverlayNotificationDelivery } from './main/runtime/overlay-notification-delivery';
|
||||
import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
notifyConfiguredStatus,
|
||||
@@ -1310,7 +1314,6 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
|
||||
},
|
||||
isSignalTargetReady: (signal) =>
|
||||
isTokenizationWarmupReady() &&
|
||||
isVisibleOverlayAutoplayTargetReady(
|
||||
{
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
@@ -1943,6 +1946,8 @@ let subtitleSidebarRequestedOpen = false;
|
||||
const SEEK_THRESHOLD_SECONDS = 3;
|
||||
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
||||
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
||||
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
|
||||
|
||||
function getCurrentAutoplayMediaPath(): string | null {
|
||||
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
|
||||
@@ -2017,6 +2022,7 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
},
|
||||
deferUncachedRefresh: true,
|
||||
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||
setCurrentSecondarySubText: (text) => {
|
||||
if (appState.mpvClient) {
|
||||
@@ -2032,6 +2038,38 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||
if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer);
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||
}
|
||||
|
||||
function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||
if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||
return;
|
||||
}
|
||||
if (!overlayManager.getVisibleOverlayVisible() || !appState.currentSubText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => {
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
const text = appState.currentSubText;
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
subtitlePrefetchService?.pause();
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
|
||||
}
|
||||
|
||||
async function primeAutoplaySubtitleFromParsedCues(
|
||||
mediaPath: string,
|
||||
cues: SubtitleCue[],
|
||||
@@ -2663,7 +2701,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
showOverlayLoadingStatusNotification(message);
|
||||
},
|
||||
dismissOverlayLoadingOsd: () => {
|
||||
dismissOverlayNotification('overlay-loading-status');
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
},
|
||||
hideNonNativeOverlayWhenTargetUnfocused: () =>
|
||||
shouldRunLinuxOverlayZOrderKeepAlive() &&
|
||||
@@ -2692,6 +2730,7 @@ const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
|
||||
// subtitle pointer interaction. Right after playback starts the overlay can briefly become the
|
||||
// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s).
|
||||
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500;
|
||||
const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500;
|
||||
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
|
||||
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
@@ -2706,6 +2745,8 @@ const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState =
|
||||
};
|
||||
let visibleOverlayInteractionActive = false;
|
||||
let linuxOverlayInputShapeActive = false;
|
||||
let linuxVisibleOverlayStartupInputPrimed = false;
|
||||
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
|
||||
// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
|
||||
// moves off measured subtitle/sidebar rects onto the popup.
|
||||
@@ -2728,6 +2769,7 @@ const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHa
|
||||
function resetVisibleOverlayInputState(): void {
|
||||
visibleOverlayInteractionActive = false;
|
||||
linuxOverlayInputShapeActive = false;
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
linuxOverlayInteractiveHint = false;
|
||||
overlayContentMeasurementStore.clear('visible');
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
@@ -3200,6 +3242,23 @@ function shouldUseLinuxOverlayInputShape(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasLinuxVisibleOverlayStartupInputGrace(): boolean {
|
||||
return (
|
||||
process.platform === 'linux' &&
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs > 0 &&
|
||||
Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs
|
||||
);
|
||||
}
|
||||
|
||||
function clearLinuxVisibleOverlayStartupInputGrace(): void {
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||
}
|
||||
|
||||
function resetLinuxVisibleOverlayStartupInputPrimer(): void {
|
||||
linuxVisibleOverlayStartupInputPrimed = false;
|
||||
clearLinuxVisibleOverlayStartupInputGrace();
|
||||
}
|
||||
|
||||
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
|
||||
if (!shouldUseLinuxOverlayInputShape()) {
|
||||
linuxOverlayInputShapeActive = false;
|
||||
@@ -3238,6 +3297,28 @@ function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}
|
||||
|
||||
function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
if (linuxVisibleOverlayStartupInputPrimed) return;
|
||||
if (shouldUseLinuxOverlayInputShape()) return;
|
||||
if (
|
||||
!shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
linuxVisibleOverlayStartupInputPrimed = true;
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs =
|
||||
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
|
||||
updateLinuxOverlayPointerInteractionActive(true);
|
||||
}
|
||||
|
||||
const linuxOverlayZOrderKeepAliveDeps = {
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
@@ -3298,7 +3379,8 @@ const linuxOverlayPointerInteractionDeps = {
|
||||
getCursorScreenPoint: () =>
|
||||
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
|
||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||
getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
|
||||
getRendererInteractiveHint: () =>
|
||||
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
|
||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
|
||||
@@ -3355,8 +3437,38 @@ function getConfiguredStatusNotificationType(): NotificationType {
|
||||
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
|
||||
}
|
||||
|
||||
function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
|
||||
if (window.isDestroyed() || !isOverlayWindowContentReady(window)) {
|
||||
return false;
|
||||
}
|
||||
if (window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const currentURL = window.webContents.getURL();
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
}
|
||||
|
||||
function hasReadyOverlayNotificationWindow(): boolean {
|
||||
return getOverlayWindows().some((window) => isOverlayWindowReadyForNotification(window));
|
||||
}
|
||||
|
||||
const overlayNotificationDelivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => hasReadyOverlayNotificationWindow(),
|
||||
send: (payload) => {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
|
||||
},
|
||||
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
});
|
||||
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
|
||||
null;
|
||||
|
||||
function flushQueuedOverlayNotifications(): void {
|
||||
overlayNotificationDelivery.flush();
|
||||
}
|
||||
|
||||
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
|
||||
overlayNotificationDelivery.send(payload);
|
||||
}
|
||||
|
||||
function showOverlayNotification(payload: OverlayNotificationPayload): void {
|
||||
@@ -3429,16 +3541,47 @@ function showYoutubeFlowStatusNotification(message: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
function showOverlayLoadingStatusNotification(message: string): void {
|
||||
showConfiguredStatusNotification(message, {
|
||||
id: 'overlay-loading-status',
|
||||
title: 'SubMiner',
|
||||
variant: 'progress',
|
||||
persistent: true,
|
||||
desktop: false,
|
||||
});
|
||||
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
|
||||
if (!overlayLoadingOsdController) {
|
||||
overlayLoadingOsdController = createOverlayLoadingOsdController({
|
||||
showOsd: (message) => {
|
||||
showMpvOsd(message);
|
||||
},
|
||||
clearOsd: () => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['show-text', '', '1']);
|
||||
},
|
||||
setInterval: (callback, delayMs) => {
|
||||
const timer = setInterval(callback, delayMs);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
},
|
||||
clearInterval: (timer) => {
|
||||
clearInterval(timer as ReturnType<typeof setInterval>);
|
||||
},
|
||||
});
|
||||
}
|
||||
return overlayLoadingOsdController;
|
||||
}
|
||||
|
||||
function showOverlayLoadingStatusNotification(message: string): void {
|
||||
void message;
|
||||
getOverlayLoadingOsdController().start();
|
||||
}
|
||||
|
||||
function dismissOverlayLoadingStatusNotification(): void {
|
||||
getOverlayLoadingOsdController().stop();
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-overlay-loading-ready']);
|
||||
dismissOverlayNotification('overlay-loading-status');
|
||||
}
|
||||
|
||||
const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({
|
||||
getVisibleOverlayRequested: () => overlayManager.getVisibleOverlayVisible(),
|
||||
isOverlayContentReady: () => isVisibleOverlayContentReady(),
|
||||
startOverlayLoadingOsd: () => {
|
||||
showOverlayLoadingStatusNotification('Overlay loading...');
|
||||
},
|
||||
});
|
||||
|
||||
const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
|
||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
@@ -5191,6 +5334,7 @@ const {
|
||||
void reportJellyfinRemoteStopped();
|
||||
},
|
||||
onMpvConnected: () => {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
if (appState.sessionBindingsInitialized) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
@@ -5228,6 +5372,7 @@ const {
|
||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
|
||||
updateCurrentMediaPath: (path) => {
|
||||
const normalizedPath = path.trim();
|
||||
maybeStartOverlayLoadingOsd(normalizedPath);
|
||||
const previousPath = appState.currentMediaPath?.trim() || null;
|
||||
const preserveParsedSubtitleCues = isSameYoutubeMediaPath(
|
||||
normalizedPath,
|
||||
@@ -6861,6 +7006,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
currentSubText: appState.currentSubText,
|
||||
currentSubtitleData: appState.currentSubtitleData,
|
||||
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
|
||||
tokenizeUncached: false,
|
||||
tokenizeSubtitle: tokenizeSubtitleForCurrent
|
||||
? (text) => tokenizeSubtitleForCurrent(text)
|
||||
: undefined,
|
||||
@@ -7014,7 +7160,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
if (overlayContentMeasurementStore.report(payload)) {
|
||||
tickLinuxOverlayPointerInteractionNow();
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
}
|
||||
},
|
||||
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
|
||||
@@ -7384,12 +7532,14 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
linuxVisibleOverlayWindowMode === 'fullscreen-override',
|
||||
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
||||
onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(),
|
||||
onWindowDidFinishLoad: () => {
|
||||
flushQueuedOverlayNotifications();
|
||||
},
|
||||
onWindowContentReady: () => {
|
||||
dismissOverlayNotification('overlay-loading-status');
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
flushQueuedOverlayNotifications();
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
if (appState.currentSubText.trim()) {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
}
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
},
|
||||
onWindowClosed: (windowKind, window) => {
|
||||
@@ -7669,10 +7819,13 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
resetVisibleOverlayInputState();
|
||||
}
|
||||
if (visible) {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void primeCurrentSubtitleForVisibleOverlay();
|
||||
@@ -7687,9 +7840,12 @@ function toggleVisibleOverlay(): void {
|
||||
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
||||
if (!nextVisible) {
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
resetVisibleOverlayInputState();
|
||||
} else {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void primeCurrentSubtitleForVisibleOverlay();
|
||||
@@ -7700,11 +7856,14 @@ function toggleVisibleOverlay(): void {
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
if (!visible) {
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
resetVisibleOverlayInputState();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void primeCurrentSubtitleForVisibleOverlay();
|
||||
|
||||
@@ -59,6 +59,50 @@ test('same media path updates do not reset autoplay ready fallback state', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('mpv startup signals start overlay loading OSD before readiness work', () => {
|
||||
const source = readMainSource();
|
||||
const connectedBlock = source.match(
|
||||
/onMpvConnected:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/,
|
||||
)?.groups?.body;
|
||||
const mediaPathBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
|
||||
)?.groups?.body;
|
||||
const setVisibleBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(connectedBlock);
|
||||
assert.ok(mediaPathBlock);
|
||||
assert.ok(setVisibleBlock);
|
||||
assert.match(connectedBlock, /maybeStartOverlayLoadingOsd\(\);/);
|
||||
assert.match(
|
||||
mediaPathBlock,
|
||||
/const normalizedPath = path\.trim\(\);\s+maybeStartOverlayLoadingOsd\(normalizedPath\);/,
|
||||
);
|
||||
assert.match(setVisibleBlock, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/);
|
||||
assert.match(
|
||||
source,
|
||||
/function toggleVisibleOverlay\(\): void \{[\s\S]*?else \{\s+maybeStartOverlayLoadingOsd\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/function setOverlayVisible\(visible: boolean\): void \{[\s\S]*?if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
|
||||
const source = readMainSource();
|
||||
const dismissBlock = source.match(
|
||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(dismissBlock);
|
||||
assert.match(
|
||||
dismissBlock,
|
||||
/sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -68,7 +112,7 @@ test('manual visible overlay toggles only release current-media autoplay when hi
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -89,15 +133,15 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
|
||||
assert.ok(setOverlayBlock);
|
||||
assert.match(
|
||||
setVisibleBlock,
|
||||
/if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
||||
/if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
setOverlayBlock,
|
||||
/if \(!visible\) \{\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
/if \(!visible\) \{\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -169,7 +213,7 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
|
||||
);
|
||||
});
|
||||
|
||||
test('startup autoplay release is tied to tokenization and visible overlay measurement readiness', () => {
|
||||
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
|
||||
const source = readMainSource();
|
||||
const gateBlock = source.match(
|
||||
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
@@ -180,7 +224,7 @@ test('startup autoplay release is tied to tokenization and visible overlay measu
|
||||
|
||||
assert.ok(gateBlock);
|
||||
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
|
||||
assert.match(gateBlock, /isTokenizationWarmupReady\(\)/);
|
||||
assert.doesNotMatch(gateBlock, /isTokenizationWarmupReady\(\)/);
|
||||
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
|
||||
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
|
||||
|
||||
@@ -189,6 +233,37 @@ test('startup autoplay release is tied to tokenization and visible overlay measu
|
||||
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
|
||||
});
|
||||
|
||||
test('visible overlay content-ready does not tokenize before first measurement', () => {
|
||||
const source = readMainSource();
|
||||
const contentReadyBlock = source.match(
|
||||
/onWindowContentReady:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(contentReadyBlock);
|
||||
assert.doesNotMatch(contentReadyBlock, /subtitleProcessingController\.refreshCurrentSubtitle/);
|
||||
assert.match(contentReadyBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
|
||||
assert.match(contentReadyBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
||||
assert.ok(
|
||||
contentReadyBlock.indexOf('overlayVisibilityRuntime.updateVisibleOverlayVisibility();') <
|
||||
contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
||||
);
|
||||
assert.ok(
|
||||
contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();') <
|
||||
contentReadyBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();'),
|
||||
);
|
||||
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
|
||||
assert.match(measurementBlock, /scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint\(\)/);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();') <
|
||||
measurementBlock.indexOf('scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();'),
|
||||
);
|
||||
});
|
||||
|
||||
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
|
||||
const source = readMainSource();
|
||||
const measurementBlock = source.match(
|
||||
@@ -198,10 +273,15 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
||||
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'),
|
||||
);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
||||
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar open state is restored for replacement visible overlay windows', () => {
|
||||
@@ -351,11 +431,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/else \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -374,7 +454,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
|
||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ test('auto sync notifications send osd updates for progress phases', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync notifications prefer overlay delivery for both when overlay is available', () => {
|
||||
test('auto sync notifications send overlay and desktop delivery for both', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||
@@ -83,7 +83,7 @@ test('auto sync notifications prefer overlay delivery for both when overlay is a
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(
|
||||
`overlay:${payload.id}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||
`overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||
),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||
@@ -95,13 +95,15 @@ test('auto sync notifications prefer overlay delivery for both when overlay is a
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(
|
||||
`overlay:${payload.id}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||
`overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||
),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'overlay:character-dictionary-auto-sync:Character dictionary:syncing:pin',
|
||||
'overlay:character-dictionary-auto-sync:Character dictionary:ready:auto',
|
||||
'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-syncing:Character dictionary:syncing:pin',
|
||||
'desktop:SubMiner:syncing',
|
||||
'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-ready:Character dictionary:ready:auto',
|
||||
'desktop:SubMiner:ready',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,25 +29,29 @@ function overlayVariantForPhase(
|
||||
return 'progress';
|
||||
}
|
||||
|
||||
function historyIdForEvent(event: CharacterDictionaryAutoSyncNotificationEvent): string {
|
||||
const mediaId = typeof event.mediaId === 'number' ? String(event.mediaId) : 'current';
|
||||
return `character-dictionary-auto-sync-${mediaId}-${event.phase}`;
|
||||
}
|
||||
|
||||
export function notifyCharacterDictionaryAutoSyncStatus(
|
||||
event: CharacterDictionaryAutoSyncNotificationEvent,
|
||||
deps: CharacterDictionaryAutoSyncNotificationDeps,
|
||||
): void {
|
||||
const type = deps.getNotificationType() ?? 'overlay';
|
||||
if (type === 'none') return;
|
||||
let overlayShown = false;
|
||||
let startupSequencerShown = false;
|
||||
|
||||
if (shouldShowOverlay(type)) {
|
||||
if (deps.showOverlayNotification) {
|
||||
deps.showOverlayNotification({
|
||||
id: 'character-dictionary-auto-sync',
|
||||
historyId: historyIdForEvent(event),
|
||||
title: 'Character dictionary',
|
||||
body: event.message,
|
||||
variant: overlayVariantForPhase(event.phase),
|
||||
persistent: !isTerminalPhase(event.phase),
|
||||
});
|
||||
overlayShown = true;
|
||||
} else if (!shouldShowDesktop(type)) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
}
|
||||
@@ -64,7 +68,7 @@ export function notifyCharacterDictionaryAutoSyncStatus(
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShowDesktop(type) && !overlayShown && !startupSequencerShown) {
|
||||
if (shouldShowDesktop(type) && !startupSequencerShown) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
notifyConfiguredStatus,
|
||||
} from './configured-status-notification';
|
||||
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||
|
||||
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
|
||||
const calls: string[] = [];
|
||||
@@ -27,7 +28,7 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus routes pre-overlay both status to osd and desktop', () => {
|
||||
test('notifyConfiguredStatus queues pre-overlay both status through overlay sender and desktop', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus('Overlay loading...', {
|
||||
@@ -42,7 +43,25 @@ test('notifyConfiguredStatus routes pre-overlay both status to osd and desktop',
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
|
||||
assert.deepEqual(calls, ['overlay::Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus queues pre-overlay overlay-only status without osd fallback', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus('Overlay loading...', {
|
||||
getNotificationType: () => 'overlay',
|
||||
isOverlayReady: () => false,
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['overlay::Overlay loading...']);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => {
|
||||
@@ -190,3 +209,231 @@ test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', (
|
||||
|
||||
assert.deepEqual(calls, ['desktop:SubMiner:Overlay unavailable.']);
|
||||
});
|
||||
|
||||
test('overlay notification delivery queues until an overlay window is ready', () => {
|
||||
const sent: string[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
|
||||
});
|
||||
|
||||
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
|
||||
delivery.send({ id: 'character-dictionary-auto-sync', title: 'Dictionary', body: 'Building' });
|
||||
|
||||
assert.equal(delivery.getQueuedCount(), 2);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
|
||||
assert.equal(delivery.getQueuedCount(), 0);
|
||||
assert.deepEqual(sent, [
|
||||
'startup-tokenization:Loading',
|
||||
'character-dictionary-auto-sync:Building',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay notification delivery upserts queued progress by notification id', () => {
|
||||
const sent: string[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
|
||||
});
|
||||
|
||||
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '|' });
|
||||
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '/' });
|
||||
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Ready' });
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
|
||||
assert.deepEqual(sent, ['startup-subtitle-annotations:/', 'startup-tokenization:Ready']);
|
||||
});
|
||||
|
||||
test('overlay notification delivery preserves queued events with distinct history ids', () => {
|
||||
const sent: string[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) =>
|
||||
sent.push(
|
||||
`${payload.id ?? ''}:${'historyId' in payload ? payload.historyId : ''}:${'body' in payload ? payload.body : ''}`,
|
||||
),
|
||||
});
|
||||
|
||||
delivery.send({
|
||||
id: 'character-dictionary-auto-sync',
|
||||
historyId: 'character-dictionary-auto-sync-checking',
|
||||
title: 'Character dictionary',
|
||||
body: 'Checking character dictionary...',
|
||||
persistent: true,
|
||||
});
|
||||
delivery.send({
|
||||
id: 'character-dictionary-auto-sync',
|
||||
historyId: 'character-dictionary-auto-sync-building',
|
||||
title: 'Character dictionary',
|
||||
body: 'Building character dictionary...',
|
||||
persistent: true,
|
||||
});
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
|
||||
assert.deepEqual(sent, [
|
||||
'character-dictionary-auto-sync:character-dictionary-auto-sync-checking:Checking character dictionary...',
|
||||
'character-dictionary-auto-sync:character-dictionary-auto-sync-building:Building character dictionary...',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay notification delivery preserves queued startup progress before terminal update', () => {
|
||||
const sent: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) =>
|
||||
sent.push(
|
||||
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
|
||||
),
|
||||
scheduleFlushRetry: (callback) => {
|
||||
scheduled.push(callback);
|
||||
},
|
||||
});
|
||||
|
||||
delivery.send({
|
||||
id: 'startup-tokenization',
|
||||
title: 'Subtitle tokenization',
|
||||
body: 'Loading subtitle tokenization...',
|
||||
variant: 'progress',
|
||||
persistent: true,
|
||||
});
|
||||
delivery.send({
|
||||
id: 'startup-tokenization',
|
||||
title: 'Subtitle tokenization',
|
||||
body: 'Subtitle tokenization ready',
|
||||
variant: 'success',
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(sent, [
|
||||
'startup-tokenization:Loading subtitle tokenization...:pin',
|
||||
'startup-tokenization:Subtitle tokenization ready:auto',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay notification delivery defers terminal update after first queued progress paint', () => {
|
||||
const sent: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const delays: number[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) =>
|
||||
sent.push(
|
||||
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
|
||||
),
|
||||
scheduleFlushRetry: (callback, delayMs) => {
|
||||
scheduled.push(callback);
|
||||
delays.push(delayMs);
|
||||
},
|
||||
terminalUpdateDelayMs: 750,
|
||||
});
|
||||
|
||||
delivery.send({
|
||||
id: 'startup-subtitle-annotations',
|
||||
title: 'Subtitle annotations',
|
||||
body: 'Loading subtitle annotations |',
|
||||
variant: 'progress',
|
||||
persistent: true,
|
||||
});
|
||||
delivery.send({
|
||||
id: 'startup-subtitle-annotations',
|
||||
title: 'Subtitle annotations',
|
||||
body: 'Subtitle annotations loaded',
|
||||
variant: 'success',
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
|
||||
assert.deepEqual(sent, ['startup-subtitle-annotations:Loading subtitle annotations |:pin']);
|
||||
assert.equal(delivery.getQueuedCount(), 1);
|
||||
assert.deepEqual(delays, [750]);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.equal(delivery.getQueuedCount(), 0);
|
||||
assert.deepEqual(sent, [
|
||||
'startup-subtitle-annotations:Loading subtitle annotations |:pin',
|
||||
'startup-subtitle-annotations:Subtitle annotations loaded:auto',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay notification delivery retries flush when lifecycle fires before window readiness settles', () => {
|
||||
const sent: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
|
||||
scheduleFlushRetry: (callback) => {
|
||||
scheduled.push(callback);
|
||||
},
|
||||
});
|
||||
|
||||
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
|
||||
delivery.flush();
|
||||
|
||||
assert.equal(delivery.getQueuedCount(), 1);
|
||||
assert.equal(scheduled.length, 1);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
ready = true;
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.equal(delivery.getQueuedCount(), 0);
|
||||
assert.deepEqual(sent, ['startup-tokenization:Loading']);
|
||||
});
|
||||
|
||||
test('overlay notification delivery drops queued notification when dismissed before flush', () => {
|
||||
const sent: string[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) =>
|
||||
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
|
||||
});
|
||||
|
||||
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
|
||||
delivery.send({ id: 'overlay-loading-status', dismiss: true });
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test('overlay notification delivery removes queued notification when dismissed at readiness', () => {
|
||||
const sent: string[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) =>
|
||||
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
|
||||
});
|
||||
|
||||
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
|
||||
|
||||
ready = true;
|
||||
delivery.send({ id: 'overlay-loading-status', dismiss: true });
|
||||
delivery.flush();
|
||||
|
||||
assert.deepEqual(sent, ['dismiss:overlay-loading-status']);
|
||||
});
|
||||
|
||||
@@ -49,9 +49,7 @@ export function notifyConfiguredStatus(
|
||||
return;
|
||||
}
|
||||
|
||||
const overlayReady = deps.isOverlayReady?.() !== false;
|
||||
|
||||
if (showOverlay && overlayReady) {
|
||||
if (showOverlay) {
|
||||
if (deps.showOverlayNotification) {
|
||||
deps.showOverlayNotification({
|
||||
id: options.id,
|
||||
@@ -63,8 +61,6 @@ export function notifyConfiguredStatus(
|
||||
} else if (desktopEnabled && !shouldShowDesktop(type)) {
|
||||
deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message });
|
||||
}
|
||||
} else if (showOverlay && !showOsd) {
|
||||
deps.showOsd(message);
|
||||
}
|
||||
|
||||
if (showOsd) {
|
||||
|
||||
@@ -62,6 +62,25 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token
|
||||
assert.deepEqual(payload.tokens, [{ text: '新' }]);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot can skip cold tokenizer for first paint', async () => {
|
||||
let tokenizerCalled = false;
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: 'まだキャッシュされていない字幕',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
tokenizeUncached: false,
|
||||
tokenizeSubtitle: async (text) => {
|
||||
tokenizerCalled = true;
|
||||
return { text, tokens: [{ text: 'ま' } as never] };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(tokenizerCalled, false);
|
||||
assert.equal(payload.text, 'まだキャッシュされていない字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.equal(payload.tokens, null);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot reports resolved payload for startup readiness', async () => {
|
||||
const resolvedPayloads: SubtitleData[] = [];
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
@@ -99,6 +118,29 @@ test('visible overlay subtitle prime refreshes current text from mpv before show
|
||||
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から', 'refresh:国内外から']);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime can defer uncached tokenization until after first paint', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
calls.push(`request:${name}`);
|
||||
return '国内外から';
|
||||
},
|
||||
}),
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getCurrentSubtitleData: () => null,
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
|
||||
deferUncachedRefresh: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から']);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => {
|
||||
const calls: string[] = [];
|
||||
const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] };
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
|
||||
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
|
||||
tokenizeUncached?: boolean;
|
||||
onResolvedSubtitle?: (payload: SubtitleData) => void;
|
||||
}): Promise<SubtitleData> {
|
||||
const resolve = (payload: SubtitleData): SubtitleData => {
|
||||
@@ -29,9 +30,11 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
});
|
||||
}
|
||||
|
||||
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
|
||||
if (tokenized) {
|
||||
return resolve(tokenized);
|
||||
if (deps.tokenizeUncached !== false) {
|
||||
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
|
||||
if (tokenized) {
|
||||
return resolve(tokenized);
|
||||
}
|
||||
}
|
||||
|
||||
return resolve({
|
||||
@@ -48,6 +51,7 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
emitSubtitle: (payload: SubtitleData) => void;
|
||||
deferUncachedRefresh?: boolean;
|
||||
setCurrentSecondarySubText?: (text: string) => void;
|
||||
emitSecondarySubtitle?: (text: string) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
@@ -114,6 +118,11 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deps.deferUncachedRefresh === true) {
|
||||
await primeSecondarySubtitle();
|
||||
return;
|
||||
}
|
||||
|
||||
deps.refreshCurrentSubtitle(text);
|
||||
await primeSecondarySubtitle();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveDesiredOverlayInteractive,
|
||||
resolveForegroundSuppressionWithGrace,
|
||||
shouldSuppressPointerInteractionForForegroundWindow,
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement,
|
||||
tickLinuxOverlayPointerInteraction,
|
||||
} from './linux-overlay-pointer-interaction';
|
||||
|
||||
@@ -136,6 +137,59 @@ test('resolveDesiredOverlayInteractive: hit-tests separate subtitle bars without
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldPrimeLinuxOverlayInteractionFromMeasurement primes input from first measured rect', () => {
|
||||
assert.equal(
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
}),
|
||||
getSubtitleMeasurement: () => ({
|
||||
...MEASUREMENT,
|
||||
interactiveRects: [{ x: 900, y: 900, width: 320, height: 80 }],
|
||||
}),
|
||||
shouldSuspend: () => false,
|
||||
shouldSuppressInteraction: () => false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldPrimeLinuxOverlayInteractionFromMeasurement skips hidden or empty startup surfaces', () => {
|
||||
assert.equal(
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => false,
|
||||
getBounds: () => BOUNDS,
|
||||
}),
|
||||
getSubtitleMeasurement: () => MEASUREMENT,
|
||||
shouldSuspend: () => false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
}),
|
||||
getSubtitleMeasurement: () => ({
|
||||
viewport: MEASUREMENT.viewport,
|
||||
contentRect: null,
|
||||
interactiveRects: [],
|
||||
}),
|
||||
shouldSuspend: () => false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('mapOverlayMeasurementForPointerInteraction preserves renderer interactive rects', () => {
|
||||
const mapped = mapOverlayMeasurementForPointerInteraction({
|
||||
layer: 'visible',
|
||||
|
||||
@@ -146,6 +146,29 @@ function measuredRectsForInput(measurement: OverlayContentMeasurementLike): Poin
|
||||
: [];
|
||||
}
|
||||
|
||||
function hasMeasuredInputRects(measurement: OverlayContentMeasurementLike): boolean {
|
||||
return measuredRectsForInput(measurement).some((rect) => rect.width > 0 && rect.height > 0);
|
||||
}
|
||||
|
||||
export function shouldPrimeLinuxOverlayInteractionFromMeasurement(deps: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => PointerInteractionWindow | null;
|
||||
getSubtitleMeasurement: () => OverlayContentMeasurementLike;
|
||||
shouldSuspend: () => boolean;
|
||||
shouldSuppressInteraction?: () => boolean;
|
||||
}): boolean {
|
||||
if (!deps.getVisibleOverlayVisible()) return false;
|
||||
if (deps.shouldSuspend()) return false;
|
||||
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deps.shouldSuppressInteraction?.()) return false;
|
||||
return hasMeasuredInputRects(deps.getSubtitleMeasurement());
|
||||
}
|
||||
|
||||
function clampRectToWindow(rect: PointerRect, bounds: PointerRect): PointerRect | null {
|
||||
const left = Math.max(0, Math.floor(rect.x));
|
||||
const top = Math.max(0, Math.floor(rect.y));
|
||||
|
||||
@@ -11,12 +11,12 @@ test('notification routing preserves system notification while overlay is not re
|
||||
assert.equal(resolveOverlayReadinessNotificationType('system', false), 'system');
|
||||
});
|
||||
|
||||
test('notification routing preserves both as osd plus system while overlay is not ready', () => {
|
||||
assert.equal(resolveOverlayReadinessNotificationType('both', false), 'osd-system');
|
||||
test('notification routing preserves both while overlay is not ready', () => {
|
||||
assert.equal(resolveOverlayReadinessNotificationType('both', false), 'both');
|
||||
});
|
||||
|
||||
test('notification routing falls back overlay-only notification to osd while overlay is not ready', () => {
|
||||
assert.equal(resolveOverlayReadinessNotificationType('overlay', false), 'osd');
|
||||
test('notification routing preserves overlay-only notification while overlay is not ready', () => {
|
||||
assert.equal(resolveOverlayReadinessNotificationType('overlay', false), 'overlay');
|
||||
});
|
||||
|
||||
test('notification routing predicates classify delivery channels', () => {
|
||||
|
||||
@@ -14,16 +14,7 @@ export function shouldShowDesktop(type: NotificationType): boolean {
|
||||
|
||||
export function resolveOverlayReadinessNotificationType(
|
||||
type: NotificationType,
|
||||
overlayReady: boolean,
|
||||
_overlayReady: boolean,
|
||||
): NotificationType {
|
||||
if (overlayReady) {
|
||||
return type;
|
||||
}
|
||||
if (type === 'overlay') {
|
||||
return 'osd';
|
||||
}
|
||||
if (type === 'both') {
|
||||
return 'osd-system';
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createMaybeStartOverlayLoadingOsdHandler,
|
||||
shouldStartOverlayLoadingOsd,
|
||||
} from './overlay-loading-osd-start';
|
||||
|
||||
test('overlay loading OSD starts for visible overlay before content is ready', () => {
|
||||
assert.equal(
|
||||
shouldStartOverlayLoadingOsd({
|
||||
visibleOverlayRequested: true,
|
||||
overlayContentReady: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay loading OSD does not start when hidden or already ready', () => {
|
||||
assert.equal(
|
||||
shouldStartOverlayLoadingOsd({
|
||||
visibleOverlayRequested: false,
|
||||
overlayContentReady: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStartOverlayLoadingOsd({
|
||||
visibleOverlayRequested: true,
|
||||
overlayContentReady: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay loading OSD media-path trigger ignores empty paths', () => {
|
||||
assert.equal(
|
||||
shouldStartOverlayLoadingOsd({
|
||||
visibleOverlayRequested: true,
|
||||
overlayContentReady: false,
|
||||
mediaPath: ' ',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay loading OSD handler starts idempotent status through injected deps', () => {
|
||||
const calls: string[] = [];
|
||||
const maybeStart = createMaybeStartOverlayLoadingOsdHandler({
|
||||
getVisibleOverlayRequested: () => true,
|
||||
isOverlayContentReady: () => false,
|
||||
startOverlayLoadingOsd: () => {
|
||||
calls.push('start');
|
||||
},
|
||||
});
|
||||
|
||||
maybeStart();
|
||||
maybeStart('/tmp/video.mkv');
|
||||
maybeStart(' ');
|
||||
|
||||
assert.deepEqual(calls, ['start', 'start']);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export function shouldStartOverlayLoadingOsd(args: {
|
||||
visibleOverlayRequested: boolean;
|
||||
overlayContentReady: boolean;
|
||||
mediaPath?: string | null;
|
||||
}): boolean {
|
||||
if (!args.visibleOverlayRequested || args.overlayContentReady) {
|
||||
return false;
|
||||
}
|
||||
if (args.mediaPath !== undefined && (args.mediaPath ?? '').trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createMaybeStartOverlayLoadingOsdHandler(deps: {
|
||||
getVisibleOverlayRequested: () => boolean;
|
||||
isOverlayContentReady: () => boolean;
|
||||
startOverlayLoadingOsd: () => void;
|
||||
}) {
|
||||
return (mediaPath?: string | null): void => {
|
||||
if (
|
||||
!shouldStartOverlayLoadingOsd({
|
||||
visibleOverlayRequested: deps.getVisibleOverlayRequested(),
|
||||
overlayContentReady: deps.isOverlayContentReady(),
|
||||
mediaPath,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deps.startOverlayLoadingOsd();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createOverlayLoadingOsdController } from './overlay-loading-osd';
|
||||
|
||||
test('overlay loading OSD shows spinner ticks and clears when stopped', () => {
|
||||
const messages: string[] = [];
|
||||
const clearedTimers: unknown[] = [];
|
||||
let tick: (() => void) | null = null;
|
||||
const controller = createOverlayLoadingOsdController({
|
||||
showOsd: (message) => {
|
||||
messages.push(message);
|
||||
},
|
||||
clearOsd: () => {
|
||||
messages.push('clear');
|
||||
},
|
||||
setInterval: (callback) => {
|
||||
tick = callback;
|
||||
return 'timer';
|
||||
},
|
||||
clearInterval: (timer) => {
|
||||
clearedTimers.push(timer);
|
||||
},
|
||||
});
|
||||
|
||||
controller.start();
|
||||
controller.start();
|
||||
|
||||
assert.deepEqual(messages, ['Overlay loading |']);
|
||||
if (!tick) {
|
||||
assert.fail('expected spinner tick callback');
|
||||
}
|
||||
const tickCallback: () => void = tick;
|
||||
tickCallback();
|
||||
tickCallback();
|
||||
|
||||
controller.stop();
|
||||
controller.stop();
|
||||
|
||||
assert.deepEqual(messages, ['Overlay loading |', 'Overlay loading /', 'Overlay loading -', 'clear']);
|
||||
assert.deepEqual(clearedTimers, ['timer']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const DEFAULT_OVERLAY_LOADING_OSD_TICK_MS = 180;
|
||||
const OVERLAY_LOADING_OSD_FRAMES = ['|', '/', '-', '\\'] as const;
|
||||
|
||||
export function createOverlayLoadingOsdController(deps: {
|
||||
showOsd: (message: string) => void;
|
||||
clearOsd: () => void;
|
||||
setInterval?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearInterval?: (timer: unknown) => void;
|
||||
}) {
|
||||
const setIntervalHandler =
|
||||
deps.setInterval ??
|
||||
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
|
||||
const clearIntervalHandler =
|
||||
deps.clearInterval ??
|
||||
((timer: unknown): void => clearInterval(timer as ReturnType<typeof setInterval>));
|
||||
let active = false;
|
||||
let frame = 0;
|
||||
let timer: unknown = null;
|
||||
|
||||
const showNextFrame = (): void => {
|
||||
deps.showOsd(
|
||||
`Overlay loading ${OVERLAY_LOADING_OSD_FRAMES[frame % OVERLAY_LOADING_OSD_FRAMES.length]}`,
|
||||
);
|
||||
frame += 1;
|
||||
};
|
||||
|
||||
return {
|
||||
start(): void {
|
||||
if (active) {
|
||||
return;
|
||||
}
|
||||
active = true;
|
||||
frame = 0;
|
||||
showNextFrame();
|
||||
timer = setIntervalHandler(showNextFrame, DEFAULT_OVERLAY_LOADING_OSD_TICK_MS);
|
||||
},
|
||||
stop(): void {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
if (timer !== null) {
|
||||
clearIntervalHandler(timer);
|
||||
timer = null;
|
||||
}
|
||||
deps.clearOsd();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { OverlayNotificationEventPayload } from '../../types/notification';
|
||||
|
||||
export interface OverlayNotificationDeliveryDeps {
|
||||
hasReadyOverlayWindow: () => boolean;
|
||||
send: (payload: OverlayNotificationEventPayload) => void;
|
||||
maxQueuedEvents?: number;
|
||||
flushRetryDelayMs?: number;
|
||||
terminalUpdateDelayMs?: number;
|
||||
scheduleFlushRetry?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearFlushRetry?: (handle: unknown) => void;
|
||||
}
|
||||
|
||||
function getPayloadId(payload: OverlayNotificationEventPayload): string | null {
|
||||
return typeof payload.id === 'string' && payload.id.trim().length > 0 ? payload.id : null;
|
||||
}
|
||||
|
||||
function getPayloadHistoryId(payload: OverlayNotificationEventPayload): string | null {
|
||||
if ('dismiss' in payload) {
|
||||
return null;
|
||||
}
|
||||
return typeof payload.historyId === 'string' && payload.historyId.trim().length > 0
|
||||
? payload.historyId
|
||||
: null;
|
||||
}
|
||||
|
||||
function isDismissPayload(
|
||||
payload: OverlayNotificationEventPayload,
|
||||
): payload is Extract<OverlayNotificationEventPayload, { dismiss: true }> {
|
||||
return 'dismiss' in payload && payload.dismiss === true;
|
||||
}
|
||||
|
||||
export function createOverlayNotificationDelivery(deps: OverlayNotificationDeliveryDeps): {
|
||||
send: (payload: OverlayNotificationEventPayload) => void;
|
||||
flush: () => void;
|
||||
getQueuedCount: () => number;
|
||||
} {
|
||||
const maxQueuedEvents = Math.max(1, deps.maxQueuedEvents ?? 32);
|
||||
const flushRetryDelayMs = Math.max(1, deps.flushRetryDelayMs ?? 50);
|
||||
const terminalUpdateDelayMs = Math.max(1, deps.terminalUpdateDelayMs ?? 750);
|
||||
const queuedEvents: OverlayNotificationEventPayload[] = [];
|
||||
let flushRetryHandle: unknown = null;
|
||||
|
||||
const removeQueuedPayloadsById = (id: string): void => {
|
||||
const nextEvents = queuedEvents.filter((queued) => getPayloadId(queued) !== id);
|
||||
queuedEvents.splice(0, queuedEvents.length, ...nextEvents);
|
||||
};
|
||||
|
||||
const clearFlushRetry = (): void => {
|
||||
if (flushRetryHandle === null) {
|
||||
return;
|
||||
}
|
||||
deps.clearFlushRetry?.(flushRetryHandle);
|
||||
flushRetryHandle = null;
|
||||
};
|
||||
|
||||
const scheduleFlushRetry = (delayMs = flushRetryDelayMs): void => {
|
||||
if (!deps.scheduleFlushRetry || flushRetryHandle !== null || queuedEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
flushRetryHandle = deps.scheduleFlushRetry(() => {
|
||||
flushRetryHandle = null;
|
||||
flush();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const queuePayload = (payload: OverlayNotificationEventPayload): void => {
|
||||
const id = getPayloadId(payload);
|
||||
if (isDismissPayload(payload)) {
|
||||
if (id) {
|
||||
removeQueuedPayloadsById(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const payloadPersistent = payload.persistent === true;
|
||||
const payloadHistoryId = getPayloadHistoryId(payload);
|
||||
const existingIndex = queuedEvents.findIndex(
|
||||
(queued) =>
|
||||
getPayloadId(queued) === id &&
|
||||
!isDismissPayload(queued) &&
|
||||
getPayloadHistoryId(queued) === payloadHistoryId &&
|
||||
(queued.persistent === true) === payloadPersistent,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
queuedEvents[existingIndex] = payload;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
queuedEvents.push(payload);
|
||||
while (queuedEvents.length > maxQueuedEvents) {
|
||||
queuedEvents.shift();
|
||||
}
|
||||
};
|
||||
|
||||
const flush = (): void => {
|
||||
if (!deps.hasReadyOverlayWindow()) {
|
||||
scheduleFlushRetry();
|
||||
return;
|
||||
}
|
||||
clearFlushRetry();
|
||||
const readyEvents = queuedEvents.splice(0, queuedEvents.length);
|
||||
const sentPersistentIds = new Set<string>();
|
||||
const deferredTerminalEvents: OverlayNotificationEventPayload[] = [];
|
||||
for (const payload of readyEvents) {
|
||||
const id = getPayloadId(payload);
|
||||
if (
|
||||
id &&
|
||||
!isDismissPayload(payload) &&
|
||||
payload.persistent !== true &&
|
||||
sentPersistentIds.has(id)
|
||||
) {
|
||||
deferredTerminalEvents.push(payload);
|
||||
continue;
|
||||
}
|
||||
deps.send(payload);
|
||||
if (id && !isDismissPayload(payload) && payload.persistent === true) {
|
||||
sentPersistentIds.add(id);
|
||||
}
|
||||
}
|
||||
if (deferredTerminalEvents.length > 0) {
|
||||
if (!deps.scheduleFlushRetry) {
|
||||
for (const payload of deferredTerminalEvents) {
|
||||
deps.send(payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
queuedEvents.unshift(...deferredTerminalEvents);
|
||||
scheduleFlushRetry(terminalUpdateDelayMs);
|
||||
}
|
||||
};
|
||||
|
||||
const send = (payload: OverlayNotificationEventPayload): void => {
|
||||
if (isDismissPayload(payload)) {
|
||||
const id = getPayloadId(payload);
|
||||
if (id) {
|
||||
removeQueuedPayloadsById(id);
|
||||
}
|
||||
if (deps.hasReadyOverlayWindow()) {
|
||||
deps.send(payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deps.hasReadyOverlayWindow()) {
|
||||
queuePayload(payload);
|
||||
return;
|
||||
}
|
||||
deps.send(payload);
|
||||
};
|
||||
|
||||
return {
|
||||
send,
|
||||
flush,
|
||||
getQueuedCount: () => queuedEvents.length,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowDidFinishLoad?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
|
||||
yomitanSession?: Session | null;
|
||||
@@ -29,6 +30,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
getLinuxX11FullscreenOverlay?: () => boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowDidFinishLoad?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
@@ -45,6 +47,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onVisibleWindowFocused: deps.onVisibleWindowFocused,
|
||||
onWindowDidFinishLoad: deps.onWindowDidFinishLoad,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||
|
||||
@@ -16,6 +16,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowDidFinishLoad?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
|
||||
yomitanSession?: Session | null;
|
||||
@@ -31,6 +32,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
getLinuxX11FullscreenOverlay?: () => boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowDidFinishLoad?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
@@ -48,6 +50,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
kind === 'visible' ? deps.getLinuxX11FullscreenOverlay?.() : undefined,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onVisibleWindowFocused: deps.onVisibleWindowFocused,
|
||||
onWindowDidFinishLoad: deps.onWindowDidFinishLoad,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
yomitanSession: deps.getYomitanSession?.() ?? null,
|
||||
|
||||
@@ -161,6 +161,34 @@ test('startup OSD reset keeps tokenization ready after first warmup', () => {
|
||||
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']);
|
||||
});
|
||||
|
||||
test('startup OSD reset preserves in-flight tokenization loading for ready update', () => {
|
||||
const calls: string[] = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) => {
|
||||
calls.push(
|
||||
`overlay:${payload.id}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||
);
|
||||
},
|
||||
showDesktopNotification: (title, options) => {
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`);
|
||||
},
|
||||
});
|
||||
|
||||
sequencer.showTokenizationLoading('Loading subtitle tokenization...');
|
||||
sequencer.reset();
|
||||
sequencer.markTokenizationReady();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'overlay:startup-tokenization:Loading subtitle tokenization...:progress:pin',
|
||||
'overlay:startup-tokenization:Subtitle tokenization ready:success:auto',
|
||||
'desktop:SubMiner:Subtitle tokenization ready',
|
||||
]);
|
||||
});
|
||||
|
||||
test('startup OSD shows later dictionary progress immediately once tokenization is ready', () => {
|
||||
const osdMessages: string[] = [];
|
||||
const sequencer = createStartupOsdSequencer({
|
||||
|
||||
@@ -135,7 +135,9 @@ export function createStartupOsdSequencer(deps: StartupOsdSequencerDeps): {
|
||||
return {
|
||||
reset: () => {
|
||||
tokenizationReady = tokenizationWarmupCompleted;
|
||||
tokenizationLoadingShown = false;
|
||||
if (tokenizationWarmupCompleted) {
|
||||
tokenizationLoadingShown = false;
|
||||
}
|
||||
annotationLoadingMessage = null;
|
||||
pendingDictionaryProgress = null;
|
||||
pendingDictionaryFailure = null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import type { OverlayNotificationEntry } from './overlay-notifications';
|
||||
import {
|
||||
createOverlayNotificationHistoryPanel,
|
||||
createOverlayNotificationHistoryStore,
|
||||
resolveHistorySideFromStack,
|
||||
} from './overlay-notification-history';
|
||||
@@ -54,6 +55,46 @@ test('history store updates an entry in place without reordering or duplicating'
|
||||
assert.equal(job?.updatedAt, 200);
|
||||
});
|
||||
|
||||
test('history store keeps same live notification id when history ids differ', () => {
|
||||
const store = createOverlayNotificationHistoryStore();
|
||||
store.record(
|
||||
entry({
|
||||
id: 'character-dictionary-auto-sync',
|
||||
title: 'Character dictionary',
|
||||
body: 'Checking character dictionary...',
|
||||
variant: 'progress',
|
||||
historyId: 'character-dictionary-auto-sync-checking',
|
||||
}),
|
||||
);
|
||||
store.record(
|
||||
entry({
|
||||
id: 'character-dictionary-auto-sync',
|
||||
title: 'Character dictionary',
|
||||
body: 'Building character dictionary...',
|
||||
variant: 'progress',
|
||||
historyId: 'character-dictionary-auto-sync-building',
|
||||
}),
|
||||
);
|
||||
store.record(
|
||||
entry({
|
||||
id: 'character-dictionary-auto-sync',
|
||||
title: 'Character dictionary',
|
||||
body: 'Character dictionary ready',
|
||||
variant: 'success',
|
||||
historyId: 'character-dictionary-auto-sync-ready',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
store.list().map((item) => `${item.id}:${item.body}`),
|
||||
[
|
||||
'character-dictionary-auto-sync-ready:Character dictionary ready',
|
||||
'character-dictionary-auto-sync-building:Building character dictionary...',
|
||||
'character-dictionary-auto-sync-checking:Checking character dictionary...',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('history store removes and clears entries', () => {
|
||||
const store = createOverlayNotificationHistoryStore();
|
||||
store.record(entry({ id: 'a' }));
|
||||
@@ -98,3 +139,99 @@ test('panel side mirrors the notification stack position', () => {
|
||||
// Center notifications open the panel from the right.
|
||||
assert.equal(resolveHistorySideFromStack(stackWith('position-top')), 'right');
|
||||
});
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
toggle: (entry: string, force?: boolean) => {
|
||||
if (force === true) tokens.add(entry);
|
||||
else if (force === false) tokens.delete(entry);
|
||||
else if (tokens.has(entry)) tokens.delete(entry);
|
||||
else tokens.add(entry);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPanelHarness(stackPositionClass: string) {
|
||||
const stack = {
|
||||
classList: createClassList([stackPositionClass]),
|
||||
};
|
||||
const clearButton = {
|
||||
disabled: false,
|
||||
addEventListener: () => undefined,
|
||||
};
|
||||
const closeButton = {
|
||||
addEventListener: () => undefined,
|
||||
};
|
||||
const list = {
|
||||
replaceChildren: () => undefined,
|
||||
};
|
||||
const empty = {
|
||||
classList: createClassList(),
|
||||
};
|
||||
const panel = {
|
||||
classList: createClassList(['notification-history', 'side-right']),
|
||||
setAttribute: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
querySelector: (selector: string) => {
|
||||
switch (selector) {
|
||||
case '.notification-history-list':
|
||||
return list;
|
||||
case '.notification-history-empty':
|
||||
return empty;
|
||||
case '.notification-history-clear':
|
||||
return clearButton;
|
||||
case '.notification-history-close':
|
||||
return closeButton;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const controller = createOverlayNotificationHistoryPanel({
|
||||
dom: {
|
||||
overlayNotificationHistory: panel,
|
||||
overlayNotificationStack: stack,
|
||||
},
|
||||
state: {
|
||||
isOverNotificationHistory: false,
|
||||
notificationHistoryOpen: false,
|
||||
},
|
||||
platform: {
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
} as never);
|
||||
|
||||
return { controller, panel, stack };
|
||||
}
|
||||
|
||||
test('history panel applies the initial stack side while still closed', () => {
|
||||
const { panel } = createPanelHarness('position-top-left');
|
||||
|
||||
assert.equal(panel.classList.contains('side-left'), true);
|
||||
assert.equal(panel.classList.contains('side-right'), false);
|
||||
assert.equal(panel.classList.contains('open'), false);
|
||||
});
|
||||
|
||||
test('history panel resyncs the closed side before first open', () => {
|
||||
const { controller, panel, stack } = createPanelHarness('position-top-right');
|
||||
|
||||
stack.classList.remove('position-top-right');
|
||||
stack.classList.add('position-top-left');
|
||||
|
||||
const syncable = controller as unknown as { syncSide?: () => void };
|
||||
assert.equal(typeof syncable.syncSide, 'function');
|
||||
syncable.syncSide?.();
|
||||
|
||||
assert.equal(panel.classList.contains('side-left'), true);
|
||||
assert.equal(panel.classList.contains('side-right'), false);
|
||||
assert.equal(panel.classList.contains('open'), false);
|
||||
});
|
||||
|
||||
@@ -35,9 +35,10 @@ function normalizeVariant(
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-scoped log of every overlay notification that was shown. Entries are keyed by id so a
|
||||
* progress notification that updates in place (same id, new body) overwrites its record rather than
|
||||
* piling up duplicates. Ordering is by first-seen so the panel can render newest-first.
|
||||
* Session-scoped log of every overlay notification that was shown. Entries are keyed by historyId
|
||||
* when provided, otherwise by live notification id. Reusing a key updates the record in place;
|
||||
* distinct history keys preserve separate visible events. Ordering is by first-seen so the panel can
|
||||
* render newest-first.
|
||||
*/
|
||||
export function createOverlayNotificationHistoryStore(
|
||||
options: OverlayNotificationHistoryStoreOptions = {},
|
||||
@@ -48,9 +49,10 @@ export function createOverlayNotificationHistoryStore(
|
||||
|
||||
function record(entry: OverlayNotificationEntry): OverlayNotificationHistoryEntry {
|
||||
const timestamp = now();
|
||||
const existing = entries.get(entry.id);
|
||||
const historyId = entry.historyId?.trim() || entry.id;
|
||||
const existing = entries.get(historyId);
|
||||
const next: OverlayNotificationHistoryEntry = {
|
||||
id: entry.id,
|
||||
id: historyId,
|
||||
title: entry.title,
|
||||
body: entry.body,
|
||||
image: entry.image,
|
||||
@@ -60,7 +62,7 @@ export function createOverlayNotificationHistoryStore(
|
||||
};
|
||||
// Setting an existing key keeps its original insertion slot, so an in-place update (same id,
|
||||
// new body) refreshes content without jumping the entry to the top of the panel.
|
||||
entries.set(entry.id, next);
|
||||
entries.set(historyId, next);
|
||||
while (entries.size > max) {
|
||||
const oldest = entries.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
@@ -221,6 +223,7 @@ export function createOverlayNotificationHistoryPanel(
|
||||
if (open) setInteractive(true);
|
||||
});
|
||||
panel.addEventListener('mouseleave', () => setInteractive(false));
|
||||
applySide();
|
||||
|
||||
function record(entry: OverlayNotificationEntry): void {
|
||||
store.record(entry);
|
||||
@@ -237,5 +240,6 @@ export function createOverlayNotificationHistoryPanel(
|
||||
open: () => setOpen(true),
|
||||
close: () => setOpen(false),
|
||||
isOpen: () => open,
|
||||
syncSide: applySide,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +58,22 @@ test('renderer reports subtitle bounds immediately after initial subtitle layout
|
||||
assert.ok(immediateMeasurementIndex < listenerIndex);
|
||||
});
|
||||
|
||||
test('renderer wires subtitle pointer handlers before first subtitle paint', () => {
|
||||
const primaryMouseEnterIndex = indexOfRequired(
|
||||
"ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);",
|
||||
);
|
||||
const pointerTrackingIndex = indexOfRequired('mouseHandlers.setupPointerTracking();');
|
||||
const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);');
|
||||
const initialMeasurementIndex = indexOfRequired(
|
||||
'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();',
|
||||
);
|
||||
|
||||
assert.ok(primaryMouseEnterIndex < initialRenderIndex);
|
||||
assert.ok(pointerTrackingIndex < initialRenderIndex);
|
||||
assert.ok(primaryMouseEnterIndex < initialMeasurementIndex);
|
||||
assert.ok(pointerTrackingIndex < initialMeasurementIndex);
|
||||
});
|
||||
|
||||
test('renderer reports subtitle bounds immediately after live subtitle layout', () => {
|
||||
const liveRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(data);');
|
||||
const liveLayoutIndex = indexOfRequired(
|
||||
|
||||
+33
-28
@@ -122,7 +122,10 @@ const notificationHistory = createOverlayNotificationHistoryPanel(ctx, {
|
||||
onChanged: () => measurementReporter.schedule(),
|
||||
});
|
||||
const overlayNotifications = createOverlayNotificationRenderer(ctx, {
|
||||
onChanged: () => measurementReporter.schedule(),
|
||||
onChanged: () => {
|
||||
notificationHistory.syncSide();
|
||||
measurementReporter.schedule();
|
||||
},
|
||||
onShow: (entry) => notificationHistory.record(entry),
|
||||
});
|
||||
const positioning = createPositioningController(ctx);
|
||||
@@ -632,6 +635,19 @@ async function init(): Promise<void> {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
// Seed the notification stack position from config before subscribing to history toggles, so the
|
||||
// closed history panel starts on the same side it will slide in from.
|
||||
try {
|
||||
const overlayNotificationPosition = await window.electronAPI.getOverlayNotificationPosition();
|
||||
ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES);
|
||||
ctx.dom.overlayNotificationStack.classList.add(
|
||||
overlayNotificationPositionClass(overlayNotificationPosition),
|
||||
);
|
||||
notificationHistory.syncSide();
|
||||
} catch {
|
||||
// Non-fatal: keep the default position class from index.html.
|
||||
}
|
||||
|
||||
window.electronAPI.onOverlayPointerRecoveryRequested(() => {
|
||||
runGuarded('overlay:pointer-recovery', () => {
|
||||
if (!ctx.platform.isMacOSPlatform || !ctx.platform.shouldToggleMouseIgnore) {
|
||||
@@ -656,18 +672,6 @@ async function init(): Promise<void> {
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
|
||||
// Seed the notification stack position from config so the stack, error/status toasts, and the
|
||||
// notification history panel side are correct before the first notification arrives.
|
||||
try {
|
||||
const overlayNotificationPosition = await window.electronAPI.getOverlayNotificationPosition();
|
||||
ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES);
|
||||
ctx.dom.overlayNotificationStack.classList.add(
|
||||
overlayNotificationPositionClass(overlayNotificationPosition),
|
||||
);
|
||||
} catch {
|
||||
// Non-fatal: keep the default position class from index.html.
|
||||
}
|
||||
|
||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
|
||||
@@ -677,6 +681,22 @@ async function init(): Promise<void> {
|
||||
);
|
||||
measurementReporter.schedule();
|
||||
|
||||
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);
|
||||
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave);
|
||||
ctx.dom.secondarySubContainer.addEventListener(
|
||||
'mouseenter',
|
||||
mouseHandlers.handleSecondaryMouseEnter,
|
||||
);
|
||||
ctx.dom.secondarySubContainer.addEventListener(
|
||||
'mouseleave',
|
||||
mouseHandlers.handleSecondaryMouseLeave,
|
||||
);
|
||||
|
||||
mouseHandlers.setupResizeHandler();
|
||||
mouseHandlers.setupPointerTracking();
|
||||
mouseHandlers.setupSelectionObserver();
|
||||
mouseHandlers.setupYomitanObserver();
|
||||
|
||||
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
|
||||
runGuarded('subtitle-position:update', () => {
|
||||
positioning.applyStoredSubtitlePosition(position, 'media-change');
|
||||
@@ -731,21 +751,6 @@ async function init(): Promise<void> {
|
||||
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
|
||||
measurementReporter.schedule();
|
||||
|
||||
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);
|
||||
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave);
|
||||
ctx.dom.secondarySubContainer.addEventListener(
|
||||
'mouseenter',
|
||||
mouseHandlers.handleSecondaryMouseEnter,
|
||||
);
|
||||
ctx.dom.secondarySubContainer.addEventListener(
|
||||
'mouseleave',
|
||||
mouseHandlers.handleSecondaryMouseLeave,
|
||||
);
|
||||
|
||||
mouseHandlers.setupResizeHandler();
|
||||
mouseHandlers.setupPointerTracking();
|
||||
mouseHandlers.setupSelectionObserver();
|
||||
mouseHandlers.setupYomitanObserver();
|
||||
setupDragDropToMpvQueue();
|
||||
window.addEventListener('resize', () => {
|
||||
measurementReporter.schedule();
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface SubminerPluginRuntimeScriptOptConfig {
|
||||
autoStart: boolean;
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
overlayLoadingOsd?: boolean;
|
||||
osdMessages: boolean;
|
||||
texthookerEnabled: boolean;
|
||||
aniskipEnabled: boolean;
|
||||
@@ -38,12 +39,16 @@ export function buildSubminerPluginRuntimeScriptOptParts(
|
||||
const socketPath = sanitizeScriptOptValue(runtimeConfig.socketPath);
|
||||
const backend = sanitizeScriptOptValue(runtimeConfig.backend);
|
||||
const aniskipButtonKey = sanitizeScriptOptValue(runtimeConfig.aniskipButtonKey);
|
||||
const overlayLoadingOsd =
|
||||
runtimeConfig.overlayLoadingOsd ??
|
||||
(runtimeConfig.autoStart && runtimeConfig.autoStartVisibleOverlay);
|
||||
return [
|
||||
`subminer-binary_path=${binaryPath}`,
|
||||
`subminer-socket_path=${socketPath}`,
|
||||
`subminer-backend=${backend}`,
|
||||
`subminer-auto_start=${boolScriptOpt(runtimeConfig.autoStart)}`,
|
||||
`subminer-auto_start_visible_overlay=${boolScriptOpt(runtimeConfig.autoStartVisibleOverlay)}`,
|
||||
`subminer-overlay_loading_osd=${boolScriptOpt(overlayLoadingOsd)}`,
|
||||
`subminer-auto_start_pause_until_ready=${boolScriptOpt(
|
||||
runtimeConfig.autoStartPauseUntilReady,
|
||||
)}`,
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface OverlayNotificationAction {
|
||||
|
||||
export interface OverlayNotificationPayload {
|
||||
id?: string;
|
||||
historyId?: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
image?: string;
|
||||
|
||||
Reference in New Issue
Block a user