From fe201a2d2f479fa5c0ac478dc307cfb9e324f0a4 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 16 May 2026 18:48:45 -0700 Subject: [PATCH] fix(macos): keep overlay interactive when mpv loses foreground - Track overlay mouse interaction state via IPC setIgnoreMouseEvents hook - Skip macOS hide/passthrough when overlayInteractionActive is set - Focus overlay window so lookup keys reach it during interaction - Record mpv duration events into AniList media state for threshold checks --- changes/fix-anilist-timepos-threshold.md | 2 +- src/core/services/ipc.test.ts | 11 +- src/core/services/ipc.ts | 10 + src/core/services/overlay-visibility.test.ts | 185 ++++++++++++++++++ src/core/services/overlay-visibility.ts | 19 +- src/main.ts | 27 +++ src/main/dependencies.ts | 2 + src/main/overlay-visibility-runtime.ts | 2 + .../anilist-media-state-main-deps.test.ts | 30 +++ .../runtime/anilist-media-state-main-deps.ts | 14 ++ src/main/runtime/anilist-media-state.test.ts | 55 ++++++ src/main/runtime/anilist-media-state.ts | 31 +++ .../anilist-tracking-composer.test.ts | 21 ++ .../composers/anilist-tracking-composer.ts | 10 + .../runtime/mpv-main-event-main-deps.test.ts | 5 + src/main/runtime/mpv-main-event-main-deps.ts | 2 + ...erlay-visibility-runtime-main-deps.test.ts | 2 + .../overlay-visibility-runtime-main-deps.ts | 1 + 18 files changed, 425 insertions(+), 4 deletions(-) diff --git a/changes/fix-anilist-timepos-threshold.md b/changes/fix-anilist-timepos-threshold.md index 4bc1c0a3..ab0bda68 100644 --- a/changes/fix-anilist-timepos-threshold.md +++ b/changes/fix-anilist-timepos-threshold.md @@ -1,4 +1,4 @@ type: fixed area: anilist -- Used fresh mpv time-position events for AniList post-watch threshold checks so progress updates still fire when playback reaches the watched threshold. +- Used fresh mpv time-position and duration events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold. diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 46e2ec80..de6b8367 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -218,6 +218,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { getMainWindow: () => null, getVisibleOverlayVisibility: () => false, onOverlayModalClosed: () => {}, + onOverlayMouseInteractionChanged: (active) => { + calls.push(`overlay-interaction:${active}`); + }, openYomitanSettings: () => {}, quitApp: () => {}, toggleVisibleOverlay: () => {}, @@ -281,6 +284,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' }); deps.clearAnilistToken(); deps.openAnilistSetup(); + deps.onOverlayMouseInteractionChanged?.(true, null); assert.deepEqual(deps.getAnilistQueueStatus(), { pending: 1, ready: 0, @@ -298,7 +302,12 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' }); assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' }); assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' }); - assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']); + assert.deepEqual(calls, [ + 'clearAnilistToken', + 'openAnilistSetup', + 'overlay-interaction:true', + 'retryAnilistQueueNow', + ]); assert.equal(deps.getPlaybackPaused(), true); }); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 89c04da1..f6b0cf58 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -44,6 +44,10 @@ export interface IpcServiceDeps { modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, ) => void; + onOverlayMouseInteractionChanged?: ( + active: boolean, + senderWindow: ElectronBrowserWindow | null, + ) => void; openYomitanSettings: () => void; quitApp: () => void; toggleDevTools: () => void; @@ -175,6 +179,10 @@ export interface IpcDepsRuntimeOptions { modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, ) => void; + onOverlayMouseInteractionChanged?: ( + active: boolean, + senderWindow: ElectronBrowserWindow | null, + ) => void; openYomitanSettings: () => void; quitApp: () => void; toggleVisibleOverlay: () => void; @@ -233,6 +241,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService return { onOverlayModalClosed: options.onOverlayModalClosed, onOverlayModalOpened: options.onOverlayModalOpened, + onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged, openYomitanSettings: options.openYomitanSettings, quitApp: options.quitApp, toggleDevTools: () => { @@ -349,6 +358,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar if (senderWindow && !senderWindow.isDestroyed()) { senderWindow.setIgnoreMouseEvents(ignore, parsedOptions); } + deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow); }, ); diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index ff345010..564c98c6 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -1110,6 +1110,140 @@ test('macOS tracked overlay hides when mpv loses foreground', () => { assert.ok(!calls.includes('show')); }); +test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + window.show(); + setFocused(false); + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + overlayInteractionActive: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + 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: true, + isWindowsPlatform: false, + } as never); + + assert.ok(calls.includes('update-bounds')); + assert.ok(calls.includes('sync-layer')); + assert.ok(calls.includes('mouse-ignore:false:plain')); + assert.ok(calls.includes('ensure-level')); + assert.ok(calls.includes('enforce-order')); + assert.ok(calls.includes('sync-shortcuts')); + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('hide')); +}); + +test('macOS lets an active overlay receive mouse input instead of forcing passthrough', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + window.show(); + setFocused(false); + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + overlayInteractionActive: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + 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: true, + isWindowsPlatform: false, + } as never); + + assert.ok(calls.includes('mouse-ignore:false:plain')); + assert.ok(!calls.includes('mouse-ignore:true:forward')); + assert.ok(!calls.includes('hide')); +}); + +test('macOS focuses an active overlay so lookup trigger keys reach it', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + window.show(); + setFocused(false); + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + overlayInteractionActive: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + 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: true, + isWindowsPlatform: false, + } as never); + + assert.ok(calls.includes('mouse-ignore:false:plain')); + assert.ok(calls.includes('focus')); + assert.ok(!calls.includes('hide')); +}); + test('macOS tracked overlay passively reappears when mpv regains foreground', () => { const { window, calls } = createMainWindowRecorder(); let targetFocused = false; @@ -1647,6 +1781,57 @@ test('macOS keeps a focused overlay visible during tracker loss', () => { assert.ok(!calls.includes('loading-osd')); }); +test('macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => false, + getGeometry: () => null, + isTargetWindowFocused: () => false, + isTargetWindowMinimized: () => false, + }; + + window.show(); + setFocused(false); + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + overlayInteractionActive: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + 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: true, + showOverlayLoadingOsd: () => { + calls.push('loading-osd'); + }, + } as never); + + assert.ok(calls.includes('sync-layer')); + assert.ok(calls.includes('mouse-ignore:false:plain')); + assert.ok(calls.includes('ensure-level')); + assert.ok(calls.includes('enforce-order')); + assert.ok(calls.includes('sync-shortcuts')); + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('hide')); + assert.ok(!calls.includes('loading-osd')); +}); + test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => { const { window } = createMainWindowRecorder(); const osdMessages: string[] = []; diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 1d984ba7..0924651b 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -63,6 +63,7 @@ export function updateVisibleOverlayVisibility(args: { visibleOverlayVisible: boolean; modalActive?: boolean; forceMousePassthrough?: boolean; + overlayInteractionActive?: boolean; mainWindow: BrowserWindow | null; windowTracker: BaseWindowTracker | null; lastKnownWindowsForegroundProcessName?: string | null; @@ -89,6 +90,7 @@ export function updateVisibleOverlayVisibility(args: { } const mainWindow = args.mainWindow; + const overlayInteractionActive = args.overlayInteractionActive === true; if (args.modalActive) { if (args.isWindowsPlatform) { @@ -104,7 +106,8 @@ export function updateVisibleOverlayVisibility(args: { const forceMousePassthrough = args.forceMousePassthrough === true; const wasVisible = mainWindow.isVisible(); const isVisibleOverlayFocused = - typeof mainWindow.isFocused === 'function' && mainWindow.isFocused(); + overlayInteractionActive || + (typeof mainWindow.isFocused === 'function' && mainWindow.isFocused()); const windowTracker = args.windowTracker; const canReportMacOSTargetMinimized = args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function'; @@ -130,7 +133,7 @@ export function updateVisibleOverlayVisibility(args: { !isVisibleOverlayFocused && !isTrackedMacOSTargetFocused; // Renderer hover tracking temporarily disables this for subtitle and popup interaction. - const shouldUseMacOSMousePassthrough = args.isMacOSPlatform; + const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive; const shouldDefaultToPassthrough = args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel; const windowsForegroundProcessName = @@ -234,6 +237,16 @@ export function updateVisibleOverlayVisibility(args: { args.syncWindowsOverlayToMpvZOrder?.(mainWindow); } + if ( + args.isMacOSPlatform && + overlayInteractionActive && + !forceMousePassthrough && + typeof mainWindow.isFocused === 'function' && + !mainWindow.isFocused() + ) { + mainWindow.focus(); + } + if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { mainWindow.focus(); } @@ -320,6 +333,7 @@ export function updateVisibleOverlayVisibility(args: { const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null; const hasActiveMacOSTargetSignal = args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false); + const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive; const canReportMacOSTargetMinimized = args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function'; const isTrackedMacOSTargetMinimized = @@ -328,6 +342,7 @@ export function updateVisibleOverlayVisibility(args: { (args.isMacOSPlatform && !isTrackedMacOSTargetMinimized && (hasRetainedTrackedGeometry || + (mainWindow.isVisible() && hasActiveMacOSOverlaySignal) || (mainWindow.isVisible() && hasActiveMacOSTargetSignal) || (canReportMacOSTargetMinimized && mainWindow.isVisible()))) || (args.isWindowsPlatform && diff --git a/src/main.ts b/src/main.ts index 8a9604c0..bbd2ad94 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2069,6 +2069,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( getModalActive: () => overlayModalInputState.getModalInputExclusive(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getForceMousePassthrough: () => appState.statsOverlayVisible, + getOverlayInteractionActive: () => visibleOverlayInteractionActive, getWindowTracker: () => appState.windowTracker, getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), @@ -2123,6 +2124,7 @@ let windowsVisibleOverlayZOrderSyncQueued = false; let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayBlurredAtMs = 0; +let visibleOverlayInteractionActive = false; function clearVisibleOverlayBlurRefreshTimeouts(): void { for (const timeout of visibleOverlayBlurRefreshTimeouts) { @@ -3045,6 +3047,7 @@ const { resetAnilistMediaTracking, getAnilistMediaGuessRuntimeState, setAnilistMediaGuessRuntimeState, + recordAnilistMediaDuration, resetAnilistMediaGuessState, maybeProbeAnilistDuration, ensureAnilistMediaGuess, @@ -3148,6 +3151,13 @@ const { ); }, }, + recordMediaDurationMainDeps: { + getCurrentMediaKey: () => getCurrentAnilistMediaKey(), + getState: () => getAnilistMediaGuessRuntimeState(), + setState: (state) => { + setAnilistMediaGuessRuntimeState(state); + }, + }, resetMediaGuessStateMainDeps: { setMediaGuess: (value) => { anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( @@ -3987,6 +3997,9 @@ const { void reportJellyfinRemoteStopped(); }, maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options), + recordAnilistMediaDuration: (durationSec) => { + recordAnilistMediaDuration(durationSec); + }, logSubtitleTimingError: (message, error) => logger.error(message, error), broadcastToOverlayWindows: (channel, payload) => { broadcastToOverlayWindows(channel, payload); @@ -5128,6 +5141,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ onOverlayModalOpened: (modal) => { overlayModalRuntime.notifyOverlayModalOpened(modal); }, + onOverlayMouseInteractionChanged: (active, senderWindow) => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || senderWindow !== mainWindow) { + return; + } + if (visibleOverlayInteractionActive === active) { + if (active && process.platform === 'darwin' && !mainWindow.isFocused()) { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + } + return; + } + visibleOverlayInteractionActive = active; + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), openYomitanSettings: () => openYomitanSettings(), quitApp: () => requestAppQuit(), diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 10420956..209e2530 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams { getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility']; onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed']; onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened']; + onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged']; onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve']; openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; quitApp: IpcDepsRuntimeOptions['quitApp']; @@ -229,6 +230,7 @@ export function createMainIpcRuntimeServiceDeps( getVisibleOverlayVisibility: params.getVisibleOverlayVisibility, onOverlayModalClosed: params.onOverlayModalClosed, onOverlayModalOpened: params.onOverlayModalOpened, + onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged, onYoutubePickerResolve: params.onYoutubePickerResolve, openYomitanSettings: params.openYomitanSettings, quitApp: params.quitApp, diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts index fff9b868..b1f101e7 100644 --- a/src/main/overlay-visibility-runtime.ts +++ b/src/main/overlay-visibility-runtime.ts @@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps { getModalActive: () => boolean; getVisibleOverlayVisible: () => boolean; getForceMousePassthrough: () => boolean; + getOverlayInteractionActive?: () => boolean; getWindowTracker: () => BaseWindowTracker | null; getLastKnownWindowsForegroundProcessName?: () => string | null; getWindowsOverlayProcessName?: () => string | null; @@ -49,6 +50,7 @@ export function createOverlayVisibilityRuntimeService( visibleOverlayVisible, modalActive: deps.getModalActive(), forceMousePassthrough, + overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false, mainWindow, windowTracker, lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(), diff --git a/src/main/runtime/anilist-media-state-main-deps.test.ts b/src/main/runtime/anilist-media-state-main-deps.test.ts index f5515b7c..eef7d466 100644 --- a/src/main/runtime/anilist-media-state-main-deps.test.ts +++ b/src/main/runtime/anilist-media-state-main-deps.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler, createBuildGetCurrentAnilistMediaKeyMainDepsHandler, + createBuildRecordAnilistMediaDurationMainDepsHandler, createBuildResetAnilistMediaGuessStateMainDepsHandler, createBuildResetAnilistMediaTrackingMainDepsHandler, createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler, @@ -70,3 +71,32 @@ test('reset anilist media guess state main deps builder maps callbacks', () => { deps.setMediaGuessPromise(null); assert.deepEqual(calls, ['guess', 'promise']); }); + +test('record anilist media duration main deps builder maps callbacks', () => { + const calls: string[] = []; + const state = { + mediaKey: '/tmp/video.mkv', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }; + const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({ + getCurrentMediaKey: () => { + calls.push('key'); + return '/tmp/video.mkv'; + }, + getState: () => { + calls.push('get'); + return state; + }, + setState: () => { + calls.push('set'); + }, + })(); + + assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv'); + deps.getState(); + deps.setState(state); + assert.deepEqual(calls, ['key', 'get', 'set']); +}); diff --git a/src/main/runtime/anilist-media-state-main-deps.ts b/src/main/runtime/anilist-media-state-main-deps.ts index c0139b59..01194458 100644 --- a/src/main/runtime/anilist-media-state-main-deps.ts +++ b/src/main/runtime/anilist-media-state-main-deps.ts @@ -1,6 +1,7 @@ import type { createGetAnilistMediaGuessRuntimeStateHandler, createGetCurrentAnilistMediaKeyHandler, + createRecordAnilistMediaDurationHandler, createResetAnilistMediaGuessStateHandler, createResetAnilistMediaTrackingHandler, createSetAnilistMediaGuessRuntimeStateHandler, @@ -18,6 +19,9 @@ type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters< type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters< typeof createSetAnilistMediaGuessRuntimeStateHandler >[0]; +type RecordAnilistMediaDurationMainDeps = Parameters< + typeof createRecordAnilistMediaDurationHandler +>[0]; type ResetAnilistMediaGuessStateMainDeps = Parameters< typeof createResetAnilistMediaGuessStateHandler >[0]; @@ -66,6 +70,16 @@ export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler( }); } +export function createBuildRecordAnilistMediaDurationMainDepsHandler( + deps: RecordAnilistMediaDurationMainDeps, +) { + return (): RecordAnilistMediaDurationMainDeps => ({ + getCurrentMediaKey: () => deps.getCurrentMediaKey(), + getState: () => deps.getState(), + setState: (state) => deps.setState(state), + }); +} + export function createBuildResetAnilistMediaGuessStateMainDepsHandler( deps: ResetAnilistMediaGuessStateMainDeps, ) { diff --git a/src/main/runtime/anilist-media-state.test.ts b/src/main/runtime/anilist-media-state.test.ts index 26b58aa9..ebfbcb64 100644 --- a/src/main/runtime/anilist-media-state.test.ts +++ b/src/main/runtime/anilist-media-state.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { createGetAnilistMediaGuessRuntimeStateHandler, createGetCurrentAnilistMediaKeyHandler, + createRecordAnilistMediaDurationHandler, createResetAnilistMediaGuessStateHandler, createResetAnilistMediaTrackingHandler, createSetAnilistMediaGuessRuntimeStateHandler, @@ -176,3 +177,57 @@ test('reset anilist media guess state clears guess and in-flight promise', () => assert.equal(state.mediaDurationSec, 240); assert.equal(state.lastDurationProbeAtMs, 321); }); + +test('record anilist media duration stores observed mpv duration for current media', () => { + const existingPromise = Promise.resolve(null); + let state = { + mediaKey: '/tmp/video.mkv' as string | null, + mediaDurationSec: null as number | null, + mediaGuess: { title: 'guess' } as { title: string } | null, + mediaGuessPromise: existingPromise as Promise | null, + lastDurationProbeAtMs: 321, + }; + + const recordDuration = createRecordAnilistMediaDurationHandler({ + getCurrentMediaKey: () => '/tmp/video.mkv', + getState: () => state as never, + setState: (nextState) => { + state = nextState as never; + }, + }); + + recordDuration(1440); + + assert.equal(state.mediaDurationSec, 1440); + assert.deepEqual(state.mediaGuess, { title: 'guess' }); + assert.equal(state.mediaGuessPromise, existingPromise); + assert.equal(state.lastDurationProbeAtMs, 321); +}); + +test('record anilist media duration resets stale media state when media key changes', () => { + let state = { + mediaKey: '/tmp/old.mkv' as string | null, + mediaDurationSec: 120 as number | null, + mediaGuess: { title: 'old' } as { title: string } | null, + mediaGuessPromise: Promise.resolve(null) as Promise | null, + lastDurationProbeAtMs: 321, + }; + + const recordDuration = createRecordAnilistMediaDurationHandler({ + getCurrentMediaKey: () => '/tmp/new.mkv', + getState: () => state as never, + setState: (nextState) => { + state = nextState as never; + }, + }); + + recordDuration(1440); + + assert.deepEqual(state, { + mediaKey: '/tmp/new.mkv', + mediaDurationSec: 1440, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }); +}); diff --git a/src/main/runtime/anilist-media-state.ts b/src/main/runtime/anilist-media-state.ts index 1660b67e..8903318d 100644 --- a/src/main/runtime/anilist-media-state.ts +++ b/src/main/runtime/anilist-media-state.ts @@ -61,6 +61,37 @@ export function createSetAnilistMediaGuessRuntimeStateHandler(deps: { }; } +export function createRecordAnilistMediaDurationHandler(deps: { + getCurrentMediaKey: () => string | null; + getState: () => AnilistMediaGuessRuntimeState; + setState: (state: AnilistMediaGuessRuntimeState) => void; +}) { + return (durationSec: number): void => { + if (!Number.isFinite(durationSec) || durationSec <= 0) { + return; + } + const mediaKey = deps.getCurrentMediaKey(); + if (!mediaKey) { + return; + } + const state = deps.getState(); + if (state.mediaKey === mediaKey) { + deps.setState({ + ...state, + mediaDurationSec: durationSec, + }); + return; + } + deps.setState({ + mediaKey, + mediaDurationSec: durationSec, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }); + }; +} + export function createResetAnilistMediaGuessStateHandler(deps: { setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void; setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void; diff --git a/src/main/runtime/composers/anilist-tracking-composer.test.ts b/src/main/runtime/composers/anilist-tracking-composer.test.ts index 5b6e4f8f..d78d62c1 100644 --- a/src/main/runtime/composers/anilist-tracking-composer.test.ts +++ b/src/main/runtime/composers/anilist-tracking-composer.test.ts @@ -80,6 +80,23 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call lastDurationProbeAtMsState = value; }, }, + recordMediaDurationMainDeps: { + getCurrentMediaKey: () => 'media-key', + getState: () => ({ + mediaKey: mediaKeyState, + mediaDurationSec: mediaDurationSecState, + mediaGuess: mediaGuessState, + mediaGuessPromise: mediaGuessPromiseState, + lastDurationProbeAtMs: lastDurationProbeAtMsState, + }), + setState: (state) => { + mediaKeyState = state.mediaKey; + mediaDurationSecState = state.mediaDurationSec; + mediaGuessState = state.mediaGuess; + mediaGuessPromiseState = state.mediaGuessPromise; + lastDurationProbeAtMsState = state.lastDurationProbeAtMs; + }, + }, resetMediaGuessStateMainDeps: { setMediaGuess: (value) => { mediaGuessState = value; @@ -192,6 +209,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call assert.equal(typeof composed.resetAnilistMediaTracking, 'function'); assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function'); assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function'); + assert.equal(typeof composed.recordAnilistMediaDuration, 'function'); assert.equal(typeof composed.resetAnilistMediaGuessState, 'function'); assert.equal(typeof composed.maybeProbeAnilistDuration, 'function'); assert.equal(typeof composed.ensureAnilistMediaGuess, 'function'); @@ -216,6 +234,9 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call }); assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90); + composed.recordAnilistMediaDuration(180); + assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 180); + composed.resetAnilistMediaGuessState(); assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null); diff --git a/src/main/runtime/composers/anilist-tracking-composer.ts b/src/main/runtime/composers/anilist-tracking-composer.ts index 42822458..74205fda 100644 --- a/src/main/runtime/composers/anilist-tracking-composer.ts +++ b/src/main/runtime/composers/anilist-tracking-composer.ts @@ -5,6 +5,7 @@ import { createBuildMaybeProbeAnilistDurationMainDepsHandler, createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler, createBuildProcessNextAnilistRetryUpdateMainDepsHandler, + createBuildRecordAnilistMediaDurationMainDepsHandler, createBuildRefreshAnilistClientSecretStateMainDepsHandler, createBuildResetAnilistMediaGuessStateMainDepsHandler, createBuildResetAnilistMediaTrackingMainDepsHandler, @@ -15,6 +16,7 @@ import { createMaybeProbeAnilistDurationHandler, createMaybeRunAnilistPostWatchUpdateHandler, createProcessNextAnilistRetryUpdateHandler, + createRecordAnilistMediaDurationHandler, createRefreshAnilistClientSecretStateHandler, createResetAnilistMediaGuessStateHandler, createResetAnilistMediaTrackingHandler, @@ -38,6 +40,9 @@ export type AnilistTrackingComposerOptions = ComposerInputs<{ setMediaGuessRuntimeStateMainDeps: Parameters< typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler >[0]; + recordMediaDurationMainDeps: Parameters< + typeof createBuildRecordAnilistMediaDurationMainDepsHandler + >[0]; resetMediaGuessStateMainDeps: Parameters< typeof createBuildResetAnilistMediaGuessStateMainDepsHandler >[0]; @@ -63,6 +68,7 @@ export type AnilistTrackingComposerResult = ComposerOutputs<{ setAnilistMediaGuessRuntimeState: ReturnType< typeof createSetAnilistMediaGuessRuntimeStateHandler >; + recordAnilistMediaDuration: ReturnType; resetAnilistMediaGuessState: ReturnType; maybeProbeAnilistDuration: ReturnType; ensureAnilistMediaGuess: ReturnType; @@ -94,6 +100,9 @@ export function composeAnilistTrackingHandlers( options.setMediaGuessRuntimeStateMainDeps, )(), ); + const recordAnilistMediaDuration = createRecordAnilistMediaDurationHandler( + createBuildRecordAnilistMediaDurationMainDepsHandler(options.recordMediaDurationMainDeps)(), + ); const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler( createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(), ); @@ -120,6 +129,7 @@ export function composeAnilistTrackingHandlers( resetAnilistMediaTracking, getAnilistMediaGuessRuntimeState, setAnilistMediaGuessRuntimeState, + recordAnilistMediaDuration, resetAnilistMediaGuessState, maybeProbeAnilistDuration, ensureAnilistMediaGuess, diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 5a97c8e8..10569e21 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -16,6 +16,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`), handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`), recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`), + recordMediaDuration: (durationSec: number) => calls.push(`immersion-duration:${durationSec}`), recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`), }, subtitleTimingTracker: { @@ -40,6 +41,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as maybeRunAnilistPostWatchUpdate: async () => { calls.push('anilist-post-watch'); }, + recordAnilistMediaDuration: (durationSec) => calls.push(`anilist-duration:${durationSec}`), logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`), broadcastToOverlayWindows: (channel, payload) => calls.push(`broadcast:${channel}:${String(payload)}`), @@ -95,6 +97,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as deps.resetAnilistMediaGuessState(); deps.notifyImmersionTitleUpdate('title'); deps.recordPlaybackPosition(10); + deps.recordMediaDuration(1234); deps.reportJellyfinRemoteProgress(true); deps.onFullscreenChange?.(true); deps.recordPauseState(true); @@ -118,6 +121,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('reset-sidebar-layout')); + assert.ok(calls.includes('immersion-duration:1234')); + assert.ok(calls.includes('anilist-duration:1234')); }); test('mpv main event main deps wire subtitle callbacks without suppression gate', () => { diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 6a1c4bb0..9874bf11 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -47,6 +47,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { reportJellyfinRemoteStopped: () => void; syncOverlayMpvSubtitleSuppression: () => void; maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise; + recordAnilistMediaDuration?: (durationSec: number) => void; logSubtitleTimingError: (message: string, error: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; @@ -184,6 +185,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { recordMediaDuration: (durationSec: number) => { deps.ensureImmersionTrackerInitialized(); deps.appState.immersionTracker?.recordMediaDuration?.(durationSec); + deps.recordAnilistMediaDuration?.(durationSec); }, reportJellyfinRemoteProgress: (forceImmediate: boolean) => deps.reportJellyfinRemoteProgress(forceImmediate), diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts index eee7c1fd..624d9ca3 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -15,6 +15,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb getModalActive: () => true, getVisibleOverlayVisible: () => true, getForceMousePassthrough: () => true, + getOverlayInteractionActive: () => true, getWindowTracker: () => tracker, getLastKnownWindowsForegroundProcessName: () => 'mpv', getWindowsOverlayProcessName: () => 'subminer', @@ -40,6 +41,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb assert.equal(deps.getModalActive(), true); assert.equal(deps.getVisibleOverlayVisible(), true); assert.equal(deps.getForceMousePassthrough(), true); + assert.equal(deps.getOverlayInteractionActive?.(), true); assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv'); assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer'); assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts index f4b7761f..525728a6 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler( getModalActive: () => deps.getModalActive(), getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), getForceMousePassthrough: () => deps.getForceMousePassthrough(), + getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false, getWindowTracker: () => deps.getWindowTracker(), getLastKnownWindowsForegroundProcessName: () => deps.getLastKnownWindowsForegroundProcessName?.() ?? null,