diff --git a/changes/overlay-notifications.md b/changes/overlay-notifications.md index 33d7acb1..e5a22ebf 100644 --- a/changes/overlay-notifications.md +++ b/changes/overlay-notifications.md @@ -8,5 +8,6 @@ breaking: true - Kept playback feedback such as subtitle visibility, subtitle track, and subtitle delay text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates. - Reused the active primary/secondary subtitle mode overlay notification while cycling modes so rapid toggles update one card instead of stacking duplicate feedback. - Fixed mined-card overlay notifications so `overlay` and `both` modes show the same generated card thumbnail as system notifications. +- Fixed character dictionary sync so duplicate MPV media-path events do not repeat check/ready notifications for the same opened video. - Changed `both` notification routing to mean overlay + system; users who used `both` for mpv OSD + system notifications should set `notificationType` to `osd-system` in `config.jsonc`. - Kept `osd` and `osd-system` as config-file-only legacy notification values; Settings normally offers only overlay, system, both, and none, while still showing an already configured legacy value as selected. diff --git a/changes/startup-overlay-ready-snapshot.md b/changes/startup-overlay-ready-snapshot.md new file mode 100644 index 00000000..79eb187a --- /dev/null +++ b/changes/startup-overlay-ready-snapshot.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed pause-until-overlay-ready startup on macOS so the initial renderer subtitle snapshot can release the mpv startup gate after the overlay paints annotations. diff --git a/src/main.ts b/src/main.ts index ab3b4e2a..c581d120 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6831,6 +6831,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ tokenizeSubtitle: tokenizeSubtitleForCurrent ? (text) => tokenizeSubtitleForCurrent(text) : undefined, + onResolvedSubtitle: (payload) => { + appState.currentSubtitleData = payload; + autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true }); + }, }); }, getCurrentSubtitleRaw: () => appState.currentSubText, diff --git a/src/main/runtime/current-subtitle-snapshot.test.ts b/src/main/runtime/current-subtitle-snapshot.test.ts index 5f6590ea..d22ad888 100644 --- a/src/main/runtime/current-subtitle-snapshot.test.ts +++ b/src/main/runtime/current-subtitle-snapshot.test.ts @@ -62,6 +62,21 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token assert.deepEqual(payload.tokens, [{ text: '新' }]); }); +test('renderer current subtitle snapshot reports resolved payload for startup readiness', async () => { + const resolvedPayloads: SubtitleData[] = []; + const payload = await resolveCurrentSubtitleForRenderer({ + currentSubText: '起動字幕', + currentSubtitleData: null, + withCurrentSubtitleTiming: withTiming, + tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '起' } as never] }), + onResolvedSubtitle: (resolved) => { + resolvedPayloads.push(resolved); + }, + }); + + assert.deepEqual(resolvedPayloads, [payload]); +}); + test('visible overlay subtitle prime refreshes current text from mpv before showing overlay', async () => { const calls: string[] = []; diff --git a/src/main/runtime/current-subtitle-snapshot.ts b/src/main/runtime/current-subtitle-snapshot.ts index 4f426b57..926df1aa 100644 --- a/src/main/runtime/current-subtitle-snapshot.ts +++ b/src/main/runtime/current-subtitle-snapshot.ts @@ -10,13 +10,20 @@ export async function resolveCurrentSubtitleForRenderer(deps: { currentSubtitleData: SubtitleData | null; withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData; tokenizeSubtitle?: (text: string) => Promise; + onResolvedSubtitle?: (payload: SubtitleData) => void; }): Promise { + const resolve = (payload: SubtitleData): SubtitleData => { + const timedPayload = deps.withCurrentSubtitleTiming(payload); + deps.onResolvedSubtitle?.(timedPayload); + return timedPayload; + }; + if (deps.currentSubtitleData?.text === deps.currentSubText) { - return deps.withCurrentSubtitleTiming(deps.currentSubtitleData); + return resolve(deps.currentSubtitleData); } if (!deps.currentSubText.trim()) { - return deps.withCurrentSubtitleTiming({ + return resolve({ text: deps.currentSubText, tokens: null, }); @@ -24,10 +31,10 @@ export async function resolveCurrentSubtitleForRenderer(deps: { const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText); if (tokenized) { - return deps.withCurrentSubtitleTiming(tokenized); + return resolve(tokenized); } - return deps.withCurrentSubtitleTiming({ + return resolve({ text: deps.currentSubText, tokens: null, }); diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index 781b6078..4b2b3277 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -183,6 +183,34 @@ test('media path change handler signals autoplay readiness from warm media path' ]); }); +test('media path change handler schedules character dictionary once per media path', () => { + const calls: string[] = []; + const handler = createHandleMpvMediaPathChangeHandler({ + updateCurrentMediaPath: (path) => calls.push(`path:${path}`), + reportJellyfinRemoteStopped: () => calls.push('stopped'), + restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), + resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'), + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`), + maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), + ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), + syncImmersionMediaState: () => calls.push('sync'), + scheduleCharacterDictionarySync: () => calls.push('dict-sync'), + refreshDiscordPresence: () => calls.push('presence'), + }); + + handler({ path: '/tmp/video.mkv' }); + handler({ path: '/tmp/video.mkv' }); + handler({ path: '/tmp/next-video.mkv' }); + handler({ path: '' }); + handler({ path: '/tmp/video.mkv' }); + + assert.deepEqual( + calls.filter((call) => call === 'dict-sync'), + ['dict-sync', 'dict-sync', 'dict-sync'], + ); +}); + test('media path change handler marks Jellyfin remote playback loaded from media path', () => { const calls: string[] = []; const handler = createHandleMpvMediaPathChangeHandler({ diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index 570320eb..4b715e44 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -74,9 +74,13 @@ export function createHandleMpvMediaPathChangeHandler(deps: { flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void; refreshDiscordPresence: () => void; }) { + let lastCharacterDictionarySyncMediaPath: string | null = null; + return ({ path }: { path: string | null }): void => { const normalizedPath = typeof path === 'string' ? path : ''; - if (!normalizedPath) { + const trimmedPath = normalizedPath.trim(); + if (!trimmedPath) { + lastCharacterDictionarySyncMediaPath = null; deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath); } deps.updateCurrentMediaPath(normalizedPath); @@ -92,9 +96,12 @@ export function createHandleMpvMediaPathChangeHandler(deps: { deps.ensureAnilistMediaGuess(mediaKey); } deps.syncImmersionMediaState(); - if (normalizedPath.trim().length > 0) { + if (trimmedPath.length > 0) { deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath); - deps.scheduleCharacterDictionarySync?.(); + if (trimmedPath !== lastCharacterDictionarySyncMediaPath) { + lastCharacterDictionarySyncMediaPath = trimmedPath; + deps.scheduleCharacterDictionarySync?.(); + } deps.signalAutoplayReadyIfWarm?.(normalizedPath); } deps.refreshDiscordPresence();