From 18e0200934d988c05f059b67c0fbf4801172640d Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 1 Aug 2026 01:31:48 -0700 Subject: [PATCH] fix(overlay): fall back to did-finish-load when ready-to-show never fires A hidden overlay window stops producing frames once it is explicitly hidden and then reloaded (the Yomitan content-script reload right after startup), so ready-to-show never fires and the content-ready gate keeps the window hidden forever behind the Overlay loading spinner. Mark content ready from did-finish-load after a 1.5s grace period so the overlay always becomes showable; ready-to-show still wins when it fires. --- .../linux-overlay-content-ready-fallback.md | 4 ++ src/core/services/overlay-window.test.ts | 63 ++++++++++++++++++- src/core/services/overlay-window.ts | 49 ++++++++++++--- 3 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 changes/linux-overlay-content-ready-fallback.md diff --git a/changes/linux-overlay-content-ready-fallback.md b/changes/linux-overlay-content-ready-fallback.md new file mode 100644 index 00000000..e63a2278 --- /dev/null +++ b/changes/linux-overlay-content-ready-fallback.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed the overlay never loading (stuck on the "Overlay loading" OSD spinner) when the Yomitan content-script reload raced overlay window creation at startup, most visible when playing from the anime browser on Linux/Wayland: a hidden window stops painting after that reload, so the ready-to-show signal that gates showing the overlay never fired. Content-ready now falls back to did-finish-load after a short grace period. diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts index 2458f017..04650cec 100644 --- a/src/core/services/overlay-window.test.ts +++ b/src/core/services/overlay-window.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { ensureOverlayWindowLevel, updateOverlayWindowBounds } from './overlay-window'; +import { + ensureOverlayWindowLevel, + updateOverlayWindowBounds, + scheduleOverlayContentReadyFallback, + OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS, +} from './overlay-window'; import { handleOverlayWindowBeforeInputEvent, handleOverlayWindowBlurred, @@ -322,3 +327,59 @@ test('updateOverlayWindowBounds aligns Linux overlay content bounds to mpv geome assert.deepEqual(calls, [{ x: 0, y: -14, width: 3440, height: 1454 }]); }); + +test('scheduleOverlayContentReadyFallback marks content ready when ready-to-show never fired', () => { + const events: string[] = []; + const scheduled: Array<() => void> = []; + + scheduleOverlayContentReadyFallback({ + isContentReady: () => false, + isDestroyed: () => false, + markContentReady: () => events.push('mark'), + setTimeoutFn: (callback, delayMs) => { + events.push(`schedule:${delayMs}`); + scheduled.push(callback); + return null; + }, + }); + + assert.deepEqual(events, [`schedule:${OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS}`]); + scheduled[0]?.(); + assert.deepEqual(events, [`schedule:${OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS}`, 'mark']); +}); + +test('scheduleOverlayContentReadyFallback no-ops once content is already ready', () => { + const scheduled: Array<() => void> = []; + const events: string[] = []; + + scheduleOverlayContentReadyFallback({ + isContentReady: () => true, + isDestroyed: () => false, + markContentReady: () => events.push('mark'), + setTimeoutFn: (callback) => { + scheduled.push(callback); + return null; + }, + }); + + scheduled[0]?.(); + assert.deepEqual(events, []); +}); + +test('scheduleOverlayContentReadyFallback no-ops after the window is destroyed', () => { + const scheduled: Array<() => void> = []; + const events: string[] = []; + + scheduleOverlayContentReadyFallback({ + isContentReady: () => false, + isDestroyed: () => true, + markContentReady: () => events.push('mark'), + setTimeoutFn: (callback) => { + scheduled.push(callback); + return null; + }, + }); + + scheduled[0]?.(); + assert.deepEqual(events, []); +}); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 2a02da31..1444ea2f 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -77,6 +77,31 @@ export function updateOverlayWindowBounds( }); } +// ready-to-show is the primary content-ready signal, but it never fires when the +// page navigates while the window is hidden: an explicit hide() ends the +// paintWhenInitiallyHidden grace, the hidden widget stops producing frames, and +// the new document never paints. The Yomitan content-script reload right after +// startup hits exactly that, and content-ready gates showing the window, so +// without a fallback the overlay deadlocks behind the "Overlay loading" OSD. +export const OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS = 1500; + +export function scheduleOverlayContentReadyFallback(deps: { + isContentReady: () => boolean; + isDestroyed: () => boolean; + markContentReady: () => void; + setTimeoutFn?: (callback: () => void, delayMs: number) => unknown; + delayMs?: number; +}): void { + const setTimeoutFn = + deps.setTimeoutFn ?? ((callback: () => void, delayMs: number): unknown => setTimeout(callback, delayMs)); + setTimeoutFn(() => { + if (deps.isContentReady() || deps.isDestroyed()) { + return; + } + deps.markContentReady(); + }, deps.delayMs ?? OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS); +} + export function ensureOverlayWindowLevel(window: BrowserWindow): void { if (process.platform === 'darwin') { window.setAlwaysOnTop(true, 'screen-saver', 1); @@ -149,10 +174,26 @@ export function createOverlayWindow( logger.error('Page failed to load:', errorCode, errorDescription, validatedURL); }); + const markContentReady = (): void => { + if (isOverlayWindowContentReady(window)) { + return; + } + overlayWindowContentReady.add(window); + (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ + OVERLAY_WINDOW_CONTENT_READY_FLAG + ] = true; + options.onWindowContentReady?.(); + }; + window.webContents.on('did-finish-load', () => { window.setTitle(OVERLAY_WINDOW_TITLES[kind]); options.onRuntimeOptionsChanged(); options.onWindowDidFinishLoad?.(); + scheduleOverlayContentReadyFallback({ + isContentReady: () => isOverlayWindowContentReady(window), + isDestroyed: () => window.isDestroyed(), + markContentReady, + }); }); window.webContents.on('page-title-updated', (event) => { @@ -160,13 +201,7 @@ export function createOverlayWindow( window.setTitle(OVERLAY_WINDOW_TITLES[kind]); }); - window.once('ready-to-show', () => { - overlayWindowContentReady.add(window); - (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ - OVERLAY_WINDOW_CONTENT_READY_FLAG - ] = true; - options.onWindowContentReady?.(); - }); + window.once('ready-to-show', markContentReady); if (kind === 'visible') { window.webContents.on('devtools-opened', () => {