Compare commits

...
Author SHA1 Message Date
sudacode 203a61a20f chore(release): v0.21.1 2026-10-26 23:27:24 -07:00
sudacode a681bbaf25 test(launcher): gate Linux AppImage env test with early return
Bun's node:test shim ignores the skip option, so the Linux-only test ran
and failed on macOS.
2026-10-26 23:27:24 -07:00
sudacode 6219a81144 feat(stats): redesign expanded session view with density chart (#304) 2026-10-26 23:08:48 -07:00
sudacode cc9af79d6e fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation (#302) 2026-10-26 22:19:08 -07:00
44 changed files with 1242 additions and 995 deletions

No files matched your search

+23
View File
@@ -1,5 +1,28 @@
# Changelog
## v0.21.1 (2026-10-26)
### Changed
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
- A reading density chart of known and unknown words replaces the cumulative chart.
- Yomitan lookups and mined cards now appear in their own lane below the chart.
### Fixed
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
### Docs
- **README**: Refreshed the anime screenshots using the current build.
<details>
<summary>Internal changes</summary>
### Internal
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
</details>
## v0.21.0 (2026-10-24)
### Added
-4
View File
@@ -1,4 +0,0 @@
type: docs
area: readme
- Refreshed the README's anime screenshots using the current SubMiner build.
-5
View File
@@ -1,5 +0,0 @@
type: internal
area: build
- Moved packaging inputs (electron-builder resources, Bun runtime manifests and licenses, committed release notes) under `packaging/`.
- Build output now lands in `dist/`: packaged apps in `dist/release/`, staged Yomitan and Hachidori in `dist/yomitan/` and `dist/hachidori/`.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- mpv keys and mouse buttons (such as `9`/`0` volume and double-click fullscreen) pass through the overlay again while the subtitle sidebar is open; only input inside the sidebar stays with it.
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## v0.21.1 (2026-10-26)
**Changed**
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
- A reading density chart of known and unknown words replaces the cumulative chart.
- Yomitan lookups and mined cards now appear in their own lane below the chart.
**Fixed**
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
**Docs**
- **README**: Refreshed the anime screenshots using the current build.
<details>
<summary>Internal changes</summary>
**Internal**
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
</details>
## v0.21.0 (2026-10-24)
**Added**
+1 -1
View File
@@ -70,7 +70,7 @@ Charts for watch time, cards, words, and sessions per day or month, running tota
### Sessions
Session history with new-word activity and pause, seek, and card markers. The **↗** button on a row opens that show's detail view.
Session history. Expand a session for its pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words over the session with Yomitan lookup ticks and clickable card-mined markers underneath. The **↗** button on a row opens that show's detail view.
![Stats Sessions](/screenshots/stats-sessions.png)
+35 -5
View File
@@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// A recording AnkiConnect stand-in: an empty collection with one deck and one
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
// Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo`
// so the app can resolve their fields; scenarios still assert on `requests`.
export async function startFakeAnki(): Promise<FakeAnki> {
const requests: AnkiRequest[] = [];
const addedNotes = new Map<number, Record<string, unknown>>();
let nextNoteId = 1000;
const handle = (body: unknown): unknown => {
@@ -59,10 +61,38 @@ export async function startFakeAnki(): Promise<FakeAnki> {
return [FAKE_ANKI_MODEL];
case 'modelFieldNames':
return MODEL_FIELDS;
case 'addNote':
return nextNoteId++;
case 'addNote': {
const noteId = nextNoteId++;
const note = isRecord(params.note) ? params.note : {};
addedNotes.set(noteId, isRecord(note.fields) ? { ...note.fields } : {});
return noteId;
}
case 'updateNoteFields': {
const note = isRecord(params.note) ? params.note : {};
const fields = typeof note.id === 'number' ? addedNotes.get(note.id) : undefined;
if (fields && isRecord(note.fields)) Object.assign(fields, note.fields);
return null;
}
case 'notesInfo': {
// Like AnkiConnect: one entry per requested id, `{}` for unknown notes.
const ids = Array.isArray(params.notes) ? params.notes : [];
return ids.map((id) => {
const fields = typeof id === 'number' ? addedNotes.get(id) : undefined;
if (!fields) return {};
return {
noteId: id,
modelName: FAKE_ANKI_MODEL,
tags: [],
fields: Object.fromEntries(
MODEL_FIELDS.map((name, order) => [
name,
{ value: String(fields[name] ?? ''), order },
]),
),
};
});
}
case 'findNotes':
case 'notesInfo':
return [];
case 'storeMediaFile':
return params.filename ?? null;
+87 -4
View File
@@ -91,6 +91,14 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToke
});
}
// Card-kind flags are written before media, so wait for the update carrying audio.
function findMediaUpdate(session: E2eSession) {
return session.anki.requests.find(
(request) =>
request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'),
);
}
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
return tokens.some((token) => token.surface === surface && token.headword === headword);
}
@@ -126,10 +134,10 @@ test(
session.anki.requests.length = 0;
await session.runAppCommand('--mine-sentence');
const update = await waitUntil(
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
);
const update = await waitUntil(() => findMediaUpdate(session), {
description: 'media fields to be written to the mined note',
timeoutMs: 30_000,
});
const added = session.anki.requests.filter((request) => request.action === 'addNote');
assert.equal(added.length, 1);
@@ -156,6 +164,38 @@ test(
},
);
test(
'mining a line retimed with sub-delay clips it where mpv plays it',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const cue = FIXTURE_CUES[1];
const delay = 2;
await session.mpv.command('set_property', 'sub-delay', delay);
try {
// Past the cue's own end, so the line is only on screen because of the delay.
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
{ description: 'overlay to show the delayed line' },
);
session.anki.requests.length = 0;
await session.runAppCommand('--mine-sentence');
await waitUntil(() => findMediaUpdate(session), {
description: 'media fields to be written to the mined note',
timeoutMs: 30_000,
});
// MiscInfo stamps the clip start: the cue's start on the video clock.
const written = JSON.stringify(session.anki.requests);
assert.ok(written.includes(`(00:00:0${cue.start + delay})`), written);
} finally {
await session.mpv.command('set_property', 'sub-delay', 0);
}
},
);
test(
'open subtitle sidebar lists the parsed cues and follows playback',
{ timeout: TEST_TIMEOUT_MS },
@@ -184,6 +224,49 @@ test(
},
);
test(
'subtitle sidebar follows and seeks a sub-delay retimed track',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const delay = 2;
await session.mpv.command('set_property', 'sub-delay', delay);
try {
// At 9s cue 2 starts on the file's clock, but mpv is still showing cue 1.
await session.mpv.command('seek', FIXTURE_CUES[2].start, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(FIXTURE_CUES[1].text)}`,
{ description: 'overlay to show the delayed line' },
);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '1'`,
{ description: 'sidebar to highlight the delayed line' },
);
await overlay.evaluate(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item[data-index="2"]').click()`,
);
const target = FIXTURE_CUES[2].start + delay;
await waitUntil(
async () => {
const time = Number(await session.mpv.command('get_property', 'time-pos'));
return time >= target && time < target + 0.5;
},
{ description: `click to seek to cue 2 at ${target}s on the video clock` },
);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
{ description: 'sidebar to close' },
);
} finally {
await session.mpv.command('set_property', 'sub-delay', 0);
}
},
);
// Runs last: it leaves the lookup popup open over the overlay.
test(
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
+2 -1
View File
@@ -174,8 +174,9 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
test(
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
{ skip: process.platform !== 'linux' },
async () => {
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
if (process.platform !== 'linux') return;
await withTempCase(({ dir }) => {
const appPath = writeFakeApp(
dir,
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.21.0",
"version": "0.21.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
+8 -36
View File
@@ -1,47 +1,19 @@
## Highlights
### Added
- **Hachidori Dictionary Backend**: Bundled Hachidori backend as an alternative to Yomitan. Select it with `dictionaryBackend` and restart SubMiner.
- Works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
- First-run setup builds Hachidori's Anki template from your existing SubMiner deck, tags, and field mappings.
- Can link an external Hachidori app, browser, or Docker host while Anki mining stays in SubMiner.
- The tray and dictionary-settings shortcut open the selected backend's settings. `--hachidori` and `--yomitan` open a specific one.
- **Jellyfin Subtitle Generation**: Japanese subtitle generation now works while playing Jellyfin episodes, with both direct-play and transcoded playback.
### Changed
- **Stats Session View**: The expanded session view in the stats dashboard has been redesigned. It shows stat tiles for pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words with lookup and card-mined markers.
### Fixed
- **Media Timing Review**: Opens on the first try and keeps playback paused while open, even when a dictionary popup closes.
- **Jellyfin Terminal Playback**:
- `subminer jellyfin -p` lists libraries when SubMiner is already running, and picker image previews work again.
- Real errors, like an expired session or an unreachable server, now appear instead of an empty list.
- Watch progress and played status reach Jellyfin even when cast discovery is off.
- When it starts SubMiner itself, SubMiner now shows a tray icon and quits when mpv closes.
- The rofi search step is now a compact input box.
- **macOS Casting**: Jellyfin casting finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight. `mpv.executablePath` and `SUBMINER_MPV_PATH` are now honored on macOS and Linux.
- **macOS Launcher PATH**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys type normally in modals and text fields instead of seeking mpv. Toggling the mode inside a modal no longer turns on a hidden keyboard-only mode.
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and the stats dashboard. Stats sentence search matches katakana in either width.
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
- **Subtitle Sidebar**: The open sidebar does far less work during playback.
- **Hyprland and Sway**: A slow or hung `hyprctl` or `swaymsg` no longer freezes the app.
- **Stats Mining**: Cards mined from the stats dashboard keep the selected history line and media.
- **Security Updates**: Updated Electron, undici, and follow-redirects to fix new security advisories.
- **Subtitle Delay**: Retiming subtitles in mpv (z/x or sub-step) now carries through SubMiner. Mined card audio, images, and timestamps match the retimed line, and so do the subtitle sidebar, subtitle navigation, and replay.
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open.
### Docs
- **Hachidori Setup**: Covers backend selection, Hachidori dictionary and Anki setup, settings flags and shortcuts, and linking an external host.
- **Jellyfin**: The docs now say SubMiner joins the cast menu at startup only when both `startupWarmups.jellyfinRemoteSession` and `jellyfin.remoteControlAutoConnect` are on. Otherwise, enable **Jellyfin Discovery** in the tray.
- **Casting and mpv**: Added how casting finds mpv on each platform, a troubleshooting entry for casts that never start mpv, and a note on the macOS 27 Accessibility list rename.
- **Jimaku Environment Variables**: Removed the unused `SUBMINER_JIMAKU_*` variables from the docs. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand` instead.
- **README**: Refreshed the anime screenshots using the current build.
## What's Changed
- feat(dictionary): add Hachidori backend support by @ksyasuda in #266
- fix(jellyfin): make terminal play work with a running app and report progress by @ksyasuda in #288
- fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread by @ksyasuda in #289
- fix(overlay): poll cheap playback state in the open subtitle sidebar by @ksyasuda in #290
- fix(overlay): keep keyboard mode from swallowing modal and text-field keys by @ksyasuda in #291
- refactor: remove dead code and enable noUnusedLocals by @ksyasuda in #292
- feat(subtitles): generate Japanese subtitles during Jellyfin playback by @ksyasuda in #294
- fix(subtitles): widen halfwidth katakana where subtitle text enters the app by @ksyasuda in #295
- fix(jellyfin): find mpv when SubMiner is launched from Finder by @ksyasuda in #298
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304
## Installation
+7 -6
View File
@@ -45,6 +45,7 @@ import {
} from './anki-field-config';
import { createLogger } from './logger';
import { captureLiveSubtitleMiningContext } from './core/services/mining';
import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock';
import {
createUiFeedbackState,
beginUpdateProgress,
@@ -1029,10 +1030,10 @@ export class AnkiIntegration {
Number.isFinite(this.mpvClient.currentSubEnd) &&
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
) {
return {
startTime: this.mpvClient.currentSubStart,
endTime: this.mpvClient.currentSubEnd,
};
return shiftSubtitleRangeToVideoClock(
{ startTime: this.mpvClient.currentSubStart, endTime: this.mpvClient.currentSubEnd },
this.mpvClient.currentSubDelay,
);
}
const currentTime = this.mpvClient.currentTimePos || 0;
@@ -1672,7 +1673,7 @@ export class AnkiIntegration {
if (this.config.fields?.miscInfo) {
result.miscInfoValue = this.formatMiscInfoPattern(
audioFilename,
this.mpvClient.currentSubStart,
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
);
}
}
@@ -1695,7 +1696,7 @@ export class AnkiIntegration {
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
result.miscInfoValue = this.formatMiscInfoPattern(
imageFilename,
this.mpvClient.currentSubStart,
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
);
}
}
+6 -3
View File
@@ -10,6 +10,7 @@ import {
type MediaTimingReviewRequest,
type WordCardKind,
} from '../types/anki';
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
import { createLogger } from '../logger';
import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -428,10 +429,12 @@ export class CardCreationService {
return;
}
let startTime = mpvClient.currentSubStart;
let endTime = mpvClient.currentSubEnd;
let { startTime, endTime } = shiftSubtitleRangeToVideoClock(
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
mpvClient.currentSubDelay,
);
if (startTime === undefined || endTime === undefined) {
if (mpvClient.currentSubStart === undefined || mpvClient.currentSubEnd === undefined) {
const currentTime = mpvClient.currentTimePos || 0;
const fallback = this.deps.getFallbackDurationSeconds() / 2;
startTime = currentTime - fallback;
+1
View File
@@ -55,6 +55,7 @@ export {
subtitleCueListSeekTime,
subtitleCueSeekTime,
} from './subtitle-cue-navigation';
export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab';
export {
+1
View File
@@ -89,6 +89,7 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
cuesRevision: 1,
cues: [],
currentTimeSec: null,
subDelay: 0,
currentSubtitle: { text: '', startTime: null, endTime: null },
config: {
enabled: false,
+19 -1
View File
@@ -77,6 +77,7 @@ test('mineSentenceCard handles missing integration and disconnected mpv', async
currentSubText: 'line',
currentSubStart: 1,
currentSubEnd: 2,
currentSubDelay: 0,
},
showMpvOsd: (text) => osd.push(text),
}),
@@ -109,6 +110,7 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
currentSubText: 'subtitle line',
currentSubStart: 10,
currentSubEnd: 12,
currentSubDelay: 0,
currentSecondarySubText: 'secondary line',
},
showMpvOsd: () => {},
@@ -148,6 +150,7 @@ test('mineSentenceCard prefers a canonical primary subtitle snapshot', async ()
currentSubText: '今今今手手手',
currentSubStart: 11.4,
currentSubEnd: 11.8,
currentSubDelay: 0,
currentSecondarySubText: 'English subtitle',
},
primarySubtitle: {
@@ -187,6 +190,7 @@ test('mineSentenceCard uses normalized secondary subtitle state instead of raw m
currentSubText: '日本語字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentSubDelay: 0,
currentSecondarySubText: 'Your\nmosaic',
requestProperty: async () => {
requestedRawSecondaryText = true;
@@ -218,6 +222,7 @@ test('mineSentenceCard omits normalized secondary text that matches the primary
currentSubText: '日本語字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentSubDelay: 0,
currentSecondarySubText: '日本語字幕',
},
showMpvOsd: () => {},
@@ -396,7 +401,7 @@ test('handleMineSentenceDigit joins per-entry secondary subtitles when available
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
const context = captureLiveSubtitleMiningContext(
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25 },
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25, currentSubDelay: 0 },
() => 1234,
);
@@ -409,6 +414,16 @@ test('captureLiveSubtitleMiningContext snapshots the current line and timings',
});
});
test('captureLiveSubtitleMiningContext moves sub-delay retimed lines onto the video clock', () => {
const context = captureLiveSubtitleMiningContext(
{ currentSubText: '食べる', currentSubStart: 5, currentSubEnd: 8, currentSubDelay: -1.5 },
() => 1234,
);
assert.equal(context?.startTime, 3.5);
assert.equal(context?.endTime, 6.5);
});
test('captureLiveSubtitleMiningContext rejects missing client, empty text and bad timings', () => {
assert.equal(captureLiveSubtitleMiningContext(null), null);
assert.equal(
@@ -416,6 +431,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
currentSubText: ' ',
currentSubStart: 1,
currentSubEnd: 2,
currentSubDelay: 0,
}),
null,
);
@@ -424,6 +440,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
currentSubText: 'line',
currentSubStart: 5,
currentSubEnd: 5,
currentSubDelay: 0,
}),
null,
);
@@ -432,6 +449,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
currentSubText: 'line',
currentSubStart: NaN,
currentSubEnd: 2,
currentSubDelay: 0,
}),
null,
);
+19 -4
View File
@@ -1,5 +1,6 @@
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
import type { SubtitleMiningContext } from '../../types/subtitle';
import { shiftSubtitleRangeToVideoClock } from './subtitle-clock';
interface SubtitleTimingTrackerLike {
getRecentBlocks: (count: number) => string[];
@@ -25,6 +26,7 @@ interface MpvClientLike {
currentSubText: string;
currentSubStart: number;
currentSubEnd: number;
currentSubDelay: number;
currentSecondarySubText?: string;
requestProperty?: (name: string) => Promise<unknown>;
}
@@ -62,7 +64,10 @@ export function handleMultiCopyDigit(
* line is on screen by then; callers capture here at mining time instead.
*/
export function captureLiveSubtitleMiningContext(
client: Pick<MpvClientLike, 'currentSubText' | 'currentSubStart' | 'currentSubEnd'> | null,
client: Pick<
MpvClientLike,
'currentSubText' | 'currentSubStart' | 'currentSubEnd' | 'currentSubDelay'
> | null,
now: () => number = Date.now,
): SubtitleMiningContext | null {
if (!client) {
@@ -73,7 +78,10 @@ export function captureLiveSubtitleMiningContext(
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
return null;
}
return { source: 'overlay', text, startTime, endTime, capturedAtMs: now() };
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
{ source: 'overlay', text, startTime, endTime, capturedAtMs: now() },
client.currentSubDelay,
);
}
export function copyCurrentSubtitle(deps: {
@@ -167,6 +175,7 @@ export async function markLastCardAsAudioCard(deps: {
export async function mineSentenceCard(deps: {
ankiIntegration: AnkiIntegrationLike | null;
mpvClient: MpvClientLike | null;
/** Already on the video clock, like every `SubtitleMiningContext`. */
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
showMpvOsd: (text: string) => void;
}): Promise<boolean> {
@@ -185,10 +194,16 @@ export async function mineSentenceCard(deps: {
}
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
const range =
deps.primarySubtitle ??
shiftSubtitleRangeToVideoClock(
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
mpvClient.currentSubDelay,
);
return await anki.createSentenceCard(
primaryText,
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
range.startTime,
range.endTime,
secondarySubText,
);
}
+1
View File
@@ -38,6 +38,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'path',
'sub-start',
'sub-end',
'sub-delay',
'time-pos',
'secondary-sub-text',
'aid',
+41
View File
@@ -73,6 +73,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
getCurrentSubStart: () => 0,
setCurrentSubEnd: () => {},
getCurrentSubEnd: () => 0,
setCurrentSubDelay: () => {},
getCurrentSubDelay: () => 0,
emitMediaPathChange: (payload) => {
state.mediaPath = payload.path;
},
@@ -131,6 +133,22 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
});
test('dispatchMpvProtocolMessage tracks sub-delay retiming', async () => {
const delays: number[] = [];
const { deps } = createDeps({ setCurrentSubDelay: (value) => delays.push(value) });
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-delay', data: 1.5 },
deps,
);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-delay', data: null },
deps,
);
assert.deepEqual(delays, [1.5, 0]);
});
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
const { deps, state } = createDeps();
@@ -379,6 +397,29 @@ test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
});
});
test('dispatchMpvProtocolMessage pauses at the sub-delay retimed sub-end', async () => {
let pauseAtTime: number | null = null;
let subDelay = 1.5;
const { deps } = createDeps({
getPendingPauseAtSubEnd: () => true,
getCurrentSubDelay: () => subDelay,
setCurrentSubDelay: (next) => {
subDelay = next;
},
getPauseAtTime: () => pauseAtTime,
setPauseAtTime: (next) => {
pauseAtTime = next;
},
});
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-end', data: 42 }, deps);
assert.equal(pauseAtTime, 43.5);
// Retiming before the replay reaches its end moves the pause with the line.
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-delay', data: 2 }, deps);
assert.equal(pauseAtTime, 44);
});
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
const calls: string[] = [];
let currentTimePos = 0;
+15 -1
View File
@@ -1,6 +1,7 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
import { toVideoClock } from './subtitle-clock';
export type MpvMessage = {
event?: string;
@@ -64,6 +65,8 @@ export interface MpvProtocolHandleMessageDeps {
getCurrentSubStart: () => number;
setCurrentSubEnd: (value: number) => void;
getCurrentSubEnd: () => number;
setCurrentSubDelay: (value: number) => void;
getCurrentSubDelay: () => number;
emitMediaPathChange: (payload: { path: string }) => void;
emitMediaTitleChange: (payload: { title: string | null }) => void;
emitTimePosChange: (payload: { time: number }) => void;
@@ -263,7 +266,8 @@ export async function dispatchMpvProtocolMessage(
const subEnd = (msg.data as number) || 0;
deps.setCurrentSubEnd(subEnd);
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
deps.setPauseAtTime(subEnd);
// `sub-end` is on the subtitle clock; the pause check compares it with `time-pos`.
deps.setPauseAtTime(toVideoClock(subEnd, deps.getCurrentSubDelay()));
deps.setPendingPauseAtSubEnd(false);
deps.sendCommand({ command: ['set_property', 'pause', false] });
}
@@ -272,6 +276,16 @@ export async function dispatchMpvProtocolMessage(
start: deps.getCurrentSubStart(),
end: deps.getCurrentSubEnd(),
});
} else if (msg.name === 'sub-delay') {
const parsed = typeof msg.data === 'number' ? msg.data : Number(msg.data);
const delay = Number.isFinite(parsed) ? parsed : 0;
// A scheduled replay pause sits at the line's end on the video clock; retiming
// mid-replay moves that end, so the pause moves with it.
const pauseAtTime = deps.getPauseAtTime();
if (pauseAtTime !== null) {
deps.setPauseAtTime(pauseAtTime + delay - deps.getCurrentSubDelay());
}
deps.setCurrentSubDelay(delay);
} else if (msg.name === 'secondary-sub-text') {
const nextSubText = (msg.data as string) || '';
deps.setCurrentSecondarySubText(nextSubText);
+6
View File
@@ -160,6 +160,8 @@ export class MpvIpcClient implements MpvClient {
public currentTimePos = 0;
public currentSubStart = 0;
public currentSubEnd = 0;
/** mpv `sub-delay`: seconds each primary line plays after its `sub-start`/`sub-end`. */
public currentSubDelay = 0;
public currentSubText = '';
public currentSecondarySubText = '';
public currentAudioStreamIndex: number | null = null;
@@ -459,6 +461,10 @@ export class MpvIpcClient implements MpvClient {
this.currentSubEnd = value;
},
getCurrentSubEnd: () => this.currentSubEnd,
setCurrentSubDelay: (value: number) => {
this.currentSubDelay = value;
},
getCurrentSubDelay: () => this.currentSubDelay,
emitMediaPathChange: (payload) => {
this.emit('media-path-change', payload);
},
+32
View File
@@ -0,0 +1,32 @@
/**
* mpv's `sub-start`/`sub-end` and parsed cues carry the subtitle file's own timestamps
* (the subtitle clock), but mpv plays each line `sub-delay` seconds later (z/x retiming,
* `sub-step`). Playback position, seeks, and media cuts are on the video clock, so any
* value crossing between the two converts here.
*/
function finiteDelay(subDelay: number): number {
return Number.isFinite(subDelay) ? subDelay : 0;
}
/** Playback position (`time-pos`) on the subtitle clock, comparable with cue times. */
export function toSubtitleClock(videoTimeSec: number, subDelay: number): number {
return videoTimeSec - finiteDelay(subDelay);
}
/** Subtitle-clock seconds on the video clock, for seeks. */
export function toVideoClock(subtitleTimeSec: number, subDelay: number): number {
return subtitleTimeSec + finiteDelay(subDelay);
}
/** A subtitle-clock range on the video clock, for mining and media generation. */
export function shiftSubtitleRangeToVideoClock<T extends { startTime: number; endTime: number }>(
range: T,
subDelay: number,
): T {
const delay = finiteDelay(subDelay);
if (delay === 0) {
return range;
}
return { ...range, startTime: range.startTime + delay, endTime: range.endTime + delay };
}
@@ -26,7 +26,7 @@ test('next subtitle navigation skips generated ASS events and seeks to the next
},
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), [
'seek',
13.08,
'absolute+exact',
@@ -41,7 +41,7 @@ test('next subtitle navigation treats simultaneous sanitized cues as one line bo
{ startTime: 13.02, endTime: 16, text: 'Next English' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1, 0), [
'seek',
13.08,
'absolute+exact',
@@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () =
{ startTime: 16, endTime: 19, text: 'next lyric' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2, 0), [
'seek',
16.08,
'absolute+exact',
@@ -68,7 +68,7 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
{ startTime: 13, endTime: 16, text: 'current line' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5, 0), [
'seek',
10.08,
'absolute+exact',
@@ -78,8 +78,22 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
test('subtitle navigation falls back when no sanitized destination exists', () => {
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2, 0), null);
});
test('subtitle cue navigation finds and seeks cues on the sub-delay retimed timeline', () => {
const cues = [
{ startTime: 10, endTime: 13, text: 'one' },
{ startTime: 13, endTime: 16, text: 'two' },
];
// At 14.5 with a 2s delay, mpv is showing "one" (12-15 on the video clock).
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 14.5, 2), [
'seek',
15.08,
'absolute+exact',
]);
});
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
+5 -2
View File
@@ -1,3 +1,4 @@
import { toSubtitleClock, toVideoClock } from './subtitle-clock';
import type { SubtitleCue } from './subtitle-cue-parser';
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
@@ -80,8 +81,10 @@ export function subtitleCueListSeekTime(
export function resolveSanitizedSubtitleSeekCommand(
command: readonly (string | number)[],
cues: readonly SubtitleCue[],
currentTimeSec: number,
videoTimeSec: number,
subDelay: number,
): (string | number)[] | null {
const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay);
if (
command.length < 2 ||
command[0] !== 'sub-seek' ||
@@ -124,5 +127,5 @@ export function resolveSanitizedSubtitleSeekCommand(
if (!destination) {
return null;
}
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact'];
}
+35 -12
View File
@@ -328,9 +328,11 @@ import {
setMpvSubVisibilityRuntime,
setOverlayDebugVisualizationEnabledRuntime,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
shiftSubtitleRangeToVideoClock,
showMpvOsdRuntime,
JellyfinPlaybackReporter,
tokenizeSubtitle as tokenizeSubtitleCore,
toSubtitleClock,
triggerFieldGrouping as triggerFieldGroupingCore,
upsertYomitanDictionarySettings,
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
@@ -1937,7 +1939,10 @@ let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null =
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
const canonical = resolveCanonicalPrimarySubtitle({
liveText: payload.text,
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
currentTimeSec: toSubtitleClock(
Number(appState.mpvClient?.currentTimePos),
appState.mpvClient?.currentSubDelay ?? 0,
),
cues: appState.activeParsedSubtitleCues,
});
return {
@@ -1951,9 +1956,11 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
// event, so a finished caption row lingering beside a fresh line would end up on the
// card. The parsed view also carries the cue's own timings for the clip range.
// Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first.
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
const resolved = resolvePrimarySubtitle({
liveText: appState.mpvClient?.currentSubText ?? '',
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
cues: appState.activeParsedSubtitleCues,
});
// Same validity bar as the live capture path: an unusable resolved span must fall
@@ -1968,13 +1975,16 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
) {
return captureLiveSubtitleMiningContext(appState.mpvClient);
}
return {
source: 'overlay',
text: resolvedText,
startTime: resolved.startTime,
endTime: resolved.endTime,
capturedAtMs: Date.now(),
};
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
{
source: 'overlay',
text: resolvedText,
startTime: resolved.startTime,
endTime: resolved.endTime,
capturedAtMs: Date.now(),
},
subDelay,
);
}
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
const timedPayload = withCurrentSubtitleTiming(payload);
@@ -2116,13 +2126,18 @@ function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
subtitleSidebarCuesRevision += 1;
}
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cuesRevision: subtitleSidebarCuesRevision,
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
currentTimeSec:
appState.mpvClient?.currentTimePos === undefined
? null
: toSubtitleClock(appState.mpvClient.currentTimePos, subDelay),
subDelay,
currentSubtitle: {
text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null,
@@ -2161,6 +2176,7 @@ function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
rawCommand,
appState.activeParsedSubtitleCues,
appState.mpvClient?.currentTimePos ?? Number.NaN,
appState.mpvClient?.currentSubDelay ?? 0,
) ?? rawCommand;
if (isExplicitMpvSeekCommand(command)) {
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
@@ -3115,7 +3131,10 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
getSubtitleContextLines: (range) =>
collectMediaTimingContextLines({
cues: appState.activeParsedSubtitleCues,
// Review ranges and tracker entries are on the video clock; parsed cues are not.
cues: appState.activeParsedSubtitleCues.map((cue) =>
shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0),
),
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
startTime: range.startTime,
endTime: range.endTime,
@@ -6028,7 +6047,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
// live mpv sub timings at lookup time so media generation clips the mined line even
// when extraction finishes long after playback has moved on.
recordSubtitleMiningContext: (context) =>
recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
recordSubtitleMiningContext(
context
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
: captureCurrentPrimarySubtitleMiningContext(),
),
tokenizeCurrentSubtitle: async () => {
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
return resolveCurrentSubtitleForRenderer({
@@ -1,3 +1,4 @@
import { toSubtitleClock } from '../../core/services/subtitle-clock';
import type { SubtitleCue, SubtitleData } from '../../types';
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
@@ -12,6 +13,7 @@ type AutoplaySubtitlePrimingMpvClient = {
requestProperty: (name: string) => Promise<unknown>;
currentVideoPath?: string;
currentTimePos?: number;
currentSubDelay?: number;
currentSubText?: string;
currentSecondarySubText?: string;
setCurrentSecondarySubText?: (text: string) => void;
@@ -156,7 +158,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
return resolvePrimarySubtitleText({
liveText: text,
currentTimeSec,
currentTimeSec: toSubtitleClock(currentTimeSec, client?.currentSubDelay ?? 0),
cues: deps.getActiveParsedSubtitleCues(),
});
}
@@ -270,7 +272,10 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSeconds = Number(
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
);
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
const resolvedTimeSeconds = toSubtitleClock(
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
client?.currentSubDelay ?? 0,
);
const cue = selectAutoplayStartupCue(
cues,
resolvedTimeSeconds,
@@ -176,6 +176,21 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
assert.deepEqual(recordedStarts.slice(-1), [20]);
});
test('subtitle timing history records lines on the video clock under sub-delay', () => {
const timing: Array<{ text: string; start: number; end: number }> = [];
const { handlers } = buildMainDeps({
mpvClient: { currentSubDelay: 2 },
subtitleTimingTracker: {
recordSubtitle: (text: string, start: number, end: number) =>
timing.push({ text, start, end }),
},
});
handlers.recordSubtitleTiming('食べる', 5, 8);
assert.deepEqual(timing, [{ text: '食べる', start: 7, end: 10 }]);
});
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
const immersion: Array<{ text: string; start: number; end: number }> = [];
const timing: Array<{ text: string; start: number; end: number }> = [];
+13 -5
View File
@@ -1,3 +1,4 @@
import { toSubtitleClock } from '../../core/services/subtitle-clock';
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
import {
@@ -23,6 +24,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
currentSubText?: string;
currentSecondarySubText?: string;
currentTimePos?: number;
currentSubDelay?: number;
requestProperty?: (name: string) => Promise<unknown>;
} | null;
immersionTracker: {
@@ -161,7 +163,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resolveSubtitleText: (liveText: string) =>
resolvePrimarySubtitleText({
liveText,
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
currentTimeSec: toSubtitleClock(
Number(deps.appState.mpvClient?.currentTimePos),
deps.appState.mpvClient?.currentSubDelay ?? 0,
),
cues: deps.appState.activeParsedSubtitleCues,
}),
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
@@ -231,6 +236,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) => {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
// The tracker feeds multi-line mining, so it stores video-clock times: the subtitle
// timings plus the `sub-delay` in effect while the line was on screen.
const subDelay = deps.appState.mpvClient?.currentSubDelay ?? 0;
const canonical = resolveCanonicalSample(text, start);
if (!canonical) {
const recordableText = resolveTextForRecording(text, start);
@@ -239,8 +247,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
}
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
recordableText,
start,
end,
start + subDelay,
end + subDelay,
secondaryText,
);
return;
@@ -253,8 +261,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordedTimingCanonicalKeys.add(key);
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
cue.text,
cue.startTime,
cue.endTime,
cue.startTime + subDelay,
cue.endTime + subDelay,
secondaryText,
);
}
@@ -28,6 +28,7 @@ export async function setup() {
],
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
currentTimeSec: 0,
subDelay: 0,
config: {
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
enabled: true,
+13 -2
View File
@@ -199,6 +199,7 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
subDelay: 0,
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
};
const requests = { snapshot: 0, playback: 0 };
@@ -226,8 +227,8 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
},
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
requests.playback += 1;
const { sourceKey, cuesRevision, currentTimeSec, currentSubtitle } = snapshot;
return { sourceKey, cuesRevision, currentTimeSec, currentSubtitle };
const { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle } = snapshot;
return { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle };
},
getPlaybackPaused: async () => false,
sendMpvCommand: (command: MpvCommand) => {
@@ -528,6 +529,16 @@ test('subtitle sidebar seeks to the selected cue start using the overlap-aware s
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.48, 'absolute+exact']);
});
test('subtitle sidebar seeks to where mpv plays a sub-delay retimed cue', async () => {
const h = createSidebarHarness();
h.setSnapshot({ subDelay: 2 });
await h.modal.openSubtitleSidebarModal();
h.modal.seekToCue({ startTime: 1, endTime: 2, text: 'first' });
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.08, 'absolute+exact']);
});
test('subtitle sidebar close reports hidden visibility and notifies the modal close', async () => {
const h = createSidebarHarness();
await h.modal.openSubtitleSidebarModal();
+6 -1
View File
@@ -4,6 +4,7 @@ import type {
SubtitleMiningContext,
SubtitleSidebarSnapshot,
} from '../../types';
import { toVideoClock } from '../../core/services/subtitle-clock.js';
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
@@ -231,6 +232,8 @@ export function createSubtitleSidebarModal(
let lastSubtitleSidebarLookupCueIndex = -1;
let subtitleSourceKey: string | null = null;
let subtitleCuesRevision: number | null = null;
// Latest mpv `sub-delay` from main; cue times plus this are where mpv plays the cue.
let subtitleSubDelay = 0;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
@@ -416,7 +419,7 @@ export function createSubtitleSidebarModal(
function seekToCue(cue: SubtitleCue): void {
window.electronAPI.sendMpvCommand([
'seek',
subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue),
toVideoClock(subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue), subtitleSubDelay),
'absolute+exact',
]);
}
@@ -591,6 +594,7 @@ export function createSubtitleSidebarModal(
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
pollsSinceFullSnapshot = 0;
subtitleCuesRevision = snapshot.cuesRevision;
subtitleSubDelay = snapshot.subDelay;
if (snapshot.sourceKey !== subtitleSourceKey) {
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
lastSubtitleSidebarLookupCueIndex = -1;
@@ -640,6 +644,7 @@ export function createSubtitleSidebarModal(
await refreshSnapshot();
return;
}
subtitleSubDelay = playback.subDelay;
updateActiveCue(playback.currentSubtitle, playback.currentTimeSec);
}
+2
View File
@@ -90,6 +90,8 @@ export interface MpvClient {
currentTimePos: number;
currentSubStart: number;
currentSubEnd: number;
/** mpv `sub-delay`; `sub-start`/`sub-end` do not include it. */
currentSubDelay: number;
currentAudioStreamIndex: number | null;
requestProperty?: (name: string) => Promise<unknown>;
send(command: { command: unknown[]; request_id?: number }): boolean;
+4
View File
@@ -235,7 +235,10 @@ export interface SubtitleSidebarPlaybackState {
sourceKey: string | null;
/** Bumps whenever main swaps in a different parsed cue array. */
cuesRevision: number;
/** Playback position on the subtitle clock (`time-pos` minus `sub-delay`), like cue times. */
currentTimeSec: number | null;
/** mpv `sub-delay`: cue times plus this give the video-clock seek target. */
subDelay: number;
currentSubtitle: {
text: string;
startTime: number | null;
@@ -251,6 +254,7 @@ export interface SubtitleSidebarSnapshot extends SubtitleSidebarPlaybackState {
export interface SubtitleMiningContext {
source: 'subtitle-sidebar' | 'overlay';
text: string;
/** Video-clock seconds: subtitle timings plus mpv `sub-delay` at capture time. */
startTime: number;
endTime: number;
capturedAtMs?: number;
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildSessionStatTiles, pickDensityTickIndexes } from './SessionDetail';
import type { SessionSummary } from '../../types/stats';
const session: SessionSummary = {
sessionId: 1,
canonicalTitle: 'Terrace House S01E08',
videoId: 1,
animeId: null,
animeTitle: null,
startedAtMs: 0,
endedAtMs: 1_860_000,
totalWatchedMs: 1_860_000,
activeWatchedMs: 1_500_000,
linesSeen: 400,
tokensSeen: 1_684,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 18,
knownWordsSeen: 673,
knownWordRate: 40,
};
test('buildSessionStatTiles derives pace, known rate, lookup rate, and paused time', () => {
const tiles = Object.fromEntries(buildSessionStatTiles(session).map((t) => [t.key, t]));
assert.equal(tiles.pace!.value, '67 words/min');
assert.equal(tiles.pace!.detail, '16.0 lines/min');
assert.equal(tiles.known!.value, '40%');
assert.equal(tiles.lookups!.detail, '1.1 lookups per 100 words');
assert.equal(tiles.paused!.value, '6m');
assert.equal(tiles.paused!.detail, '19% of 31m wall time');
});
test('buildSessionStatTiles shows placeholders when there is no active time or known data', () => {
const tiles = Object.fromEntries(
buildSessionStatTiles({
...session,
activeWatchedMs: 0,
totalWatchedMs: 0,
knownWordsSeen: 0,
knownWordRate: 0,
}).map((t) => [t.key, t]),
);
assert.equal(tiles.pace!.value, '—');
assert.equal(tiles.known!.value, '—');
assert.equal(tiles.paused!.value, 'none');
});
test('pickDensityTickIndexes spaces about five labels across the buckets', () => {
assert.deepEqual(pickDensityTickIndexes(3), [0, 1, 2]);
assert.deepEqual(pickDensityTickIndexes(50), [0, 10, 20, 30, 40]);
assert.deepEqual(pickDensityTickIndexes(0), []);
});
File diff suppressed because it is too large. Load diff
@@ -29,23 +29,12 @@ function toPercent(tsMs: number, tsMin: number, tsMax: number): number {
return Math.max(0, Math.min(100, ratio));
}
function markerLabel(marker: SessionChartMarker): string {
switch (marker.kind) {
case 'pause':
return '||';
case 'card':
return '\u26CF';
}
}
function markerColors(marker: SessionChartMarker): { border: string; bg: string; text: string } {
switch (marker.kind) {
case 'pause':
return { border: '#f5a97f', bg: 'rgba(245,169,127,0.16)', text: '#f5a97f' };
case 'card':
return { border: '#a6da95', bg: 'rgba(166,218,149,0.16)', text: '#a6da95' };
}
}
const CARD_MARKER_LABEL = '\u26CF';
const CARD_MARKER_COLORS = {
border: '#f5bde6',
bg: 'rgba(245,189,230,0.18)',
text: '#f5bde6',
};
function popupAlignment(percent: number): string {
if (percent <= 15) return 'left-0 translate-x-0';
@@ -137,16 +126,14 @@ export function SessionEventOverlay({
plotWidthPx: plotArea.width,
})}px`
: `${percent}%`;
const colors = markerColors(marker);
const isActive = marker.key === activeMarkerKey;
const isPinned = marker.key === pinnedMarkerKey;
const loading =
marker.kind === 'card' && marker.noteIds.some((noteId) => loadingNoteIds.has(noteId));
const loading = marker.noteIds.some((noteId) => loadingNoteIds.has(noteId));
return (
<div
key={marker.key}
className="pointer-events-auto absolute top-0 -translate-x-1/2 pt-1"
className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
style={{ left }}
onMouseEnter={() => onHoveredMarkerChange(marker.key)}
onMouseLeave={(event) =>
@@ -160,13 +147,14 @@ export function SessionEventOverlay({
<div className="relative flex flex-col items-center">
<button
type="button"
aria-label={`Show ${marker.kind} event details`}
aria-label="Show card mined event details"
aria-pressed={isPinned}
className="flex h-5 min-w-5 items-center justify-center rounded-full border px-1 text-[10px] font-semibold shadow-sm backdrop-blur-sm"
title={`${marker.cardsDelta} ${marker.cardsDelta === 1 ? 'card' : 'cards'} mined`}
className="flex h-5 min-w-5 items-center justify-center rounded-full border px-1 text-[10px] font-semibold shadow-sm backdrop-blur-sm transition-transform hover:scale-110"
style={{
borderColor: colors.border,
background: colors.bg,
color: colors.text,
borderColor: CARD_MARKER_COLORS.border,
background: CARD_MARKER_COLORS.bg,
color: CARD_MARKER_COLORS.text,
}}
onClick={(event) => {
event.preventDefault();
@@ -175,7 +163,7 @@ export function SessionEventOverlay({
onPinnedMarkerChange(togglePinnedSessionMarkerKey(pinnedMarkerKey, marker.key));
}}
>
{markerLabel(marker)}
{CARD_MARKER_LABEL}
</button>
{isActive ? (
<div
@@ -1,8 +1,4 @@
import {
formatEventSeconds,
type SessionChartMarker,
type SessionEventNoteInfo,
} from '../../lib/session-events';
import type { SessionChartMarker, SessionEventNoteInfo } from '../../lib/session-events';
interface SessionEventPopoverProps {
marker: SessionChartMarker;
@@ -35,10 +31,7 @@ export function SessionEventPopover({
<div className="relative z-50 w-64 rounded-xl border border-ctp-surface2 bg-ctp-surface0/95 p-3 shadow-2xl shadow-black/30 backdrop-blur-sm">
<div className="mb-2 flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold text-ctp-text">
{marker.kind === 'pause' && 'Paused'}
{marker.kind === 'card' && 'Card mined'}
</div>
<div className="text-xs font-semibold text-ctp-text">Card mined</div>
<div className="text-[10px] text-ctp-overlay1">{formatEventTime(marker.eventTsMs)}</div>
</div>
<div className="flex items-center gap-1.5">
@@ -64,78 +57,65 @@ export function SessionEventPopover({
×
</button>
) : null}
<div className="text-sm">
{marker.kind === 'pause' && '||'}
{marker.kind === 'card' && '\u26CF'}
</div>
<div className="text-sm text-ctp-cards-mined">{'\u26CF'}</div>
</div>
</div>
{marker.kind === 'pause' && (
<div className="text-xs text-ctp-subtext0">
Duration: <span className="text-ctp-peach">{formatEventSeconds(marker.durationMs)}</span>
<div className="space-y-2">
<div className="text-xs text-ctp-cards-mined">
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
</div>
)}
{marker.kind === 'card' && (
<div className="space-y-2">
<div className="text-xs text-ctp-cards-mined">
+{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
</div>
{loading ? (
<div className="text-xs text-ctp-overlay1">Loading Anki note info...</div>
) : null}
<div className="space-y-1.5">
{marker.noteIds.length > 0 ? (
marker.noteIds.map((noteId) => {
const info = noteInfos.get(noteId);
const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
const showUnavailableFallback = !loading && !hasPreview;
return (
<div
key={noteId}
className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/80 px-2.5 py-2"
>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
Note {noteId}
</div>
{showUnavailableFallback ? (
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
) : null}
{loading ? (
<div className="text-xs text-ctp-overlay1">Loading Anki note info...</div>
) : null}
<div className="space-y-1.5">
{marker.noteIds.length > 0 ? (
marker.noteIds.map((noteId) => {
const info = noteInfos.get(noteId);
const hasPreview = Boolean(info?.expression || info?.context || info?.meaning);
const showUnavailableFallback = !loading && !hasPreview;
return (
<div
key={noteId}
className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/80 px-2.5 py-2"
>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="rounded-full bg-ctp-surface1 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-ctp-overlay1">
Note {noteId}
</div>
{info?.expression ? (
<div className="mb-1 text-sm font-medium text-ctp-text">
{info.expression}
</div>
) : null}
{info?.context ? (
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
) : null}
{info?.meaning ? (
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
) : null}
{showUnavailableFallback ? (
<div className="mb-2 text-xs text-ctp-overlay1">
Preview unavailable from AnkiConnect.
</div>
<div className="text-[10px] text-ctp-overlay1">Preview unavailable</div>
) : null}
<button
type="button"
onClick={() => onOpenNote(noteId)}
className="rounded-md bg-ctp-surface1 px-2 py-1 text-[10px] text-ctp-blue transition-colors hover:bg-ctp-surface2"
>
Open in Anki
</button>
</div>
);
})
) : (
<div className="text-xs text-ctp-overlay1">No linked note ids recorded.</div>
)}
</div>
{info?.expression ? (
<div className="mb-1 text-sm font-medium text-ctp-text">{info.expression}</div>
) : null}
{info?.context ? (
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
) : null}
{info?.meaning ? (
<div className="mb-2 text-xs text-ctp-teal">{info.meaning}</div>
) : null}
{showUnavailableFallback ? (
<div className="mb-2 text-xs text-ctp-overlay1">
Preview unavailable from AnkiConnect.
</div>
) : null}
<button
type="button"
onClick={() => onOpenNote(noteId)}
className="rounded-md bg-ctp-surface1 px-2 py-1 text-[10px] text-ctp-blue transition-colors hover:bg-ctp-surface2"
>
Open in Anki
</button>
</div>
);
})
) : (
<div className="text-xs text-ctp-overlay1">No linked note ids recorded.</div>
)}
</div>
)}
</div>
</div>
);
}
+7
View File
@@ -4,6 +4,7 @@ import test from 'node:test';
import {
epochDayToDate,
epochMsFromDbTimestamp,
formatClockDuration,
formatRelativeDate,
formatSessionDayLabel,
} from './formatters';
@@ -153,3 +154,9 @@ test('formatSessionDayLabel includes year for past-year dates', () => {
assert.notEqual(label, withoutYear);
}, fixedNow);
});
test('formatClockDuration keeps seconds for sub-hour spans', () => {
assert.equal(formatClockDuration(42_000), '42s');
assert.equal(formatClockDuration(372_000), '6m 12s');
assert.equal(formatClockDuration(3_780_000), '1h 3m');
});
+14
View File
@@ -74,3 +74,17 @@ export function formatSessionDayLabel(sessionStartedAtMs: number): string {
...(includeYear ? { year: 'numeric' } : {}),
});
}
/** Second-precise duration for short spans ("42s", "6m 12s", "1h 3m"). */
export function formatClockDuration(ms: number): string {
const totalSec = Math.max(0, Math.round(ms / 1000));
if (totalSec < 60) return `${totalSec}s`;
const totalMin = Math.floor(totalSec / 60);
if (totalMin < 60) {
const sec = totalSec % 60;
return sec > 0 ? `${totalMin}m ${sec}s` : `${totalMin}m`;
}
const hours = Math.floor(totalMin / 60);
const mins = totalMin % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildSessionDensity, pickDensityBucketMs } from './session-density';
import { EventType } from '../types/stats';
function sample(sampleMs: number, linesSeen: number, tokensSeen: number) {
return { sampleMs, totalWatchedMs: 0, activeWatchedMs: 0, linesSeen, tokensSeen, cardsMined: 0 };
}
test('pickDensityBucketMs grows the bucket until the session fits in the bucket budget', () => {
assert.equal(pickDensityBucketMs(5 * 60_000), 10_000);
assert.equal(pickDensityBucketMs(25 * 60_000), 30_000);
assert.equal(pickDensityBucketMs(3 * 3_600_000), 300_000);
});
test('pickDensityBucketMs scales past the largest candidate so very long spans stay in budget', () => {
const spanMs = 48 * 3_600_000;
const bucketMs = pickDensityBucketMs(spanMs);
assert.ok(Math.ceil(spanMs / bucketMs) <= 60);
assert.equal(bucketMs % 60_000, 0);
});
test('buildSessionDensity converts cumulative samples into per-bucket known/unknown deltas', () => {
const density = buildSessionDensity(
[sample(0, 0, 0), sample(5_000, 1, 10), sample(12_000, 2, 25), sample(25_000, 3, 40)],
[
{ linesSeen: 1, knownWordsSeen: 4, totalWordsSeen: 8 },
{ linesSeen: 2, knownWordsSeen: 10, totalWordsSeen: 20 },
{ linesSeen: 3, knownWordsSeen: 20, totalWordsSeen: 32 },
],
[
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 3_000, payload: null },
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 14_000, payload: null },
{ eventType: EventType.CARD_MINED, tsMs: 24_000, payload: '{"cardsMined":2}' },
{ eventType: EventType.PAUSE_START, tsMs: 24_500, payload: null },
],
);
assert.equal(density.bucketMs, 10_000);
assert.equal(density.hasKnownWords, true);
assert.deepEqual(
density.buckets.map((b) => [b.knownWords, b.unknownWords, b.lookups, b.cards]),
[
[4, 4, 1, 0],
[6, 6, 1, 0],
[10, 2, 0, 2],
],
);
});
test('buildSessionDensity falls back to raw token counts without known-word data', () => {
const density = buildSessionDensity(
[sample(0, 0, 0), sample(9_000, 1, 7), sample(15_000, 2, 12)],
[],
[],
);
assert.equal(density.hasKnownWords, false);
assert.deepEqual(
density.buckets.map((b) => [b.words, b.knownWords]),
[
[7, 0],
[5, 0],
],
);
});
test('buildSessionDensity returns no buckets for an empty timeline', () => {
assert.deepEqual(buildSessionDensity([], [], []).buckets, []);
});
test('buildSessionDensity keeps the final sample when the span ends exactly on a bucket edge', () => {
const density = buildSessionDensity([sample(0, 0, 0), sample(300_000, 1, 42)], [], []);
assert.equal(density.bucketMs, 10_000);
assert.equal(density.buckets.length, 30);
assert.equal(density.buckets.at(-1)!.words, 42);
assert.equal(
density.buckets.reduce((sum, b) => sum + b.words, 0),
42,
);
});
+149
View File
@@ -0,0 +1,149 @@
import type { KnownWordsTimelinePoint } from '../hooks/useSessions';
import { readCardsMinedDelta } from './session-events';
import { EventType } from '../types/stats';
import type { SessionEvent, SessionTimelinePoint } from '../types/stats';
/** One time slice of a session: words read, lookups made, and cards mined inside it. */
export interface SessionDensityBucket {
index: number;
startMs: number;
endMs: number;
words: number;
knownWords: number;
unknownWords: number;
lookups: number;
cards: number;
}
export interface SessionDensity {
bucketMs: number;
startMs: number;
endMs: number;
/** True when the known-words timeline had data, so known/unknown splits are meaningful. */
hasKnownWords: boolean;
buckets: SessionDensityBucket[];
}
const BUCKET_SIZE_CANDIDATES_MS = [
10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000, 1_800_000,
];
export const MAX_DENSITY_BUCKETS = 60;
/**
* Smallest candidate bucket size that keeps the whole span within maxBuckets.
* Spans too long for every candidate get whole-minute buckets sized to fit.
*/
export function pickDensityBucketMs(spanMs: number, maxBuckets = MAX_DENSITY_BUCKETS): number {
for (const candidate of BUCKET_SIZE_CANDIDATES_MS) {
if (Math.ceil(spanMs / candidate) <= maxBuckets) return candidate;
}
return Math.ceil(spanMs / maxBuckets / 60_000) * 60_000;
}
export function formatBucketSize(bucketMs: number): string {
if (bucketMs < 60_000) return `${Math.round(bucketMs / 1000)}s`;
const minutes = Math.round(bucketMs / 60_000);
return `${minutes}m`;
}
type KnownWordCounts = { knownWordsSeen: number; totalWordsSeen: number };
/** Cumulative known/total counts at a lines-seen checkpoint, using the nearest earlier point. */
function lookupKnownWordCounts(
sortedPoints: KnownWordsTimelinePoint[],
linesSeen: number,
): KnownWordCounts {
let best: KnownWordCounts = { knownWordsSeen: 0, totalWordsSeen: 0 };
for (const point of sortedPoints) {
if (point.linesSeen > linesSeen) break;
best = point;
}
return best;
}
/**
* Turn cumulative telemetry samples into per-bucket deltas so the chart shows how
* dense the dialogue was over the session instead of a monotonically rising total.
*/
export function buildSessionDensity(
timeline: SessionTimelinePoint[],
knownWordsTimeline: KnownWordsTimelinePoint[],
events: SessionEvent[],
): SessionDensity {
const samples = [...timeline].sort((a, b) => a.sampleMs - b.sampleMs);
if (samples.length === 0) {
return { bucketMs: 0, startMs: 0, endMs: 0, hasKnownWords: false, buckets: [] };
}
const knownPoints = [...knownWordsTimeline].sort((a, b) => a.linesSeen - b.linesSeen);
const hasKnownWords = knownPoints.length > 0;
const startMs = samples[0]!.sampleMs;
const lastSampleMs = samples[samples.length - 1]!.sampleMs;
const spanMs = Math.max(1, lastSampleMs - startMs);
const bucketMs = pickDensityBucketMs(spanMs);
const bucketCount = Math.max(1, Math.ceil(spanMs / bucketMs));
const endMs = startMs + bucketCount * bucketMs;
const buckets: SessionDensityBucket[] = [];
let sampleIndex = -1;
let prevWords = 0;
let prevKnown = 0;
for (let index = 0; index < bucketCount; index += 1) {
const bucketStart = startMs + index * bucketMs;
const bucketEnd = bucketStart + bucketMs;
// The last bucket also owns a sample sitting exactly on the session's end edge.
const isLastBucket = index === bucketCount - 1;
while (
sampleIndex + 1 < samples.length &&
(isLastBucket || samples[sampleIndex + 1]!.sampleMs < bucketEnd)
) {
sampleIndex += 1;
}
let cumulativeWords = prevWords;
let cumulativeKnown = prevKnown;
if (sampleIndex >= 0) {
const sample = samples[sampleIndex]!;
if (hasKnownWords) {
const counts = lookupKnownWordCounts(knownPoints, sample.linesSeen);
cumulativeWords = counts.totalWordsSeen;
cumulativeKnown = Math.min(counts.knownWordsSeen, counts.totalWordsSeen);
} else {
cumulativeWords = sample.tokensSeen;
}
}
const words = Math.max(0, cumulativeWords - prevWords);
const knownWords = Math.min(words, Math.max(0, cumulativeKnown - prevKnown));
prevWords = Math.max(prevWords, cumulativeWords);
prevKnown = Math.max(prevKnown, cumulativeKnown);
buckets.push({
index,
startMs: bucketStart,
endMs: bucketEnd,
words,
knownWords,
unknownWords: words - knownWords,
lookups: 0,
cards: 0,
});
}
for (const event of events) {
if (event.eventType !== EventType.YOMITAN_LOOKUP && event.eventType !== EventType.CARD_MINED) {
continue;
}
const rawIndex = Math.floor((event.tsMs - startMs) / bucketMs);
const bucket = buckets[Math.max(0, Math.min(bucketCount - 1, rawIndex))]!;
if (event.eventType === EventType.YOMITAN_LOOKUP) {
bucket.lookups += 1;
} else {
bucket.cards += readCardsMinedDelta(event);
}
}
return { bucketMs, startMs, endMs, hasKnownWords, buckets };
}
-62
View File
@@ -1,62 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildKnownWordsRatioChartData,
getKnownPctAxisMax,
} from '../components/sessions/SessionDetail';
import { buildSessionChartEvents } from './session-events';
import { EventType } from '../types/stats';
test('buildSessionChartEvents keeps only chart-relevant events and pairs pause ranges', () => {
const chartEvents = buildSessionChartEvents([
{ eventType: EventType.SUBTITLE_LINE, tsMs: 1_000, payload: '{"line":"ignored"}' },
{ eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null },
{ eventType: EventType.SEEK_FORWARD, tsMs: 3_000, payload: null },
{ eventType: EventType.PAUSE_END, tsMs: 4_000, payload: null },
{ eventType: EventType.CARD_MINED, tsMs: 5_000, payload: '{"cardsMined":1}' },
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 6_000, payload: null },
{ eventType: EventType.SEEK_BACKWARD, tsMs: 7_000, payload: null },
{ eventType: EventType.LOOKUP, tsMs: 8_000, payload: '{"hit":true}' },
]);
// Seek events are intentionally dropped from the chart — they were too noisy.
assert.deepEqual(
chartEvents.markers.filter((marker) => marker.kind !== 'pause' && marker.kind !== 'card'),
[],
);
assert.deepEqual(
chartEvents.cardEvents.map((event) => event.tsMs),
[5_000],
);
assert.deepEqual(
chartEvents.yomitanLookupEvents.map((event) => event.tsMs),
[6_000],
);
assert.deepEqual(chartEvents.pauseRegions, [{ startMs: 2_000, endMs: 4_000 }]);
});
test('getKnownPctAxisMax adds headroom above the highest known percentage', () => {
assert.equal(getKnownPctAxisMax([22.4, 31.2, 29.8]), 40);
});
test('getKnownPctAxisMax caps the chart top at 100%', () => {
assert.equal(getKnownPctAxisMax([97.1, 98.6]), 100);
});
test('buildKnownWordsRatioChartData uses filtered known-word timeline totals', () => {
const chartData = buildKnownWordsRatioChartData(
[
{ sampleMs: 1_000, linesSeen: 1, tokensSeen: 10 },
{ sampleMs: 2_000, linesSeen: 2, tokensSeen: 20 },
],
new Map([
[1, { knownWordsSeen: 2, totalWordsSeen: 3 }],
[2, { knownWordsSeen: 3, totalWordsSeen: 4 }],
]),
);
assert.deepEqual(chartData, [
{ tsMs: 1_000, knownWords: 2, unknownWords: 1, totalWords: 3 },
{ tsMs: 2_000, knownWords: 3, unknownWords: 1, totalWords: 4 },
]);
});
+3 -10
View File
@@ -29,20 +29,13 @@ test('buildSessionChartEvents produces typed hover markers with parsed payload m
{ eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null },
]);
// Seek events are intentionally dropped — too noisy on the session chart.
// Pause and seek events are intentionally dropped — too noisy on the session chart.
assert.deepEqual(
chartEvents.markers.map((marker) => marker.kind),
['pause', 'card'],
['card'],
);
const pauseMarker = chartEvents.markers[0]!;
assert.equal(pauseMarker.kind, 'pause');
assert.equal(pauseMarker.startMs, 2_000);
assert.equal(pauseMarker.endMs, 5_000);
assert.equal(pauseMarker.durationMs, 3_000);
assert.equal(pauseMarker.anchorTsMs, 3_500);
const cardMarker = chartEvents.markers[1]!;
const cardMarker = chartEvents.markers[0]!;
assert.equal(cardMarker.kind, 'card');
assert.deepEqual(cardMarker.noteIds, [11, 22]);
assert.equal(cardMarker.cardsDelta, 2);
+27 -86
View File
@@ -1,21 +1,10 @@
import { EventType, type SessionEvent } from '../types/stats';
export const SESSION_CHART_EVENT_TYPES = [
EventType.CARD_MINED,
EventType.PAUSE_START,
EventType.PAUSE_END,
EventType.YOMITAN_LOOKUP,
] as const;
export interface PauseRegion {
startMs: number;
endMs: number;
}
export const SESSION_CHART_EVENT_TYPES = [EventType.CARD_MINED, EventType.YOMITAN_LOOKUP] as const;
export interface SessionChartEvents {
cardEvents: SessionEvent[];
yomitanLookupEvents: SessionEvent[];
pauseRegions: PauseRegion[];
markers: SessionChartMarker[];
}
@@ -45,24 +34,15 @@ interface SessionEventNoteRecord {
fields?: Record<string, SessionEventNoteField> | null;
}
export type SessionChartMarker =
| {
key: string;
kind: 'pause';
anchorTsMs: number;
eventTsMs: number;
startMs: number;
endMs: number;
durationMs: number;
}
| {
key: string;
kind: 'card';
anchorTsMs: number;
eventTsMs: number;
noteIds: number[];
cardsDelta: number;
};
/** Interactive card-mined marker shown on the session event lane. */
export interface SessionChartMarker {
key: string;
kind: 'card';
anchorTsMs: number;
eventTsMs: number;
noteIds: number[];
cardsDelta: number;
}
function parsePayload(payload: string | null): Record<string, unknown> | null {
if (!payload) return null;
@@ -85,6 +65,11 @@ function readNoteIds(value: unknown): number[] {
);
}
/** Cards added by one CARD_MINED event; older events without a count mean one card. */
export function readCardsMinedDelta(event: SessionEvent): number {
return readNumberField(parsePayload(event.payload)?.cardsMined) ?? 1;
}
function stripHtml(value: string): string {
return value
.replace(/\[sound:[^\]]+\]/gi, ' ')
@@ -257,11 +242,6 @@ export function togglePinnedSessionMarkerKey(
return currentPinnedMarkerKey === nextMarkerKey ? null : nextMarkerKey;
}
export function formatEventSeconds(ms: number | null): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
return `${(ms / 1000).toFixed(1)}s`;
}
export function projectSessionMarkerLeftPx({
anchorTsMs,
tsMin,
@@ -284,71 +264,32 @@ export function projectSessionMarkerLeftPx({
export function buildSessionChartEvents(events: SessionEvent[]): SessionChartEvents {
const cardEvents: SessionEvent[] = [];
const yomitanLookupEvents: SessionEvent[] = [];
const pauseRegions: PauseRegion[] = [];
const markers: SessionChartMarker[] = [];
let pendingPauseStartMs: number | null = null;
for (const event of events) {
switch (event.eventType) {
case EventType.CARD_MINED:
case EventType.CARD_MINED: {
cardEvents.push(event);
{
const payload = parsePayload(event.payload);
markers.push({
key: `card-${event.tsMs}`,
kind: 'card',
anchorTsMs: event.tsMs,
eventTsMs: event.tsMs,
noteIds: readNoteIds(payload?.noteIds),
cardsDelta: readNumberField(payload?.cardsMined) ?? 1,
});
}
const payload = parsePayload(event.payload);
markers.push({
key: `card-${event.tsMs}`,
kind: 'card',
anchorTsMs: event.tsMs,
eventTsMs: event.tsMs,
noteIds: readNoteIds(payload?.noteIds),
cardsDelta: readCardsMinedDelta(event),
});
break;
}
case EventType.YOMITAN_LOOKUP:
yomitanLookupEvents.push(event);
break;
case EventType.PAUSE_START:
pendingPauseStartMs = event.tsMs;
break;
case EventType.PAUSE_END:
if (pendingPauseStartMs !== null) {
pauseRegions.push({ startMs: pendingPauseStartMs, endMs: event.tsMs });
markers.push({
key: `pause-${pendingPauseStartMs}-${event.tsMs}`,
kind: 'pause',
anchorTsMs: pendingPauseStartMs + Math.round((event.tsMs - pendingPauseStartMs) / 2),
eventTsMs: pendingPauseStartMs,
startMs: pendingPauseStartMs,
endMs: event.tsMs,
durationMs: Math.max(0, event.tsMs - pendingPauseStartMs),
});
pendingPauseStartMs = null;
}
break;
default:
break;
}
}
if (pendingPauseStartMs !== null) {
pauseRegions.push({ startMs: pendingPauseStartMs, endMs: pendingPauseStartMs + 2_000 });
markers.push({
key: `pause-${pendingPauseStartMs}-${pendingPauseStartMs + 2_000}`,
kind: 'pause',
anchorTsMs: pendingPauseStartMs + 1_000,
eventTsMs: pendingPauseStartMs,
startMs: pendingPauseStartMs,
endMs: pendingPauseStartMs + 2_000,
durationMs: 2_000,
});
}
markers.sort((left, right) => left.anchorTsMs - right.anchorTsMs);
return {
cardEvents,
yomitanLookupEvents,
pauseRegions,
markers,
};
return { cardEvents, yomitanLookupEvents, markers };
}