From 7e6f9672cfccf0f67f7261fb9ff984067760d2f8 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 25 May 2026 02:30:33 -0700 Subject: [PATCH] fix: suppress overlay subtitle immediately when character dictionary modal opens (#84) --- ...fix-character-dictionary-modal-hyprland.md | 4 + src/core/services/mpv-control.test.ts | 9 +- src/core/services/mpv.ts | 5 +- src/main.ts | 4 +- ...dictionary-auto-sync-notifications.test.ts | 85 ++++++++++++++++-- ...cter-dictionary-auto-sync-notifications.ts | 26 +++++- .../runtime/mpv-osd-log-main-deps.test.ts | 6 +- src/main/runtime/mpv-osd-log.test.ts | 4 +- src/main/runtime/mpv-osd-log.ts | 6 +- .../runtime/mpv-osd-runtime-handlers.test.ts | 4 +- .../runtime/startup-osd-sequencer.test.ts | 42 ++++++++- src/main/runtime/startup-osd-sequencer.ts | 69 +++++++++----- .../modals/character-dictionary.test.ts | 89 +++++++++++++++++++ src/renderer/modals/character-dictionary.ts | 2 + src/renderer/renderer.ts | 1 - 15 files changed, 307 insertions(+), 49 deletions(-) create mode 100644 changes/fix-character-dictionary-modal-hyprland.md diff --git a/changes/fix-character-dictionary-modal-hyprland.md b/changes/fix-character-dictionary-modal-hyprland.md new file mode 100644 index 00000000..973fc87c --- /dev/null +++ b/changes/fix-character-dictionary-modal-hyprland.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Hid the visible subtitle overlay as soon as the character dictionary modal opens, including while AniList lookup is still loading or returns no results. diff --git a/src/core/services/mpv-control.test.ts b/src/core/services/mpv-control.test.ts index b3c0322d..d13fd003 100644 --- a/src/core/services/mpv-control.test.ts +++ b/src/core/services/mpv-control.test.ts @@ -10,7 +10,7 @@ import { test('showMpvOsdRuntime sends show-text when connected', () => { const commands: (string | number)[][] = []; - showMpvOsdRuntime( + const shown = showMpvOsdRuntime( { connected: true, send: ({ command }) => { @@ -19,12 +19,13 @@ test('showMpvOsdRuntime sends show-text when connected', () => { }, 'hello', ); + assert.equal(shown, true); assert.deepEqual(commands, [['show-text', 'hello', '3000']]); }); test('showMpvOsdRuntime enables property expansion for placeholder-based messages', () => { const commands: (string | number)[][] = []; - showMpvOsdRuntime( + const shown = showMpvOsdRuntime( { connected: true, send: ({ command }) => { @@ -33,6 +34,7 @@ test('showMpvOsdRuntime enables property expansion for placeholder-based message }, 'Subtitle delay: ${sub-delay}', ); + assert.equal(shown, true); assert.deepEqual(commands, [ ['expand-properties', 'show-text', 'Subtitle delay: ${sub-delay}', '3000'], ]); @@ -40,7 +42,7 @@ test('showMpvOsdRuntime enables property expansion for placeholder-based message test('showMpvOsdRuntime logs fallback when disconnected', () => { const logs: string[] = []; - showMpvOsdRuntime( + const shown = showMpvOsdRuntime( { connected: false, send: () => {}, @@ -50,6 +52,7 @@ test('showMpvOsdRuntime logs fallback when disconnected', () => { logs.push(line); }, ); + assert.equal(shown, false); assert.deepEqual(logs, ['OSD (MPV not connected): hello']); }); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 10d3f0a7..eb08d16b 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -51,15 +51,16 @@ export function showMpvOsdRuntime( mpvClient: MpvRuntimeClientLike | null, text: string, fallbackLog: (text: string) => void = (line) => logger.info(line), -): void { +): boolean { if (mpvClient && mpvClient.connected) { const command = text.includes('${') ? ['expand-properties', 'show-text', text, '3000'] : ['show-text', text, '3000']; mpvClient.send({ command }); - return; + return true; } fallbackLog(`OSD (MPV not connected): ${text}`); + return false; } export function replayCurrentSubtitleRuntime(mpvClient: MpvRuntimeClientLike | null): void { diff --git a/src/main.ts b/src/main.ts index f484970f..30600421 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5347,7 +5347,9 @@ function getUpdateService() { { notificationType: getResolvedConfig().updates.notificationType, version }, { showSystemNotification: (title, body) => showDesktopNotification(title, { body }), - showOsdNotification: (message) => showMpvOsd(message), + showOsdNotification: (message) => { + showMpvOsd(message); + }, log: (message) => logger.warn(message), }, ), diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts index 7608ebcf..d252957c 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts @@ -22,31 +22,41 @@ test('auto sync notifications send osd updates for progress phases', () => { notifyCharacterDictionaryAutoSyncStatus(makeEvent('checking', 'checking'), { getNotificationType: () => 'osd', - showOsd: (message) => calls.push(`osd:${message}`), + showOsd: (message) => { + calls.push(`osd:${message}`); + }, showDesktopNotification: (title, options) => calls.push(`desktop:${title}:${options.body ?? ''}`), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), { getNotificationType: () => 'osd', - showOsd: (message) => calls.push(`osd:${message}`), + showOsd: (message) => { + calls.push(`osd:${message}`); + }, showDesktopNotification: (title, options) => calls.push(`desktop:${title}:${options.body ?? ''}`), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), { getNotificationType: () => 'osd', - showOsd: (message) => calls.push(`osd:${message}`), + showOsd: (message) => { + calls.push(`osd:${message}`); + }, showDesktopNotification: (title, options) => calls.push(`desktop:${title}:${options.body ?? ''}`), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), { getNotificationType: () => 'osd', - showOsd: (message) => calls.push(`osd:${message}`), + showOsd: (message) => { + calls.push(`osd:${message}`); + }, showDesktopNotification: (title, options) => calls.push(`desktop:${title}:${options.body ?? ''}`), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), { getNotificationType: () => 'osd', - showOsd: (message) => calls.push(`osd:${message}`), + showOsd: (message) => { + calls.push(`osd:${message}`); + }, showDesktopNotification: (title, options) => calls.push(`desktop:${title}:${options.body ?? ''}`), }); @@ -65,28 +75,85 @@ test('auto sync notifications never send desktop notifications', () => { notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), { getNotificationType: () => 'both', - showOsd: (message) => calls.push(`osd:${message}`), + showOsd: (message) => { + calls.push(`osd:${message}`); + }, showDesktopNotification: (title, options) => calls.push(`desktop:${title}:${options.body ?? ''}`), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), { getNotificationType: () => 'both', - showOsd: (message) => calls.push(`osd:${message}`), + showOsd: (message) => { + calls.push(`osd:${message}`); + }, showDesktopNotification: (title, options) => calls.push(`desktop:${title}:${options.body ?? ''}`), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), { getNotificationType: () => 'both', - showOsd: (message) => calls.push(`osd:${message}`), + showOsd: (message) => { + calls.push(`osd:${message}`); + }, showDesktopNotification: (title, options) => calls.push(`desktop:${title}:${options.body ?? ''}`), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('failed', 'failed'), { getNotificationType: () => 'both', - showOsd: (message) => calls.push(`osd:${message}`), + showOsd: (message) => { + calls.push(`osd:${message}`); + }, showDesktopNotification: (title, options) => calls.push(`desktop:${title}:${options.body ?? ''}`), }); assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']); }); + +test('auto sync notifications fall back to desktop for long progress when osd is unavailable', () => { + const calls: string[] = []; + + notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), { + getNotificationType: () => 'both', + showOsd: (message) => { + calls.push(`osd:${message}`); + return false; + }, + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }); + notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), { + getNotificationType: () => 'both', + showOsd: (message) => { + calls.push(`osd:${message}`); + return false; + }, + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }); + + assert.deepEqual(calls, ['osd:generating', 'desktop:SubMiner:generating', 'osd:ready']); +}); + +test('auto sync notifications fall back to desktop when startup sequencer cannot show osd', () => { + const calls: string[] = []; + + notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), { + getNotificationType: () => 'both', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + startupOsdSequencer: { + notifyCharacterDictionaryStatus: (event) => { + calls.push(`sequencer:${event.phase}:${event.message}`); + return false; + }, + }, + }); + + assert.deepEqual(calls, [ + 'sequencer:importing:importing', + 'desktop:SubMiner:importing', + ]); +}); diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.ts index 3b384b4b..3610c951 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.ts @@ -5,10 +5,12 @@ export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAu export interface CharacterDictionaryAutoSyncNotificationDeps { getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined; - showOsd: (message: string) => void; + showOsd: (message: string) => boolean | void; showDesktopNotification: (title: string, options: { body?: string }) => void; startupOsdSequencer?: { - notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void; + notifyCharacterDictionaryStatus: ( + event: StartupOsdSequencerCharacterDictionaryEvent, + ) => boolean; }; } @@ -16,6 +18,16 @@ function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): bo return type !== 'none'; } +function shouldFallbackToDesktop( + type: 'osd' | 'system' | 'both' | 'none' | undefined, + phase: CharacterDictionaryAutoSyncNotificationEvent['phase'], +): boolean { + return ( + (type === 'system' || type === 'both') && + (phase === 'generating' || phase === 'building' || phase === 'importing') + ); +} + export function notifyCharacterDictionaryAutoSyncStatus( event: CharacterDictionaryAutoSyncNotificationEvent, deps: CharacterDictionaryAutoSyncNotificationDeps, @@ -23,12 +35,18 @@ export function notifyCharacterDictionaryAutoSyncStatus( const type = deps.getNotificationType(); if (shouldShowOsd(type)) { if (deps.startupOsdSequencer) { - deps.startupOsdSequencer.notifyCharacterDictionaryStatus({ + const shown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({ phase: event.phase, message: event.message, }); + if (!shown && shouldFallbackToDesktop(type, event.phase)) { + deps.showDesktopNotification('SubMiner', { body: event.message }); + } return; } - deps.showOsd(event.message); + const shown = deps.showOsd(event.message) !== false; + if (!shown && shouldFallbackToDesktop(type, event.phase)) { + deps.showDesktopNotification('SubMiner', { body: event.message }); + } } } diff --git a/src/main/runtime/mpv-osd-log-main-deps.test.ts b/src/main/runtime/mpv-osd-log-main-deps.test.ts index de5131d2..a8b630a4 100644 --- a/src/main/runtime/mpv-osd-log-main-deps.test.ts +++ b/src/main/runtime/mpv-osd-log-main-deps.test.ts @@ -41,6 +41,7 @@ test('show mpv osd main deps map runtime delegates and logging callback', () => showMpvOsdRuntime: (_mpvClient, text, fallbackLog) => { calls.push(`show:${text}`); fallbackLog('fallback'); + return false; }, getMpvClient: () => client, logInfo: (line) => calls.push(`info:${line}`), @@ -48,6 +49,9 @@ test('show mpv osd main deps map runtime delegates and logging callback', () => assert.deepEqual(deps.getMpvClient(), client); deps.appendToMpvLog('hello'); - deps.showMpvOsdRuntime(deps.getMpvClient(), 'subtitle', (line) => deps.logInfo(line)); + const shown = deps.showMpvOsdRuntime(deps.getMpvClient(), 'subtitle', (line) => + deps.logInfo(line), + ); + assert.equal(shown, false); assert.deepEqual(calls, ['append:hello', 'show:subtitle', 'info:fallback']); }); diff --git a/src/main/runtime/mpv-osd-log.test.ts b/src/main/runtime/mpv-osd-log.test.ts index 1f23f700..7653a6f7 100644 --- a/src/main/runtime/mpv-osd-log.test.ts +++ b/src/main/runtime/mpv-osd-log.test.ts @@ -99,12 +99,14 @@ test('show mpv osd logs marker and forwards fallback logging', () => { showMpvOsdRuntime: (_client, text, fallbackLog) => { calls.push(`show:${text}`); fallbackLog('fallback-line'); + return false; }, getMpvClient: () => client, logInfo: (line) => calls.push(`info:${line}`), }); - showMpvOsd('subtitle copied'); + const shown = showMpvOsd('subtitle copied'); + assert.equal(shown, false); assert.deepEqual(calls, [ 'append:[OSD] subtitle copied', 'show:subtitle copied', diff --git a/src/main/runtime/mpv-osd-log.ts b/src/main/runtime/mpv-osd-log.ts index 931d68c4..040e7126 100644 --- a/src/main/runtime/mpv-osd-log.ts +++ b/src/main/runtime/mpv-osd-log.ts @@ -57,13 +57,13 @@ export function createShowMpvOsdHandler(deps: { mpvClient: MpvRuntimeClientLike | null, text: string, fallbackLog: (line: string) => void, - ) => void; + ) => boolean; getMpvClient: () => MpvRuntimeClientLike | null; logInfo: (line: string) => void; }) { - return (text: string): void => { + return (text: string): boolean => { deps.appendToMpvLog(`[OSD] ${text}`); - deps.showMpvOsdRuntime(deps.getMpvClient(), text, (line) => { + return deps.showMpvOsdRuntime(deps.getMpvClient(), text, (line) => { deps.logInfo(line); }); }; diff --git a/src/main/runtime/mpv-osd-runtime-handlers.test.ts b/src/main/runtime/mpv-osd-runtime-handlers.test.ts index 592b13ca..a9877f70 100644 --- a/src/main/runtime/mpv-osd-runtime-handlers.test.ts +++ b/src/main/runtime/mpv-osd-runtime-handlers.test.ts @@ -19,15 +19,17 @@ test('mpv osd runtime handlers compose append and osd logging flow', async () => showMpvOsdRuntime: (_client, text, fallbackLog) => { calls.push(`show:${text}`); fallbackLog('fallback'); + return false; }, getMpvClient: () => null, logInfo: (line) => calls.push(`info:${line}`), }), }); - runtime.showMpvOsd('hello'); + const shown = runtime.showMpvOsd('hello'); await runtime.flushMpvLog(); + assert.equal(shown, false); assert.deepEqual(calls, [ 'show:hello', 'info:fallback', diff --git a/src/main/runtime/startup-osd-sequencer.test.ts b/src/main/runtime/startup-osd-sequencer.test.ts index 66e6008f..48d6da85 100644 --- a/src/main/runtime/startup-osd-sequencer.test.ts +++ b/src/main/runtime/startup-osd-sequencer.test.ts @@ -100,7 +100,7 @@ test('startup OSD replaces earlier dictionary progress with later building progr ]); }); -test('startup OSD skips buffered dictionary ready messages when progress completed before it became visible', () => { +test('startup OSD shows dictionary ready when progress completed before it became visible', () => { const osdMessages: string[] = []; const sequencer = createStartupOsdSequencer({ showOsd: (message) => { @@ -117,7 +117,10 @@ test('startup OSD skips buffered dictionary ready messages when progress complet sequencer.markTokenizationReady(); sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded'); - assert.deepEqual(osdMessages, ['Subtitle annotations loaded']); + assert.deepEqual(osdMessages, [ + 'Character dictionary ready for Frieren', + 'Subtitle annotations loaded', + ]); }); test('startup OSD shows dictionary failure after annotation loading completes', () => { @@ -184,3 +187,38 @@ test('startup OSD shows later dictionary progress immediately once tokenization 'Generating character dictionary for Frieren...', ]); }); + +test('startup OSD keeps dictionary progress pending when mpv osd is unavailable', () => { + const osdMessages: string[] = []; + let osdAvailable = false; + const sequencer = createStartupOsdSequencer({ + showOsd: (message) => { + osdMessages.push(message); + return osdAvailable; + }, + }); + + sequencer.markTokenizationReady(); + sequencer.notifyCharacterDictionaryStatus( + makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'), + ); + sequencer.notifyCharacterDictionaryStatus( + makeDictionaryEvent('ready', 'Character dictionary ready for Frieren'), + ); + + assert.deepEqual(osdMessages, [ + 'Generating character dictionary for Frieren...', + 'Character dictionary ready for Frieren', + ]); + + osdAvailable = true; + sequencer.notifyCharacterDictionaryStatus( + makeDictionaryEvent('ready', 'Character dictionary ready for Frieren'), + ); + + assert.deepEqual(osdMessages, [ + 'Generating character dictionary for Frieren...', + 'Character dictionary ready for Frieren', + 'Character dictionary ready for Frieren', + ]); +}); diff --git a/src/main/runtime/startup-osd-sequencer.ts b/src/main/runtime/startup-osd-sequencer.ts index 8e5bb60c..8b7cc9f3 100644 --- a/src/main/runtime/startup-osd-sequencer.ts +++ b/src/main/runtime/startup-osd-sequencer.ts @@ -3,22 +3,24 @@ export interface StartupOsdSequencerCharacterDictionaryEvent { message: string; } -export function createStartupOsdSequencer(deps: { showOsd: (message: string) => void }): { +export function createStartupOsdSequencer(deps: { showOsd: (message: string) => boolean | void }): { reset: () => void; markTokenizationReady: () => void; showAnnotationLoading: (message: string) => void; markAnnotationLoadingComplete: (message: string) => void; - notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void; + notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => boolean; } { let tokenizationReady = false; let tokenizationWarmupCompleted = false; let annotationLoadingMessage: string | null = null; let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null; let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null; + let pendingDictionaryReady: StartupOsdSequencerCharacterDictionaryEvent | null = null; let dictionaryProgressShown = false; const canShowDictionaryStatus = (): boolean => tokenizationReady && annotationLoadingMessage === null; + const showOsd = (message: string): boolean => deps.showOsd(message) !== false; const flushBufferedDictionaryStatus = (): boolean => { if (!canShowDictionaryStatus()) { @@ -28,15 +30,24 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => if (dictionaryProgressShown) { return true; } - deps.showOsd(pendingDictionaryProgress.message); - dictionaryProgressShown = true; - return true; + dictionaryProgressShown = showOsd(pendingDictionaryProgress.message); + return dictionaryProgressShown; + } + if (pendingDictionaryReady) { + const shown = showOsd(pendingDictionaryReady.message); + if (shown) { + pendingDictionaryReady = null; + dictionaryProgressShown = false; + } + return shown; } if (pendingDictionaryFailure) { - deps.showOsd(pendingDictionaryFailure.message); - pendingDictionaryFailure = null; - dictionaryProgressShown = false; - return true; + const shown = showOsd(pendingDictionaryFailure.message); + if (shown) { + pendingDictionaryFailure = null; + dictionaryProgressShown = false; + } + return shown; } return false; }; @@ -47,13 +58,14 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => annotationLoadingMessage = null; pendingDictionaryProgress = null; pendingDictionaryFailure = null; + pendingDictionaryReady = null; dictionaryProgressShown = false; }, markTokenizationReady: () => { tokenizationWarmupCompleted = true; tokenizationReady = true; if (annotationLoadingMessage !== null) { - deps.showOsd(annotationLoadingMessage); + showOsd(annotationLoadingMessage); return; } flushBufferedDictionaryStatus(); @@ -61,7 +73,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => showAnnotationLoading: (message) => { annotationLoadingMessage = message; if (tokenizationReady) { - deps.showOsd(message); + showOsd(message); } }, markAnnotationLoadingComplete: (message) => { @@ -72,7 +84,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => if (flushBufferedDictionaryStatus()) { return; } - deps.showOsd(message); + showOsd(message); }, notifyCharacterDictionaryStatus: (event) => { if ( @@ -84,32 +96,47 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => ) { pendingDictionaryProgress = event; pendingDictionaryFailure = null; + pendingDictionaryReady = null; if (canShowDictionaryStatus()) { - deps.showOsd(event.message); - dictionaryProgressShown = true; + dictionaryProgressShown = showOsd(event.message); } else if (tokenizationReady) { - deps.showOsd(event.message); - dictionaryProgressShown = true; + dictionaryProgressShown = showOsd(event.message); } - return; + return dictionaryProgressShown; } pendingDictionaryProgress = null; if (event.phase === 'failed') { + pendingDictionaryReady = null; if (canShowDictionaryStatus()) { - deps.showOsd(event.message); + if (!showOsd(event.message)) { + pendingDictionaryFailure = event; + return false; + } + dictionaryProgressShown = false; + return true; } else { pendingDictionaryFailure = event; } dictionaryProgressShown = false; - return; + return false; } pendingDictionaryFailure = null; - if (canShowDictionaryStatus() && dictionaryProgressShown) { - deps.showOsd(event.message); + if (canShowDictionaryStatus()) { + if (!showOsd(event.message)) { + pendingDictionaryReady = event; + dictionaryProgressShown = false; + return false; + } + pendingDictionaryReady = null; + dictionaryProgressShown = false; + return true; + } else { + pendingDictionaryReady = event; } dictionaryProgressShown = false; + return false; }, }; } diff --git a/src/renderer/modals/character-dictionary.test.ts b/src/renderer/modals/character-dictionary.test.ts index 3d9bd678..84129430 100644 --- a/src/renderer/modals/character-dictionary.test.ts +++ b/src/renderer/modals/character-dictionary.test.ts @@ -62,6 +62,91 @@ function flushAsyncWork(): Promise { }); } +test('character dictionary modal announces open before AniList refresh resolves', async () => { + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + let resolveSelection: (snapshot: CharacterDictionarySelectionSnapshot) => void = () => {}; + const selectionPromise = new Promise((resolve) => { + resolveSelection = resolve; + }); + const events: string[] = []; + const overlay = createNodeStub(); + const modalNode = createNodeStub(true); + const state = createRendererState(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + getCharacterDictionarySelection: () => selectionPromise, + setCharacterDictionarySelection: async () => ({ + ok: false, + seriesKey: 'test', + selected: { id: 0, title: '', episodes: null }, + staleMediaIds: [], + }), + notifyOverlayModalClosed: () => {}, + notifyOverlayModalOpened: (modal: string) => { + events.push(`notify:${modal}`); + }, + } satisfies Pick< + ElectronAPI, + | 'getCharacterDictionarySelection' + | 'setCharacterDictionarySelection' + | 'notifyOverlayModalClosed' + | 'notifyOverlayModalOpened' + >, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createElementStub(), + }, + }); + + try { + const modal = createCharacterDictionaryModal( + { + state, + dom: { + overlay, + characterDictionaryModal: modalNode, + characterDictionaryClose: createNodeStub(), + characterDictionarySummary: createNodeStub(), + characterDictionaryCurrent: createNodeStub(), + characterDictionaryCandidates: createNodeStub(), + characterDictionaryStatus: createNodeStub(), + }, + } as never, + { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => { + events.push('sync-subtitle-suppression'); + }, + }, + ); + + const openPromise = modal.openCharacterDictionaryModal(); + + assert.equal(state.characterDictionaryModalOpen, true); + assert.equal(modalNode.classList.contains('hidden'), false); + assert.deepEqual(events, ['sync-subtitle-suppression', 'notify:character-dictionary']); + + resolveSelection({ + seriesKey: 'tower-of-god-2020', + guessTitle: 'Tower of God', + current: null, + override: null, + candidates: [{ id: 115230, title: 'Tower of God', episodes: 13 }], + }); + await openPromise; + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + test('character dictionary modal loads candidates and applies selected override', async () => { const previousWindow = globalThis.window; const previousDocument = globalThis.document; @@ -95,11 +180,13 @@ test('character dictionary modal loads candidates and applies selected override' }; }, notifyOverlayModalClosed: () => {}, + notifyOverlayModalOpened: () => {}, } satisfies Pick< ElectronAPI, | 'getCharacterDictionarySelection' | 'setCharacterDictionarySelection' | 'notifyOverlayModalClosed' + | 'notifyOverlayModalOpened' >, }, }); @@ -175,11 +262,13 @@ test('character dictionary modal shows refresh errors without rejecting open', a staleMediaIds: [], }), notifyOverlayModalClosed: () => {}, + notifyOverlayModalOpened: () => {}, } satisfies Pick< ElectronAPI, | 'getCharacterDictionarySelection' | 'setCharacterDictionarySelection' | 'notifyOverlayModalClosed' + | 'notifyOverlayModalOpened' >, }, }); diff --git a/src/renderer/modals/character-dictionary.ts b/src/renderer/modals/character-dictionary.ts index b39dae48..17d7db47 100644 --- a/src/renderer/modals/character-dictionary.ts +++ b/src/renderer/modals/character-dictionary.ts @@ -153,6 +153,7 @@ export function createCharacterDictionaryModal( ctx.dom.overlay.classList.add('interactive'); ctx.dom.characterDictionaryModal.classList.remove('hidden'); ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false'); + window.electronAPI.notifyOverlayModalOpened('character-dictionary'); setStatus('Loading AniList candidates...'); } @@ -160,6 +161,7 @@ export function createCharacterDictionaryModal( if (!ctx.state.characterDictionaryModalOpen) { showShell(); } else { + window.electronAPI.notifyOverlayModalOpened('character-dictionary'); setStatus('Refreshing AniList candidates...'); } try { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 84cc5e1c..0f3aff63 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -463,7 +463,6 @@ function registerModalOpenHandlers(): void { window.electronAPI.onOpenCharacterDictionary(() => { runGuardedAsync('character-dictionary:open', async () => { await characterDictionaryModal.openCharacterDictionaryModal(); - window.electronAPI.notifyOverlayModalOpened('character-dictionary'); }); }); window.electronAPI.onOpenSessionHelp(() => {