mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
+27
@@ -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<typeof setInterval> | 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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.(),
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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<unknown> | 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<unknown> | 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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<typeof createRecordAnilistMediaDurationHandler>;
|
||||
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
|
||||
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
|
||||
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||
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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user