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.
This commit is contained in:
2026-03-17 19:52:43 -07:00
parent 75f2c212c7
commit a5a6426fe1
12 changed files with 222 additions and 3 deletions

View File

@@ -98,6 +98,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async () => {},
@@ -121,6 +122,39 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
};
}
function createFakeImmersionTracker(
overrides: Partial<NonNullable<IpcServiceDeps['immersionTracker']>> = {},
): NonNullable<IpcServiceDeps['immersionTracker']> {
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 () => {},

View File

@@ -51,6 +51,7 @@ export interface IpcServiceDeps {
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getStatsToggleKey: () => string;
getMarkWatchedKey: () => string;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
@@ -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<unknown>;
getDailyRollups: (limit?: number) => Promise<unknown>;
getMonthlyRollups: (limit?: number) => Promise<unknown>;
@@ -93,6 +95,7 @@ export interface IpcServiceDeps {
getMediaSessions: (videoId: number, limit?: number) => Promise<unknown>;
getMediaDailyRollups: (videoId: number, limit?: number) => Promise<unknown>;
getCoverArt: (videoId: number) => Promise<unknown>;
markActiveVideoWatched: () => Promise<boolean>;
} | null;
}
@@ -143,6 +146,7 @@ export interface IpcDepsRuntimeOptions {
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getStatsToggleKey: () => string;
getMarkWatchedKey: () => string;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
@@ -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();
});

View File

@@ -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();

View File

@@ -188,6 +188,10 @@ const electronAPI: ElectronAPI = {
ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings);
},
recordYomitanLookup: () => {
ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup);
},
getSubtitlePosition: (): Promise<SubtitlePosition | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitlePosition),
saveSubtitlePosition: (position: SubtitlePosition) => {
@@ -210,6 +214,10 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
getStatsToggleKey: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey),
getMarkWatchedKey: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getMarkWatchedKey),
markActiveVideoWatched: (): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.command.markActiveVideoWatched),
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
saveControllerConfig: (update: ControllerConfigUpdate): Promise<void> =>

View File

@@ -52,6 +52,9 @@ function installKeyboardTestGlobals() {
const mpvCommands: Array<Array<string | number>> = [];
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();
}
});

View File

@@ -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<void> {
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<HTMLElement>('.word[data-token-index]'),
@@ -704,12 +723,14 @@ export function createKeyboardHandlers(
}
async function setupMpvInputForwarding(): Promise<void> {
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)

View File

@@ -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): void {
registerModalOpenHandlers();
registerKeyboardCommandHandlers();
registerYomitanLookupListener(window, () => {
runGuarded('yomitan:lookup', () => {
window.electronAPI.recordYomitanLookup();
});
});
async function init(): Promise<void> {
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);

View File

@@ -92,6 +92,7 @@ export type RendererState = {
keybindingsMap: Map<string, (string | number)[]>;
statsToggleKey: string;
markWatchedKey: string;
chordPending: boolean;
chordTimeout: ReturnType<typeof setTimeout> | null;
keyboardDrivenModeEnabled: boolean;
@@ -172,6 +173,7 @@ export function createRendererState(): RendererState {
keybindingsMap: new Map(),
statsToggleKey: 'Backquote',
markWatchedKey: 'KeyW',
chordPending: false,
chordTimeout: null,
keyboardDrivenModeEnabled: false,

View File

@@ -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']);
});

View File

@@ -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;

View File

@@ -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',

View File

@@ -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<SubtitlePosition | null>;
saveSubtitlePosition: (position: SubtitlePosition) => void;
getMecabStatus: () => Promise<MecabStatus>;
@@ -1079,6 +1082,8 @@ export interface ElectronAPI {
getKeybindings: () => Promise<Keybinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
getStatsToggleKey: () => Promise<string>;
getMarkWatchedKey: () => Promise<string>;
markActiveVideoWatched: () => Promise<boolean>;
getControllerConfig: () => Promise<ResolvedControllerConfig>;
saveControllerConfig: (update: ControllerConfigUpdate) => Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;