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:
2026-06-07 23:13:51 -07:00
parent d033884b09
commit 9d77907877
49 changed files with 1613 additions and 132 deletions
+6 -11
View File
@@ -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 () => {
+65 -2
View File
@@ -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', () => {
+20 -6
View File
@@ -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);
+2
View File
@@ -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) => {
+4 -3
View File
@@ -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 () => {
+12 -3
View File
@@ -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
View File
@@ -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();
+89 -9
View File
@@ -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] };
+12 -3
View File
@@ -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', () => {
+1 -10
View File
@@ -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']);
});
+49
View File
@@ -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({
+3 -1
View File
@@ -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);
});
+10 -6
View File
@@ -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,
};
}
+16
View File
@@ -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
View File
@@ -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,
)}`,
+1
View File
@@ -21,6 +21,7 @@ export interface OverlayNotificationAction {
export interface OverlayNotificationPayload {
id?: string;
historyId?: string;
title: string;
body?: string;
image?: string;