From a5a6426fe108e8d3e8e4db49b72e97ca2edb745b Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 17 Mar 2026 19:52:43 -0700 Subject: [PATCH] feat: add mark-as-watched keybinding and Yomitan lookup tracking Add configurable keybinding to mark the current video as watched with IPC plumbing between renderer and main process. Add event listener infrastructure for tracking Yomitan dictionary lookups per session. --- src/core/services/ipc.test.ts | 63 ++++++++++++++++++++++++++ src/core/services/ipc.ts | 17 +++++++ src/main.ts | 1 + src/preload.ts | 8 ++++ src/renderer/handlers/keyboard.test.ts | 58 +++++++++++++++++++++++- src/renderer/handlers/keyboard.ts | 29 +++++++++++- src/renderer/renderer.ts | 7 ++- src/renderer/state.ts | 2 + src/renderer/yomitan-popup.test.ts | 18 ++++++++ src/renderer/yomitan-popup.ts | 14 ++++++ src/shared/ipc/contracts.ts | 3 ++ src/types.ts | 5 ++ 12 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/renderer/yomitan-popup.test.ts 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;