diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 6ed12c7..6806f32 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -98,6 +98,7 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ getKeybindings: () => [], getConfiguredShortcuts: () => ({}), getStatsToggleKey: () => 'Backquote', + getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: async () => {}, saveControllerPreference: async () => {}, @@ -121,6 +122,39 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ }; } +function createFakeImmersionTracker( + overrides: Partial> = {}, +): NonNullable { + return { + recordYomitanLookup: () => {}, + getSessionSummaries: async () => [], + getDailyRollups: async () => [], + getMonthlyRollups: async () => [], + getQueryHints: async () => ({ + totalSessions: 0, + activeSessions: 0, + episodesToday: 0, + activeAnimeCount: 0, + totalActiveMin: 0, + totalCards: 0, + activeDays: 0, + totalEpisodesWatched: 0, + totalAnimeCompleted: 0, + }), + getSessionTimeline: async () => [], + getSessionEvents: async () => [], + getVocabularyStats: async () => [], + getKanjiStats: async () => [], + getMediaLibrary: async () => [], + getMediaDetail: async () => null, + getMediaSessions: async () => [], + getMediaDailyRollups: async () => [], + getCoverArt: async () => null, + markActiveVideoWatched: async () => false, + ...overrides, + }; +} + test('createIpcDepsRuntime wires AniList handlers', async () => { const calls: string[] = []; const deps = createIpcDepsRuntime({ @@ -142,6 +176,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { getKeybindings: () => [], getConfiguredShortcuts: () => ({}), getStatsToggleKey: () => 'Backquote', + getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: () => {}, saveControllerPreference: () => {}, @@ -210,6 +245,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = getKeybindings: () => [], getConfiguredShortcuts: () => ({}), getStatsToggleKey: () => 'Backquote', + getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: () => {}, saveControllerPreference: () => {}, @@ -278,6 +314,28 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = ); }); +test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const calls: string[] = []; + registerIpcHandlers( + createRegisterIpcDeps({ + immersionTracker: createFakeImmersionTracker({ + recordYomitanLookup: () => { + calls.push('lookup'); + }, + }), + }), + registrar, + ); + + const handler = handlers.on.get(IPC_CHANNELS.command.recordYomitanLookup); + assert.equal(typeof handler, 'function'); + + handler?.({}, null); + + assert.deepEqual(calls, ['lookup']); +}); + test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); registerIpcHandlers(createRegisterIpcDeps(), registrar); @@ -308,6 +366,7 @@ test('registerIpcHandlers validates and clamps stats request limits', async () = registerIpcHandlers( createRegisterIpcDeps({ immersionTracker: { + recordYomitanLookup: () => {}, getSessionSummaries: async (limit = 0) => { calls.push(['sessions', limit]); return []; @@ -352,6 +411,7 @@ test('registerIpcHandlers validates and clamps stats request limits', async () = getMediaSessions: async () => [], getMediaDailyRollups: async () => [], getCoverArt: async () => null, + markActiveVideoWatched: async () => false, }, }), registrar, @@ -413,6 +473,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { getKeybindings: () => [], getConfiguredShortcuts: () => ({}), getStatsToggleKey: () => 'Backquote', + getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: () => {}, saveControllerPreference: (update) => { @@ -476,6 +537,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon getKeybindings: () => [], getConfiguredShortcuts: () => ({}), getStatsToggleKey: () => 'Backquote', + getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: async () => {}, saveControllerPreference: async (update) => { @@ -546,6 +608,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy getKeybindings: () => [], getConfiguredShortcuts: () => ({}), getStatsToggleKey: () => 'Backquote', + getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: async () => {}, saveControllerPreference: async () => {}, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 99a6aac..8fca6be 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -51,6 +51,7 @@ export interface IpcServiceDeps { getKeybindings: () => unknown; getConfiguredShortcuts: () => unknown; getStatsToggleKey: () => string; + getMarkWatchedKey: () => string; getControllerConfig: () => ResolvedControllerConfig; saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; @@ -70,6 +71,7 @@ export interface IpcServiceDeps { retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; immersionTracker?: { + recordYomitanLookup: () => void; getSessionSummaries: (limit?: number) => Promise; getDailyRollups: (limit?: number) => Promise; getMonthlyRollups: (limit?: number) => Promise; @@ -93,6 +95,7 @@ export interface IpcServiceDeps { getMediaSessions: (videoId: number, limit?: number) => Promise; getMediaDailyRollups: (videoId: number, limit?: number) => Promise; getCoverArt: (videoId: number) => Promise; + markActiveVideoWatched: () => Promise; } | null; } @@ -143,6 +146,7 @@ export interface IpcDepsRuntimeOptions { getKeybindings: () => unknown; getConfiguredShortcuts: () => unknown; getStatsToggleKey: () => string; + getMarkWatchedKey: () => string; getControllerConfig: () => ResolvedControllerConfig; saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; @@ -199,6 +203,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService getKeybindings: options.getKeybindings, getConfiguredShortcuts: options.getConfiguredShortcuts, getStatsToggleKey: options.getStatsToggleKey, + getMarkWatchedKey: options.getMarkWatchedKey, getControllerConfig: options.getControllerConfig, saveControllerConfig: options.saveControllerConfig, saveControllerPreference: options.saveControllerPreference, @@ -274,6 +279,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar deps.openYomitanSettings(); }); + ipc.on(IPC_CHANNELS.command.recordYomitanLookup, () => { + deps.immersionTracker?.recordYomitanLookup(); + }); + + ipc.handle(IPC_CHANNELS.command.markActiveVideoWatched, async () => { + return (await deps.immersionTracker?.markActiveVideoWatched()) ?? false; + }); + ipc.on(IPC_CHANNELS.command.quitApp, () => { deps.quitApp(); }); @@ -366,6 +379,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.getStatsToggleKey(); }); + ipc.handle(IPC_CHANNELS.request.getMarkWatchedKey, () => { + return deps.getMarkWatchedKey(); + }); + ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => { return deps.getControllerConfig(); }); diff --git a/src/main.ts b/src/main.ts index 5a0448b..d09b32c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3743,6 +3743,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getKeybindings: () => appState.keybindings, getConfiguredShortcuts: () => getConfiguredShortcuts(), getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, + getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey, getControllerConfig: () => getResolvedConfig().controller, saveControllerConfig: (update) => { const currentRawConfig = configService.getRawConfig(); diff --git a/src/preload.ts b/src/preload.ts index 3073d60..55b3dd7 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -188,6 +188,10 @@ const electronAPI: ElectronAPI = { ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings); }, + recordYomitanLookup: () => { + ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup); + }, + getSubtitlePosition: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitlePosition), saveSubtitlePosition: (position: SubtitlePosition) => { @@ -210,6 +214,10 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), getStatsToggleKey: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey), + getMarkWatchedKey: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getMarkWatchedKey), + markActiveVideoWatched: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.command.markActiveVideoWatched), getControllerConfig: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig), saveControllerConfig: (update: ControllerConfigUpdate): Promise => diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 629932b..2104eef 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -52,6 +52,9 @@ function installKeyboardTestGlobals() { const mpvCommands: Array> = []; let playbackPausedResponse: boolean | null = false; let statsToggleKey = 'Backquote'; + let markWatchedKey = 'KeyW'; + let markActiveVideoWatchedResult = true; + let markActiveVideoWatchedCalls = 0; let statsToggleOverlayCalls = 0; let selectionClearCount = 0; let selectionAddCount = 0; @@ -140,6 +143,11 @@ function installKeyboardTestGlobals() { }, getPlaybackPaused: async () => playbackPausedResponse, getStatsToggleKey: async () => statsToggleKey, + getMarkWatchedKey: async () => markWatchedKey, + markActiveVideoWatched: async () => { + markActiveVideoWatchedCalls += 1; + return markActiveVideoWatchedResult; + }, toggleDevTools: () => {}, toggleStatsOverlay: () => { statsToggleOverlayCalls += 1; @@ -262,6 +270,13 @@ function installKeyboardTestGlobals() { setStatsToggleKey: (value: string) => { statsToggleKey = value; }, + setMarkWatchedKey: (value: string) => { + markWatchedKey = value; + }, + setMarkActiveVideoWatchedResult: (value: boolean) => { + markActiveVideoWatchedResult = value; + }, + markActiveVideoWatchedCalls: () => markActiveVideoWatchedCalls, statsToggleOverlayCalls: () => statsToggleOverlayCalls, getPlaybackPaused: async () => playbackPausedResponse, setPlaybackPausedResponse: (value: boolean | null) => { @@ -287,7 +302,7 @@ function createKeyboardHandlerHarness() { }); let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)]; - const ctx = { + const ctx = { dom: { subtitleRoot: { classList: subtitleRootClassList, @@ -1048,3 +1063,44 @@ test('keyboard mode: popup iframe focusin reclaims overlay keyboard focus', asyn testGlobals.restore(); } }); + +test('mark-watched keybinding calls markActiveVideoWatched and sends mpv commands', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + const beforeCalls = testGlobals.markActiveVideoWatchedCalls(); + const beforeMpvCount = testGlobals.mpvCommands.length; + + testGlobals.dispatchKeydown({ key: 'w', code: 'KeyW' }); + await wait(10); + + assert.equal(testGlobals.markActiveVideoWatchedCalls(), beforeCalls + 1); + const newMpvCommands = testGlobals.mpvCommands.slice(beforeMpvCount); + assert.deepEqual(newMpvCommands, [ + ['show-text', 'Marked as watched', '1500'], + ['playlist-next', 'force'], + ]); + } finally { + testGlobals.restore(); + } +}); + +test('mark-watched keybinding does not send mpv commands when no active session', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + testGlobals.setMarkActiveVideoWatchedResult(false); + const beforeMpvCount = testGlobals.mpvCommands.length; + + testGlobals.dispatchKeydown({ key: 'w', code: 'KeyW' }); + await wait(10); + + assert.equal(testGlobals.markActiveVideoWatchedCalls() > 0, true); + const newMpvCommands = testGlobals.mpvCommands.slice(beforeMpvCount); + assert.deepEqual(newMpvCommands, []); + } finally { + testGlobals.restore(); + } +}); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 240c9e3..853596a 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -192,6 +192,25 @@ export function createKeyboardHandlers( ); } + function isMarkWatchedKey(e: KeyboardEvent): boolean { + return ( + e.code === ctx.state.markWatchedKey && + !e.ctrlKey && + !e.altKey && + !e.metaKey && + !e.shiftKey && + !e.repeat + ); + } + + async function handleMarkWatched(): Promise { + const marked = await window.electronAPI.markActiveVideoWatched(); + if (marked) { + window.electronAPI.sendMpvCommand(['show-text', 'Marked as watched', '1500']); + window.electronAPI.sendMpvCommand(['playlist-next', 'force']); + } + } + function getSubtitleWordNodes(): HTMLElement[] { return Array.from( ctx.dom.subtitleRoot.querySelectorAll('.word[data-token-index]'), @@ -704,12 +723,14 @@ export function createKeyboardHandlers( } async function setupMpvInputForwarding(): Promise { - const [keybindings, statsToggleKey] = await Promise.all([ + const [keybindings, statsToggleKey, markWatchedKey] = await Promise.all([ window.electronAPI.getKeybindings(), window.electronAPI.getStatsToggleKey(), + window.electronAPI.getMarkWatchedKey(), ]); updateKeybindings(keybindings); ctx.state.statsToggleKey = statsToggleKey; + ctx.state.markWatchedKey = markWatchedKey; syncKeyboardTokenSelection(); const subtitleMutationObserver = new MutationObserver(() => { @@ -811,6 +832,12 @@ export function createKeyboardHandlers( return; } + if (isMarkWatchedKey(e)) { + e.preventDefault(); + void handleMarkWatched(); + return; + } + if ( (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) && !isControllerModalShortcut(e) diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 7ba7fb9..615ba2b 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -40,7 +40,7 @@ import { createPositioningController } from './positioning.js'; import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js'; import { createRendererState } from './state.js'; import { createSubtitleRenderer } from './subtitle-render.js'; -import { isYomitanPopupVisible } from './yomitan-popup.js'; +import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js'; import { createRendererRecoveryController, registerRendererGlobalErrorHandlers, @@ -451,6 +451,11 @@ function runGuardedAsync(action: string, fn: () => Promise | void): void { registerModalOpenHandlers(); registerKeyboardCommandHandlers(); +registerYomitanLookupListener(window, () => { + runGuarded('yomitan:lookup', () => { + window.electronAPI.recordYomitanLookup(); + }); +}); async function init(): Promise { document.body.classList.add(`layer-${ctx.platform.overlayLayer}`); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index a9f9427..f10af4b 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -92,6 +92,7 @@ export type RendererState = { keybindingsMap: Map; statsToggleKey: string; + markWatchedKey: string; chordPending: boolean; chordTimeout: ReturnType | null; keyboardDrivenModeEnabled: boolean; @@ -172,6 +173,7 @@ export function createRendererState(): RendererState { keybindingsMap: new Map(), statsToggleKey: 'Backquote', + markWatchedKey: 'KeyW', chordPending: false, chordTimeout: null, keyboardDrivenModeEnabled: false, diff --git a/src/renderer/yomitan-popup.test.ts b/src/renderer/yomitan-popup.test.ts new file mode 100644 index 0000000..239550c --- /dev/null +++ b/src/renderer/yomitan-popup.test.ts @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { YOMITAN_LOOKUP_EVENT, registerYomitanLookupListener } from './yomitan-popup.js'; + +test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', () => { + const target = new EventTarget(); + const calls: string[] = []; + + const dispose = registerYomitanLookupListener(target, () => { + calls.push('lookup'); + }); + + target.dispatchEvent(new CustomEvent(YOMITAN_LOOKUP_EVENT)); + dispose(); + target.dispatchEvent(new CustomEvent(YOMITAN_LOOKUP_EVENT)); + + assert.deepEqual(calls, ['lookup']); +}); diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index 6a5be7f..28aa62f 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -4,6 +4,20 @@ export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden'; export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter'; export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave'; export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command'; +export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup'; + +export function registerYomitanLookupListener( + target: EventTarget = window, + listener: () => void, +): () => void { + const wrapped = (): void => { + listener(); + }; + target.addEventListener(YOMITAN_LOOKUP_EVENT, wrapped); + return () => { + target.removeEventListener(YOMITAN_LOOKUP_EVENT, wrapped); + }; +} export function isYomitanPopupIframe(element: Element | null): boolean { if (!element) return false; diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 2876a28..4589d0f 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -15,6 +15,7 @@ export const IPC_CHANNELS = { setIgnoreMouseEvents: 'set-ignore-mouse-events', overlayModalClosed: 'overlay:modal-closed', openYomitanSettings: 'open-yomitan-settings', + recordYomitanLookup: 'record-yomitan-lookup', quitApp: 'quit-app', toggleDevTools: 'toggle-dev-tools', toggleOverlay: 'toggle-overlay', @@ -30,6 +31,7 @@ export const IPC_CHANNELS = { reportOverlayContentBounds: 'overlay-content-bounds:report', overlayModalOpened: 'overlay:modal-opened', toggleStatsOverlay: 'stats:toggle-overlay', + markActiveVideoWatched: 'immersion:mark-active-video-watched', }, request: { getVisibleOverlayVisibility: 'get-visible-overlay-visibility', @@ -43,6 +45,7 @@ export const IPC_CHANNELS = { getKeybindings: 'get-keybindings', getConfigShortcuts: 'get-config-shortcuts', getStatsToggleKey: 'get-stats-toggle-key', + getMarkWatchedKey: 'get-mark-watched-key', getControllerConfig: 'get-controller-config', getSecondarySubMode: 'get-secondary-sub-mode', getCurrentSecondarySub: 'get-current-secondary-sub', diff --git a/src/types.ts b/src/types.ts index 7e4bac5..e2b8f2b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -625,6 +625,7 @@ export interface YoutubeSubgenConfig { export interface StatsConfig { toggleKey?: string; + markWatchedKey?: string; serverPort?: number; autoStartServer?: boolean; autoOpenBrowser?: boolean; @@ -888,6 +889,7 @@ export interface ResolvedConfig { }; stats: { toggleKey: string; + markWatchedKey: string; serverPort: number; autoStartServer: boolean; autoOpenBrowser: boolean; @@ -1071,6 +1073,7 @@ export interface ElectronAPI { onSubtitleAss: (callback: (assText: string) => void) => void; setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; openYomitanSettings: () => void; + recordYomitanLookup: () => void; getSubtitlePosition: () => Promise; saveSubtitlePosition: (position: SubtitlePosition) => void; getMecabStatus: () => Promise; @@ -1079,6 +1082,8 @@ export interface ElectronAPI { getKeybindings: () => Promise; getConfiguredShortcuts: () => Promise>; getStatsToggleKey: () => Promise; + getMarkWatchedKey: () => Promise; + markActiveVideoWatched: () => Promise; getControllerConfig: () => Promise; saveControllerConfig: (update: ControllerConfigUpdate) => Promise; saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise;