diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8adc04..36c90496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + +
+Internal changes + +### Internal +- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`. + +
+ ## v0.21.0 (2026-10-24) ### Added diff --git a/changes/anilist-add-missing-entry.md b/changes/anilist-add-missing-entry.md new file mode 100644 index 00000000..e8fd83e4 --- /dev/null +++ b/changes/anilist-add-missing-entry.md @@ -0,0 +1,5 @@ +type: fixed +area: anilist + +- AniList progress sync now adds a show to your Watching list (or Completed on the final episode) when it is not on your list yet, instead of failing with "AniList update not possible". This only happens when the file title exactly matches an AniList title or the entry is pinned with the character dictionary override; loose matches still require adding the show yourself. +- Entries marked Completed, Paused, Dropped, or Rewatching are still never changed. diff --git a/changes/anki-sentence-audio-default.md b/changes/anki-sentence-audio-default.md new file mode 100644 index 00000000..a893407d --- /dev/null +++ b/changes/anki-sentence-audio-default.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- `ankiConnect.fields.audio` now defaults to `SentenceAudio` instead of `ExpressionAudio`, so generated sentence audio no longer overwrites Yomitan's word audio on default configs. `fields.wordAudio` still defaults to `ExpressionAudio`. If you relied on the old default, set `fields.audio` to `ExpressionAudio` explicitly. diff --git a/changes/appimage-keepalive-uutils.md b/changes/appimage-keepalive-uutils.md new file mode 100644 index 00000000..408c3f76 --- /dev/null +++ b/changes/appimage-keepalive-uutils.md @@ -0,0 +1,4 @@ +type: fixed +area: linux + +- Fixed AppImage builds on Ubuntu 26.04+ (uutils coreutils) releasing their mount before Chromium child processes finished shutting down, which brought back "Service Crash" notifications on quit. diff --git a/changes/readme-anime-screenshots.md b/changes/readme-anime-screenshots.md deleted file mode 100644 index 69a4f482..00000000 --- a/changes/readme-anime-screenshots.md +++ /dev/null @@ -1,4 +0,0 @@ -type: docs -area: readme - -- Refreshed the README's anime screenshots using the current SubMiner build. diff --git a/changes/repo-root-packaging-layout.md b/changes/repo-root-packaging-layout.md deleted file mode 100644 index 2c7541d3..00000000 --- a/changes/repo-root-packaging-layout.md +++ /dev/null @@ -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/`. diff --git a/changes/subtitle-sidebar-mpv-key-passthrough.md b/changes/subtitle-sidebar-mpv-key-passthrough.md deleted file mode 100644 index ef4f373f..00000000 --- a/changes/subtitle-sidebar-mpv-key-passthrough.md +++ /dev/null @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index adb6472a..cf2a4e8c 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -586,7 +586,7 @@ "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. "fields": { "word": "Expression", // Card field for the mined word or expression text. - "audio": "ExpressionAudio", // Card field that receives generated sentence audio. + "audio": "SentenceAudio", // Card field that receives generated sentence audio. "wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization. "image": "Picture", // Card field that receives the captured screenshot or animated image. "sentence": "Sentence", // Card field that receives the source sentence text. diff --git a/docs-site/anilist-integration.md b/docs-site/anilist-integration.md index dab55b0c..5e2bd178 100644 --- a/docs-site/anilist-integration.md +++ b/docs-site/anilist-integration.md @@ -29,7 +29,7 @@ An episode counts as watched after 85% of its length and at least 10 minutes of 2. Finds the matching AniList entry. For season 2 and later, it follows the show's sequels. 3. Sets your progress to that episode and marks the entry Watching, or Completed on the final episode. -The show must already be on your Planning or Watching list. SubMiner does not add new entries, and it never lowers your progress. +If the show is not on your list yet, SubMiner adds it as Watching, but only when the file title exactly matches an AniList title or you pinned the entry with the character dictionary's AniList override. For a looser match, add the show to Planning or Watching yourself. SubMiner never changes entries marked Completed, Paused, Dropped, or Rewatching, and it never lowers your progress. Failed updates are saved and retried in the background, up to 8 times with growing delays. The queue survives restarts. @@ -61,7 +61,7 @@ Character dictionary settings live under `anilist.characterDictionary` and are c **No update after an episode.** Check that `anilist.enabled` is `true` and that you watched at least 85% of the episode. -**"AniList update not possible."** Add the show to your Planning or Watching list, then mark the episode watched again. +**"AniList update not possible."** The show is marked Completed, Paused, Dropped, or Rewatching, or it is not on your list and the title match was not exact. Add the show to your Planning or Watching list, or pin it with the character dictionary's AniList override, then mark the episode watched again. **Wrong show or episode.** Install guessit and make sure it is on your `PATH`. Unusual file names parse poorly without it. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 23a04cbf..0b71127b 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -103,7 +103,7 @@ Field names are matched case-insensitively. A mapped field that is missing from Hachidori prepares downloadable word audio before it saves a note through the proxy, so the animated image delay works on the first mine. Set a downloadable pronunciation source in Hachidori's Audio settings. Browser speech cannot be saved to Anki. Without word audio, Hachidori shows a warning and the card gets no word-audio hold. -`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note, so point `fields.audio` at a separate field such as `SentenceAudio`. The default, `ExpressionAudio`, is the field many note types use for Yomitan's word audio, so leaving it would overwrite that audio. +`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note (often `ExpressionAudio`), so keep `fields.audio` pointed at a separate field such as the default `SentenceAudio`. Pointing it at Yomitan's word-audio field would overwrite that audio. `ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none. diff --git a/docs-site/changelog.md b/docs-site/changelog.md index 4c02c132..1220a018 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -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. + +
+Internal changes + +**Internal** +- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`. + +
+ ## v0.21.0 (2026-10-24) **Added** diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 06bef814..ddbdb91a 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -338,15 +338,15 @@ Creates and updates Anki cards with sentence, audio, and screenshot. Needs the [ **Fields** -| Key | Default | What it does | -| ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `ankiConnect.fields.word` | `"Expression"` | Word field | -| `ankiConnect.fields.audio` | `"ExpressionAudio"` | Field that receives sentence audio. Set a separate field such as `SentenceAudio` so it does not overwrite Yomitan's word audio | -| `ankiConnect.fields.wordAudio` | `"ExpressionAudio"` | Existing word-audio field, read only to time animated images | -| `ankiConnect.fields.image` | `"Picture"` | Screenshot field | -| `ankiConnect.fields.sentence` | `"Sentence"` | Sentence field | -| `ankiConnect.fields.miscInfo` | `"MiscInfo"` | Metadata field. `null` disables | -| `ankiConnect.metadata.pattern` | `"[SubMiner] %f (%t)"` | MiscInfo template: `%f` filename, `%F` filename with extension, `%t` time, `%T` time with ms, `
` newline | +| Key | Default | What it does | +| ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `ankiConnect.fields.word` | `"Expression"` | Word field | +| `ankiConnect.fields.audio` | `"SentenceAudio"` | Field that receives sentence audio. Keep it separate from Yomitan's word-audio field so it does not overwrite that audio | +| `ankiConnect.fields.wordAudio` | `"ExpressionAudio"` | Existing word-audio field, read only to time animated images | +| `ankiConnect.fields.image` | `"Picture"` | Screenshot field | +| `ankiConnect.fields.sentence` | `"Sentence"` | Sentence field | +| `ankiConnect.fields.miscInfo` | `"MiscInfo"` | Metadata field. `null` disables | +| `ankiConnect.metadata.pattern` | `"[SubMiner] %f (%t)"` | MiscInfo template: `%f` filename, `%F` filename with extension, `%t` time, `%T` time with ms, `
` newline | **Media** diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index ce281114..ac967c5f 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -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) diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index adb6472a..cf2a4e8c 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -586,7 +586,7 @@ "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. "fields": { "word": "Expression", // Card field for the mined word or expression text. - "audio": "ExpressionAudio", // Card field that receives generated sentence audio. + "audio": "SentenceAudio", // Card field that receives generated sentence audio. "wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization. "image": "Picture", // Card field that receives the captured screenshot or animated image. "sentence": "Sentence", // Card field that receives the source sentence text. diff --git a/e2e/harness/fake-anki.ts b/e2e/harness/fake-anki.ts index c848e82b..93aa4d0e 100644 --- a/e2e/harness/fake-anki.ts +++ b/e2e/harness/fake-anki.ts @@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record { 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 { const requests: AnkiRequest[] = []; + const addedNotes = new Map>(); let nextNoteId = 1000; const handle = (body: unknown): unknown => { @@ -59,10 +61,38 @@ export async function startFakeAnki(): Promise { 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; diff --git a/e2e/playback.e2e.test.ts b/e2e/playback.e2e.test.ts index 547b3bff..786b2bc1 100644 --- a/e2e/playback.e2e.test.ts +++ b/e2e/playback.e2e.test.ts @@ -91,6 +91,14 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise + 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', diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index ff0fab42..5d00f9cb 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -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, diff --git a/package.json b/package.json index 05b7f9a2..1e5206d9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packaging/release-notes/release-notes.md b/packaging/release-notes/release-notes.md index 0ee4fbf6..0f06cc24 100644 --- a/packaging/release-notes/release-notes.md +++ b/packaging/release-notes/release-notes.md @@ -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 diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index b6146fd4..14547fa7 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -729,7 +729,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs noteId: 404, noteInfo: { noteId: 404, - fields: { ExpressionAudio: { value: '' }, Picture: { value: '' } }, + fields: { SentenceAudio: { value: '' }, Picture: { value: '' } }, }, label: 'resolved source', }); @@ -740,8 +740,8 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs assert.equal(queued, true); assert.equal(updatedNotes.length, 1); assert.equal(updatedNotes[0]?.noteId, 404); - assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio_/); - assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined); + assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/); + assert.equal(updatedNotes[0]?.fields.ExpressionAudio, undefined); assert.match(updatedNotes[0]?.fields.Picture ?? '', /^ { + const originalFetch = globalThis.fetch; + let call = 0; + let saveVariables: Record | undefined; + globalThis.fetch = (async (_input, init) => { + call += 1; + if (call === 1) { + return createJsonResponse({ + data: { + Media: { + id: 108489, + episodes: 12, + title: { english: 'Pinned Show' }, + mediaListEntry: null, + }, + }, + }); + } + saveVariables = (JSON.parse(String(init?.body)) as { variables?: Record }) + .variables; + return createJsonResponse({ + data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } }, + }); + }) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress('token', 'Wrong Guess', 12, { + mediaId: 108489, + }); + assert.equal(result.status, 'updated'); + assert.match(result.message, /added "Pinned Show" at episode 12 and marked it completed/i); + assert.deepEqual(saveVariables, { mediaId: 108489, progress: 12, status: 'COMPLETED' }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('updateAnilistPostWatchProgress does not add a loosely matched show missing from the list', async () => { const originalFetch = globalThis.fetch; let call = 0; globalThis.fetch = (async () => { @@ -410,7 +486,7 @@ test('updateAnilistPostWatchProgress returns non-retryable error when media is n return createJsonResponse({ data: { Page: { - media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }], + media: [{ id: 33, episodes: 12, title: { english: 'Missing Show Abridged Edition' } }], }, }, }); @@ -427,6 +503,39 @@ test('updateAnilistPostWatchProgress returns non-retryable error when media is n assert.equal(result.status, 'error'); assert.equal(result.retryable, false); assert.match(result.message, /not in your AniList Planning or Watching list/i); + assert.equal(call, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('updateAnilistPostWatchProgress does not add the season 1 entry when the title carries an unparsed season marker', async () => { + const originalFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async () => { + call += 1; + if (call === 1) { + return createJsonResponse({ + data: { + Page: { + media: [{ id: 33, episodes: 12, format: 'TV', title: { english: 'Demo Show' } }], + }, + }, + }); + } + return createJsonResponse({ + data: { + Media: { id: 33, mediaListEntry: null }, + }, + }); + }) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress('token', 'Demo Show S2', 3); + assert.equal(result.status, 'error'); + assert.equal(result.retryable, false); + assert.match(result.message, /not in your AniList Planning or Watching list/i); + assert.equal(call, 2); } finally { globalThis.fetch = originalFetch; } diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index 0b80435f..f8ec465c 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import { parseMediaInfo } from '../../../jimaku/utils'; import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity'; import type { AnilistRateLimiter } from './rate-limiter'; -import { resolveAnilistSeasonMedia } from './season-resolver'; +import { resolveAnilistSeasonMedia, stripSeasonSuffix } from './season-resolver'; const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'; @@ -273,6 +273,9 @@ export async function updateAnilistPostWatchProgress( let mediaId = pinnedMediaId; let resolvedTitle: string | null = null; let resolvedEpisodes: number | null = null; + // A missing list entry is only created when we trust the match; otherwise a fuzzy + // search hit would add an unrelated show to the user's list. + let confidentMatch = pinnedMediaId !== null; if (mediaId === null) { let resolution: Awaited>; @@ -312,6 +315,11 @@ export async function updateAnilistPostWatchProgress( mediaId = resolution.id; resolvedTitle = resolution.title; resolvedEpisodes = resolution.episodes; + // With no parsed season, an exact match on a season-stripped title ("Show S2" -> + // "Show") only certifies the season 1 anchor, not the entry being watched. + const seasonMarkerStripped = stripSeasonSuffix(title).trim() !== title.trim(); + confidentMatch = + resolution.exactTitleMatch && (resolution.requestedSeason !== null || !seasonMarkerStripped); } const entryResponse = await anilistGraphQl( @@ -358,7 +366,8 @@ export async function updateAnilistPostWatchProgress( : null); const entry = entryMedia?.mediaListEntry ?? null; - if (!entry || !isUpdateableListStatus(entry.status)) { + const createEntry = !entry && confidentMatch; + if (!createEntry && (!entry || !isUpdateableListStatus(entry.status))) { return { status: 'error', retryable: false, @@ -366,7 +375,7 @@ export async function updateAnilistPostWatchProgress( }; } - const currentProgress = entry.progress ?? 0; + const currentProgress = entry?.progress ?? 0; const shouldMarkCompleted = isKnownFinalEpisode(pickedEpisodes, episode); if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) { return { @@ -397,6 +406,15 @@ export async function updateAnilistPostWatchProgress( return { status: 'error', message: `AniList update failed: ${saveError}` }; } + if (createEntry) { + return { + status: 'updated', + message: shouldMarkCompleted + ? `AniList added "${pickedTitle}" at episode ${episode} and marked it completed.` + : `AniList added "${pickedTitle}" to Watching at episode ${episode}.`, + }; + } + return { status: 'updated', message: shouldMarkCompleted diff --git a/src/core/services/index.ts b/src/core/services/index.ts index c184fe21..9b7006c9 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -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 { diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 88381106..0f4c0ea2 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -89,6 +89,7 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot { cuesRevision: 1, cues: [], currentTimeSec: null, + subDelay: 0, currentSubtitle: { text: '', startTime: null, endTime: null }, config: { enabled: false, diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts index 772f1f94..d089f368 100644 --- a/src/core/services/mining.test.ts +++ b/src/core/services/mining.test.ts @@ -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, ); diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts index b6aa59e7..a9a178bb 100644 --- a/src/core/services/mining.ts +++ b/src/core/services/mining.ts @@ -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; } @@ -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 | 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( + { 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; showMpvOsd: (text: string) => void; }): Promise { @@ -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, ); } diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index 158ffdc3..71b6a266 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -38,6 +38,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ 'path', 'sub-start', 'sub-end', + 'sub-delay', 'time-pos', 'secondary-sub-text', 'aid', diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index eb9df2ef..2cd47155 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -73,6 +73,8 @@ function createDeps(overrides: Partial = {}): { 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; diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 55e1b0b4..897fbf3c 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -2,6 +2,7 @@ import { MpvSubtitleRenderMetrics } from '../../types'; import { sanitizeMediaTitle } from '../../shared/media-identity'; import { widenHalfwidthKana } from '../utils/halfwidth-kana'; import type { PluginMpvEventMap } from '../../plugin-api'; +import { toVideoClock } from './subtitle-clock'; export type MpvMessage = { event?: string; @@ -83,6 +84,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; @@ -284,7 +287,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] }); } @@ -293,6 +297,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); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index ed38e313..6993dec9 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -145,6 +145,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; @@ -444,6 +446,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); }, diff --git a/src/core/services/subtitle-clock.ts b/src/core/services/subtitle-clock.ts new file mode 100644 index 00000000..7daf59c6 --- /dev/null +++ b/src/core/services/subtitle-clock.ts @@ -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( + range: T, + subDelay: number, +): T { + const delay = finiteDelay(subDelay); + if (delay === 0) { + return range; + } + return { ...range, startTime: range.startTime + delay, endTime: range.endTime + delay }; +} diff --git a/src/core/services/subtitle-cue-navigation.test.ts b/src/core/services/subtitle-cue-navigation.test.ts index 00198fb4..28f859e9 100644 --- a/src/core/services/subtitle-cue-navigation.test.ts +++ b/src/core/services/subtitle-cue-navigation.test.ts @@ -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', () => { diff --git a/src/core/services/subtitle-cue-navigation.ts b/src/core/services/subtitle-cue-navigation.ts index f0248102..c1cd0a04 100644 --- a/src/core/services/subtitle-cue-navigation.ts +++ b/src/core/services/subtitle-cue-navigation.ts @@ -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']; } diff --git a/src/main.ts b/src/main.ts index 9fba165f..3a8bb66b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -328,9 +328,11 @@ import { setMpvSubVisibilityRuntime, setOverlayDebugVisualizationEnabledRuntime, setVisibleOverlayVisible as setVisibleOverlayVisibleCore, + shiftSubtitleRangeToVideoClock, showMpvOsdRuntime, JellyfinPlaybackReporter, tokenizeSubtitle as tokenizeSubtitleCore, + toSubtitleClock, triggerFieldGrouping as triggerFieldGroupingCore, upsertYomitanDictionarySettings, updateLastCardFromClipboard as updateLastCardFromClipboardCore, @@ -1949,7 +1951,10 @@ let tokenizeSubtitleDeferred: ((text: string) => Promise) | 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 { @@ -1963,9 +1968,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 @@ -1980,13 +1987,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( + { + 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); @@ -2128,13 +2138,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, @@ -2173,6 +2188,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; @@ -3224,7 +3240,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, @@ -6163,7 +6182,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({ diff --git a/src/main/appimage-mount-keepalive.test.ts b/src/main/appimage-mount-keepalive.test.ts index 7971fcf8..35a7d0a2 100644 --- a/src/main/appimage-mount-keepalive.test.ts +++ b/src/main/appimage-mount-keepalive.test.ts @@ -114,14 +114,22 @@ linuxTest('keepalive script releases the mount only after straggler processes ex fs.mkdirSync(mountDir); // AppRun leaves behind a straggler that keeps executing *from the mount* - // after AppRun itself exits — mimicking Chromium utility children. - fs.copyFileSync('/usr/bin/sleep', path.join(mountDir, 'straggler')); - fs.chmodSync(path.join(mountDir, 'straggler'), 0o755); + // after AppRun itself exits — mimicking Chromium utility children. AppRun waits + // until the straggler has exec'd, otherwise under load the supervisor can scan + // /proc while the forked child is still /bin/sh and release the mount early. + // The copy must keep the name `sleep`: multicall coreutils (uutils on Ubuntu + // 26.04+, busybox) pick the utility from argv[0] and exit immediately otherwise. + const stragglerPath = path.join(mountDir, 'sleep'); + fs.copyFileSync('/usr/bin/sleep', stragglerPath); + fs.chmodSync(stragglerPath, 0o755); writeExecutable( path.join(mountDir, 'AppRun'), [ '#!/bin/sh', - `"${mountDir}/straggler" 1 &`, + `"${stragglerPath}" 1 &`, + 'straggler=$!', + // Bail out if the straggler dies early so the drain assertion reports it. + `while kill -0 "$straggler" 2>/dev/null && [ "$(readlink "/proc/$straggler/exe")" != "${stragglerPath}" ]; do sleep 0.01; done`, `date +%s%N > "${resultsDir}/apprun-exited"`, 'exit 42', ].join('\n'), diff --git a/src/main/appimage-mount-keepalive.ts b/src/main/appimage-mount-keepalive.ts index 02f76bc8..3db5b726 100644 --- a/src/main/appimage-mount-keepalive.ts +++ b/src/main/appimage-mount-keepalive.ts @@ -52,13 +52,23 @@ if [ -z "$app_run" ]; then kill "$holder" 2>/dev/null run_direct "$@" fi +# One readlink per link: uutils readlink (Ubuntu 26.04+) aborts at the first +# unreadable operand, and other users' /proc//exe are always unreadable. +mount_busy() { + for exe in /proc/[0-9]*/exe; do + case "$(readlink "$exe" 2>/dev/null)" in + "$mount_point"/*) return 0 ;; + esac + done + return 1 +} APPDIR="$mount_point" "$app_run" "$@" rc=$? # Do not release the mount while any process still executes from it; releasing # early SIGBUSes Chromium children that are mid-shutdown. tries=0 while [ "$tries" -lt 100 ]; do - if readlink /proc/[0-9]*/exe 2>/dev/null | grep -qF "$mount_point/"; then + if mount_busy; then sleep 0.1 tries=$((tries + 1)) else diff --git a/src/main/runtime/autoplay-subtitle-priming-runtime.ts b/src/main/runtime/autoplay-subtitle-priming-runtime.ts index 3ce188a2..b2b8854b 100644 --- a/src/main/runtime/autoplay-subtitle-priming-runtime.ts +++ b/src/main/runtime/autoplay-subtitle-priming-runtime.ts @@ -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; 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, diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index d46d4357..aeb9f5f4 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -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 }> = []; diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index ac30fd73..426e0f64 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -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; } | 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, ); } diff --git a/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts b/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts index 99beb518..a3cb09ce 100644 --- a/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts +++ b/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts @@ -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, diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index ec5ecb70..c3de5006 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -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 => { 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(); diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index df3ad235..47f28c97 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -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); } diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 599237bc..b31db46b 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -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; send(command: { command: unknown[]; request_id?: number }): boolean; diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 0785a77a..8fd30907 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -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; diff --git a/stats/src/components/sessions/SessionDetail.test.tsx b/stats/src/components/sessions/SessionDetail.test.tsx new file mode 100644 index 00000000..5fcb36a2 --- /dev/null +++ b/stats/src/components/sessions/SessionDetail.test.tsx @@ -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), []); +}); diff --git a/stats/src/components/sessions/SessionDetail.tsx b/stats/src/components/sessions/SessionDetail.tsx index 52ee8006..d6fb229d 100644 --- a/stats/src/components/sessions/SessionDetail.tsx +++ b/stats/src/components/sessions/SessionDetail.tsx @@ -1,35 +1,37 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { - AreaChart, - Area, - LineChart, - Line, - XAxis, - YAxis, - Tooltip, - ResponsiveContainer, - ReferenceArea, - ReferenceLine, + Bar, + BarChart, CartesianGrid, Customized, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, } from 'recharts'; import { useSessionDetail } from '../../hooks/useSessions'; import { getStatsClient } from '../../hooks/useStatsApi'; -import type { KnownWordsTimelinePoint } from '../../hooks/useSessions'; import { CHART_THEME } from '../../lib/chart-theme'; +import { formatClockDuration, formatDuration, formatNumber } from '../../lib/formatters'; +import { + buildSessionDensity, + formatBucketSize, + type SessionDensity, + type SessionDensityBucket, +} from '../../lib/session-density'; import { buildSessionChartEvents, collectPendingSessionEventNoteIds, getSessionEventCardRequest, mergeSessionEventNoteInfos, + projectSessionMarkerLeftPx, resolveActiveSessionMarkerKey, type SessionChartMarker, type SessionEventNoteInfo, type SessionChartPlotArea, } from '../../lib/session-events'; -import { buildLookupRateDisplay } from '../../lib/yomitan-lookup'; import { getSessionDisplayWordCount } from '../../lib/session-word-count'; -import { EventType } from '../../types/stats'; +import { buildLookupRateDisplay } from '../../lib/yomitan-lookup'; import type { SessionEvent, SessionSummary } from '../../types/stats'; import { SessionEventOverlay } from './SessionEventOverlay'; @@ -37,15 +39,21 @@ interface SessionDetailProps { session: SessionSummary; } -const tooltipStyle = { - background: CHART_THEME.tooltipBg, - border: `1px solid ${CHART_THEME.tooltipBorder}`, - borderRadius: 6, - color: CHART_THEME.tooltipText, - fontSize: 11, -}; +const SERIES_COLORS = { + known: '#a6da95', + unknown: '#c6a0f6', + words: '#8aadf4', + lookup: '#b7bdf8', + card: '#f5bde6', +} as const; + +const CHART_HEIGHT = 150; function formatTime(ms: number): string { + return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +function formatTimeWithSeconds(ms: number): string { return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', @@ -53,85 +61,7 @@ function formatTime(ms: number): string { }); } -type KnownWordsLineCounts = { - knownWordsSeen: number; - totalWordsSeen: number; -}; - -/** Build a lookup: linesSeen -> filtered known/total word counts */ -function buildKnownWordsLookup( - knownWordsTimeline: KnownWordsTimelinePoint[], -): Map { - const map = new Map(); - for (const pt of knownWordsTimeline) { - map.set(pt.linesSeen, { - knownWordsSeen: pt.knownWordsSeen, - totalWordsSeen: pt.totalWordsSeen, - }); - } - return map; -} - -/** For a given linesSeen value, find the closest filtered word counts (floor lookup). */ -function lookupKnownWordCounts( - map: Map, - linesSeen: number, -): KnownWordsLineCounts { - if (map.size === 0) return { knownWordsSeen: 0, totalWordsSeen: 0 }; - if (map.has(linesSeen)) return map.get(linesSeen)!; - let best = 0; - for (const k of map.keys()) { - if (k <= linesSeen && k > best) { - best = k; - } - } - return best > 0 ? map.get(best)! : { knownWordsSeen: 0, totalWordsSeen: 0 }; -} - -interface RatioChartPoint { - tsMs: number; - knownWords: number; - unknownWords: number; - totalWords: number; -} - -interface FallbackChartPoint { - tsMs: number; - totalWords: number; -} - -type TimelineEntry = { - sampleMs: number; - linesSeen: number; - tokensSeen: number; -}; - -export function buildKnownWordsRatioChartData( - sorted: TimelineEntry[], - knownWordsMap: Map, -): RatioChartPoint[] { - const chartData: RatioChartPoint[] = []; - for (const t of sorted) { - const counts = lookupKnownWordCounts(knownWordsMap, t.linesSeen); - const totalWords = counts.totalWordsSeen; - if (totalWords === 0) continue; - const knownWords = Math.min(counts.knownWordsSeen, totalWords); - const unknownWords = totalWords - knownWords; - chartData.push({ - tsMs: t.sampleMs, - knownWords, - unknownWords, - totalWords, - }); - } - return chartData; -} - -export function getKnownPctAxisMax(values: number[]): number { - const max = Math.max(0, ...values.filter((value) => Number.isFinite(value))); - return Math.min(100, Math.ceil((max + 5) / 10) * 10); -} - +/** Reads the plot offset recharts computed so the event lane can line up with the bars. */ function SessionChartOffsetProbe({ offset, onPlotAreaChange, @@ -160,18 +90,11 @@ export function SessionDetail({ session }: SessionDetailProps) { const [loadingNoteIds, setLoadingNoteIds] = useState>(new Set()); const pendingNoteIdsRef = useRef>(new Set()); - const sorted = [...timeline].reverse(); - const knownWordsMap = buildKnownWordsLookup(knownWordsTimeline); - const hasKnownWords = knownWordsMap.size > 0; - - const { cardEvents, yomitanLookupEvents, pauseRegions, markers } = - buildSessionChartEvents(events); - const lookupRate = buildLookupRateDisplay( - session.yomitanLookupCount, - getSessionDisplayWordCount(session), + const density = useMemo( + () => buildSessionDensity(timeline, knownWordsTimeline, events), + [timeline, knownWordsTimeline, events], ); - const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length; - const cardEventCount = cardEvents.length; + const { yomitanLookupEvents, markers } = useMemo(() => buildSessionChartEvents(events), [events]); const activeMarkerKey = resolveActiveSessionMarkerKey(hoveredMarkerKey, pinnedMarkerKey); const activeMarker = useMemo( () => markers.find((marker) => marker.key === activeMarkerKey) ?? null, @@ -259,544 +182,378 @@ export function SessionDetail({ session }: SessionDetailProps) { void getStatsClient().ankiBrowse(noteId); }; - if (loading) return
Loading timeline...
; + if (loading) return
Loading session...
; if (error) return
Error: {error}
; - if (hasKnownWords) { - return ( - - ); - } - - return ( - - ); -} - -/* ── Ratio View (primary design) ────────────────────────────────── */ - -function RatioView({ - sorted, - knownWordsMap, - cardEvents, - yomitanLookupEvents, - pauseRegions, - markers, - hoveredMarkerKey, - onHoveredMarkerChange, - pinnedMarkerKey, - onPinnedMarkerChange, - noteInfos, - loadingNoteIds, - onOpenNote, - pauseCount, - cardEventCount, - lookupRate, - session, -}: { - sorted: TimelineEntry[]; - knownWordsMap: Map; - cardEvents: SessionEvent[]; - yomitanLookupEvents: SessionEvent[]; - pauseRegions: Array<{ startMs: number; endMs: number }>; - markers: SessionChartMarker[]; - hoveredMarkerKey: string | null; - onHoveredMarkerChange: (markerKey: string | null) => void; - pinnedMarkerKey: string | null; - onPinnedMarkerChange: (markerKey: string | null) => void; - noteInfos: Map; - loadingNoteIds: Set; - onOpenNote: (noteId: number) => void; - pauseCount: number; - cardEventCount: number; - lookupRate: ReturnType; - session: SessionSummary; -}) { - const [plotArea, setPlotArea] = useState(null); - const chartData = buildKnownWordsRatioChartData(sorted, knownWordsMap); - - if (chartData.length === 0) { - return
No word data for this session.
; - } - - const tsMin = chartData[0]!.tsMs; - const tsMax = chartData[chartData.length - 1]!.tsMs; - const finalTotal = chartData[chartData.length - 1]!.totalWords; - - const sparkData = chartData.map((d) => ({ tsMs: d.tsMs, totalWords: d.totalWords })); - - return ( -
- {/* ── Top: Percentage area chart ── */} -
- - - { - setPlotArea((prevPlotArea) => - prevPlotArea && - prevPlotArea.left === nextPlotArea.left && - prevPlotArea.width === nextPlotArea.width - ? prevPlotArea - : nextPlotArea, - ); - }} - /> - } - /> - - - - - - - - - - - - - - - `${v.toLocaleString()}`} - axisLine={false} - tickLine={false} - width={32} - /> - - { - const d = props.payload; - if (!d) return [_value, name]; - if (name === 'Known words') { - const knownPct = d.totalWords === 0 ? 0 : (d.knownWords / d.totalWords) * 100; - return [`${d.knownWords.toLocaleString()} (${knownPct.toFixed(1)}%)`, name]; - } - if (name === 'Unknown words') return [d.unknownWords.toLocaleString(), name]; - return [_value, name]; - }} - itemSorter={() => -1} - /> - - {/* Pause shaded regions */} - {pauseRegions.map((r, i) => ( - - ))} - - {/* Card mine markers */} - {cardEvents.map((e, i) => ( - - ))} - - {/* Yomitan lookup markers */} - {yomitanLookupEvents.map((e, i) => ( - - ))} - - - - - - -
- - {/* ── Bottom: Token accumulation sparkline ── */} -
- total words -
- - - - - - - -
- - {finalTotal.toLocaleString()} - -
- - {/* ── Stats bar ── */} - -
- ); -} - -/* ── Fallback View (no known words data) ────────────────────────── */ - -function FallbackView({ - sorted, - cardEvents, - yomitanLookupEvents, - pauseRegions, - markers, - hoveredMarkerKey, - onHoveredMarkerChange, - pinnedMarkerKey, - onPinnedMarkerChange, - noteInfos, - loadingNoteIds, - onOpenNote, - pauseCount, - cardEventCount, - lookupRate, - session, -}: { - sorted: TimelineEntry[]; - cardEvents: SessionEvent[]; - yomitanLookupEvents: SessionEvent[]; - pauseRegions: Array<{ startMs: number; endMs: number }>; - markers: SessionChartMarker[]; - hoveredMarkerKey: string | null; - onHoveredMarkerChange: (markerKey: string | null) => void; - pinnedMarkerKey: string | null; - onPinnedMarkerChange: (markerKey: string | null) => void; - noteInfos: Map; - loadingNoteIds: Set; - onOpenNote: (noteId: number) => void; - pauseCount: number; - cardEventCount: number; - lookupRate: ReturnType; - session: SessionSummary; -}) { - const [plotArea, setPlotArea] = useState(null); - const chartData: FallbackChartPoint[] = []; - for (const t of sorted) { - const totalWords = getSessionDisplayWordCount(t); - if (totalWords === 0) continue; - chartData.push({ tsMs: t.sampleMs, totalWords }); - } - - if (chartData.length === 0) { - return
No word data for this session.
; - } - - const tsMin = chartData[0]!.tsMs; - const tsMax = chartData[chartData.length - 1]!.tsMs; - return (
-
- - - { - setPlotArea((prevPlotArea) => - prevPlotArea && - prevPlotArea.left === nextPlotArea.left && - prevPlotArea.width === nextPlotArea.width - ? prevPlotArea - : nextPlotArea, - ); - }} - /> - } - /> - - - [`${value.toLocaleString()}`, 'Total words']} - /> - - {pauseRegions.map((r, i) => ( - - ))} - - {cardEvents.map((e, i) => ( - - ))} - {yomitanLookupEvents.map((e, i) => ( - - ))} - - - - - + {density.buckets.length === 0 ? ( +
No activity samples for this session.
+ ) : ( + + )} +
+ ); +} + +/* ── Stat tiles ─────────────────────────────────────────────────── */ + +export function buildSessionStatTiles(session: SessionSummary): Array<{ + key: string; + label: string; + value: string; + detail: string; + colorClass: string; +}> { + const words = getSessionDisplayWordCount(session); + const activeMin = session.activeWatchedMs / 60_000; + const wordsPerMin = activeMin > 0 ? words / activeMin : 0; + const linesPerMin = activeMin > 0 ? session.linesSeen / activeMin : 0; + const pausedMs = Math.max(0, session.totalWatchedMs - session.activeWatchedMs); + const pausedPct = + session.totalWatchedMs > 0 ? Math.round((pausedMs / session.totalWatchedMs) * 100) : 0; + const lookupRate = buildLookupRateDisplay(session.yomitanLookupCount, words); + const hasKnownRate = session.knownWordsSeen > 0 && words > 0; + + return [ + { + key: 'pace', + label: 'Pace', + value: activeMin > 0 ? `${wordsPerMin.toFixed(0)} words/min` : '—', + detail: activeMin > 0 ? `${linesPerMin.toFixed(1)} lines/min` : 'no active time', + colorClass: 'text-ctp-blue', + }, + { + key: 'lines', + label: 'Lines', + value: formatNumber(session.linesSeen), + detail: `${formatNumber(words)} words`, + colorClass: 'text-ctp-mauve', + }, + { + key: 'known', + label: 'Known', + value: hasKnownRate ? `${session.knownWordRate.toFixed(0)}%` : '—', + detail: hasKnownRate + ? `${formatNumber(session.knownWordsSeen)} known words` + : 'no known-word data', + colorClass: 'text-ctp-green', + }, + { + key: 'lookups', + label: 'Lookups', + value: formatNumber(session.yomitanLookupCount), + detail: lookupRate ? lookupRate.longValue : 'Yomitan lookups', + colorClass: 'text-ctp-lavender', + }, + { + key: 'paused', + label: 'Paused', + value: pausedMs > 0 ? formatClockDuration(pausedMs) : 'none', + detail: `${pausedPct}% of ${formatDuration(session.totalWatchedMs)} wall time`, + colorClass: 'text-ctp-peach', + }, + ]; +} + +function SessionStatTiles({ session }: { session: SessionSummary }) { + const tiles = buildSessionStatTiles(session); + return ( +
+ {tiles.map((tile) => ( +
+
{tile.label}
+
+ {tile.value} +
+
{tile.detail}
+
+ ))} +
+ ); +} + +/* ── Density chart + event lane ─────────────────────────────────── */ + +interface SessionDensityChartProps { + density: SessionDensity; + lookupEvents: SessionEvent[]; + markers: SessionChartMarker[]; + hoveredMarkerKey: string | null; + onHoveredMarkerChange: (markerKey: string | null) => void; + pinnedMarkerKey: string | null; + onPinnedMarkerChange: (markerKey: string | null) => void; + noteInfos: Map; + loadingNoteIds: Set; + onOpenNote: (noteId: number) => void; +} + +/** Roughly five evenly spaced tick indexes, always including the first bucket. */ +export function pickDensityTickIndexes(bucketCount: number, maxTicks = 5): number[] { + if (bucketCount <= 0) return []; + const step = Math.max(1, Math.ceil(bucketCount / maxTicks)); + const ticks: number[] = []; + for (let index = 0; index < bucketCount; index += step) { + ticks.push(index); + } + return ticks; +} + +function DensityTooltip({ + active, + payload, + hasKnownWords, +}: { + active?: boolean; + payload?: Array<{ payload?: SessionDensityBucket }>; + hasKnownWords: boolean; +}) { + const bucket = payload?.[0]?.payload; + if (!active || !bucket) return null; + const knownPct = bucket.words > 0 ? Math.round((bucket.knownWords / bucket.words) * 100) : 0; + + return ( +
+
+ {formatTimeWithSeconds(bucket.startMs)} – {formatTimeWithSeconds(bucket.endMs)} +
+
+ {formatNumber(bucket.words)} {bucket.words === 1 ? 'word' : 'words'} +
+ {hasKnownWords && bucket.words > 0 ? ( +
+ + {formatNumber(bucket.knownWords)} known + + ({knownPct}%) + · + + {formatNumber(bucket.unknownWords)} unknown + +
+ ) : null} + {bucket.lookups > 0 ? ( +
+ {bucket.lookups} {bucket.lookups === 1 ? 'lookup' : 'lookups'} +
+ ) : null} + {bucket.cards > 0 ? ( +
+ {bucket.cards} {bucket.cards === 1 ? 'card' : 'cards'} mined +
+ ) : null} +
+ ); +} + +function SessionDensityChart({ + density, + lookupEvents, + markers, + hoveredMarkerKey, + onHoveredMarkerChange, + pinnedMarkerKey, + onPinnedMarkerChange, + noteInfos, + loadingNoteIds, + onOpenNote, +}: SessionDensityChartProps) { + const [plotArea, setPlotArea] = useState(null); + const { buckets, startMs, endMs, hasKnownWords } = density; + const tickIndexes = pickDensityTickIndexes(buckets.length); + const hasLane = lookupEvents.length > 0 || markers.length > 0; + + const laneLeft = (tsMs: number): string => { + if (plotArea) { + return `${projectSessionMarkerLeftPx({ + anchorTsMs: tsMs, + tsMin: startMs, + tsMax: endMs, + plotLeftPx: plotArea.left, + plotWidthPx: plotArea.width, + })}px`; + } + const ratio = endMs > startMs ? (tsMs - startMs) / (endMs - startMs) : 0.5; + return `${Math.max(0, Math.min(100, ratio * 100))}%`; + }; + + return ( +
+
+
Reading density
+
+ words per {formatBucketSize(density.bucketMs)} +
- + + + { + setPlotArea((prevPlotArea) => + prevPlotArea && + prevPlotArea.left === nextPlotArea.left && + prevPlotArea.width === nextPlotArea.width + ? prevPlotArea + : nextPlotArea, + ); + }} + /> + } + /> + + formatTime(buckets[index]?.startMs ?? startMs)} + tick={{ fontSize: 9, fill: CHART_THEME.tick }} + axisLine={false} + tickLine={false} + /> + + } + isAnimationActive={false} + /> + {/* recharts only registers direct Bar children, so no fragment wrapper here */} + {hasKnownWords ? ( + + ) : null} + {hasKnownWords ? ( + + ) : ( + + )} + + + + {hasLane ? ( +
+
+ {lookupEvents.map((event, index) => ( + + ))} + +
+ ) : null} + +
+ {hasKnownWords ? ( + <> + + + + ) : ( + + )} + {lookupEvents.length > 0 ? ( + + + Yomitan lookup + + ) : null} + {markers.length > 0 ? ( + + {'⛏'} + Card mined (click for note) + + ) : null} +
); } -/* ── Stats Bar ──────────────────────────────────────────────────── */ - -function StatsBar({ - hasKnownWords, - pauseCount, - cardEventCount, - session, - lookupRate, -}: { - hasKnownWords: boolean; - pauseCount: number; - cardEventCount: number; - session: SessionSummary; - lookupRate: ReturnType; -}) { +function LegendSwatch({ color, label }: { color: string; label: string }) { return ( -
- {/* Group 1: Legend */} - {hasKnownWords && ( - <> - - - Known - - - - Unknown - - | - - )} - - {/* Group 2: Playback stats */} - {pauseCount > 0 && ( - - {pauseCount} pause - {pauseCount !== 1 ? 's' : ''} - - )} - {pauseCount > 0 && |} - - {/* Group 3: Learning events */} - - - - {session.yomitanLookupCount} Yomitan lookup - {session.yomitanLookupCount !== 1 ? 's' : ''} - - - {lookupRate && ( - - lookup rate: {lookupRate.shortValue}{' '} - ({lookupRate.longValue}) - - )} - - {'\u26CF'} - - {Math.max(cardEventCount, session.cardsMined)} card - {Math.max(cardEventCount, session.cardsMined) !== 1 ? 's' : ''} mined - - -
+ + + {label} + ); } diff --git a/stats/src/components/sessions/SessionEventOverlay.tsx b/stats/src/components/sessions/SessionEventOverlay.tsx index e1eee596..7d6a0aab 100644 --- a/stats/src/components/sessions/SessionEventOverlay.tsx +++ b/stats/src/components/sessions/SessionEventOverlay.tsx @@ -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 (
onHoveredMarkerChange(marker.key)} onMouseLeave={(event) => @@ -160,13 +147,14 @@ export function SessionEventOverlay({
{isActive ? (
-
- {marker.kind === 'pause' && 'Paused'} - {marker.kind === 'card' && 'Card mined'} -
+
Card mined
{formatEventTime(marker.eventTsMs)}
@@ -64,78 +57,65 @@ export function SessionEventPopover({ × ) : null} -
- {marker.kind === 'pause' && '||'} - {marker.kind === 'card' && '\u26CF'} -
+
{'\u26CF'}
- {marker.kind === 'pause' && ( -
- Duration: {formatEventSeconds(marker.durationMs)} +
+
+ +{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'}
- )} - - {marker.kind === 'card' && ( -
-
- +{marker.cardsDelta} {marker.cardsDelta === 1 ? 'card' : 'cards'} -
- {loading ? ( -
Loading Anki note info...
- ) : null} -
- {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 ( -
-
-
- Note {noteId} -
- {showUnavailableFallback ? ( -
Preview unavailable
- ) : null} + {loading ? ( +
Loading Anki note info...
+ ) : null} +
+ {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 ( +
+
+
+ Note {noteId}
- {info?.expression ? ( -
- {info.expression} -
- ) : null} - {info?.context ? ( -
{info.context}
- ) : null} - {info?.meaning ? ( -
{info.meaning}
- ) : null} {showUnavailableFallback ? ( -
- Preview unavailable from AnkiConnect. -
+
Preview unavailable
) : null} -
- ); - }) - ) : ( -
No linked note ids recorded.
- )} -
+ {info?.expression ? ( +
{info.expression}
+ ) : null} + {info?.context ? ( +
{info.context}
+ ) : null} + {info?.meaning ? ( +
{info.meaning}
+ ) : null} + {showUnavailableFallback ? ( +
+ Preview unavailable from AnkiConnect. +
+ ) : null} + +
+ ); + }) + ) : ( +
No linked note ids recorded.
+ )}
- )} +
); } diff --git a/stats/src/lib/formatters.test.ts b/stats/src/lib/formatters.test.ts index 1f60926a..b842e5df 100644 --- a/stats/src/lib/formatters.test.ts +++ b/stats/src/lib/formatters.test.ts @@ -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'); +}); diff --git a/stats/src/lib/formatters.ts b/stats/src/lib/formatters.ts index 0184d154..e6ae3072 100644 --- a/stats/src/lib/formatters.ts +++ b/stats/src/lib/formatters.ts @@ -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`; +} diff --git a/stats/src/lib/session-density.test.ts b/stats/src/lib/session-density.test.ts new file mode 100644 index 00000000..d9360570 --- /dev/null +++ b/stats/src/lib/session-density.test.ts @@ -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, + ); +}); diff --git a/stats/src/lib/session-density.ts b/stats/src/lib/session-density.ts new file mode 100644 index 00000000..f4c5dad9 --- /dev/null +++ b/stats/src/lib/session-density.ts @@ -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 }; +} diff --git a/stats/src/lib/session-detail.test.tsx b/stats/src/lib/session-detail.test.tsx deleted file mode 100644 index 4a5c6eef..00000000 --- a/stats/src/lib/session-detail.test.tsx +++ /dev/null @@ -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 }, - ]); -}); diff --git a/stats/src/lib/session-events.test.ts b/stats/src/lib/session-events.test.ts index a8a3fcbc..87adcefe 100644 --- a/stats/src/lib/session-events.test.ts +++ b/stats/src/lib/session-events.test.ts @@ -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); diff --git a/stats/src/lib/session-events.ts b/stats/src/lib/session-events.ts index 1ef473f9..e8ea9acb 100644 --- a/stats/src/lib/session-events.ts +++ b/stats/src/lib/session-events.ts @@ -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 | 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 | 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 }; }