feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions

View File

@@ -51,6 +51,11 @@ function installKeyboardTestGlobals() {
const commandEvents: CommandEventDetail[] = [];
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;
@@ -137,7 +142,16 @@ function installKeyboardTestGlobals() {
mpvCommands.push(command);
},
getPlaybackPaused: async () => playbackPausedResponse,
getStatsToggleKey: async () => statsToggleKey,
getMarkWatchedKey: async () => markWatchedKey,
markActiveVideoWatched: async () => {
markActiveVideoWatchedCalls += 1;
return markActiveVideoWatchedResult;
},
toggleDevTools: () => {},
toggleStatsOverlay: () => {
statsToggleOverlayCalls += 1;
},
focusMainWindow: () => {
focusMainWindowCalls += 1;
return Promise.resolve();
@@ -253,6 +267,17 @@ function installKeyboardTestGlobals() {
setPopupVisible: (value: boolean) => {
popupVisible = value;
},
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) => {
playbackPausedResponse = value;
@@ -291,6 +316,7 @@ function createKeyboardHandlerHarness() {
platform: {
shouldToggleMouseIgnore: false,
isMacOSPlatform: false,
isModalLayer: false,
overlayLayer: 'always-on-top',
},
state: createRendererState(),
@@ -548,6 +574,22 @@ test('keyboard mode: controller select modal handles arrow keys before yomitan p
}
});
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setPopupVisible(true);
testGlobals.setStatsToggleKey('KeyG');
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h moves left when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -620,6 +662,42 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
}
});
test('keyboard mode: visible-layer Ctrl+Shift+Y should not be toggled by renderer keydown', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.platform.isModalLayer = false;
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
handlers.handleKeyboardModeToggleRequested();
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: modal-layer Ctrl+Shift+Y still toggles via renderer keydown', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.platform.isModalLayer = true;
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: turning mode off clears selected token highlight', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -985,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

@@ -181,6 +181,36 @@ export function createKeyboardHandlers(
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
}
function isStatsOverlayToggle(e: KeyboardEvent): boolean {
return (
e.code === ctx.state.statsToggleKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!e.shiftKey &&
!e.repeat
);
}
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]'),
@@ -693,7 +723,14 @@ export function createKeyboardHandlers(
}
async function setupMpvInputForwarding(): Promise<void> {
updateKeybindings(await window.electronAPI.getKeybindings());
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(() => {
@@ -743,7 +780,7 @@ export function createKeyboardHandlers(
);
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (isKeyboardDrivenModeToggle(e)) {
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
e.preventDefault();
handleKeyboardModeToggleRequested();
return;
@@ -789,6 +826,18 @@ export function createKeyboardHandlers(
return;
}
if (isStatsOverlayToggle(e)) {
e.preventDefault();
window.electronAPI.toggleStatsOverlay();
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

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

View File

@@ -90,6 +90,15 @@ class FakeElement {
this.ownTextContent = '';
}
}
replaceChildren(): void {
this.childNodes = [];
this.ownTextContent = '';
}
cloneNode(_deep: boolean): FakeElement {
return new FakeElement(this.tagName);
}
}
function installFakeDocument() {
@@ -227,9 +236,11 @@ test('computeWordClass preserves known and n+1 classes while adding JLPT classes
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
});
test('computeWordClass applies name-match class ahead of known and frequency classes', () => {
test('computeWordClass applies name-match class ahead of known, n+1, frequency, and JLPT classes', () => {
const token = createToken({
isKnown: true,
isNPlusOneTarget: true,
jlptLevel: 'N2',
frequencyRank: 10,
surface: 'アクア',
}) as MergedToken & { isNameMatch?: boolean };
@@ -502,19 +513,32 @@ test('getFrequencyRankLabelForToken returns rank only for frequency-colored toke
const knownToken = createToken({ surface: '既知', isKnown: true, frequencyRank: 20 });
const nPlusOneToken = createToken({ surface: '目標', isNPlusOneTarget: true, frequencyRank: 20 });
const outOfRangeToken = createToken({ surface: '圏外', frequencyRank: 1000 });
const nameToken = createToken({ surface: 'アクア', frequencyRank: 20 }) as MergedToken & {
isNameMatch?: boolean;
};
nameToken.isNameMatch = true;
assert.equal(getFrequencyRankLabelForToken(frequencyToken, settings), '20');
assert.equal(getFrequencyRankLabelForToken(knownToken, settings), '20');
assert.equal(getFrequencyRankLabelForToken(nPlusOneToken, settings), '20');
assert.equal(getFrequencyRankLabelForToken(outOfRangeToken, settings), null);
assert.equal(
getFrequencyRankLabelForToken(nameToken, { ...settings, nameMatchEnabled: true }),
null,
);
});
test('getJlptLevelLabelForToken returns level when token has jlpt metadata', () => {
const jlptToken = createToken({ surface: '語彙', jlptLevel: 'N2' });
const noJlptToken = createToken({ surface: '語彙' });
const nameToken = createToken({ surface: 'アクア', jlptLevel: 'N5' }) as MergedToken & {
isNameMatch?: boolean;
};
nameToken.isNameMatch = true;
assert.equal(getJlptLevelLabelForToken(jlptToken), 'N2');
assert.equal(getJlptLevelLabelForToken(noJlptToken), null);
assert.equal(getJlptLevelLabelForToken(nameToken, { nameMatchEnabled: true }), null);
});
test('sanitizeSubtitleHoverTokenColor falls back for pure black values', () => {
@@ -658,6 +682,61 @@ test('renderSubtitle preserves unsupported punctuation while keeping it non-inte
}
});
test('renderSubtitle keeps excluded interjection tokens hoverable while rendering them without annotation styling', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const secondaryRoot = new FakeElement('div');
const renderer = createSubtitleRenderer({
dom: {
subtitleRoot,
secondarySubtitleRoot: secondaryRoot,
},
config: {
subtitleStyle: {},
frequencyDictionary: {
colorTopX: 1000,
colorMode: 'single',
colorSingle: '#f5a97f',
colorBanded: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
},
secondarySubtitles: { mode: 'hidden' },
},
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
runtime: {
secondaryMode: 'hidden' as const,
shouldToggleMouseIgnore: false,
},
state: createRendererState(),
} as never);
renderer.renderSubtitle({
text: 'ぐはっ 猫',
tokens: [
createToken({ surface: 'ぐはっ', headword: 'ぐはっ', reading: 'ぐはっ' }),
createToken({ surface: '猫', headword: '猫', reading: 'ねこ' }),
],
});
assert.equal(subtitleRoot.textContent, 'ぐはっ 猫');
assert.deepEqual(
collectWordNodes(subtitleRoot).map((node) => [node.textContent, node.dataset.tokenIndex]),
[
['ぐはっ', '0'],
['猫', '1'],
],
);
} finally {
restoreDocument();
}
});
test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks is enabled', () => {
assert.equal(
normalizeSubtitle('常人が使えば\\Nその圧倒的な力に\\n体が耐えきれず死に至るが…', true, true),

View File

@@ -19,6 +19,14 @@ export type SubtitleTokenHoverRange = {
tokenIndex: number;
};
let _spanTemplate: HTMLSpanElement | null = null;
function getSpanTemplate(): HTMLSpanElement {
if (!_spanTemplate) {
_spanTemplate = document.createElement('span');
}
return _spanTemplate;
}
export function shouldRenderTokenizedSubtitle(tokenCount: number): boolean {
return tokenCount > 0;
}
@@ -83,6 +91,16 @@ const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
};
const DEFAULT_NAME_MATCH_ENABLED = true;
function hasPrioritizedNameMatch(
token: MergedToken,
tokenRenderSettings?: Partial<Pick<TokenRenderSettings, 'nameMatchEnabled'>>,
): boolean {
return (
(tokenRenderSettings?.nameMatchEnabled ?? DEFAULT_NAME_MATCH_ENABLED) &&
token.isNameMatch === true
);
}
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return fallback;
@@ -219,8 +237,12 @@ function getNormalizedFrequencyRank(token: MergedToken): number | null {
export function getFrequencyRankLabelForToken(
token: MergedToken,
frequencySettings?: Partial<FrequencyRenderSettings>,
frequencySettings?: Partial<TokenRenderSettings>,
): string | null {
if (hasPrioritizedNameMatch(token, frequencySettings)) {
return null;
}
const resolvedFrequencySettings = {
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
...frequencySettings,
@@ -243,7 +265,14 @@ export function getFrequencyRankLabelForToken(
return rank === null ? null : String(rank);
}
export function getJlptLevelLabelForToken(token: MergedToken): string | null {
export function getJlptLevelLabelForToken(
token: MergedToken,
tokenRenderSettings?: Partial<Pick<TokenRenderSettings, 'nameMatchEnabled'>>,
): string | null {
if (hasPrioritizedNameMatch(token, tokenRenderSettings)) {
return null;
}
return token.jlptLevel ?? null;
}
@@ -286,7 +315,7 @@ function renderWithTokens(
}
const token = segment.token;
const span = document.createElement('span');
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
span.className = computeWordClass(token, resolvedTokenRenderSettings);
span.textContent = token.surface;
span.dataset.tokenIndex = String(segment.tokenIndex);
@@ -296,7 +325,7 @@ function renderWithTokens(
if (frequencyRankLabel) {
span.dataset.frequencyRank = frequencyRankLabel;
}
const jlptLevelLabel = getJlptLevelLabelForToken(token);
const jlptLevelLabel = getJlptLevelLabelForToken(token, resolvedTokenRenderSettings);
if (jlptLevelLabel) {
span.dataset.jlptLevel = jlptLevelLabel;
}
@@ -322,7 +351,7 @@ function renderWithTokens(
continue;
}
const span = document.createElement('span');
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
span.className = computeWordClass(token, resolvedTokenRenderSettings);
span.textContent = surface;
span.dataset.tokenIndex = String(index);
@@ -332,7 +361,7 @@ function renderWithTokens(
if (frequencyRankLabel) {
span.dataset.frequencyRank = frequencyRankLabel;
}
const jlptLevelLabel = getJlptLevelLabelForToken(token);
const jlptLevelLabel = getJlptLevelLabelForToken(token, resolvedTokenRenderSettings);
if (jlptLevelLabel) {
span.dataset.jlptLevel = jlptLevelLabel;
}
@@ -444,22 +473,22 @@ export function computeWordClass(
const classes = ['word'];
if (token.isNPlusOneTarget) {
classes.push('word-n-plus-one');
} else if (resolvedTokenRenderSettings.nameMatchEnabled && token.isNameMatch) {
if (hasPrioritizedNameMatch(token, resolvedTokenRenderSettings)) {
classes.push('word-name-match');
} else if (token.isNPlusOneTarget) {
classes.push('word-n-plus-one');
} else if (token.isKnown) {
classes.push('word-known');
}
if (token.jlptLevel) {
if (!hasPrioritizedNameMatch(token, resolvedTokenRenderSettings) && token.jlptLevel) {
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
}
if (
!token.isKnown &&
!token.isNPlusOneTarget &&
!(resolvedTokenRenderSettings.nameMatchEnabled && token.isNameMatch)
!hasPrioritizedNameMatch(token, resolvedTokenRenderSettings)
) {
const frequencyClass = getFrequencyDictionaryClass(token, resolvedTokenRenderSettings);
if (frequencyClass) {
@@ -478,7 +507,7 @@ function renderCharacterLevel(root: HTMLElement, text: string): void {
fragment.appendChild(document.createElement('br'));
continue;
}
const span = document.createElement('span');
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
span.className = 'c';
span.textContent = char;
fragment.appendChild(span);
@@ -503,7 +532,7 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
export function createSubtitleRenderer(ctx: RendererContext) {
function renderSubtitle(data: SubtitleData | string): void {
ctx.dom.subtitleRoot.innerHTML = '';
ctx.dom.subtitleRoot.replaceChildren();
let text: string;
let tokens: MergedToken[] | null;
@@ -552,7 +581,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
}
function renderSecondarySub(text: string): void {
ctx.dom.secondarySubRoot.innerHTML = '';
ctx.dom.secondarySubRoot.replaceChildren();
if (!text) return;
const normalized = text

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;