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
+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();