mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Compare commits
6
Commits
5f635f3042
...
bc7e82fee6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc7e82fee6
|
||
|
|
c3e672efdb
|
||
|
|
269c0708cb | ||
|
|
f89f78b861 | ||
|
|
14ef7e823c | ||
|
|
a8bc585f75
|
No files matched your search
@@ -1,51 +0,0 @@
|
||||
// Opens the Jimaku subtitle search when a file starts without a Japanese subtitle track.
|
||||
// It waits a few seconds after the file loads so external subtitles mpv or SubMiner add count.
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
// Built-in action to run, e.g. `openTsukihime` or `openSubtitleSelection`.
|
||||
action: 'openJimaku',
|
||||
// Track `lang` codes that count as Japanese. Track titles with "Japanese" or 日本語 count too.
|
||||
languages: ['ja', 'jp', 'jpn'],
|
||||
delayMs: 3_000,
|
||||
// Also check http(s) streams; Jimaku can only search by name, which streams rarely have.
|
||||
streams: false,
|
||||
};
|
||||
|
||||
const JAPANESE_TITLE = /japanese|日本語/i;
|
||||
|
||||
function activate(ctx) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||
const languages = new Set(settings.languages.map((lang) => lang.toLowerCase()));
|
||||
let cancelCheck = null;
|
||||
let playbackId = null;
|
||||
|
||||
function hasJapaneseSubtitle(trackList) {
|
||||
return trackList.some(
|
||||
(track) =>
|
||||
track?.type === 'sub' &&
|
||||
(languages.has(String(track.lang ?? '').toLowerCase()) ||
|
||||
JAPANESE_TITLE.test(String(track.title ?? ''))),
|
||||
);
|
||||
}
|
||||
|
||||
async function check(media) {
|
||||
cancelCheck = null;
|
||||
const trackList = await ctx.mpv.getProperty('track-list');
|
||||
if (ctx.media.current()?.playbackId !== media.playbackId || !Array.isArray(trackList)) return;
|
||||
if (hasJapaneseSubtitle(trackList)) return;
|
||||
ctx.log.info(`No Japanese subtitles for ${media.path}; running ${settings.action}.`);
|
||||
await ctx.actions.run(settings.action);
|
||||
}
|
||||
|
||||
ctx.media.on('change', (media) => {
|
||||
if (media?.playbackId === playbackId) return;
|
||||
playbackId = media?.playbackId ?? null;
|
||||
cancelCheck?.();
|
||||
cancelCheck = null;
|
||||
if (!media) return;
|
||||
if (!settings.streams && /^[a-z]+:\/\//i.test(media.path)) return;
|
||||
cancelCheck = ctx.after(settings.delayMs, () => check(media));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { activate };
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Auto Jimaku",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -28,9 +28,6 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
"app-builder-lib": "26.16.1",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- With the Xbox **Button Style**, `Alt+C` names the face buttons `A`, `B`, `X` and `Y` instead of `A / Cross`, `B / Circle`, `X / Square` and `Y / Triangle`; PlayStation and Nintendo already showed their own names.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: overlay
|
||||
|
||||
- Listed the keyboard-only `C` key in Keyboard shortcuts, and pointed the configuration reference to Controller support for default button actions.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: dictionary
|
||||
|
||||
- Updated the bundled Hachidori to upstream 0.4.0 (d959a09), including every pitch accent in every popup entry, pitch drawn on the headword's reading, headword furigana split per kanji, and Yomitan-style definition tags.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Keyboard-only mode can move by character instead of by word: press `C`, or the new **Toggle Character Navigation** controller binding (unbound by default). The cursor box turns orange and covers one character, and a lookup searches from it, which reaches words the subtitle split cuts in two.
|
||||
@@ -5,6 +5,5 @@ area: plugins
|
||||
- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation.
|
||||
- Added an example `anki-sync` plugin that syncs Anki to AnkiWeb after you mine cards, once mining goes quiet or playback ends.
|
||||
- Added an example `episode-tags` plugin that tags mined cards with the show and episode, such as `show::Frieren::S01E07`.
|
||||
- Added an example `auto-jimaku` plugin that opens the Jimaku search when a file starts without a Japanese subtitle track.
|
||||
- Added a versioned plugin API with standalone authoring types, cached playback snapshots, lifecycle cancellation and cleanup, and awaitable mpv commands. Async activation is bounded and actions wait only for their own plugin. Shutdown skips pending loads, runs each deactivation once, and blocks calls from disposed plugins.
|
||||
- Mining events preserve original and kept note ids with the media and subtitle captured when mining starts, so delayed enrichment and merges into older notes keep the source episode. Stats-dashboard cards do not trigger plugin events. Plugin subtitle lookups and mining provenance account for subtitle delay and return timing on the subtitle file's timeline.
|
||||
@@ -0,0 +1,15 @@
|
||||
type: changed
|
||||
area: stats
|
||||
|
||||
- Rebuilt the Trends tab: summary totals with period comparison, a Learning section (new words per 1,000 seen, new words by frequency rank), a day and hour heatmap, a title timeline, and a single running-total chart with a metric switcher that climbs from zero. These replace the seven cumulative charts, the Sessions chart, the day-of-week and hour charts, the four overlapping per-title charts, and the top titles chart.
|
||||
- Added week grouping, custom date ranges, a title filter for the whole page, and click-through from charts to sessions and from titles to their library page. Range and grouping are remembered, and changing them no longer blanks the page or loses your scroll position.
|
||||
- Fixed the Library section including days outside the selected range, so its totals now match Activity.
|
||||
- Fixed episode counts: Trends now counts finished episodes, each once, instead of counting a video again for every day it was opened.
|
||||
- New word counts across Trends and Vocabulary now count dictionary words, so a word's conjugations count once. Totals are lower than before.
|
||||
- Rate charts no longer plot idle days as zero or spike on very short days: they show gaps when there is too little data, and daily views use a rolling 7 days.
|
||||
- Fixed clipped chart axis labels and month labels that read like dates, and the All range ignoring Show empty days.
|
||||
- Rebuilt the Vocabulary tab: one word table with Worth mining, All words, Across titles, and Newest views, search across every word, form, and reading, sortable columns, and inline Hide. It replaces three lists that could only see your 500 most seen word forms. Also added known words by frequency band, a vocabulary growth chart, and a kanji view covering every kanji, with a filter for kanji outside your known words.
|
||||
- Fixed word counts and readings: lists and the word detail panel now total every form of a word and show the dictionary form's reading, so 見る no longer shows みてる. The detail panel loads example lines straight away and lists a word's forms.
|
||||
- Fixed names slipping past Hide Names and the Trends new-word counts when tagged with more than one part of speech.
|
||||
- Stats now says when a word or line can't be mined because it came from a stream, in Search and the word panel, instead of showing Unavailable buttons. Lines you can mine are listed first. The word panel counts affected lines, and the word table marks words with only streamed lines as Stream only.
|
||||
- Fixed stats treating the day as one earlier in time zones ahead of UTC, which affected today's totals, the custom date picker, and first-seen dates.
|
||||
@@ -169,6 +169,9 @@
|
||||
"nextEntry": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"toggleCharacterNavigation": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"previousSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
|
||||
@@ -24,7 +24,7 @@ src/
|
||||
preload*.ts # preload bridges (overlay, settings, stats, sync, Jellyfin setup)
|
||||
main/ # main-process runtime modules and IPC/CLI wiring
|
||||
boot/ # pre-ready boot helpers
|
||||
assemblies/ # per-domain assemblies main.ts builds once (startup, overlay, playback, mining, stats, Yomitan, shortcuts, tray, first-run setup, YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals)
|
||||
assemblies/ # per-domain assemblies main.ts builds once (startup, overlay, playback, mining, stats, Yomitan, shortcuts, session actions, plugins, subtitle generation, tray, first-run setup, YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals)
|
||||
runtime/composers/ # larger runtime clusters assembled for main.ts
|
||||
runtime/domains/ # domain barrels (startup, overlay, mpv, ipc, shortcuts, anilist, jellyfin, mining)
|
||||
core/services/ # focused services: mpv client, overlay, tokenizer, mining, integrations, stats
|
||||
@@ -139,7 +139,7 @@ Runtime code uses dependency injection:
|
||||
2. Build its runtime dependencies in a `src/main/` module. Pass simple dependencies inline; extract an adapter only when it adds behavior or gets reused.
|
||||
3. Call the service from lifecycle or command wiring.
|
||||
|
||||
Domain handlers live in the barrels under `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. A domain whose runtimes share state, such as app startup, the overlay windows, mpv playback and subtitle state, card mining, stats, YouTube, Jellyfin, AniList, config settings, sync, or the character dictionary, is grouped into one assembly in `src/main/assemblies/` that owns that state and returns its runtimes. An assembly that answers renderer IPC or CLI commands also returns typed `ipc`/`cli` slices, and `main.ts` registers the handlers by combining those slices. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test.
|
||||
Domain handlers live in the barrels under `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. A domain whose runtimes share state, such as app startup, the overlay windows, mpv playback and subtitle state, card mining, stats, plugins, YouTube, Jellyfin, AniList, config settings, sync, or the character dictionary, is grouped into one assembly in `src/main/assemblies/` that owns that state and returns its runtimes. An assembly that answers renderer IPC or CLI commands also returns typed `ipc`/`cli` slices, and `main.ts` registers the handlers by combining those slices. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test.
|
||||
|
||||
Composers declare their inputs with `ComposerInputs<T>` and results with `ComposerOutputs<T>` from `src/main/runtime/composers/contracts.ts`. A missing dependency then fails at compile time.
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
|
||||
|
||||
### Controller support
|
||||
|
||||
Gamepad input for the overlay, through the browser Gamepad API. Most actions only work while keyboard-only mode is on; the toggle for that mode, the secondary subtitle hold (`controller.bindings.holdSecondarySub`), pause (`toggleMpvPause`) and the subtitle line actions (`previousSubtitle`, `nextSubtitle`, `replaySubtitle`, `playNextSubtitle`) work either way. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts).
|
||||
Gamepad input for the overlay, through the browser Gamepad API. Most actions only work while keyboard-only mode is on; the toggle for that mode, the secondary subtitle hold (`controller.bindings.holdSecondarySub`), pause (`toggleMpvPause`) and the subtitle line actions (`previousSubtitle`, `nextSubtitle`, `replaySubtitle`, `playNextSubtitle`) work either way. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are in [Controller support](/usage#controller-support).
|
||||
|
||||
| Key | Default | What it does |
|
||||
| ---------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -64,7 +64,17 @@ The same show can end up on several cards when release names disagree. To fix th
|
||||
|
||||
### Trends
|
||||
|
||||
Charts for watch time, cards, words, and sessions per day or month, running totals, efficiency (words per minute, cards per hour), and viewing patterns by weekday and hour. Each chart has its own date range and grouping.
|
||||
Pick a date range (or set **Custom** dates), group by day, week, or month, and the whole page follows. Your choices are remembered.
|
||||
|
||||
- **Summary**: totals for the range, compared with the period just before it.
|
||||
- **Activity**: watch time, cards, words, and finished episodes. Click a bar to open that period's sessions.
|
||||
- **Learning**: new words per 1,000 words seen, and how common those new words are. Conjugations of a word count once, and names are left out.
|
||||
- **Efficiency**: words per minute, cards per hour, and lookups per 100 words. Daily views use a rolling 7 days, and gaps mean too little data to rate.
|
||||
- **Running total**: one chart with a switcher for each metric, climbing from zero at the start of the range.
|
||||
- **Patterns**: a day and hour heatmap of when you watch.
|
||||
- **Titles**: a timeline of what you watched when, and a sortable summary table. Click a title, or use the filter box, to narrow the page to it. **↗** opens the title.
|
||||
|
||||
An episode counts as finished once, on the last day you watched it.
|
||||
|
||||

|
||||
|
||||
@@ -76,10 +86,15 @@ Session history. Expand a session for its pace, lines, known-word rate, lookups,
|
||||
|
||||
### Vocabulary
|
||||
|
||||
Unique words and kanji you have seen, new words per day, frequency rank tables with Hide Known and Hide Kana filters, and a kanji breakdown. Click a word to see every line it appeared in.
|
||||
Words are counted as dictionary words, so a word's conjugations count once. Names are left out unless you turn off **Hide names**.
|
||||
|
||||
- **Exclusions** hides words from every vocabulary view. You can restore them from the same dialog.
|
||||
- **Duplicates** cleans up lines repeated by karaoke openings and animated signs (see [Repeated lines](#repeated-lines)).
|
||||
- **Summary**: words seen, how many are in Anki, new words this week, and kanji seen.
|
||||
- **Known Words by Frequency**: how much of each frequency band you already know.
|
||||
- **Vocabulary Growth**: your running total of words seen.
|
||||
- **Words**: one table for every word you have seen. **Worth mining** lists common words you haven't mined, most seen first. **All words**, **Across titles**, and **Newest** are the other views. Sort by any column, or filter by known words, names, kana, and part of speech. Searching looks through everything, including conjugated forms and readings. Click a word for its forms, where it appeared, and example lines you can mine from. **Hide** excludes a word.
|
||||
- **Kanji**: every kanji you have seen, shaded by how often. Dashed kanji are not in any word you know.
|
||||
|
||||
**Exclusions** at the bottom of the page lists hidden words so you can restore them. **Clean up duplicates** removes lines repeated by karaoke openings and animated signs (see [Repeated lines](#repeated-lines)).
|
||||
|
||||

|
||||
|
||||
@@ -89,7 +104,7 @@ Searches the primary subtitle lines and titles in your history. **Search by head
|
||||
|
||||
## Mining from the dashboard
|
||||
|
||||
Search results and the Vocabulary word panel can create cards from past lines, as long as the source video file is still available:
|
||||
Search results and the Vocabulary word panel can create cards from past lines, as long as the source video file is still available. Lines from a stream, such as Jellyfin or YouTube, have no file to cut clips from, so they say so instead of offering mining. Lines you can mine are listed first. The word panel counts how many of a word's lines this affects, and the word table marks words with only streamed lines as **Stream only**.
|
||||
|
||||
- **Mine Word**: looks up the word with the selected dictionary backend (Yomitan or Hachidori), plus sentence, audio, and image. The history line you picked is the card's sentence, even while mpv plays another line. Hachidori uses its own Anki template, dictionary aliases, and frequency data.
|
||||
- **Mine Sentence**: a sentence card with `IsSentenceCard` set, for Lapis and Kiku note types.
|
||||
@@ -103,7 +118,7 @@ Dashboard cards also get the `SubMiner::Stats` tag. With the Anki proxy off, Sub
|
||||
|
||||
Karaoke openings and animated signs repeat the same text once per frame. SubMiner collapses these as it records, so one lyric is stored once. Stats recorded before that can hold hundreds of copies and skew Top Repeated Words.
|
||||
|
||||
To clean them, use **Duplicates** in the Vocabulary tab: pick a time window, **Scan** to preview, then **Clean Up**. Or from the terminal:
|
||||
To clean them, use **Clean up duplicates** in the Vocabulary tab: pick a time window, **Scan** to preview, then **Clean Up**. Or from the terminal:
|
||||
|
||||
```bash
|
||||
subminer stats cleanup --duplicate-lines --dry-run --lookback-days 30
|
||||
|
||||
@@ -83,17 +83,6 @@ Anki must be running and logged in to AnkiWeb. Cards mined from the stats dashbo
|
||||
|
||||
Templates can use `{show}`, `{season}`, `{episode}`, and `{ep}` (`S01E07`, or `E07` when there is no season). When a placeholder has no value, the tag stops before that `::` level, so a movie gets `show::<title>`. The parsing is a guess from the name: a title ending in a number, like `Mob Psycho 100`, reads that number as the episode.
|
||||
|
||||
## Example: open Jimaku when a file has no Japanese subtitles
|
||||
|
||||
[`assets/plugins/auto-jimaku`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/auto-jimaku) opens the Jimaku search a few seconds after a file starts if none of its subtitle tracks is Japanese. A track counts as Japanese when its language code is in `languages`, or its title contains "Japanese" or 日本語.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
| ----------- | --------------------- | --------------------------------------------------------------- |
|
||||
| `action` | `"openJimaku"` | Built-in action to run, e.g. `openTsukihime` |
|
||||
| `languages` | `["ja", "jp", "jpn"]` | Track language codes that count as Japanese |
|
||||
| `delayMs` | `3000` | Wait this long after the file starts, so external subs load |
|
||||
| `streams` | `false` | Also check `http(s)` streams, which Jimaku usually can't search |
|
||||
|
||||
## Write a plugin
|
||||
|
||||
A plugin folder needs a `plugin.json`:
|
||||
|
||||
@@ -169,6 +169,9 @@
|
||||
"nextEntry": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"toggleCharacterNavigation": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"previousSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
|
||||
@@ -62,6 +62,7 @@ Built into the overlay, not configurable:
|
||||
| Input | Action |
|
||||
| ------------------------- | -------------------------------------------------- |
|
||||
| `V` | Cycle primary subtitle bar: hidden, visible, hover |
|
||||
| `C` (keyboard-only mode) | Move by word or by character |
|
||||
| Right-click | Pause or resume (outside the subtitle area) |
|
||||
| Right-click + drag | Move the subtitles |
|
||||
| Drop files on the overlay | Replace the mpv playlist |
|
||||
|
||||
@@ -184,6 +184,7 @@ Press `Alt+C` to choose a controller and remap buttons. Click an action's **Lear
|
||||
| Unbound by default | Previous / next subtitle line |
|
||||
| Unbound by default | Replay the current line, then pause |
|
||||
| Unbound by default | Play the next line, then pause |
|
||||
| Unbound by default | Move by word or by character |
|
||||
|
||||
The secondary subtitle hold works with or without keyboard-only mode. Assign it with **Learn** on the **Show Secondary Subtitle (Hold)** row in `Alt+C`. While held, the secondary subtitle shows as if its mode were `visible`; releasing the button returns it to your chosen mode.
|
||||
|
||||
@@ -191,6 +192,8 @@ The **Subtitles** rows in `Alt+C` do the same as the keyboard shortcuts: **Previ
|
||||
|
||||
**Previous Entry** and **Next Entry** move one word entry at a time in the open popup, and Mine Card then mines the selected entry. They send Yomitan's default `Alt+Up` / `Alt+Down` entry shortcuts, so they stop working if you rebind those in Yomitan's keyboard settings. If you assign them to the D-pad, click **Clear** on the **Popup Scroll (D-pad)** row too, or the D-pad will also scroll the popup.
|
||||
|
||||
When the subtitle is split in the wrong place, a word can start in the middle of a box (in もっとハ|キ|ハキしなくちゃ, ハキハキ starts at the last character of the first box) and moving by word never reaches it. In keyboard-only mode, press `C` on the keyboard, or the **Toggle Character Navigation** button (unbound by default, under **Navigation** in `Alt+C`), to move one character at a time: the box turns orange and covers one character, and a lookup searches from that character, so Yomitan finds the longest word starting there. Press it again to go back to moving by word. It stays on until you switch it back, even after leaving keyboard-only mode.
|
||||
|
||||
The media timing review and duplicate card (field grouping) dialogs have their own controller layouts, which you can change in the **Review media timing** and **Duplicate card (field grouping)** groups of `Alt+C`; see [Review media timing](/anki-integration#with-a-controller) and [Field grouping](/anki-integration#field-grouping-with-a-controller).
|
||||
|
||||
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`, and the default pause button on `Start` instead of `L3`. The default right-stick popup jump does nothing on these controllers; click **Learn** on **Popup Jump (Stick)** and move the right stick up or down to set it. Remap anything else with `Alt+C`, which names buttons according to the layout your controller reports. All options are in [Configuration](/configuration#controller-support).
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Architecture Map
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-11-01
|
||||
Owner: Kyle Yasuda
|
||||
Read when: runtime ownership, composition boundaries, or layering questions
|
||||
|
||||
@@ -30,14 +30,14 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
|
||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
|
||||
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; the overlay assembly supplies window creation and subtitle refresh hooks.
|
||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same media. For Jellyfin playback it reads the item's original-file stream (`toJellyfinOriginalStreamUrl`) into one reused output folder, where each run best-effort deletes earlier files and writes a uniquely named SRT; transcoded playback uses Jellyfin's audio index and only external references, since mpv's track indexes describe the transcode. Subprocess errors redact URL tokens; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
||||
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
|
||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them first in the `will-quit` cleanup, before any teardown. Plugins run in the main process with no sandbox. `src/plugin-api.ts` is the standalone public v1 type contract; internal mpv event and session-action types derive from it. Manifests may declare `apiVersion`, which is checked before loading code. Async activation is bounded and actions wait only for their own plugin. The host owns cached media snapshots and playback ids, including repeated loads of the same path. `ctx.signal` aborts as unloading starts; `ctx.onDispose` manages outside resources and late registrations are disposed immediately. Awaitable `ctx.mpv.request` uses the existing request/response transport. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `ctx.anki` carries the `card-mined` event with unique kept `noteIds` and per-original-note records. Sentence-card creation snapshots media and subtitle context before asynchronous work; the proxy snapshots before forwarding an upstream request. `AnkiIntegration` keeps provenance under original ids through merges and queued YouTube jobs; polling uses null provenance. The event is fed by `AnkiIntegration.setCardsReadyCallback`: polling, the AnkiConnect proxy, and sentence-card creation report a note once its enrichment finishes (or when they see it, if auto-update is off); `AnkiIntegration` holds a sentence card's report until field grouping finishes, and the pending YouTube media queue reports notes whose media it is still waiting on (directly or through the note field grouping merged them into), which is later than `recordCardsMined` (stats counts at add time). Stats-dashboard mining does not emit it. `ctx.anki.invoke` sends one action to the effective `ankiConnect.url` with no retries. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
||||
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `src/main/assemblies/plugins.ts` builds the host. The playback assembly attaches each new mpv client so plugin listeners survive client replacement, the startup assembly starts plugins from the app-ready path after logging is configured, and the `will-quit` cleanup unloads them first, before any teardown. Plugins run in the main process with no sandbox. `src/plugin-api.ts` is the standalone public v1 type contract; internal mpv event and session-action types derive from it. Manifests may declare `apiVersion`, which is checked before loading code. Async activation is bounded and actions wait only for their own plugin. The host owns cached media snapshots and playback ids, including repeated loads of the same path. `ctx.signal` aborts as unloading starts; `ctx.onDispose` manages outside resources and late registrations are disposed immediately. Awaitable `ctx.mpv.request` uses the existing request/response transport. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `ctx.anki` carries the `card-mined` event with unique kept `noteIds` and per-original-note records. Sentence-card creation snapshots media and subtitle context before asynchronous work; the proxy snapshots before forwarding an upstream request. `AnkiIntegration` keeps provenance under original ids through merges and queued YouTube jobs; polling uses null provenance. The event is fed by `AnkiIntegration.setCardsReadyCallback`: polling, the AnkiConnect proxy, and sentence-card creation report a note once its enrichment finishes (or when they see it, if auto-update is off); `AnkiIntegration` holds a sentence card's report until field grouping finishes, and the pending YouTube media queue reports notes whose media it is still waiting on (directly or through the note field grouping merged them into), which is later than `recordCardsMined` (stats counts at add time). Stats-dashboard mining does not emit it. `ctx.anki.invoke` sends one action to the effective `ankiConnect.url` with no retries. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Domain Ownership
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-10-31
|
||||
Last verified: 2026-11-01
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
@@ -23,19 +23,27 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- App startup: app-ready sequence, headless known-word refresh, overlay startup prerequisites and
|
||||
overlay runtime bootstrap: `src/main/assemblies/startup.ts`
|
||||
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
||||
Overlay windows, visibility, Linux fullscreen handling, and the overlay-window IPC handlers
|
||||
(modal open/close, pointer interaction, content measurement): `src/main/assemblies/overlay.ts`
|
||||
Overlay windows, visibility, geometry and pointer interaction, Linux fullscreen handling, mpv
|
||||
subtitle suppression while the overlay shows, and the overlay-window IPC handlers (modal
|
||||
open/close, pointer interaction, content measurement): `src/main/assemblies/overlay.ts`
|
||||
Overlay-hosted modal openers: `src/main/assemblies/overlay-modals.ts`
|
||||
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
||||
mpv client creation, mpv event wiring, AniSkip, subtitle prefetch, and subtitle sidebar state: `src/main/assemblies/playback.ts`
|
||||
mpv client creation, mpv event wiring, AniSkip, the autoplay-ready gate that releases mpv's
|
||||
startup pause, managed local subtitle selection, subtitle prefetch, and subtitle sidebar state:
|
||||
`src/main/assemblies/playback.ts`
|
||||
- Keyboard input outside the renderer (overlay, global and numeric shortcuts, session bindings):
|
||||
`src/main/assemblies/shortcuts.ts`
|
||||
- Session actions (the dispatcher behind shortcuts, controller buttons, CLI commands and plugin
|
||||
actions), the secondary-subtitle mode cycle, and the overlay renderer's mpv commands:
|
||||
`src/main/assemblies/session-actions.ts`
|
||||
- System tray: `src/main/assemblies/tray.ts`
|
||||
- Yomitan/Hachidori extension loading, parser runtime deps, and dictionary settings windows:
|
||||
`src/main/assemblies/yomitan.ts`
|
||||
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
|
||||
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
||||
JLPT and frequency annotation lookups: `src/main/jlpt-runtime.ts`, `src/main/frequency-dictionary-runtime.ts`, assembled by `src/main/assemblies/dictionaries.ts`
|
||||
Annotation toggles (a cycled runtime option over its config value): `src/main/runtime/annotation-options.ts`
|
||||
Whisper subtitle generation runtime, modal and IPC: `src/main/assemblies/subtitle-generation.ts`
|
||||
Subtitle text filters (`subtitleFilter`): `src/shared/subtitle-text-filter.ts`. Primary text is filtered at its two entry points, live `sub-text` in `mpv-protocol.ts` and parsed cues in `playback.ts`, so every consumer sees the same text and live/cue matching still works. A config change re-reads `sub-text` and re-parses the active track (the prefetch controller's parse key).
|
||||
- First-run setup: `src/main/runtime/first-run-setup-*.ts`, assembled by `src/main/assemblies/first-run-setup.ts`
|
||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||
@@ -55,7 +63,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
assembled by `src/main/assemblies/jellyfin.ts`
|
||||
- YouTube playback, subtitles, media cache, and in-app browser: `src/core/services/youtube/`,
|
||||
`src/main/runtime/youtube-*`, assembled by `src/main/assemblies/youtube.ts`
|
||||
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; examples in `assets/plugins/`
|
||||
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), assembled by `src/main/assemblies/plugins.ts`; `src/renderer/modals/plugin-panel*.ts`; examples in `assets/plugins/`
|
||||
- Window trackers: `src/window-trackers/`
|
||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||
in `src/core/services/stats-server/`
|
||||
|
||||
@@ -12,21 +12,42 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
|
||||
- collapse multiple network round-trips into one request
|
||||
- keep heavy chart shaping close to tracker/query logic
|
||||
|
||||
## Date Ranges And Buckets
|
||||
|
||||
- A range resolves to calendar days (`resolveTrendWindow`). Rollups are selected by day span, never by "last N rows", so sparse activity cannot pull older days into a range. Custom ranges are `{ startDay, endDay }` from `range=custom&fromDay&toDay`.
|
||||
- `all` spans the recorded data, not today.
|
||||
- Day and week buckets read daily rollups. Month buckets read monthly rollups for months wholly in range, because the `minimal` and `deep-history` retention presets prune daily rollups first, and daily rollups for a partly covered month. Weeks start on Monday.
|
||||
- `buckets` is the one axis for `activity`, `progress`, and `ratios`, index for index. Charts use it to map a clicked bar back to its days.
|
||||
- Each `progress` point is the total at the end of its period, and the chart adds the zero starting point.
|
||||
|
||||
## Data Sources
|
||||
|
||||
- rollup-backed:
|
||||
- activity charts
|
||||
- cumulative watch/cards/tokens/sessions trends
|
||||
- per-anime watch/cards/tokens/episodes series (`animeDaily`: sparse per-title values for active days only; `TrendsTab` builds the running totals with `buildAnimeCumulative`, because a dense title x day payload grew to several MB)
|
||||
- activity charts and running totals
|
||||
- `titleTimeline`: sparse per-title minutes for active days only, because a dense title x day payload grew to several MB
|
||||
- session-metric-backed:
|
||||
- lookup trends
|
||||
- lookup rate trends
|
||||
- watch-time by day-of-week/hour
|
||||
- lookups and lookup rate
|
||||
- `watchTimeByDayHour`, attributed to the hour each session started
|
||||
- rollup word counts and session metrics share one session query per request (`getTrendSessionRows`), so the per-session display-word count runs once instead of once per rollup query
|
||||
- `imm_videos.watched`-backed:
|
||||
- finished episodes count each watched video once, on the last day it has a rollup, since the flag has no timestamp
|
||||
- vocabulary-backed:
|
||||
- new-words trend reads permanent daily lexical rollups
|
||||
- rollup rows count only vocabulary-visible tokens and normalize mixed legacy timestamp units
|
||||
- a persisted rollup version invalidates stale materializations and triggers an atomic background rebuild
|
||||
- `getNewWordFirstSeenRows` folds `imm_words` rows into dictionary words first, because the table is unique on (headword, word, reading) and stores each conjugation separately
|
||||
- the aggregate scans every word, so it is cached per connection until `total_changes()` or `PRAGMA data_version` moves
|
||||
- Trends new-word metrics exclude names; the Vocabulary tab offers both
|
||||
- `imm_lexical_daily_rollups` is no longer read for new-word counts (it counts surface forms). Its version state still gates the `vocabulary_visible` backfill.
|
||||
|
||||
## Vocabulary Word List
|
||||
|
||||
`/api/stats/vocabulary/words` (`query-vocabulary-words.ts`) folds `imm_words` into dictionary words on the server and filters, sorts, and pages there, because a client-side list can only hold a slice of the table. The fold is cached per connection until a write, the same way as the new-word aggregate. `titles` is computed for the returned rows; sorting or filtering by it needs every word's count (about 900ms), so that map tolerates five minutes of staleness. Names are detected with `isProperNounPos2`, since merged tokens store tags like `固有名詞|一般`.
|
||||
|
||||
## Rates
|
||||
|
||||
Rates are `null` when the underlying data is under 30 active minutes or 2,000 words, and charts draw that as a gap. Daily views rate a trailing 7 calendar days, volume-weighted, which is why the query loads days before the range. Week and month views rate each bucket.
|
||||
|
||||
## Title Filter
|
||||
|
||||
`title` narrows totals, charts, and patterns. `librarySummary` and `titleTimeline` stay complete so the page can switch titles. New words have no per-title record, so `learning` and `summary.current.newWords` are `null` while filtered.
|
||||
|
||||
## Metric Semantics
|
||||
|
||||
@@ -40,6 +61,6 @@ Stats data crosses the process boundary over HTTP only. Shared JSON models live
|
||||
|
||||
The stats preload bridge remains limited to native window behavior such as confirmation-dialog layering. Do not add stats data request channels back to Electron IPC.
|
||||
|
||||
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine (including the per-anime running totals, memoized per payload), but rebuilding the main trend datasets from raw sessions should stay out of the render path.
|
||||
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine (including the title timeline layout), but rebuilding the main trend datasets from raw sessions should stay out of the render path.
|
||||
|
||||
For session detail timelines, omitting `limit` now means "return the full retained session telemetry/history". Explicit `limit` remains available for bounded callers, but the default stats UI path should not trim long sessions to the newest 200 samples.
|
||||
@@ -3,7 +3,7 @@
|
||||
# Subtitle Overlay Priming
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-08-19
|
||||
Last verified: 2026-11-01
|
||||
Owner: Kyle Yasuda
|
||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||
|
||||
@@ -19,13 +19,14 @@ subtitles do not draw.
|
||||
|
||||
## Entry Points
|
||||
|
||||
- `src/main.ts` calls `primeCurrentSubtitleForVisibleOverlay()` when manual visible-overlay show
|
||||
paths run.
|
||||
- `src/main.ts` calls `restoreVisibleOverlayWindowShapeForShow()` before visible-overlay show
|
||||
actions on Linux, and `resetVisibleOverlayInputState()` restores a full shape instead of applying
|
||||
an empty shape.
|
||||
- `src/main.ts` also restores the Linux/X11 shape after applying mpv overlay bounds, so a newly
|
||||
created 800x600 hidden Electron window cannot keep clipping after it is resized to mpv geometry.
|
||||
- The overlay assembly (`src/main/assemblies/overlay.ts`) calls
|
||||
`primeCurrentSubtitleForVisibleOverlay()` when manual visible-overlay show paths run.
|
||||
- It calls `restoreVisibleOverlayWindowShapeForShow()` before visible-overlay show actions on
|
||||
Linux, and `resetVisibleOverlayInputState()` restores a full shape instead of applying an empty
|
||||
shape (both in `src/main/runtime/visible-overlay-interaction-runtime.ts`).
|
||||
- `src/main/runtime/overlay-geometry-runtime.ts` also restores the Linux/X11 shape after applying
|
||||
mpv overlay bounds, so a newly created 800x600 hidden Electron window cannot keep clipping after
|
||||
it is resized to mpv geometry.
|
||||
- `primeCurrentSubtitleForVisibleOverlay()` delegates to
|
||||
`primeVisibleOverlaySubtitleFromMpv()` in `src/main/runtime/current-subtitle-snapshot.ts`.
|
||||
- `restoreVisibleOverlayWindowShapeForShow()` delegates to `restoreLinuxOverlayWindowShape()` in
|
||||
@@ -54,7 +55,7 @@ Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchServ
|
||||
the matching `subtitleProcessingController` method, giving the visible overlay priority over
|
||||
background prefetch work. Prefetch is not re-centered here: restarting the run per line
|
||||
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
|
||||
seeks restart it (see `onTimePosUpdate` in `src/main.ts`).
|
||||
seeks restart it (see the time-pos handling in `src/main/assemblies/playback.ts`).
|
||||
|
||||
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
|
||||
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
|
||||
|
||||
@@ -44,10 +44,5 @@ good test looks like here.
|
||||
|
||||
## Known Debt
|
||||
|
||||
- `src/main/main-wiring.test.ts` and `src/renderer/renderer-init-order.test.ts` still
|
||||
regex `main.ts` / `renderer.ts` to guard startup ordering. Replace each test with a
|
||||
behavioral one as the logic moves out of `main.ts` closures.
|
||||
- `src/main/runtime/overlay-geometry-runtime.ts` and
|
||||
`src/main/runtime/visible-overlay-interaction-runtime.ts` value-import Electron, so they
|
||||
cannot load under `bun test` and stay outside `src/main/assemblies/overlay.ts`. Inject
|
||||
`screen`/`dialog` to make them testable and movable.
|
||||
- `src/renderer/renderer-init-order.test.ts` still regexes `renderer.ts` to guard startup
|
||||
ordering. Replace it with a behavioral test as that logic moves out of `renderer.ts`.
|
||||
@@ -287,8 +287,5 @@
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/package.json b/package.json
|
||||
index 02d2d8809a98c5d32b889fceba11458660f1fa6a..72cd7004d92809689daaeec1cd8a274e1cabdff5 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -77,7 +77,7 @@
|
||||
"discord-api-types": "^0.38.40",
|
||||
"magic-bytes.js": "^1.13.0",
|
||||
"tslib": "^2.6.3",
|
||||
- "undici": "6.24.1",
|
||||
+ "undici": "6.27.0",
|
||||
"@discordjs/collection": "^2.1.1",
|
||||
"@discordjs/util": "^1.2.0"
|
||||
},
|
||||
@@ -85,6 +85,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
|
||||
@@ -82,6 +82,12 @@ export function buildCoreConfigOptionRegistry(
|
||||
description:
|
||||
'Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry.',
|
||||
},
|
||||
{
|
||||
id: 'toggleCharacterNavigation',
|
||||
defaultValue: defaultConfig.controller.bindings.toggleCharacterNavigation,
|
||||
description:
|
||||
'Controller binding descriptor that switches keyboard-only mode between moving by word and moving by character, like C. A lookup on a character searches from that character, which reaches words the subtitle split cuts in two.',
|
||||
},
|
||||
{
|
||||
id: 'previousSubtitle',
|
||||
defaultValue: defaultConfig.controller.bindings.previousSubtitle,
|
||||
|
||||
@@ -101,6 +101,7 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
||||
'holdSecondarySub',
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'toggleCharacterNavigation',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
|
||||
@@ -155,15 +155,20 @@ const WATCH_TIME_PER_ANIME = [
|
||||
];
|
||||
|
||||
const TRENDS_DASHBOARD = {
|
||||
window: { startDay: 20_000, endDay: 20_000 },
|
||||
buckets: [{ label: 'Mar 1', startDay: 20_000, endDay: 20_000 }],
|
||||
summary: {
|
||||
current: { watchTimeMin: 25, words: 300, newWords: 12, cards: 5, episodes: 2, activeDays: 1 },
|
||||
previous: null,
|
||||
},
|
||||
activity: {
|
||||
watchTime: [{ label: 'Mar 1', value: 25 }],
|
||||
cards: [{ label: 'Mar 1', value: 5 }],
|
||||
words: [{ label: 'Mar 1', value: 300 }],
|
||||
sessions: [{ label: 'Mar 1', value: 3 }],
|
||||
episodes: [{ label: 'Mar 1', value: 2 }],
|
||||
},
|
||||
progress: {
|
||||
watchTime: [{ label: 'Mar 1', value: 25 }],
|
||||
sessions: [{ label: 'Mar 1', value: 3 }],
|
||||
words: [{ label: 'Mar 1', value: 300 }],
|
||||
newWords: [{ label: 'Mar 1', value: 12 }],
|
||||
cards: [{ label: 'Mar 1', value: 5 }],
|
||||
@@ -175,11 +180,19 @@ const TRENDS_DASHBOARD = {
|
||||
cardsPerHour: [{ label: 'Mar 1', value: 12 }],
|
||||
readingSpeed: [{ label: 'Mar 1', value: 180 }],
|
||||
},
|
||||
learning: {
|
||||
newWordsPerThousand: [{ label: 'Mar 1', value: null }],
|
||||
frequencyBands: [],
|
||||
},
|
||||
watchTimeByDayHour: [],
|
||||
librarySummary: [
|
||||
{
|
||||
title: 'Little Witch Academia',
|
||||
animeId: 1,
|
||||
videoId: 1,
|
||||
watchTimeMin: 25,
|
||||
videos: 1,
|
||||
episodesFinished: 1,
|
||||
sessions: 1,
|
||||
cards: 5,
|
||||
words: 300,
|
||||
@@ -189,16 +202,7 @@ const TRENDS_DASHBOARD = {
|
||||
lastWatched: 20_000,
|
||||
},
|
||||
],
|
||||
animeDaily: {
|
||||
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
||||
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
||||
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
|
||||
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
|
||||
},
|
||||
patterns: {
|
||||
watchTimeByDayOfWeek: [{ label: 'Sun', value: 25 }],
|
||||
watchTimeByHour: [{ label: '12:00', value: 25 }],
|
||||
},
|
||||
titleTimeline: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
||||
};
|
||||
|
||||
const ANIME_EPISODES = [
|
||||
@@ -227,6 +231,12 @@ const WORD_DETAIL = {
|
||||
pos2: '一般',
|
||||
pos3: null,
|
||||
frequency: 42,
|
||||
frequencyRank: 100,
|
||||
dictionaryReading: 'ねこ',
|
||||
formCount: 1,
|
||||
forms: [{ wordId: 1, word: '猫', reading: 'ねこ', seen: 42 }],
|
||||
lineCount: 40,
|
||||
streamLineCount: 0,
|
||||
firstSeen: Date.now() - 100_000,
|
||||
lastSeen: Date.now(),
|
||||
};
|
||||
@@ -313,11 +323,11 @@ function createMockTracker(
|
||||
}),
|
||||
getVocabularyChartData: async () => ({
|
||||
ready: true,
|
||||
topWords: [{ wordId: 1, headword: 'する', frequency: 50 }],
|
||||
topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }],
|
||||
newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }],
|
||||
newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }],
|
||||
}),
|
||||
getDictionaryWords: async () => ({ rows: [], total: 0, maxSeen: 0 }),
|
||||
getVocabularyCoverage: async () => [],
|
||||
getStatsExcludedWords: async () => [],
|
||||
replaceStatsExcludedWords: async () => {},
|
||||
getKanjiStats: async () => KANJI_STATS,
|
||||
@@ -674,9 +684,16 @@ const CLAMPED_QUERIES: Array<{
|
||||
];
|
||||
|
||||
const TRENDS_DASHBOARD_QUERIES = [
|
||||
{ query: 'range=90d&groupBy=month', expectedArgs: ['90d', 'month', true] },
|
||||
{ query: 'range=weird&groupBy=year', expectedArgs: ['30d', 'day', true] },
|
||||
{ query: 'range=30d&groupBy=day&fillEmpty=false', expectedArgs: ['30d', 'day', false] },
|
||||
{ query: 'range=90d&groupBy=month', expectedArgs: ['90d', 'month', true, null] },
|
||||
{ query: 'range=90d&groupBy=week', expectedArgs: ['90d', 'week', true, null] },
|
||||
{ query: 'range=weird&groupBy=year', expectedArgs: ['30d', 'day', true, null] },
|
||||
{ query: 'range=30d&groupBy=day&fillEmpty=false', expectedArgs: ['30d', 'day', false, null] },
|
||||
{
|
||||
query: 'range=custom&fromDay=20000&toDay=20010',
|
||||
expectedArgs: [{ startDay: 20000, endDay: 20010 }, 'day', true, null],
|
||||
},
|
||||
{ query: 'range=custom&fromDay=20010&toDay=20000', expectedArgs: ['30d', 'day', true, null] },
|
||||
{ query: 'range=30d&title=Bocchi%20%26%20Co', expectedArgs: ['30d', 'day', true, 'Bocchi & Co'] },
|
||||
];
|
||||
|
||||
const MALFORMED_IDS = [
|
||||
@@ -991,7 +1008,7 @@ describe('stats server API routes', () => {
|
||||
});
|
||||
|
||||
for (const { query, expectedArgs } of TRENDS_DASHBOARD_QUERIES) {
|
||||
it(`GET /api/stats/trends/dashboard?${query} requests ${expectedArgs.join('/')}`, async () => {
|
||||
it(`GET /api/stats/trends/dashboard?${query} requests ${JSON.stringify(expectedArgs)}`, async () => {
|
||||
let seenArgs: unknown[] = [];
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
@@ -1008,6 +1025,69 @@ describe('stats server API routes', () => {
|
||||
});
|
||||
}
|
||||
|
||||
it('GET /api/stats/vocabulary/words passes a parsed query and ignores unknown values', async () => {
|
||||
const seenQueries: unknown[] = [];
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getDictionaryWords: async (_knownWords: unknown, query: unknown) => {
|
||||
seenQueries.push(query);
|
||||
return { rows: [], total: 0, maxSeen: 0 };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await app.request(
|
||||
'/api/stats/vocabulary/words?search=%20%E7%8C%AB%20&sort=rank&order=asc&hideKnown=true&hideNames=true&pos=verb&maxRank=10000&minTitles=2&limit=25&offset=50',
|
||||
);
|
||||
await app.request('/api/stats/vocabulary/words?sort=bogus&pos=bogus&maxRank=-1&limit=99999');
|
||||
|
||||
assert.deepEqual(seenQueries, [
|
||||
{
|
||||
search: '猫',
|
||||
sort: 'rank',
|
||||
descending: false,
|
||||
hideKnown: true,
|
||||
hideNames: true,
|
||||
hideKana: false,
|
||||
partOfSpeech: 'verb',
|
||||
maxRank: 10000,
|
||||
minTitles: 2,
|
||||
limit: 25,
|
||||
offset: 50,
|
||||
},
|
||||
{
|
||||
search: undefined,
|
||||
sort: 'seen',
|
||||
descending: true,
|
||||
hideKnown: false,
|
||||
hideNames: false,
|
||||
hideKana: false,
|
||||
partOfSpeech: undefined,
|
||||
maxRank: undefined,
|
||||
minTitles: undefined,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions narrows to a valid day range and ignores a reversed one', async () => {
|
||||
const seenRanges: unknown[] = [];
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getSessionSummaries: async (_limit?: number, dayRange?: unknown) => {
|
||||
seenRanges.push(dayRange);
|
||||
return SESSION_SUMMARIES;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await app.request('/api/stats/sessions?limit=500&fromDay=20000&toDay=20006');
|
||||
await app.request('/api/stats/sessions?fromDay=20006&toDay=20000');
|
||||
|
||||
assert.deepEqual(seenRanges, [{ startDay: 20000, endDay: 20006 }, undefined]);
|
||||
});
|
||||
|
||||
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
|
||||
let seenArgs: unknown[] = [];
|
||||
const app = createStatsApp(
|
||||
@@ -1026,7 +1106,7 @@ describe('stats server API routes', () => {
|
||||
const body = await res.json();
|
||||
assert.ok(Array.isArray(body));
|
||||
assert.equal(body[0].animeTitle, 'Little Witch Academia');
|
||||
assert.deepEqual(seenArgs, ['猫', '猫', 'ねこ', 500, 25]);
|
||||
assert.deepEqual(seenArgs, ['猫', '猫', 'ねこ', 500, 25, false]);
|
||||
});
|
||||
|
||||
it('GET /api/stats/kanji/occurrences returns recent occurrence rows for a kanji', async () => {
|
||||
@@ -1056,6 +1136,33 @@ describe('stats server API routes', () => {
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('GET /api/stats/vocabulary/occurrences finds all forms of legacy words missing text', async () => {
|
||||
const seenHeadwords: unknown[] = [];
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getWordOccurrences: async (...args: unknown[]) => {
|
||||
seenHeadwords.push(args[0]);
|
||||
return OCCURRENCES;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const request = (params: string) =>
|
||||
app.request(`/api/stats/vocabulary/occurrences?allForms=true&${params}`);
|
||||
|
||||
assert.equal((await request('headword=&word=%E7%8C%AB&reading=')).status, 200);
|
||||
assert.equal((await request('headword=%E7%8A%AC&word=&reading=')).status, 200);
|
||||
assert.equal((await request('headword=&word=&reading=%E3%81%AD%E3%81%93')).status, 200);
|
||||
assert.deepEqual(seenHeadwords, ['猫', '犬', 'ねこ']);
|
||||
|
||||
assert.equal((await request('headword=%20&word=&reading=')).status, 400);
|
||||
});
|
||||
|
||||
it('GET /api/stats/vocabulary/occurrences needs a headword and form for an exact lookup', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const res = await app.request('/api/stats/vocabulary/occurrences?headword=&word=%E7%8C%AB');
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('PUT /api/stats/excluded-words replaces tracker exclusion rows', async () => {
|
||||
let seenWords: unknown = null;
|
||||
const app = createStatsApp(
|
||||
|
||||
@@ -51,6 +51,16 @@ import {
|
||||
getSessionWordsByLine,
|
||||
} from './immersion-tracker/query-sessions';
|
||||
import { getTrendsDashboard } from './immersion-tracker/query-trends';
|
||||
import {
|
||||
getDictionaryWordPage,
|
||||
getVocabularyCoverage,
|
||||
} from './immersion-tracker/query-vocabulary-words';
|
||||
import type {
|
||||
StatsDayRange,
|
||||
StatsDictionaryWordQuery,
|
||||
StatsTrendGroupBy,
|
||||
StatsTrendRange,
|
||||
} from '../../types/stats-http-contract';
|
||||
import {
|
||||
getKanjiAnimeAppearances,
|
||||
getKanjiDetail,
|
||||
@@ -618,8 +628,11 @@ export class ImmersionTrackerService {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
async getSessionSummaries(limit = 50): Promise<SessionSummaryQueryRow[]> {
|
||||
return getSessionSummaries(this.db, limit);
|
||||
async getSessionSummaries(
|
||||
limit = 50,
|
||||
dayRange?: StatsDayRange,
|
||||
): Promise<SessionSummaryQueryRow[]> {
|
||||
return getSessionSummaries(this.db, limit, dayRange);
|
||||
}
|
||||
|
||||
async getSessionTimeline(sessionId: number, limit?: number): Promise<SessionTimelineRow[]> {
|
||||
@@ -673,11 +686,12 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
async getTrendsDashboard(
|
||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
||||
groupBy: 'day' | 'month' = 'day',
|
||||
range: StatsTrendRange = '30d',
|
||||
groupBy: StatsTrendGroupBy = 'day',
|
||||
fillEmptyBuckets = true,
|
||||
title: string | null = null,
|
||||
) {
|
||||
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
|
||||
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets, title);
|
||||
}
|
||||
|
||||
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
|
||||
@@ -703,6 +717,17 @@ export class ImmersionTrackerService {
|
||||
return getVocabularyChartData(this.db);
|
||||
}
|
||||
|
||||
async getDictionaryWords(
|
||||
knownWords: ReadonlySet<string> | null,
|
||||
query: StatsDictionaryWordQuery,
|
||||
) {
|
||||
return getDictionaryWordPage(this.db, knownWords, query);
|
||||
}
|
||||
|
||||
async getVocabularyCoverage(knownWords: ReadonlySet<string>) {
|
||||
return getVocabularyCoverage(this.db, knownWords);
|
||||
}
|
||||
|
||||
async getStatsExcludedWords(): Promise<StatsExcludedWordRow[]> {
|
||||
return getStatsExcludedWords(this.db);
|
||||
}
|
||||
@@ -754,8 +779,9 @@ export class ImmersionTrackerService {
|
||||
reading: string,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
allForms = false,
|
||||
): Promise<WordOccurrenceRow[]> {
|
||||
return getWordOccurrences(this.db, headword, word, reading, limit, offset);
|
||||
return getWordOccurrences(this.db, headword, word, reading, limit, offset, allForms);
|
||||
}
|
||||
|
||||
async getKanjiOccurrences(kanji: string, limit = 100, offset = 0): Promise<KanjiOccurrenceRow[]> {
|
||||
|
||||
@@ -704,7 +704,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
total_tokens_seen, total_cards
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(Math.floor(dayOneStart / 86_400_000), videoId, 1, 30, 10, 120, 2);
|
||||
).run(getLocalEpochDay(db, dayOneStart), videoId, 1, 30, 10, 120, 2);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
@@ -713,7 +713,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
total_tokens_seen, total_cards
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(Math.floor(dayTwoStart / 86_400_000), videoId, 1, 45, 10, 140, 3);
|
||||
).run(getLocalEpochDay(db, dayTwoStart), videoId, 1, 45, 10, 140, 3);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
@@ -739,18 +739,17 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
assert.equal(dashboard.activity.watchTime[0]?.value, 30);
|
||||
assert.equal(dashboard.progress.watchTime[1]?.value, 75);
|
||||
assert.equal(dashboard.progress.lookups[1]?.value, 18);
|
||||
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
|
||||
assert.equal(dashboard.ratios.cardsPerHour[0]?.value, +(2 / (30 / 60)).toFixed(1));
|
||||
assert.equal(dashboard.ratios.cardsPerHour[1]?.value, +(3 / (45 / 60)).toFixed(1));
|
||||
assert.equal(dashboard.ratios.cardsPerHour[1]?.value, +(5 / (75 / 60)).toFixed(1));
|
||||
assert.equal(dashboard.ratios.readingSpeed[0]?.value, +(120 / 30).toFixed(1));
|
||||
assert.equal(dashboard.ratios.readingSpeed[1]?.value, +(140 / 45).toFixed(1));
|
||||
assert.equal(dashboard.ratios.readingSpeed[1]?.value, +(260 / 75).toFixed(1));
|
||||
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
|
||||
assert.deepEqual(
|
||||
dashboard.animeDaily.watchTime.map((point) => point.value).sort((a, b) => a - b),
|
||||
dashboard.titleTimeline.map((point) => point.value).sort((a, b) => a - b),
|
||||
[30, 45],
|
||||
);
|
||||
assert.equal(
|
||||
dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0),
|
||||
dashboard.watchTimeByDayHour.flat().reduce((sum, minutes) => sum + minutes, 0),
|
||||
75,
|
||||
);
|
||||
} finally {
|
||||
@@ -822,7 +821,7 @@ test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
|
||||
|
||||
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||
const titles = [
|
||||
...dashboard.animeDaily.watchTime.map((point) => point.animeTitle),
|
||||
...dashboard.titleTimeline.map((point) => point.animeTitle),
|
||||
...dashboard.librarySummary.map((row) => row.title),
|
||||
];
|
||||
|
||||
@@ -4751,7 +4750,7 @@ test('deleteSession removes zero-session media from library and trends', () => {
|
||||
|
||||
const trends = getTrendsDashboard(db, 'all', 'day');
|
||||
assert.deepEqual(trends.activity.watchTime, []);
|
||||
assert.deepEqual(trends.activity.sessions, []);
|
||||
assert.deepEqual(trends.activity.cards, []);
|
||||
|
||||
const dailyRollups = getDailyRollups(db, 30);
|
||||
const monthlyRollups = getMonthlyRollups(db, 30);
|
||||
@@ -5073,7 +5072,7 @@ test('getTrendsDashboard rollup word metrics keep persisted totals over partial
|
||||
total_tokens_seen, total_cards
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(Math.floor(row.start / 86_400_000), videoId, 1, 10, 1, row.rawWords, 0);
|
||||
).run(getLocalEpochDay(db, row.start), videoId, 1, 10, 1, row.rawWords, 0);
|
||||
}
|
||||
|
||||
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||
@@ -5085,13 +5084,8 @@ test('getTrendsDashboard rollup word metrics keep persisted totals over partial
|
||||
dashboard.progress.words.map((point) => point.value),
|
||||
[10, 30],
|
||||
);
|
||||
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, 200);
|
||||
assert.equal(dashboard.librarySummary[0]?.words, 30);
|
||||
assert.equal(dashboard.librarySummary[0]?.lookupsPerHundred, 33.3);
|
||||
assert.equal(
|
||||
dashboard.animeDaily.words.reduce((sum, point) => sum + point.value, 0),
|
||||
30,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getLexicalDailyRollups,
|
||||
rebuildLexicalDailyRollups,
|
||||
} from './lexical-rollups';
|
||||
import { getTrendsDashboard } from './query-trends';
|
||||
import {
|
||||
getVocabularyChartData,
|
||||
getVocabularySummary,
|
||||
@@ -262,7 +261,7 @@ test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
|
||||
assert.equal(areLexicalDailyRollupsReady(db), true);
|
||||
assert.equal(getVocabularySummary(db, null).uniqueWords, 1);
|
||||
assert.equal(getVocabularySummary(db, new Set(['猫'])).knownWordCount, 1);
|
||||
assert.equal(getVocabularyChartData(db).topWords[0]?.headword, '猫');
|
||||
assert.equal(getVocabularyChartData(db).newWordsTimeline[0]?.wordCount, 1);
|
||||
} finally {
|
||||
db.close();
|
||||
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
||||
@@ -593,7 +592,7 @@ test('imm_words persists vocabulary visibility for rollup maintenance', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('vocabulary charts use complete top-word and lexical rollup data', () => {
|
||||
test('vocabulary charts count every word in new-word history', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
@@ -611,8 +610,6 @@ test('vocabulary charts use complete top-word and lexical rollup data', () => {
|
||||
|
||||
const charts = getVocabularyChartData(db);
|
||||
|
||||
assert.equal(charts.topWords[0]?.headword, '語500');
|
||||
assert.equal(charts.topWords[0]?.frequency, 10_000);
|
||||
assert.equal(charts.newWordsTimeline[0]?.wordCount, 501);
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -620,41 +617,6 @@ test('vocabulary charts use complete top-word and lexical rollup data', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('vocabulary charts find full top-word sets beyond excluded and name rows', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const insertWord = db.prepare(
|
||||
`INSERT INTO imm_words(headword, word, reading, pos2, first_seen, last_seen, frequency)
|
||||
VALUES (?, ?, '', ?, 1700000000, 1700000000, ?)`,
|
||||
);
|
||||
const exclusions = [];
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const headword = `語${index}`;
|
||||
insertWord.run(
|
||||
headword,
|
||||
headword,
|
||||
index < 80 && index >= 60 ? '固有名詞' : '一般',
|
||||
100 - index,
|
||||
);
|
||||
if (index < 60) exclusions.push({ headword, word: headword, reading: '' });
|
||||
}
|
||||
replaceStatsExcludedWords(db, exclusions);
|
||||
|
||||
const charts = getVocabularyChartData(db);
|
||||
|
||||
assert.equal(charts.topWords.length, 12);
|
||||
assert.equal(charts.topWords[0]?.headword, '語60');
|
||||
assert.equal(charts.topWordsWithoutNames.length, 12);
|
||||
assert.equal(charts.topWordsWithoutNames[0]?.headword, '語80');
|
||||
} finally {
|
||||
db.close();
|
||||
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('vocabulary charts handle exclusion lists above one SQLite variable batch', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -674,7 +636,6 @@ test('vocabulary charts handle exclusion lists above one SQLite variable batch',
|
||||
|
||||
const charts = getVocabularyChartData(db);
|
||||
|
||||
assert.deepEqual(charts.topWords, []);
|
||||
assert.deepEqual(charts.newWordsTimeline, []);
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -697,24 +658,3 @@ test('lexical rollup rebuild preserves the original error when rollback also fai
|
||||
|
||||
assert.throws(() => rebuildLexicalDailyRollups(db), originalError);
|
||||
});
|
||||
|
||||
test('trends read historical new-word buckets from lexical rollups', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
|
||||
VALUES ('海', '海', 'うみ', 1700000000, 1700000000, 1)`,
|
||||
).run();
|
||||
db.prepare(`UPDATE imm_lexical_daily_rollups SET word_count = 9`).run();
|
||||
|
||||
const dashboard = getTrendsDashboard(db, 'all', 'day', false);
|
||||
|
||||
assert.equal(dashboard.progress.newWords[0]?.value, 9);
|
||||
} finally {
|
||||
db.close();
|
||||
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TrendFrequencyBand } from '../../../types/stats-wire';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import type {
|
||||
KanjiAnimeAppearanceRow,
|
||||
@@ -19,17 +20,44 @@ import type {
|
||||
import { forEachIdChunk, fromDbTimestamp, makePlaceholders, toDbTimestamp } from './query-shared';
|
||||
import { nowMs } from './time';
|
||||
import { narrowFullwidthKana, widenHalfwidthKana } from '../../utils/halfwidth-kana';
|
||||
import {
|
||||
areLexicalDailyRollupsReady,
|
||||
getLexicalDailyRollups,
|
||||
localEpochDaySql,
|
||||
} from './lexical-rollups';
|
||||
import { areLexicalDailyRollupsReady, localEpochDaySql } from './lexical-rollups';
|
||||
import { isVocabularyStatsRowVisible } from './vocabulary-visibility';
|
||||
|
||||
// Merged tokens join MeCab tags, so a name can be stored as 固有名詞|一般 or 固有名詞|接尾.
|
||||
const PROPER_NOUN_TAG = '固有名詞';
|
||||
const NOT_A_NAME_SQL = `COALESCE(pos2, '') NOT LIKE '%${PROPER_NOUN_TAG}%'`;
|
||||
|
||||
/** The dictionary word an `imm_words` row belongs to. Legacy rows can lack a headword or form. */
|
||||
export const DICTIONARY_WORD_KEY_SQL = `COALESCE(NULLIF(TRIM(headword), ''), NULLIF(TRIM(word), ''), TRIM(reading))`;
|
||||
|
||||
/** True for lines from a stream: only local videos have a path to cut clips from. */
|
||||
export const NO_LOCAL_FILE_SQL = `TRIM(COALESCE(v.source_path, '')) = ''`;
|
||||
|
||||
// Sorts lines that can be mined ahead of those that can't, because newest-first alone
|
||||
// lets a run of recent streams bury every line with a usable clip.
|
||||
// Mirrors getStatsMineCardUnavailableReason in the stats client.
|
||||
const MINEABLE_LINES_FIRST_SQL = `
|
||||
CASE
|
||||
WHEN NOT (${NO_LOCAL_FILE_SQL})
|
||||
AND l.segment_start_ms IS NOT NULL
|
||||
AND l.segment_end_ms > l.segment_start_ms
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END`;
|
||||
|
||||
export function isProperNounPos2(pos2: string | null | undefined): boolean {
|
||||
return pos2?.includes(PROPER_NOUN_TAG) ?? false;
|
||||
}
|
||||
|
||||
export interface NewWordFirstSeenRow {
|
||||
firstSeenDay: number;
|
||||
firstSeenDayWithoutNames: number | null;
|
||||
band: TrendFrequencyBand;
|
||||
wordCount: number;
|
||||
}
|
||||
|
||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
|
||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
|
||||
const VOCABULARY_CHART_LIMIT = 12;
|
||||
const VOCABULARY_CHART_PAGE_SIZE = 100;
|
||||
const EXCLUSION_ALIAS_BATCH_SIZE = 300;
|
||||
const VOCABULARY_SUMMARY_SCAN_BATCH_SIZE = 5_000;
|
||||
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
|
||||
@@ -38,8 +66,6 @@ const KANJI_PATTERN = /\p{Script=Han}/gu;
|
||||
|
||||
export interface VocabularyChartData {
|
||||
ready: boolean;
|
||||
topWords: Array<{ wordId: number; headword: string; frequency: number }>;
|
||||
topWordsWithoutNames: Array<{ wordId: number; headword: string; frequency: number }>;
|
||||
newWordsTimeline: Array<{ epochDay: number; wordCount: number }>;
|
||||
newWordsTimelineWithoutNames: Array<{ epochDay: number; wordCount: number }>;
|
||||
}
|
||||
@@ -161,111 +187,137 @@ function getWordAnimeCounts(db: DatabaseSync, wordIds: number[]): Map<number, nu
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart data is intentionally independent of the paginated vocabulary tables.
|
||||
* Top words use the frequency index; new-word history reads permanent daily
|
||||
* lexical rollups rather than loading every vocabulary row into the dashboard.
|
||||
* New-word history is a SQL aggregate over first-seen dates rather than loading
|
||||
* every vocabulary row into the dashboard.
|
||||
*/
|
||||
export function getVocabularyChartData(db: DatabaseSync): VocabularyChartData {
|
||||
const ready = areLexicalDailyRollupsReady(db);
|
||||
const excludedAliases = new Set(
|
||||
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
|
||||
);
|
||||
const isExcluded = (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>): boolean =>
|
||||
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias));
|
||||
const topWords = getTopVocabularyChartWords(db, isExcluded);
|
||||
const rollups = ready ? getLexicalDailyRollups(db) : [];
|
||||
const timeline = new Map(rollups.map((row) => [row.epochDay, { ...row }]));
|
||||
if (excludedAliases.size > 0 && ready) {
|
||||
const aliases = [...excludedAliases];
|
||||
const excludedRows = new Map<
|
||||
number,
|
||||
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & {
|
||||
wordId: number;
|
||||
epochDay: number;
|
||||
}
|
||||
>();
|
||||
for (let offset = 0; offset < aliases.length; offset += EXCLUSION_ALIAS_BATCH_SIZE) {
|
||||
const batch = aliases.slice(offset, offset + EXCLUSION_ALIAS_BATCH_SIZE);
|
||||
const placeholders = batch.map(() => '?').join(', ');
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS wordId, headword, word, reading, pos2,
|
||||
${localEpochDaySql('first_seen')} AS epochDay
|
||||
FROM imm_words
|
||||
WHERE vocabulary_visible = 1
|
||||
AND (headword IN (${placeholders}) OR word IN (${placeholders}) OR reading IN (${placeholders}))
|
||||
`,
|
||||
)
|
||||
.all(...batch, ...batch, ...batch) as Array<
|
||||
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & {
|
||||
wordId: number;
|
||||
epochDay: number;
|
||||
}
|
||||
>;
|
||||
for (const row of rows) excludedRows.set(row.wordId, row);
|
||||
}
|
||||
for (const word of excludedRows.values()) {
|
||||
if (!isExcluded(word)) continue;
|
||||
const rollup = timeline.get(word.epochDay);
|
||||
if (!rollup) continue;
|
||||
rollup.wordCount -= 1;
|
||||
if (word.pos2 !== '固有名詞') rollup.wordCountWithoutNames -= 1;
|
||||
const firstSeenRows = ready ? getNewWordFirstSeenRows(db) : [];
|
||||
const timeline = new Map<number, number>();
|
||||
const timelineWithoutNames = new Map<number, number>();
|
||||
for (const row of firstSeenRows) {
|
||||
timeline.set(row.firstSeenDay, (timeline.get(row.firstSeenDay) ?? 0) + row.wordCount);
|
||||
if (row.firstSeenDayWithoutNames !== null) {
|
||||
timelineWithoutNames.set(
|
||||
row.firstSeenDayWithoutNames,
|
||||
(timelineWithoutNames.get(row.firstSeenDayWithoutNames) ?? 0) + row.wordCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
ready,
|
||||
topWords: topWords.all.map((word) => ({
|
||||
wordId: word.wordId,
|
||||
headword: vocabularyDisplayHeadword(word),
|
||||
frequency: word.frequency,
|
||||
})),
|
||||
topWordsWithoutNames: topWords.withoutNames.map((word) => ({
|
||||
wordId: word.wordId,
|
||||
headword: vocabularyDisplayHeadword(word),
|
||||
frequency: word.frequency,
|
||||
})),
|
||||
newWordsTimeline: [...timeline.values()]
|
||||
.filter((row) => row.wordCount > 0)
|
||||
.map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCount })),
|
||||
newWordsTimelineWithoutNames: [...timeline.values()]
|
||||
.filter((row) => row.wordCountWithoutNames > 0)
|
||||
.map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCountWithoutNames })),
|
||||
newWordsTimeline: toNewWordTimeline(timeline),
|
||||
newWordsTimelineWithoutNames: toNewWordTimeline(timelineWithoutNames),
|
||||
};
|
||||
}
|
||||
|
||||
function getTopVocabularyChartWords(
|
||||
db: DatabaseSync,
|
||||
isExcluded: (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>) => boolean,
|
||||
): { all: VocabularyStatsRow[]; withoutNames: VocabularyStatsRow[] } {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id AS wordId, headword, word, reading,
|
||||
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
||||
frequency, frequency_rank AS frequencyRank,
|
||||
first_seen AS firstSeen, last_seen AS lastSeen,
|
||||
0 AS animeCount
|
||||
FROM imm_words
|
||||
ORDER BY frequency DESC, id
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
const all: VocabularyStatsRow[] = [];
|
||||
const withoutNames: VocabularyStatsRow[] = [];
|
||||
let offset = 0;
|
||||
function toNewWordTimeline(byDay: Map<number, number>): VocabularyChartData['newWordsTimeline'] {
|
||||
return [...byDay.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([epochDay, wordCount]) => ({ epochDay, wordCount }));
|
||||
}
|
||||
|
||||
while (all.length < VOCABULARY_CHART_LIMIT || withoutNames.length < VOCABULARY_CHART_LIMIT) {
|
||||
const page = stmt.all(VOCABULARY_CHART_PAGE_SIZE, offset) as VocabularyStatsRow[];
|
||||
if (page.length === 0) break;
|
||||
for (const word of page) {
|
||||
if (!isVocabularyStatsRowVisible(word) || isExcluded(word)) continue;
|
||||
if (all.length < VOCABULARY_CHART_LIMIT) all.push(word);
|
||||
if (word.pos2 !== '固有名詞' && withoutNames.length < VOCABULARY_CHART_LIMIT) {
|
||||
withoutNames.push(word);
|
||||
/** Word rows matching the user's stats exclusions, using the same aliases as the summary. */
|
||||
export function getExcludedWordIds(db: DatabaseSync): number[] {
|
||||
const excludedAliases = new Set(
|
||||
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
|
||||
);
|
||||
const aliases = [...excludedAliases];
|
||||
const ids = new Set<number>();
|
||||
for (let offset = 0; offset < aliases.length; offset += EXCLUSION_ALIAS_BATCH_SIZE) {
|
||||
const batch = aliases.slice(offset, offset + EXCLUSION_ALIAS_BATCH_SIZE);
|
||||
const placeholders = batch.map(() => '?').join(', ');
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS wordId, headword, word, reading
|
||||
FROM imm_words
|
||||
WHERE vocabulary_visible = 1
|
||||
AND (headword IN (${placeholders}) OR word IN (${placeholders}) OR reading IN (${placeholders}))
|
||||
`,
|
||||
)
|
||||
.all(...batch, ...batch, ...batch) as Array<
|
||||
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'> & { wordId: number }
|
||||
>;
|
||||
for (const row of rows) {
|
||||
if (excludedVocabularyAliases(row).some((alias) => excludedAliases.has(alias))) {
|
||||
ids.add(row.wordId);
|
||||
}
|
||||
}
|
||||
offset += page.length;
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
return { all, withoutNames };
|
||||
// The aggregate below scans every word (about 200ms on 68k rows), and the Trends tab
|
||||
// runs it on each control change. Results stay valid until something writes:
|
||||
// total_changes covers this connection, data_version covers other connections.
|
||||
const newWordFirstSeenCache = new WeakMap<
|
||||
DatabaseSync,
|
||||
{ version: string; rows: NewWordFirstSeenRow[] }
|
||||
>();
|
||||
|
||||
export function databaseWriteVersion(db: DatabaseSync): string {
|
||||
const { changes } = db.prepare('SELECT total_changes() AS changes').get() as { changes: number };
|
||||
const { data_version: dataVersion } = db.prepare('PRAGMA data_version').get() as {
|
||||
data_version: number;
|
||||
};
|
||||
return `${changes}:${dataVersion}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* When each dictionary word was first seen, folded across its surface forms:
|
||||
* `imm_words` stores 言う and 言った as separate rows, so counting rows would count
|
||||
* conjugations as new words. Rows group identical (day, day without names, band)
|
||||
* results so the caller gets a small aggregate rather than one row per word.
|
||||
* `firstSeenDayWithoutNames` and `band` ignore proper-noun forms, and the day is
|
||||
* null when every form of the word is a proper noun.
|
||||
*/
|
||||
export function getNewWordFirstSeenRows(db: DatabaseSync): NewWordFirstSeenRow[] {
|
||||
const version = databaseWriteVersion(db);
|
||||
const cached = newWordFirstSeenCache.get(db);
|
||||
if (cached?.version === version) return cached.rows;
|
||||
|
||||
const excludedIds = getExcludedWordIds(db);
|
||||
const exclusion =
|
||||
excludedIds.length > 0 ? `AND id NOT IN (${makePlaceholders(excludedIds)})` : '';
|
||||
const seconds = `CASE
|
||||
WHEN ABS(CAST(first_seen AS REAL)) >= 10000000000 THEN CAST(first_seen AS REAL) / 1000
|
||||
ELSE CAST(first_seen AS REAL)
|
||||
END`;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
WITH dictionary_words AS (
|
||||
SELECT
|
||||
MIN(${seconds}) AS firstSeen,
|
||||
MIN(CASE WHEN ${NOT_A_NAME_SQL} THEN ${seconds} END) AS firstSeenWithoutNames,
|
||||
MIN(CASE WHEN ${NOT_A_NAME_SQL} THEN frequency_rank END) AS rank
|
||||
FROM imm_words
|
||||
WHERE vocabulary_visible = 1 AND first_seen IS NOT NULL ${exclusion}
|
||||
GROUP BY ${DICTIONARY_WORD_KEY_SQL}
|
||||
)
|
||||
SELECT
|
||||
${localEpochDaySql('firstSeen')} AS firstSeenDay,
|
||||
CASE
|
||||
WHEN firstSeenWithoutNames IS NULL THEN NULL
|
||||
ELSE ${localEpochDaySql('firstSeenWithoutNames')}
|
||||
END AS firstSeenDayWithoutNames,
|
||||
CASE
|
||||
WHEN rank IS NULL THEN 'unranked'
|
||||
WHEN rank <= 1000 THEN 'top1k'
|
||||
WHEN rank <= 5000 THEN 'top5k'
|
||||
WHEN rank <= 10000 THEN 'top10k'
|
||||
WHEN rank <= 20000 THEN 'top20k'
|
||||
ELSE 'beyond20k'
|
||||
END AS band,
|
||||
COUNT(*) AS wordCount
|
||||
FROM dictionary_words
|
||||
GROUP BY 1, 2, 3
|
||||
`,
|
||||
)
|
||||
.all(...excludedIds) as unknown as NewWordFirstSeenRow[];
|
||||
newWordFirstSeenCache.set(db, { version, rows });
|
||||
return rows;
|
||||
}
|
||||
|
||||
function excludedVocabularyAliases(
|
||||
@@ -324,6 +376,12 @@ export function getVocabularySummary(
|
||||
knownWordCount: knownWords ? 0 : null,
|
||||
knownWordCountWithoutNames: knownWords ? 0 : null,
|
||||
};
|
||||
// Keyed by dictionary form, so a word's conjugations count once and it is new
|
||||
// this week only if no form of it was seen earlier.
|
||||
const dictionaryWords = new Map<
|
||||
string,
|
||||
{ firstSeenSec: number; firstSeenSecWithoutNames: number | null; known: boolean }
|
||||
>();
|
||||
|
||||
let lastId = Number.MIN_SAFE_INTEGER;
|
||||
for (;;) {
|
||||
@@ -337,23 +395,40 @@ export function getVocabularySummary(
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const isName = word.pos2 === '固有名詞';
|
||||
const isNewThisWeek = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0) >= weekAgoSec;
|
||||
const isKnown = knownWords?.has(vocabularyDisplayHeadword(word)) ?? false;
|
||||
summary.uniqueWords += 1;
|
||||
if (!isName) summary.uniqueWordsWithoutNames += 1;
|
||||
if (isNewThisWeek) {
|
||||
summary.newThisWeek += 1;
|
||||
if (!isName) summary.newThisWeekWithoutNames += 1;
|
||||
}
|
||||
if (isKnown) {
|
||||
summary.knownWordCount! += 1;
|
||||
if (!isName) summary.knownWordCountWithoutNames! += 1;
|
||||
const firstSeenSec = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0);
|
||||
const key = vocabularyDisplayHeadword(word);
|
||||
const entry = dictionaryWords.get(key) ?? {
|
||||
firstSeenSec,
|
||||
firstSeenSecWithoutNames: null,
|
||||
known: false,
|
||||
};
|
||||
entry.known ||= (knownWords?.has(key) ?? false) || (knownWords?.has(word.word) ?? false);
|
||||
entry.firstSeenSec = Math.min(entry.firstSeenSec, firstSeenSec);
|
||||
if (!isProperNounPos2(word.pos2)) {
|
||||
entry.firstSeenSecWithoutNames = Math.min(
|
||||
entry.firstSeenSecWithoutNames ?? firstSeenSec,
|
||||
firstSeenSec,
|
||||
);
|
||||
}
|
||||
dictionaryWords.set(key, entry);
|
||||
}
|
||||
if (words.length < scanBatchSize) break;
|
||||
}
|
||||
|
||||
for (const { firstSeenSec, firstSeenSecWithoutNames, known } of dictionaryWords.values()) {
|
||||
const hasNonNameForm = firstSeenSecWithoutNames !== null;
|
||||
summary.uniqueWords += 1;
|
||||
if (hasNonNameForm) summary.uniqueWordsWithoutNames += 1;
|
||||
if (firstSeenSec >= weekAgoSec) summary.newThisWeek += 1;
|
||||
if (hasNonNameForm && firstSeenSecWithoutNames >= weekAgoSec) {
|
||||
summary.newThisWeekWithoutNames += 1;
|
||||
}
|
||||
if (known) {
|
||||
summary.knownWordCount! += 1;
|
||||
if (hasNonNameForm) summary.knownWordCountWithoutNames! += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -418,7 +493,12 @@ export function getWordOccurrences(
|
||||
reading: string,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
allForms = false,
|
||||
): WordOccurrenceRow[] {
|
||||
const wordFilter = allForms
|
||||
? `w.id IN (SELECT id FROM imm_words WHERE ${DICTIONARY_WORD_KEY_SQL} = ?)`
|
||||
: 'w.headword = ? AND w.word = ? AND w.reading = ?';
|
||||
const wordParams = allForms ? [headword] : [headword, word, reading];
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
@@ -440,13 +520,13 @@ export function getWordOccurrences(
|
||||
JOIN imm_subtitle_lines l ON l.line_id = o.line_id
|
||||
JOIN imm_videos v ON v.video_id = l.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
|
||||
WHERE w.headword = ? AND w.word = ? AND w.reading = ?
|
||||
ORDER BY l.CREATED_DATE DESC, l.line_id DESC
|
||||
WHERE ${wordFilter}
|
||||
ORDER BY ${MINEABLE_LINES_FIRST_SQL}, l.CREATED_DATE DESC, l.line_id DESC
|
||||
LIMIT ?
|
||||
OFFSET ?
|
||||
`,
|
||||
)
|
||||
.all(headword, word, reading, limit, offset) as unknown as WordOccurrenceRow[];
|
||||
.all(...wordParams, limit, offset) as unknown as WordOccurrenceRow[];
|
||||
return rows.map(widenLineText);
|
||||
}
|
||||
|
||||
@@ -553,7 +633,7 @@ export function searchSubtitleSentences(
|
||||
JOIN imm_videos v ON v.video_id = l.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY l.CREATED_DATE DESC, l.line_id DESC
|
||||
ORDER BY ${MINEABLE_LINES_FIRST_SQL}, l.CREATED_DATE DESC, l.line_id DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
@@ -598,17 +678,99 @@ export function getSessionEvents(
|
||||
}));
|
||||
}
|
||||
|
||||
const WORD_DETAIL_FORM_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* One entry per written form. A form can be stored more than once when its reading
|
||||
* was recorded differently, as 見て is under both みて and み. Returns most seen first.
|
||||
*/
|
||||
export function mergeFormsByWord<T extends { word: string; seen: number }>(forms: T[]): T[] {
|
||||
const byWord = new Map<string, T>();
|
||||
for (const form of forms) {
|
||||
const existing = byWord.get(form.word);
|
||||
if (existing) {
|
||||
existing.seen += form.seen;
|
||||
} else {
|
||||
byWord.set(form.word, { ...form });
|
||||
}
|
||||
}
|
||||
return [...byWord.values()].sort((left, right) => right.seen - left.seen);
|
||||
}
|
||||
|
||||
/** A word's form plus totals across every form of its dictionary word. */
|
||||
export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null {
|
||||
return db
|
||||
const word = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS wordId, headword, word, reading,
|
||||
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
||||
frequency, first_seen AS firstSeen, last_seen AS lastSeen
|
||||
SELECT id AS wordId, COALESCE(headword, '') AS headword, COALESCE(word, '') AS word,
|
||||
COALESCE(reading, '') AS reading,
|
||||
part_of_speech AS partOfSpeech, pos1, pos2, pos3
|
||||
FROM imm_words WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.get(wordId) as WordDetailRow | null;
|
||||
.get(wordId) as Pick<
|
||||
WordDetailRow,
|
||||
'wordId' | 'headword' | 'word' | 'reading' | 'partOfSpeech' | 'pos1' | 'pos2' | 'pos3'
|
||||
> | null;
|
||||
if (!word) return null;
|
||||
|
||||
const hasHeadword = word.headword.trim() !== '';
|
||||
const forms = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS wordId, COALESCE(word, '') AS word, COALESCE(reading, '') AS reading,
|
||||
frequency AS seen, frequency_rank AS rank,
|
||||
first_seen AS firstSeen, last_seen AS lastSeen
|
||||
FROM imm_words
|
||||
WHERE ${hasHeadword ? '(headword = ? AND vocabulary_visible = 1) OR id = ?' : 'id = ?'}
|
||||
ORDER BY frequency DESC, id
|
||||
`,
|
||||
)
|
||||
.all(...(hasHeadword ? [word.headword, wordId] : [wordId])) as Array<{
|
||||
wordId: number;
|
||||
word: string;
|
||||
reading: string;
|
||||
seen: number | null;
|
||||
rank: number | null;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
}>;
|
||||
|
||||
const lines = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(DISTINCT l.line_id) AS lineCount,
|
||||
COUNT(DISTINCT CASE WHEN ${NO_LOCAL_FILE_SQL} THEN l.line_id END) AS streamLineCount
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_words w ON w.id = o.word_id
|
||||
JOIN imm_subtitle_lines l ON l.line_id = o.line_id
|
||||
JOIN imm_videos v ON v.video_id = l.video_id
|
||||
WHERE ${hasHeadword ? 'w.headword = ?' : 'w.id = ?'}
|
||||
`,
|
||||
)
|
||||
.get(hasHeadword ? word.headword : wordId) as { lineCount: number; streamLineCount: number };
|
||||
const merged = mergeFormsByWord(forms.map((form) => ({ ...form, seen: form.seen ?? 0 })));
|
||||
const toSeconds = (value: number): number => timestampSeconds(fromDbTimestamp(value) ?? 0);
|
||||
const ranks = forms.map((form) => form.rank).filter((rank): rank is number => (rank ?? 0) > 0);
|
||||
return {
|
||||
...word,
|
||||
frequency: forms.reduce((sum, form) => sum + (form.seen ?? 0), 0),
|
||||
frequencyRank: ranks.length > 0 ? Math.min(...ranks) : null,
|
||||
dictionaryReading:
|
||||
forms.find((form) => form.word === word.headword && form.reading.trim() !== '')?.reading ??
|
||||
'',
|
||||
formCount: merged.length,
|
||||
forms: merged.slice(0, WORD_DETAIL_FORM_LIMIT).map((form) => ({
|
||||
wordId: form.wordId,
|
||||
word: form.word,
|
||||
reading: form.reading,
|
||||
seen: form.seen,
|
||||
})),
|
||||
lineCount: lines.lineCount,
|
||||
streamLineCount: lines.streamLineCount,
|
||||
firstSeen: Math.min(...forms.map((form) => toSeconds(form.firstSeen))),
|
||||
lastSeen: Math.max(...forms.map((form) => toSeconds(form.lastSeen))),
|
||||
};
|
||||
}
|
||||
|
||||
export function getWordAnimeAppearances(
|
||||
@@ -621,21 +783,26 @@ export function getWordAnimeAppearances(
|
||||
SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle,
|
||||
SUM(o.occurrence_count) AS occurrenceCount
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_words w ON w.id = o.word_id
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
JOIN imm_anime a ON a.anime_id = sl.anime_id
|
||||
WHERE o.word_id = ? AND sl.anime_id IS NOT NULL
|
||||
WHERE (w.id = ? OR w.headword = (SELECT NULLIF(TRIM(headword), '') FROM imm_words WHERE id = ?))
|
||||
AND sl.anime_id IS NOT NULL
|
||||
GROUP BY a.anime_id
|
||||
ORDER BY occurrenceCount DESC
|
||||
`,
|
||||
)
|
||||
.all(wordId) as WordAnimeAppearanceRow[];
|
||||
.all(wordId, wordId) as WordAnimeAppearanceRow[];
|
||||
}
|
||||
|
||||
/** Other dictionary words sharing a reading or kanji; a word's own forms don't count. */
|
||||
export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): SimilarWordRow[] {
|
||||
const word = db.prepare('SELECT headword, reading FROM imm_words WHERE id = ?').get(wordId) as {
|
||||
headword: string;
|
||||
reading: string;
|
||||
} | null;
|
||||
const word = db
|
||||
.prepare(
|
||||
`SELECT COALESCE(headword, '') AS headword, COALESCE(reading, '') AS reading
|
||||
FROM imm_words WHERE id = ?`,
|
||||
)
|
||||
.get(wordId) as { headword: string; reading: string } | null;
|
||||
if (!word || word.headword.trim() === '') return [];
|
||||
|
||||
const clauses: string[] = [];
|
||||
@@ -653,22 +820,41 @@ export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): S
|
||||
|
||||
if (clauses.length === 0) return [];
|
||||
|
||||
const orderBy =
|
||||
reading !== '' ? 'CASE WHEN reading = ? THEN 0 ELSE 1 END, frequency DESC' : 'frequency DESC';
|
||||
const orderParams = reading !== '' ? [word.reading] : [];
|
||||
const readingMatch =
|
||||
reading !== ''
|
||||
? 'MAX(CASE WHEN reading = ? THEN 1 ELSE 0 END) OVER (PARTITION BY headword)'
|
||||
: '0';
|
||||
const readingMatchParams = reading !== '' ? [word.reading] : [];
|
||||
|
||||
// One form matching is enough to relate a word, but its total and the form it shows
|
||||
// come from all its visible forms, so a rare form with the same reading can't stand in.
|
||||
// The blank check lists its characters because SQL TRIM alone strips only ASCII spaces.
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS wordId, headword, word, reading, frequency
|
||||
FROM imm_words
|
||||
WHERE id != ?
|
||||
AND (${clauses.join(' OR ')})
|
||||
ORDER BY ${orderBy}
|
||||
WITH related AS (
|
||||
SELECT DISTINCT headword
|
||||
FROM imm_words
|
||||
WHERE headword != ? AND vocabulary_visible = 1
|
||||
AND TRIM(headword, ' ' || char(9, 10, 11, 12, 13, 12288)) != ''
|
||||
AND (${clauses.join(' OR ')})
|
||||
),
|
||||
forms AS (
|
||||
SELECT id, headword, word, reading,
|
||||
SUM(frequency) OVER (PARTITION BY headword) AS totalSeen,
|
||||
${readingMatch} AS readingMatch,
|
||||
ROW_NUMBER() OVER (PARTITION BY headword ORDER BY frequency DESC, id) AS formOrder
|
||||
FROM imm_words
|
||||
WHERE vocabulary_visible = 1 AND headword IN (SELECT headword FROM related)
|
||||
)
|
||||
SELECT id AS wordId, headword, word, reading, totalSeen AS frequency
|
||||
FROM forms
|
||||
WHERE formOrder = 1
|
||||
ORDER BY readingMatch DESC, totalSeen DESC, headword
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(wordId, ...params, ...orderParams, limit) as SimilarWordRow[];
|
||||
.all(word.headword, ...params, ...readingMatchParams, limit) as SimilarWordRow[];
|
||||
}
|
||||
|
||||
export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { StatsDayRange } from '../../../types/stats-http-contract';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import type {
|
||||
ImmersionSessionRollupRow,
|
||||
@@ -15,7 +16,12 @@ import {
|
||||
visibleWordSql,
|
||||
} from './query-shared';
|
||||
|
||||
export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] {
|
||||
export function getSessionSummaries(
|
||||
db: DatabaseSync,
|
||||
limit = 50,
|
||||
dayRange?: StatsDayRange,
|
||||
): SessionSummaryQueryRow[] {
|
||||
const dayFilter = dayRange ? `WHERE ${sessionRollupDaySql('s')} BETWEEN ? AND ?` : '';
|
||||
const prepared = db.prepare(`
|
||||
${ACTIVE_SESSION_METRICS_CTE}
|
||||
SELECT
|
||||
@@ -38,10 +44,12 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
|
||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||
LEFT JOIN imm_videos v ON v.video_id = s.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
${dayFilter}
|
||||
ORDER BY s.started_at_ms DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const rows = prepared.all(limit) as Array<
|
||||
const params = dayRange ? [dayRange.startDay, dayRange.endDay, limit] : [limit];
|
||||
const rows = prepared.all(...params) as Array<
|
||||
SessionSummaryQueryRow & {
|
||||
startedAtMs: number | string;
|
||||
endedAtMs: number | string | null;
|
||||
@@ -393,6 +401,63 @@ export function selectDailyRollupRows(db: DatabaseSync, limit: number): RawRollu
|
||||
.all(limit) as unknown as RawRollupRow[];
|
||||
}
|
||||
|
||||
/** Raw daily rollup rows for an inclusive day span; a null start means no lower bound. */
|
||||
export function selectDailyRollupRowsBetween(
|
||||
db: DatabaseSync,
|
||||
startDay: number | null,
|
||||
endDay: number,
|
||||
): RawRollupRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
r.rollup_day AS rollupDayOrMonth,
|
||||
r.video_id AS videoId,
|
||||
r.total_sessions AS totalSessions,
|
||||
r.total_active_min AS totalActiveMin,
|
||||
r.total_lines_seen AS totalLinesSeen,
|
||||
r.total_tokens_seen AS totalTokensSeen,
|
||||
r.total_cards AS totalCards,
|
||||
r.cards_per_hour AS cardsPerHour,
|
||||
r.lookup_hit_rate AS lookupHitRate
|
||||
FROM imm_daily_rollups r
|
||||
WHERE r.rollup_day BETWEEN ? AND ?
|
||||
ORDER BY r.rollup_day DESC, r.video_id DESC
|
||||
`,
|
||||
)
|
||||
.all(startDay ?? Number.MIN_SAFE_INTEGER, endDay) as unknown as RawRollupRow[];
|
||||
}
|
||||
|
||||
/** Raw monthly rollup rows for an inclusive `YYYYMM` span; a null start means no lower bound. */
|
||||
export function selectMonthlyRollupRowsBetween(
|
||||
db: DatabaseSync,
|
||||
startMonth: number | null,
|
||||
endMonth: number,
|
||||
): RawRollupRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
r.rollup_month AS rollupDayOrMonth,
|
||||
r.video_id AS videoId,
|
||||
r.total_sessions AS totalSessions,
|
||||
r.total_active_min AS totalActiveMin,
|
||||
r.total_lines_seen AS totalLinesSeen,
|
||||
r.total_tokens_seen AS totalTokensSeen,
|
||||
r.total_cards AS totalCards,
|
||||
CASE
|
||||
WHEN r.total_active_min > 0 THEN (r.total_cards * 60.0) / r.total_active_min
|
||||
ELSE NULL
|
||||
END AS cardsPerHour,
|
||||
NULL AS lookupHitRate
|
||||
FROM imm_monthly_rollups r
|
||||
WHERE r.rollup_month BETWEEN ? AND ?
|
||||
ORDER BY r.rollup_month DESC, r.video_id DESC
|
||||
`,
|
||||
)
|
||||
.all(startMonth ?? Number.MIN_SAFE_INTEGER, endMonth) as unknown as RawRollupRow[];
|
||||
}
|
||||
|
||||
/** Monthly counterpart of `selectDailyRollupRows`. */
|
||||
export function selectMonthlyRollupRows(db: DatabaseSync, limit: number): RawRollupRow[] {
|
||||
return db
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { Database, type DatabaseSync } from './sqlite';
|
||||
import { getOrCreateAnimeRecord, getOrCreateVideoRecord, linkVideoToAnimeRecord } from './storage';
|
||||
import { ensureSchema } from './storage';
|
||||
import { SOURCE_TYPE_LOCAL } from './types';
|
||||
import { getTrendsDashboard } from './query-trends';
|
||||
import { getSessionSummaries } from './query-sessions';
|
||||
import { getVocabularyChartData, getVocabularySummary } from './query-lexical';
|
||||
import { markLexicalDailyRollupsReady } from './lexical-rollups';
|
||||
|
||||
/** Noon, local time, on a calendar day, so the local epoch day holds in every time zone. */
|
||||
function localNoonMs(epochDay: number): number {
|
||||
const date = new Date(epochDay * 86_400_000);
|
||||
return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 12).getTime();
|
||||
}
|
||||
|
||||
// Mocked "now" is noon on 2026-03-01, a Sunday.
|
||||
const TODAY = Date.UTC(2026, 2, 1) / 86_400_000;
|
||||
const NOW_MS = String(localNoonMs(TODAY));
|
||||
|
||||
function withDashboardDb(run: (db: DatabaseSync) => void): void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-trends-'));
|
||||
const db = new Database(path.join(dir, 'immersion.sqlite'));
|
||||
const previousNow = globalThis.__subminerTestNowMs;
|
||||
globalThis.__subminerTestNowMs = NOW_MS;
|
||||
try {
|
||||
ensureSchema(db);
|
||||
run(db);
|
||||
} finally {
|
||||
globalThis.__subminerTestNowMs = previousNow;
|
||||
db.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function addVideo(db: DatabaseSync, title: string, episode: number, watched = false): number {
|
||||
const key = `${title}-${episode}`;
|
||||
const videoId = getOrCreateVideoRecord(db, `local:/tmp/${key}.mkv`, {
|
||||
canonicalTitle: key,
|
||||
sourcePath: `/tmp/${key}.mkv`,
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: title,
|
||||
canonicalTitle: title,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: `${key}.mkv`,
|
||||
parsedTitle: title,
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: episode,
|
||||
parserSource: 'test',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
if (watched) db.prepare('UPDATE imm_videos SET watched = 1 WHERE video_id = ?').run(videoId);
|
||||
return videoId;
|
||||
}
|
||||
|
||||
function addRollup(
|
||||
db: DatabaseSync,
|
||||
day: number,
|
||||
videoId: number,
|
||||
values: { minutes?: number; words?: number; cards?: number } = {},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards
|
||||
) VALUES (?, ?, 1, ?, 1, ?, ?)`,
|
||||
).run(day, videoId, values.minutes ?? 30, values.words ?? 100, values.cards ?? 0);
|
||||
}
|
||||
|
||||
function addSession(
|
||||
db: DatabaseSync,
|
||||
day: number,
|
||||
videoId: number,
|
||||
values: { minutes?: number; lookups?: number } = {},
|
||||
): void {
|
||||
const startedAtMs = localNoonMs(day).toString();
|
||||
const activeMs = (values.minutes ?? 30) * 60_000;
|
||||
db.prepare(
|
||||
`INSERT INTO imm_sessions (
|
||||
session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms,
|
||||
total_watched_ms, yomitan_lookup_count
|
||||
) VALUES (?, ?, ?, ?, 2, ?, ?, ?)`,
|
||||
).run(
|
||||
`session-${day}-${videoId}-${Math.random()}`,
|
||||
videoId,
|
||||
startedAtMs,
|
||||
(BigInt(startedAtMs) + BigInt(activeMs)).toString(),
|
||||
activeMs,
|
||||
activeMs,
|
||||
values.lookups ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function addWord(
|
||||
db: DatabaseSync,
|
||||
values: {
|
||||
headword: string;
|
||||
word?: string;
|
||||
day: number;
|
||||
pos2?: string | null;
|
||||
rank?: number | null;
|
||||
},
|
||||
): void {
|
||||
const firstSeen = localNoonMs(values.day) / 1000;
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen,
|
||||
frequency, frequency_rank
|
||||
) VALUES (?, ?, ?, 'noun', '名詞', ?, '', ?, ?, 1, ?)`,
|
||||
).run(
|
||||
values.headword,
|
||||
values.word ?? values.headword,
|
||||
values.word ?? values.headword,
|
||||
values.pos2 ?? null,
|
||||
firstSeen,
|
||||
firstSeen,
|
||||
values.rank ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
test('the title lists stay inside the calendar range when activity is sparse', () => {
|
||||
withDashboardDb((db) => {
|
||||
const recent = addVideo(db, 'Recent Show', 1);
|
||||
const old = addVideo(db, 'Old Show', 1);
|
||||
addRollup(db, TODAY, recent, { minutes: 20 });
|
||||
// Outside a 7-day calendar window, though well within the last 7 active days.
|
||||
addRollup(db, TODAY - 40, old, { minutes: 90 });
|
||||
addSession(db, TODAY - 40, old);
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '7d', 'day');
|
||||
|
||||
assert.deepEqual(
|
||||
dashboard.librarySummary.map((row) => row.title),
|
||||
['Recent Show'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.titleTimeline.map((point) => point.animeTitle),
|
||||
['Recent Show'],
|
||||
);
|
||||
assert.equal(dashboard.summary.current.watchTimeMin, 20);
|
||||
assert.equal(
|
||||
dashboard.librarySummary.reduce((sum, row) => sum + row.watchTimeMin, 0),
|
||||
dashboard.activity.watchTime.reduce((sum, point) => sum + point.value, 0),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('every grouping reports the same totals for a range', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
for (const day of [TODAY, TODAY - 3, TODAY - 20, TODAY - 29, TODAY - 45]) {
|
||||
addRollup(db, day, videoId, { minutes: 10, cards: 1 });
|
||||
}
|
||||
// January is only partly in range, and its monthly total includes days that are not.
|
||||
const insertMonthly = db.prepare(
|
||||
`INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards
|
||||
) VALUES (?, ?, 1, ?, 1, 100, 1)`,
|
||||
);
|
||||
insertMonthly.run(202601, videoId, 20);
|
||||
insertMonthly.run(202602, videoId, 20);
|
||||
insertMonthly.run(202603, videoId, 10);
|
||||
|
||||
const sum = (groupBy: 'day' | 'week' | 'month') =>
|
||||
getTrendsDashboard(db, '30d', groupBy).activity.watchTime.reduce(
|
||||
(total, point) => total + point.value,
|
||||
0,
|
||||
);
|
||||
|
||||
assert.equal(sum('day'), 40);
|
||||
assert.equal(sum('week'), 40);
|
||||
assert.equal(sum('month'), 40);
|
||||
});
|
||||
});
|
||||
|
||||
test('all-time month buckets use monthly rollups that outlive daily ones', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
const insertMonthly = db.prepare(
|
||||
`INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards
|
||||
) VALUES (?, ?, 3, ?, 1, 100, 50)`,
|
||||
);
|
||||
insertMonthly.run(202501, videoId, 500);
|
||||
insertMonthly.run(202503, videoId, 200);
|
||||
|
||||
assert.deepEqual(getTrendsDashboard(db, 'all', 'month').activity.watchTime, [
|
||||
{ label: 'Jan 2025', value: 500 },
|
||||
{ label: 'Feb 2025', value: 0 },
|
||||
{ label: 'Mar 2025', value: 200 },
|
||||
]);
|
||||
assert.deepEqual(getTrendsDashboard(db, 'all', 'month', false).activity.watchTime, [
|
||||
{ label: 'Jan 2025', value: 500 },
|
||||
{ label: 'Mar 2025', value: 200 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('a finished video counts once however many days it was watched', () => {
|
||||
withDashboardDb((db) => {
|
||||
const finished = addVideo(db, 'Show', 1, true);
|
||||
const unfinished = addVideo(db, 'Show', 2, false);
|
||||
addRollup(db, TODAY - 2, finished);
|
||||
addRollup(db, TODAY - 1, finished);
|
||||
addRollup(db, TODAY, unfinished);
|
||||
|
||||
for (const groupBy of ['day', 'week', 'month'] as const) {
|
||||
const dashboard = getTrendsDashboard(db, '30d', groupBy);
|
||||
assert.equal(dashboard.progress.episodes.at(-1)?.value, 1, groupBy);
|
||||
assert.equal(dashboard.summary.current.episodes, 1, groupBy);
|
||||
}
|
||||
|
||||
const daily = getTrendsDashboard(db, '7d', 'day').activity.episodes;
|
||||
// Attributed to the last day it was watched.
|
||||
assert.equal(daily.at(-2)?.value, 1);
|
||||
assert.equal(daily.at(-3)?.value, 0);
|
||||
assert.equal(getTrendsDashboard(db, '30d', 'day').librarySummary[0]?.episodesFinished, 1);
|
||||
assert.equal(getTrendsDashboard(db, '30d', 'day').librarySummary[0]?.videos, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('rates are gaps without enough data, not zeros', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
// A two-minute day with one card: 30 cards per hour if rated on its own.
|
||||
addRollup(db, TODAY - 20, videoId, { minutes: 2, words: 50, cards: 1 });
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '30d', 'day');
|
||||
|
||||
assert.equal(dashboard.ratios.cardsPerHour.at(-1)?.value, null);
|
||||
assert.equal(
|
||||
dashboard.ratios.cardsPerHour.every((point) => point.value === null),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
dashboard.ratios.readingSpeed.every((point) => point.value === null),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('daily rates are volume-weighted over a trailing week, reaching before the range', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
// Eight days before today: outside the 7d range, inside the first day's lookback.
|
||||
addRollup(db, TODAY - 8, videoId, { minutes: 60, words: 3_000, cards: 6 });
|
||||
addRollup(db, TODAY - 6, videoId, { minutes: 60, words: 1_000, cards: 2 });
|
||||
|
||||
const speed = getTrendsDashboard(db, '7d', 'day').ratios.readingSpeed;
|
||||
|
||||
// (3000 + 1000) words / (60 + 60) min, not an average of 50 and 16.7 ...
|
||||
assert.equal(speed[0]?.value, +(4_000 / 120).toFixed(1));
|
||||
// ... and once the older day leaves the trailing week only the second remains.
|
||||
assert.equal(speed[5]?.value, +(1_000 / 60).toFixed(1));
|
||||
});
|
||||
});
|
||||
|
||||
test('week grouping starts on Monday and clips the first bucket to the range', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
addRollup(db, TODAY, videoId);
|
||||
|
||||
const { buckets } = getTrendsDashboard(db, '30d', 'week');
|
||||
|
||||
assert.equal(buckets[0]?.startDay, TODAY - 29);
|
||||
assert.equal(buckets.at(-1)?.endDay, TODAY);
|
||||
// Today is a Sunday, so the final bucket is a full Monday-to-Sunday week.
|
||||
assert.equal(buckets.at(-1)?.startDay, TODAY - 6);
|
||||
for (let index = 1; index < buckets.length; index += 1) {
|
||||
assert.equal(buckets[index]!.startDay, buckets[index - 1]!.endDay + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('showing empty periods on all-time fills the calendar between active days', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
addRollup(db, TODAY - 10, videoId);
|
||||
addRollup(db, TODAY, videoId);
|
||||
|
||||
assert.equal(getTrendsDashboard(db, 'all', 'day', true).activity.watchTime.length, 11);
|
||||
assert.equal(getTrendsDashboard(db, 'all', 'day', false).activity.watchTime.length, 2);
|
||||
assert.deepEqual(getTrendsDashboard(db, 'all', 'day', true).window, {
|
||||
startDay: TODAY - 10,
|
||||
endDay: TODAY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('month labels name the year, and day labels do when the range crosses one', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
addRollup(db, TODAY, videoId);
|
||||
|
||||
assert.equal(getTrendsDashboard(db, '30d', 'month').buckets.at(-1)?.label, 'Mar 2026');
|
||||
assert.equal(getTrendsDashboard(db, '7d', 'day').buckets.at(-1)?.label, 'Mar 1');
|
||||
assert.equal(getTrendsDashboard(db, '90d', 'day').buckets.at(-1)?.label, 'Mar 1, 2026');
|
||||
});
|
||||
});
|
||||
|
||||
test('a custom range clips to its days and compares against the same-length span before it', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
addRollup(db, TODAY - 5, videoId, { minutes: 10 });
|
||||
addRollup(db, TODAY - 15, videoId, { minutes: 20 });
|
||||
addRollup(db, TODAY - 25, videoId, { minutes: 40 });
|
||||
|
||||
const dashboard = getTrendsDashboard(db, { startDay: TODAY - 19, endDay: TODAY - 10 });
|
||||
|
||||
assert.equal(dashboard.activity.watchTime.length, 10);
|
||||
assert.equal(dashboard.summary.current.watchTimeMin, 20);
|
||||
assert.equal(dashboard.summary.previous?.watchTimeMin, 40);
|
||||
assert.deepEqual(dashboard.window, { startDay: TODAY - 19, endDay: TODAY - 10 });
|
||||
});
|
||||
});
|
||||
|
||||
test('summary compares with the previous period and all-time has no comparison', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
addRollup(db, TODAY, videoId, { minutes: 10, cards: 1 });
|
||||
addRollup(db, TODAY - 10, videoId, { minutes: 25, cards: 4 });
|
||||
|
||||
const { current, previous } = getTrendsDashboard(db, '7d', 'day').summary;
|
||||
|
||||
assert.equal(current.watchTimeMin, 10);
|
||||
assert.equal(current.activeDays, 1);
|
||||
assert.equal(previous?.watchTimeMin, 25);
|
||||
assert.equal(previous?.cards, 4);
|
||||
assert.equal(getTrendsDashboard(db, 'all', 'day').summary.previous, null);
|
||||
});
|
||||
});
|
||||
|
||||
test('a title filter narrows the page but keeps the title lists complete', () => {
|
||||
withDashboardDb((db) => {
|
||||
const first = addVideo(db, 'First', 1);
|
||||
const second = addVideo(db, 'Second', 1);
|
||||
addRollup(db, TODAY, first, { minutes: 10 });
|
||||
addRollup(db, TODAY, second, { minutes: 50 });
|
||||
addSession(db, TODAY, first, { minutes: 10, lookups: 3 });
|
||||
addSession(db, TODAY, second, { minutes: 50, lookups: 9 });
|
||||
addWord(db, { headword: '海', day: TODAY });
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '7d', 'day', true, 'First');
|
||||
|
||||
assert.equal(dashboard.summary.current.watchTimeMin, 10);
|
||||
assert.equal(dashboard.progress.lookups.at(-1)?.value, 3);
|
||||
assert.equal(
|
||||
dashboard.watchTimeByDayHour.flat().reduce((sum, value) => sum + value, 0),
|
||||
10,
|
||||
);
|
||||
assert.equal(dashboard.librarySummary.length, 2);
|
||||
assert.equal(dashboard.titleTimeline.length, 2);
|
||||
// New words are not tracked per title.
|
||||
assert.equal(dashboard.summary.current.newWords, null);
|
||||
assert.equal(dashboard.learning, null);
|
||||
assert.deepEqual(dashboard.progress.newWords, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('running totals follow the activity charts when empty days are hidden', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
addRollup(db, TODAY - 3, videoId, { minutes: 30 });
|
||||
addRollup(db, TODAY, videoId, { minutes: 10 });
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '7d', 'day', false);
|
||||
|
||||
assert.deepEqual(
|
||||
dashboard.progress.watchTime.map((point) => point.value),
|
||||
[30, 40],
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.watchTime.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('the watch time running total rounds its sum, so it ends on the summary total', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
addRollup(db, TODAY - 2, videoId, { minutes: 10.4 });
|
||||
addRollup(db, TODAY - 1, videoId, { minutes: 10.4 });
|
||||
addRollup(db, TODAY, videoId, { minutes: 10.4 });
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '7d', 'day', false);
|
||||
|
||||
assert.deepEqual(
|
||||
dashboard.activity.watchTime.map((point) => point.value),
|
||||
[10, 10, 10],
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.watchTime.map((point) => point.value),
|
||||
[10, 21, 31],
|
||||
);
|
||||
assert.equal(dashboard.summary.current.watchTimeMin, 31);
|
||||
});
|
||||
});
|
||||
|
||||
test('the summary adds watch time in date order, so a total near .5 rounds like the chart', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
// Newest first these add up to 4.499999999999999, and oldest first to 4.5.
|
||||
addRollup(db, TODAY - 2, videoId, { minutes: 0.1 });
|
||||
addRollup(db, TODAY - 1, videoId, { minutes: 0.1 });
|
||||
addRollup(db, TODAY, videoId, { minutes: 4.3 });
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '7d', 'day');
|
||||
|
||||
assert.equal(
|
||||
dashboard.summary.current.watchTimeMin,
|
||||
dashboard.progress.watchTime.at(-1)?.value,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('new words count a dictionary word once, on its first form, without names', () => {
|
||||
withDashboardDb((db) => {
|
||||
addWord(db, { headword: '言う', word: '言う', day: TODAY - 2, rank: 50 });
|
||||
addWord(db, { headword: '言う', word: '言った', day: TODAY, rank: 50 });
|
||||
addWord(db, { headword: '東京', day: TODAY - 1, pos2: '固有名詞', rank: 800 });
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '7d', 'day');
|
||||
|
||||
assert.equal(dashboard.summary.current.newWords, 1);
|
||||
assert.equal(dashboard.progress.newWords.at(-1)?.value, 1);
|
||||
assert.equal(dashboard.progress.newWords.at(-3)?.value, 1);
|
||||
assert.equal(dashboard.progress.newWords.at(-4)?.value, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('new words per thousand and frequency bands read dictionary words', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
addRollup(db, TODAY, videoId, { words: 2_000 });
|
||||
addWord(db, { headword: '一', day: TODAY, rank: 500 });
|
||||
addWord(db, { headword: '二', day: TODAY, rank: 3_000 });
|
||||
addWord(db, { headword: '三', day: TODAY, rank: 30_000 });
|
||||
addWord(db, { headword: '四', day: TODAY, rank: null });
|
||||
|
||||
const { learning } = getTrendsDashboard(db, '7d', 'day');
|
||||
|
||||
assert.equal(learning?.newWordsPerThousand.at(-1)?.value, 2);
|
||||
assert.deepEqual(learning?.frequencyBands.at(-1)?.counts, {
|
||||
top1k: 1,
|
||||
top5k: 1,
|
||||
top10k: 0,
|
||||
top20k: 0,
|
||||
beyond20k: 1,
|
||||
unranked: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('vocabulary charts and summary count dictionary words, not surface forms', () => {
|
||||
withDashboardDb((db) => {
|
||||
markLexicalDailyRollupsReady(db);
|
||||
addWord(db, { headword: '言う', word: '言う', day: TODAY - 20 });
|
||||
addWord(db, { headword: '言う', word: '言った', day: TODAY });
|
||||
addWord(db, { headword: '東京', day: TODAY, pos2: '固有名詞' });
|
||||
|
||||
const charts = getVocabularyChartData(db);
|
||||
const summary = getVocabularySummary(db, null, Number(NOW_MS));
|
||||
|
||||
assert.deepEqual(charts.newWordsTimelineWithoutNames, [{ epochDay: TODAY - 20, wordCount: 1 }]);
|
||||
assert.deepEqual(charts.newWordsTimeline, [
|
||||
{ epochDay: TODAY - 20, wordCount: 1 },
|
||||
{ epochDay: TODAY, wordCount: 1 },
|
||||
]);
|
||||
assert.equal(summary.uniqueWords, 2);
|
||||
assert.equal(summary.uniqueWordsWithoutNames, 1);
|
||||
// 言う was first seen long ago, so its newer conjugation is not a new word.
|
||||
assert.equal(summary.newThisWeek, 1);
|
||||
assert.equal(summary.newThisWeekWithoutNames, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('names tagged with merged parts of speech count as names', () => {
|
||||
withDashboardDb((db) => {
|
||||
markLexicalDailyRollupsReady(db);
|
||||
addWord(db, { headword: '美琴', day: TODAY, pos2: '固有名詞|一般' });
|
||||
addWord(db, { headword: '谷君', day: TODAY, pos2: '固有名詞|接尾' });
|
||||
addWord(db, { headword: '海', day: TODAY, pos2: '一般' });
|
||||
|
||||
assert.equal(getTrendsDashboard(db, '7d', 'day').summary.current.newWords, 1);
|
||||
const summary = getVocabularySummary(db, null, Number(NOW_MS));
|
||||
assert.equal(summary.uniqueWords, 3);
|
||||
assert.equal(summary.uniqueWordsWithoutNames, 1);
|
||||
assert.deepEqual(getVocabularyChartData(db).newWordsTimelineWithoutNames, [
|
||||
{ epochDay: TODAY, wordCount: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('new word counts pick up writes from this and other connections', () => {
|
||||
withDashboardDb((db) => {
|
||||
addWord(db, { headword: '海', day: TODAY });
|
||||
assert.equal(getTrendsDashboard(db, '7d', 'day').summary.current.newWords, 1);
|
||||
|
||||
addWord(db, { headword: '山', day: TODAY });
|
||||
assert.equal(getTrendsDashboard(db, '7d', 'day').summary.current.newWords, 2);
|
||||
|
||||
const dbPath = (db.prepare('PRAGMA database_list').get() as { file: string }).file;
|
||||
const other = new Database(dbPath);
|
||||
try {
|
||||
addWord(other, { headword: '川', day: TODAY });
|
||||
} finally {
|
||||
other.close();
|
||||
}
|
||||
assert.equal(getTrendsDashboard(db, '7d', 'day').summary.current.newWords, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test('getSessionSummaries can narrow to a span of local days', () => {
|
||||
withDashboardDb((db) => {
|
||||
const videoId = addVideo(db, 'Show', 1);
|
||||
addSession(db, TODAY - 5, videoId);
|
||||
addSession(db, TODAY - 4, videoId);
|
||||
addSession(db, TODAY, videoId);
|
||||
|
||||
assert.equal(getSessionSummaries(db, 50).length, 3);
|
||||
assert.equal(getSessionSummaries(db, 50, { startDay: TODAY - 5, endDay: TODAY - 4 }).length, 2);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,562 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import type { StatsDictionaryWordQuery } from '../../../types/stats-http-contract';
|
||||
import {
|
||||
getSimilarWords,
|
||||
getWordAnimeAppearances,
|
||||
getVocabularySummary,
|
||||
getWordDetail,
|
||||
getWordOccurrences,
|
||||
replaceStatsExcludedWords,
|
||||
searchSubtitleSentences,
|
||||
} from './query-lexical';
|
||||
import { markLexicalDailyRollupsReady } from './lexical-rollups';
|
||||
import { getDictionaryWordPage, getVocabularyCoverage } from './query-vocabulary-words';
|
||||
import { startSessionRecord } from './session';
|
||||
import { Database, type DatabaseSync } from './sqlite';
|
||||
import { ensureSchema, getOrCreateAnimeRecord, getOrCreateVideoRecord } from './storage';
|
||||
import { SOURCE_TYPE_LOCAL, SOURCE_TYPE_REMOTE } from './types';
|
||||
|
||||
function withDb(run: (db: DatabaseSync) => void): void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-vocabulary-'));
|
||||
const db = new Database(path.join(dir, 'immersion.sqlite'));
|
||||
try {
|
||||
ensureSchema(db);
|
||||
run(db);
|
||||
} finally {
|
||||
db.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
interface WordFixture {
|
||||
headword: string;
|
||||
word?: string;
|
||||
reading?: string;
|
||||
seen?: number;
|
||||
rank?: number | null;
|
||||
pos2?: string;
|
||||
partOfSpeech?: string;
|
||||
firstSeen?: number;
|
||||
}
|
||||
|
||||
function addWord(db: DatabaseSync, word: WordFixture): number {
|
||||
const firstSeen = word.firstSeen ?? 1_700_000_000;
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen,
|
||||
frequency, frequency_rank
|
||||
) VALUES (?, ?, ?, ?, '', ?, '', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
word.headword,
|
||||
word.word ?? word.headword,
|
||||
word.reading ?? '',
|
||||
word.partOfSpeech ?? 'noun',
|
||||
word.pos2 ?? '',
|
||||
firstSeen,
|
||||
firstSeen,
|
||||
word.seen ?? 1,
|
||||
word.rank ?? null,
|
||||
);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
function getIdOf(db: DatabaseSync, headword: string): number {
|
||||
const row = db.prepare('SELECT id FROM imm_words WHERE headword = ?').get(headword) as {
|
||||
id: number;
|
||||
};
|
||||
return row.id;
|
||||
}
|
||||
|
||||
function addOccurrence(
|
||||
db: DatabaseSync,
|
||||
wordId: number,
|
||||
animeTitle: string,
|
||||
count = 1,
|
||||
source: 'file' | 'stream' = 'file',
|
||||
timing: { startMs: number | null; endMs: number | null } = { startMs: 1_000, endMs: 2_000 },
|
||||
): void {
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: animeTitle,
|
||||
canonicalTitle: animeTitle,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
const videoId = getOrCreateVideoRecord(
|
||||
db,
|
||||
source === 'file' ? `local:/tmp/${animeTitle}.mkv` : `remote:jellyfin://${animeTitle}`,
|
||||
{
|
||||
canonicalTitle: animeTitle,
|
||||
sourcePath: source === 'file' ? `/tmp/${animeTitle}.mkv` : null,
|
||||
sourceUrl: source === 'file' ? null : `jellyfin://${animeTitle}`,
|
||||
sourceType: source === 'file' ? SOURCE_TYPE_LOCAL : SOURCE_TYPE_REMOTE,
|
||||
},
|
||||
);
|
||||
const { sessionId } = startSessionRecord(db, videoId, 1_700_000_000_000);
|
||||
const lineId = Number(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO imm_subtitle_lines (
|
||||
session_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms, text
|
||||
) VALUES (?, ?, ?, 0, ?, ?, ?)`,
|
||||
)
|
||||
.run(sessionId, videoId, animeId, timing.startMs, timing.endMs, `line ${animeTitle}`)
|
||||
.lastInsertRowid,
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count) VALUES (?, ?, ?)`,
|
||||
).run(lineId, wordId, count);
|
||||
}
|
||||
|
||||
function query(overrides: Partial<StatsDictionaryWordQuery> = {}): StatsDictionaryWordQuery {
|
||||
return {
|
||||
sort: 'seen',
|
||||
descending: true,
|
||||
hideKnown: false,
|
||||
hideNames: false,
|
||||
hideKana: false,
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const headwords = (db: DatabaseSync, overrides: Partial<StatsDictionaryWordQuery>) =>
|
||||
getDictionaryWordPage(db, null, query(overrides)).rows.map((row) => row.headword);
|
||||
|
||||
test('conjugated forms fold into one word with its total and dictionary reading', () => {
|
||||
withDb((db) => {
|
||||
addWord(db, { headword: '見る', word: '見て', reading: 'みて', seen: 263, rank: 36 });
|
||||
const dictionaryId = addWord(db, {
|
||||
headword: '見る',
|
||||
word: '見る',
|
||||
reading: 'みる',
|
||||
seen: 172,
|
||||
rank: 36,
|
||||
});
|
||||
addWord(db, { headword: '見る', word: '見てる', reading: 'みてる', seen: 78, rank: 36 });
|
||||
|
||||
const { rows, total } = getDictionaryWordPage(db, null, query());
|
||||
|
||||
assert.equal(total, 1);
|
||||
assert.equal(rows[0]?.seen, 513);
|
||||
assert.equal(rows[0]?.reading, 'みる');
|
||||
assert.equal(rows[0]?.wordId, dictionaryId);
|
||||
assert.equal(rows[0]?.formCount, 3);
|
||||
assert.deepEqual(
|
||||
rows[0]?.forms.map((form) => form.word),
|
||||
['見て', '見る', '見てる'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a form stored under two readings counts as one form', () => {
|
||||
withDb((db) => {
|
||||
const first = addWord(db, { headword: '見る', word: '見て', reading: 'みて', seen: 263 });
|
||||
addWord(db, { headword: '見る', word: '見て', reading: 'み', seen: 122 });
|
||||
addWord(db, { headword: '見る', word: '見た', reading: 'みた', seen: 200 });
|
||||
|
||||
const row = getDictionaryWordPage(db, null, query()).rows[0];
|
||||
assert.equal(row?.formCount, 2);
|
||||
assert.deepEqual(
|
||||
row?.forms.map((form) => [form.word, form.seen]),
|
||||
[
|
||||
['見て', 385],
|
||||
['見た', 200],
|
||||
],
|
||||
);
|
||||
assert.equal(getWordDetail(db, first)?.formCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('a word seen only conjugated has no reading rather than a conjugated one', () => {
|
||||
withDb((db) => {
|
||||
addWord(db, { headword: '待つ', word: '待って', reading: 'まって', seen: 5 });
|
||||
assert.equal(getDictionaryWordPage(db, null, query()).rows[0]?.reading, '');
|
||||
});
|
||||
});
|
||||
|
||||
test('the list reaches rarely seen words, not just the most seen ones', () => {
|
||||
withDb((db) => {
|
||||
for (let index = 0; index < 600; index += 1) {
|
||||
addWord(db, { headword: `語${index}`, seen: 1_000 - index });
|
||||
}
|
||||
const { rows, total } = getDictionaryWordPage(db, null, query({ limit: 10, offset: 590 }));
|
||||
assert.equal(total, 600);
|
||||
assert.equal(rows[9]?.headword, '語599');
|
||||
});
|
||||
});
|
||||
|
||||
test('search matches any form or reading, treating katakana and hiragana alike', () => {
|
||||
withDb((db) => {
|
||||
addWord(db, { headword: '猫', word: '猫', reading: 'ねこ' });
|
||||
addWord(db, { headword: '行く', word: '行った', reading: 'いった' });
|
||||
addWord(db, { headword: '犬', word: '犬', reading: 'いぬ' });
|
||||
|
||||
assert.deepEqual(headwords(db, { search: 'ネコ' }), ['猫']);
|
||||
assert.deepEqual(headwords(db, { search: '行った' }), ['行く']);
|
||||
assert.deepEqual(headwords(db, { search: 'いった' }), ['行く']);
|
||||
});
|
||||
});
|
||||
|
||||
for (const c of [
|
||||
{ name: 'known words', filter: { hideKnown: true }, expected: ['海', '田中', 'なる'] },
|
||||
{ name: 'names', filter: { hideNames: true }, expected: ['山', '海', 'なる'] },
|
||||
{ name: 'kana', filter: { hideKana: true }, expected: ['山', '海', '田中'] },
|
||||
{ name: 'a rank limit', filter: { maxRank: 500 }, expected: ['山', 'なる'] },
|
||||
{ name: 'a part of speech', filter: { partOfSpeech: 'verb' as const }, expected: ['なる'] },
|
||||
]) {
|
||||
test(`the list filters by ${c.name}`, () => {
|
||||
withDb((db) => {
|
||||
addWord(db, { headword: '山', seen: 40, rank: 100 });
|
||||
addWord(db, { headword: '海', seen: 30, rank: 9_000 });
|
||||
addWord(db, { headword: '田中', seen: 20, rank: 800, pos2: '固有名詞|一般' });
|
||||
addWord(db, { headword: 'なる', seen: 10, rank: 16, partOfSpeech: 'verb' });
|
||||
|
||||
const rows = getDictionaryWordPage(db, new Set(['山']), query(c.filter)).rows;
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.headword),
|
||||
c.expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('a word with a non-name form is not a name and ranks as that form', () => {
|
||||
withDb((db) => {
|
||||
addWord(db, { headword: '白', word: 'しろ', rank: 49_000, pos2: '固有名詞' });
|
||||
addWord(db, { headword: '白', word: '白', rank: 1_500, pos2: '一般' });
|
||||
|
||||
const row = getDictionaryWordPage(db, null, query({ hideNames: true })).rows[0];
|
||||
assert.equal(row?.isName, false);
|
||||
assert.equal(row?.rank, 1_500);
|
||||
});
|
||||
});
|
||||
|
||||
test('excluded words stay out of the list', () => {
|
||||
withDb((db) => {
|
||||
addWord(db, { headword: '音', seen: 50 });
|
||||
addWord(db, { headword: '海', seen: 10 });
|
||||
assert.deepEqual(headwords(db, {}), ['音', '海']);
|
||||
|
||||
replaceStatsExcludedWords(db, [{ headword: '音', word: '音', reading: '' }]);
|
||||
assert.deepEqual(headwords(db, {}), ['海']);
|
||||
});
|
||||
});
|
||||
|
||||
test('unranked words sort last in both rank directions', () => {
|
||||
withDb((db) => {
|
||||
addWord(db, { headword: '無', rank: null });
|
||||
addWord(db, { headword: '高', rank: 10 });
|
||||
addWord(db, { headword: '低', rank: 900 });
|
||||
|
||||
assert.deepEqual(headwords(db, { sort: 'rank', descending: false }), ['高', '低', '無']);
|
||||
assert.deepEqual(headwords(db, { sort: 'rank', descending: true }), ['低', '高', '無']);
|
||||
});
|
||||
});
|
||||
|
||||
test('titles count distinct titles across every form and can filter and sort', () => {
|
||||
withDb((db) => {
|
||||
const seen = addWord(db, { headword: '見る', word: '見て', seen: 5 });
|
||||
const seenAgain = addWord(db, { headword: '見る', word: '見た', seen: 5 });
|
||||
const single = addWord(db, { headword: '海', seen: 50 });
|
||||
addOccurrence(db, seen, 'A');
|
||||
addOccurrence(db, seenAgain, 'B');
|
||||
addOccurrence(db, single, 'C');
|
||||
|
||||
const page = getDictionaryWordPage(db, null, query());
|
||||
assert.deepEqual(
|
||||
page.rows.map((row) => [row.headword, row.titles]),
|
||||
[
|
||||
['海', 1],
|
||||
['見る', 2],
|
||||
],
|
||||
);
|
||||
const across = getDictionaryWordPage(db, null, query({ sort: 'titles', minTitles: 2 }));
|
||||
assert.deepEqual(
|
||||
across.rows.map((row) => [row.headword, row.titles]),
|
||||
[['見る', 2]],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a word is stream only when every line, across its forms, came from a stream', () => {
|
||||
withDb((db) => {
|
||||
const streamed = addWord(db, { headword: '流', word: '流れ', seen: 30 });
|
||||
addOccurrence(db, streamed, 'Stream A', 1, 'stream');
|
||||
const mixed = addWord(db, { headword: '混', word: '混ぜ', seen: 20 });
|
||||
const mixedFile = addWord(db, { headword: '混', word: '混ぜた', seen: 10 });
|
||||
addOccurrence(db, mixed, 'Stream B', 1, 'stream');
|
||||
addOccurrence(db, mixedFile, 'File A', 1, 'file');
|
||||
addWord(db, { headword: '無', seen: 1 });
|
||||
|
||||
const streamOnly = Object.fromEntries(
|
||||
getDictionaryWordPage(db, null, query()).rows.map((row) => [row.headword, row.streamOnly]),
|
||||
);
|
||||
// A word with no stored lines isn't blocked by streams.
|
||||
assert.deepEqual(streamOnly, { 流: true, 混: false, 無: false });
|
||||
});
|
||||
});
|
||||
|
||||
test('word detail counts lines from streams across every form', () => {
|
||||
withDb((db) => {
|
||||
const streamed = addWord(db, { headword: '混', word: '混ぜ' });
|
||||
const file = addWord(db, { headword: '混', word: '混ぜた' });
|
||||
addOccurrence(db, streamed, 'Stream A', 3, 'stream');
|
||||
addOccurrence(db, streamed, 'Stream B', 1, 'stream');
|
||||
addOccurrence(db, file, 'File A', 2, 'file');
|
||||
|
||||
const detail = getWordDetail(db, file);
|
||||
assert.equal(detail?.lineCount, 3);
|
||||
assert.equal(detail?.streamLineCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('lines that can be mined come first in examples and search, newest first within each', () => {
|
||||
withDb((db) => {
|
||||
const wordId = addWord(db, { headword: '言う', word: '言った', reading: 'いった' });
|
||||
addOccurrence(db, wordId, 'File old', 1, 'file');
|
||||
addOccurrence(db, wordId, 'File new', 1, 'file');
|
||||
addOccurrence(db, wordId, 'File untimed', 1, 'file', { startMs: null, endMs: null });
|
||||
addOccurrence(db, wordId, 'Stream newest', 1, 'stream');
|
||||
|
||||
const expected = ['File new', 'File old', 'Stream newest', 'File untimed'];
|
||||
assert.deepEqual(
|
||||
getWordOccurrences(db, '言う', '言った', 'いった', 10, 0, true).map((row) => row.videoTitle),
|
||||
expected,
|
||||
);
|
||||
assert.deepEqual(
|
||||
searchSubtitleSentences(db, 'line').map((row) => row.videoTitle),
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy rows with NULL text columns list, search, and open without failing', () => {
|
||||
withDb((db) => {
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO imm_words (headword, word, reading, first_seen, last_seen, frequency)
|
||||
VALUES (?, ?, ?, 1700000000, 1700000000, 3)`,
|
||||
);
|
||||
const noHeadword = Number(insert.run(null, '猫', null).lastInsertRowid);
|
||||
const noForm = Number(insert.run('犬', null, null).lastInsertRowid);
|
||||
|
||||
const { rows } = getDictionaryWordPage(db, null, query());
|
||||
assert.deepEqual(rows.map((row) => [row.headword, row.forms[0]?.word, row.reading]).sort(), [
|
||||
['犬', '犬', ''],
|
||||
['猫', '猫', ''],
|
||||
]);
|
||||
// NULL must not become searchable text.
|
||||
assert.deepEqual(headwords(db, { search: 'null' }), []);
|
||||
|
||||
for (const id of [noHeadword, noForm]) {
|
||||
assert.equal(getWordDetail(db, id)?.frequency, 3);
|
||||
assert.deepEqual(getWordAnimeAppearances(db, id), []);
|
||||
assert.deepEqual(getSimilarWords(db, id), []);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('maxSeen and total describe every page, for scaling bars and paging', () => {
|
||||
withDb((db) => {
|
||||
addWord(db, { headword: '一', seen: 100 });
|
||||
addWord(db, { headword: '二', seen: 5 });
|
||||
|
||||
const page = getDictionaryWordPage(db, null, query({ limit: 1, offset: 1 }));
|
||||
assert.equal(page.total, 2);
|
||||
assert.equal(page.maxSeen, 100);
|
||||
assert.deepEqual(
|
||||
page.rows.map((row) => row.headword),
|
||||
['二'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('the summary counts a word as known when any of its forms is known', () => {
|
||||
withDb((db) => {
|
||||
markLexicalDailyRollupsReady(db);
|
||||
addWord(db, { headword: '見る', word: '見て', reading: 'みて' });
|
||||
addWord(db, { headword: '海' });
|
||||
|
||||
assert.equal(getVocabularySummary(db, new Set(['見て'])).knownWordCount, 1);
|
||||
assert.equal(
|
||||
getDictionaryWordPage(db, new Set(['見て']), query()).rows.filter((row) => row.known).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('coverage counts seen and known words per frequency band, without names', () => {
|
||||
withDb((db) => {
|
||||
addWord(db, { headword: '一', rank: 500 });
|
||||
addWord(db, { headword: '二', rank: 900 });
|
||||
addWord(db, { headword: '三', rank: 4_000 });
|
||||
addWord(db, { headword: '四', rank: 30_000 });
|
||||
addWord(db, { headword: '田中', rank: 700, pos2: '固有名詞' });
|
||||
addWord(db, { headword: '無', rank: null });
|
||||
|
||||
assert.deepEqual(getVocabularyCoverage(db, new Set(['一', '三', '田中'])), [
|
||||
{ band: 'top1k', seen: 2, known: 1 },
|
||||
{ band: 'top5k', seen: 1, known: 1 },
|
||||
{ band: 'top10k', seen: 0, known: 0 },
|
||||
{ band: 'top20k', seen: 0, known: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('word detail reports totals and a reading for the dictionary word, not one form', () => {
|
||||
withDb((db) => {
|
||||
const form = addWord(db, {
|
||||
headword: '見る',
|
||||
word: '見て',
|
||||
reading: 'みて',
|
||||
seen: 263,
|
||||
rank: 36,
|
||||
firstSeen: 1_700_000_500,
|
||||
});
|
||||
addWord(db, {
|
||||
headword: '見る',
|
||||
word: '見る',
|
||||
reading: 'みる',
|
||||
seen: 172,
|
||||
rank: 36,
|
||||
firstSeen: 1_700_000_100,
|
||||
});
|
||||
|
||||
const detail = getWordDetail(db, form);
|
||||
|
||||
assert.equal(detail?.word, '見て');
|
||||
assert.equal(detail?.frequency, 435);
|
||||
assert.equal(detail?.dictionaryReading, 'みる');
|
||||
assert.equal(detail?.frequencyRank, 36);
|
||||
assert.equal(detail?.formCount, 2);
|
||||
assert.equal(detail?.firstSeen, 1_700_000_100);
|
||||
assert.equal(detail?.lastSeen, 1_700_000_500);
|
||||
});
|
||||
});
|
||||
|
||||
test('word appearances and examples can cover every form', () => {
|
||||
withDb((db) => {
|
||||
const first = addWord(db, { headword: '見る', word: '見て', reading: 'みて' });
|
||||
const second = addWord(db, { headword: '見る', word: '見た', reading: 'みた' });
|
||||
addOccurrence(db, first, 'A', 3);
|
||||
addOccurrence(db, second, 'B', 2);
|
||||
|
||||
assert.deepEqual(
|
||||
getWordAnimeAppearances(db, first).map((row) => [row.animeTitle, row.occurrenceCount]),
|
||||
[
|
||||
['A', 3],
|
||||
['B', 2],
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("related words leave out a word's own forms and show one entry per word", () => {
|
||||
withDb((db) => {
|
||||
const form = addWord(db, { headword: '見る', word: '見て', reading: 'みて', seen: 10 });
|
||||
addWord(db, { headword: '見る', word: '見た', reading: 'みた', seen: 9 });
|
||||
addWord(db, { headword: '見える', word: '見えた', reading: 'みえた', seen: 4 });
|
||||
addWord(db, { headword: '見える', word: '見える', reading: 'みえる', seen: 3 });
|
||||
|
||||
const similar = getSimilarWords(db, form);
|
||||
|
||||
assert.deepEqual(
|
||||
similar.map((row) => [row.headword, row.frequency]),
|
||||
[['見える', 7]],
|
||||
);
|
||||
assert.equal(similar[0]?.word, '見えた');
|
||||
});
|
||||
});
|
||||
|
||||
test('a reading match ranks a related word without choosing the form it shows', () => {
|
||||
withDb((db) => {
|
||||
const form = addWord(db, { headword: '見る', word: '見て', reading: 'みて', seen: 10 });
|
||||
// Matches on the kanji with every form, and on the reading with its minor form.
|
||||
const kanjiAndReading = addWord(db, {
|
||||
headword: '見える',
|
||||
word: '見えた',
|
||||
reading: 'みえた',
|
||||
seen: 9,
|
||||
});
|
||||
addWord(db, { headword: '見える', word: '見えて', reading: 'みて', seen: 2 });
|
||||
// Matches only on the reading of its minor form, so its other forms must still count.
|
||||
const readingOnly = addWord(db, {
|
||||
headword: '満ちる',
|
||||
word: '満ちた',
|
||||
reading: 'みちた',
|
||||
seen: 7,
|
||||
});
|
||||
addWord(db, { headword: '満ちる', word: '満ちて', reading: 'みて', seen: 1 });
|
||||
// More seen, but no reading match, so it still ranks after both.
|
||||
addWord(db, { headword: '見物', word: '見物', reading: 'けんぶつ', seen: 100 });
|
||||
// A hidden form supplies neither the shown form nor the total.
|
||||
const hidden = addWord(db, { headword: '満ちる', word: '満ち', reading: 'みち', seen: 50 });
|
||||
db.prepare('UPDATE imm_words SET vocabulary_visible = 0 WHERE id = ?').run(hidden);
|
||||
|
||||
assert.deepEqual(
|
||||
getSimilarWords(db, form).map((row) => [row.wordId, row.word, row.reading, row.frequency]),
|
||||
[
|
||||
[kanjiAndReading, '見えた', 'みえた', 11],
|
||||
[readingOnly, '満ちた', 'みちた', 8],
|
||||
[getIdOf(db, '見物'), '見物', 'けんぶつ', 100],
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('related words show the same form when a word has tied top forms', () => {
|
||||
withDb((db) => {
|
||||
const form = addWord(db, { headword: '見る', word: '見て', reading: 'みて', seen: 10 });
|
||||
const first = addWord(db, { headword: '見える', word: '見えた', reading: 'みえた', seen: 5 });
|
||||
addWord(db, { headword: '見える', word: '見えて', reading: 'みえて', seen: 5 });
|
||||
|
||||
assert.equal(getSimilarWords(db, form)[0]?.wordId, first);
|
||||
});
|
||||
});
|
||||
|
||||
test('related words leave out rows with a blank headword', () => {
|
||||
withDb((db) => {
|
||||
const form = addWord(db, { headword: '見る', word: '見て', reading: 'みて', seen: 10 });
|
||||
for (const [index, headword] of ['', ' ', '\t', '\v', '\f', ' '].entries()) {
|
||||
addWord(db, { headword, word: `空${index}`, reading: 'みて', seen: 50 });
|
||||
}
|
||||
addWord(db, { headword: '満ちる', word: '満ちて', reading: 'みて', seen: 1 });
|
||||
|
||||
assert.deepEqual(
|
||||
getSimilarWords(db, form).map((row) => row.headword),
|
||||
['満ちる'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('all-forms examples include legacy rows stored without a headword', () => {
|
||||
withDb((db) => {
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO imm_words (headword, word, reading, first_seen, last_seen, frequency)
|
||||
VALUES (?, ?, ?, 1700000000, 1700000000, 1)`,
|
||||
);
|
||||
const titlesFor = (headword: string): string[] =>
|
||||
getWordOccurrences(db, headword, '', '', 10, 0, true)
|
||||
.map((row) => row.videoTitle)
|
||||
.sort();
|
||||
|
||||
addOccurrence(db, Number(insert.run('猫', '猫', 'ねこ').lastInsertRowid), 'Headword');
|
||||
addOccurrence(db, Number(insert.run(null, '猫', 'ねこ').lastInsertRowid), 'Null headword');
|
||||
addOccurrence(db, Number(insert.run(' ', '猫', 'ねこ').lastInsertRowid), 'Blank headword');
|
||||
addOccurrence(db, Number(insert.run(null, null, 'いぬ').lastInsertRowid), 'Reading only');
|
||||
addOccurrence(db, Number(insert.run('犬', '猫', 'いぬ').lastInsertRowid), 'Other headword');
|
||||
|
||||
assert.deepEqual(titlesFor('猫'), ['Blank headword', 'Headword', 'Null headword']);
|
||||
assert.deepEqual(titlesFor('いぬ'), ['Reading only']);
|
||||
assert.deepEqual(titlesFor('犬'), ['Other headword']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import type {
|
||||
StatsDictionaryWordPos,
|
||||
StatsDictionaryWordQuery,
|
||||
} from '../../../types/stats-http-contract';
|
||||
import type {
|
||||
DictionaryWordForm,
|
||||
DictionaryWordPage,
|
||||
DictionaryWordRow,
|
||||
VocabularyCoverageBand,
|
||||
VocabularyCoverageBandId,
|
||||
} from '../../../types/stats-wire';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import {
|
||||
databaseWriteVersion,
|
||||
DICTIONARY_WORD_KEY_SQL,
|
||||
getExcludedWordIds,
|
||||
isProperNounPos2,
|
||||
mergeFormsByWord,
|
||||
NO_LOCAL_FILE_SQL,
|
||||
} from './query-lexical';
|
||||
import { forEachIdChunk, makePlaceholders } from './query-shared';
|
||||
import { nowMs } from './time';
|
||||
|
||||
const MAX_FORMS_PER_ROW = 8;
|
||||
// Counting titles for every word walks every occurrence (about 900ms on 500k rows),
|
||||
// and playback writes constantly, so that count tolerates being this stale.
|
||||
const ALL_TITLE_COUNTS_MAX_AGE_MS = 5 * 60_000;
|
||||
|
||||
const COVERAGE_BAND_LIMITS: ReadonlyArray<{ band: VocabularyCoverageBandId; maxRank: number }> = [
|
||||
{ band: 'top1k', maxRank: 1_000 },
|
||||
{ band: 'top5k', maxRank: 5_000 },
|
||||
{ band: 'top10k', maxRank: 10_000 },
|
||||
{ band: 'top20k', maxRank: 20_000 },
|
||||
];
|
||||
|
||||
const PART_OF_SPEECH_GROUPS: Record<StatsDictionaryWordPos, ReadonlySet<string>> = {
|
||||
noun: new Set(['noun']),
|
||||
verb: new Set(['verb']),
|
||||
adjective: new Set(['i_adjective', 'na_adjective']),
|
||||
};
|
||||
|
||||
const KANA_ONLY_TEXT = /^[\p{Script=Hiragana}\p{Script=Katakana}ーゝゞヽヾ]+$/u;
|
||||
|
||||
interface DictionaryWordFormRow {
|
||||
id: number;
|
||||
key: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
seen: number | null;
|
||||
rank: number | null;
|
||||
pos2: string | null;
|
||||
partOfSpeech: string | null;
|
||||
firstSeen: number | null;
|
||||
lastSeen: number | null;
|
||||
}
|
||||
|
||||
interface DictionaryWord {
|
||||
key: string;
|
||||
seen: number;
|
||||
rank: number | null;
|
||||
isName: boolean;
|
||||
isKana: boolean;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
searchText: string;
|
||||
/** Most seen first. */
|
||||
forms: DictionaryWordFormRow[];
|
||||
}
|
||||
|
||||
const wordCache = new WeakMap<DatabaseSync, { version: string; words: DictionaryWord[] }>();
|
||||
const allTitleCountsCache = new WeakMap<
|
||||
DatabaseSync,
|
||||
{ version: string; computedAtMs: number; counts: Map<string, number> }
|
||||
>();
|
||||
|
||||
function toSeconds(value: number | null): number {
|
||||
const numeric = Number(value ?? 0);
|
||||
return Math.abs(numeric) >= 10_000_000_000 ? numeric / 1000 : numeric;
|
||||
}
|
||||
|
||||
/** Lowercase hiragana, so a search matches regardless of kana type or case. */
|
||||
function normalizeSearchText(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[ァ-ヶ]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0x60));
|
||||
}
|
||||
|
||||
function loadDictionaryWords(db: DatabaseSync): DictionaryWord[] {
|
||||
const version = databaseWriteVersion(db);
|
||||
const cached = wordCache.get(db);
|
||||
if (cached?.version === version) return cached.words;
|
||||
|
||||
const excludedIds = getExcludedWordIds(db);
|
||||
const exclusion =
|
||||
excludedIds.length > 0 ? `AND id NOT IN (${makePlaceholders(excludedIds)})` : '';
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, ${DICTIONARY_WORD_KEY_SQL} AS key,
|
||||
COALESCE(NULLIF(TRIM(word), ''), ${DICTIONARY_WORD_KEY_SQL}) AS word,
|
||||
COALESCE(reading, '') AS reading, frequency AS seen,
|
||||
frequency_rank AS rank, pos2, part_of_speech AS partOfSpeech,
|
||||
first_seen AS firstSeen, last_seen AS lastSeen
|
||||
FROM imm_words
|
||||
WHERE vocabulary_visible = 1 ${exclusion}
|
||||
ORDER BY frequency DESC, id
|
||||
`,
|
||||
)
|
||||
.all(...excludedIds) as unknown as DictionaryWordFormRow[];
|
||||
|
||||
const byKey = new Map<string, DictionaryWord>();
|
||||
for (const row of rows) {
|
||||
if (!row.key) continue;
|
||||
const firstSeen = toSeconds(row.firstSeen);
|
||||
const lastSeen = toSeconds(row.lastSeen);
|
||||
const isName = isProperNounPos2(row.pos2);
|
||||
const entry = byKey.get(row.key);
|
||||
if (entry) {
|
||||
entry.seen += row.seen ?? 0;
|
||||
entry.forms.push(row);
|
||||
entry.firstSeen = Math.min(entry.firstSeen, firstSeen);
|
||||
entry.lastSeen = Math.max(entry.lastSeen, lastSeen);
|
||||
entry.isName &&= isName;
|
||||
entry.searchText += `\n${row.word}\n${row.reading}`;
|
||||
} else {
|
||||
byKey.set(row.key, {
|
||||
key: row.key,
|
||||
seen: row.seen ?? 0,
|
||||
rank: null,
|
||||
isName,
|
||||
isKana: KANA_ONLY_TEXT.test(row.key),
|
||||
firstSeen,
|
||||
lastSeen,
|
||||
searchText: `${row.key}\n${row.word}\n${row.reading}`,
|
||||
forms: [row],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const words = [...byKey.values()];
|
||||
for (const word of words) {
|
||||
word.searchText = normalizeSearchText(word.searchText);
|
||||
word.rank = bestRank(word.forms);
|
||||
}
|
||||
wordCache.set(db, { version, words });
|
||||
return words;
|
||||
}
|
||||
|
||||
/** Rank from the word's non-name forms when it has any, so 白 ranks as the color. */
|
||||
function bestRank(forms: DictionaryWordFormRow[]): number | null {
|
||||
const nonNames = forms.filter((form) => !isProperNounPos2(form.pos2));
|
||||
let best: number | null = null;
|
||||
for (const form of nonNames.length > 0 ? nonNames : forms) {
|
||||
if (form.rank !== null && form.rank > 0 && (best === null || form.rank < best)) {
|
||||
best = form.rank;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function isKnown(word: DictionaryWord, knownWords: ReadonlySet<string> | null): boolean {
|
||||
if (!knownWords) return false;
|
||||
return knownWords.has(word.key) || word.forms.some((form) => knownWords.has(form.word));
|
||||
}
|
||||
|
||||
function dictionaryForm(word: DictionaryWord): DictionaryWordFormRow | undefined {
|
||||
return word.forms.find((form) => form.word === word.key);
|
||||
}
|
||||
|
||||
function getAllTitleCounts(db: DatabaseSync): Map<string, number> {
|
||||
const version = databaseWriteVersion(db);
|
||||
const cached = allTitleCountsCache.get(db);
|
||||
if (
|
||||
cached &&
|
||||
(cached.version === version || nowMs() - cached.computedAtMs < ALL_TITLE_COUNTS_MAX_AGE_MS)
|
||||
) {
|
||||
return cached.counts;
|
||||
}
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT ${DICTIONARY_WORD_KEY_SQL} AS key, COUNT(DISTINCT sl.anime_id) AS titles
|
||||
FROM imm_words
|
||||
JOIN imm_word_line_occurrences o ON o.word_id = imm_words.id
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE sl.anime_id IS NOT NULL AND vocabulary_visible = 1
|
||||
GROUP BY key
|
||||
`,
|
||||
)
|
||||
.all() as Array<{ key: string; titles: number }>;
|
||||
const counts = new Map(rows.map((row) => [row.key, row.titles]));
|
||||
allTitleCountsCache.set(db, { version, computedAtMs: nowMs(), counts });
|
||||
return counts;
|
||||
}
|
||||
|
||||
function getTitleCountsForWords(db: DatabaseSync, words: DictionaryWord[]): Map<string, number> {
|
||||
const keyByWordId = new Map<number, string>();
|
||||
for (const word of words) {
|
||||
for (const form of word.forms) keyByWordId.set(form.id, word.key);
|
||||
}
|
||||
const animeByKey = new Map<string, Set<number>>();
|
||||
forEachIdChunk([...keyByWordId.keys()], (chunk) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT o.word_id AS wordId, sl.anime_id AS animeId
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id IN (${makePlaceholders(chunk)}) AND sl.anime_id IS NOT NULL
|
||||
`,
|
||||
)
|
||||
.all(...chunk) as Array<{ wordId: number; animeId: number }>;
|
||||
for (const row of rows) {
|
||||
const key = keyByWordId.get(row.wordId);
|
||||
if (key === undefined) continue;
|
||||
const anime = animeByKey.get(key) ?? new Set<number>();
|
||||
anime.add(row.animeId);
|
||||
animeByKey.set(key, anime);
|
||||
}
|
||||
});
|
||||
return new Map([...animeByKey].map(([key, anime]) => [key, anime.size]));
|
||||
}
|
||||
|
||||
/** Words whose every stored line came from a stream. Words with no lines don't count. */
|
||||
function getStreamOnlyWords(db: DatabaseSync, words: DictionaryWord[]): Set<string> {
|
||||
const keyByWordId = new Map<number, string>();
|
||||
for (const word of words) {
|
||||
for (const form of word.forms) keyByWordId.set(form.id, word.key);
|
||||
}
|
||||
const withLines = new Set<string>();
|
||||
const withFile = new Set<string>();
|
||||
forEachIdChunk([...keyByWordId.keys()], (chunk) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT o.word_id AS wordId, MIN(${NO_LOCAL_FILE_SQL}) AS allStreamed
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines l ON l.line_id = o.line_id
|
||||
JOIN imm_videos v ON v.video_id = l.video_id
|
||||
WHERE o.word_id IN (${makePlaceholders(chunk)})
|
||||
GROUP BY o.word_id
|
||||
`,
|
||||
)
|
||||
.all(...chunk) as Array<{ wordId: number; allStreamed: number }>;
|
||||
for (const row of rows) {
|
||||
const key = keyByWordId.get(row.wordId);
|
||||
if (key === undefined) continue;
|
||||
withLines.add(key);
|
||||
if (row.allStreamed === 0) withFile.add(key);
|
||||
}
|
||||
});
|
||||
return new Set([...withLines].filter((key) => !withFile.has(key)));
|
||||
}
|
||||
|
||||
function compareWords(
|
||||
left: DictionaryWord,
|
||||
right: DictionaryWord,
|
||||
query: StatsDictionaryWordQuery,
|
||||
titleCounts: Map<string, number> | null,
|
||||
): number {
|
||||
const sign = query.descending ? -1 : 1;
|
||||
const byText = left.key < right.key ? -1 : left.key > right.key ? 1 : 0;
|
||||
switch (query.sort) {
|
||||
case 'headword':
|
||||
return byText * sign;
|
||||
case 'rank':
|
||||
// Unranked words go last in either direction.
|
||||
if (left.rank === null || right.rank === null) {
|
||||
return (left.rank === null ? 1 : 0) - (right.rank === null ? 1 : 0) || byText;
|
||||
}
|
||||
return (left.rank - right.rank) * sign || byText;
|
||||
case 'titles':
|
||||
return (
|
||||
((titleCounts?.get(left.key) ?? 0) - (titleCounts?.get(right.key) ?? 0)) * sign ||
|
||||
right.seen - left.seen ||
|
||||
byText
|
||||
);
|
||||
case 'firstSeen':
|
||||
return (left.firstSeen - right.firstSeen) * sign || byText;
|
||||
case 'lastSeen':
|
||||
return (left.lastSeen - right.lastSeen) * sign || byText;
|
||||
case 'seen':
|
||||
return (left.seen - right.seen) * sign || byText;
|
||||
}
|
||||
}
|
||||
|
||||
function toRow(
|
||||
word: DictionaryWord,
|
||||
knownWords: ReadonlySet<string> | null,
|
||||
titleCounts: Map<string, number>,
|
||||
streamOnlyWords: ReadonlySet<string>,
|
||||
): DictionaryWordRow {
|
||||
const dictionary = dictionaryForm(word);
|
||||
const topForm = word.forms[0]!;
|
||||
const reading = word.forms.find(
|
||||
(form) => form.word === word.key && form.reading.trim() !== '',
|
||||
)?.reading;
|
||||
const forms = mergeFormsByWord<DictionaryWordForm>(
|
||||
word.forms.map((form) => ({
|
||||
wordId: form.id,
|
||||
word: form.word,
|
||||
reading: form.reading,
|
||||
seen: form.seen ?? 0,
|
||||
})),
|
||||
);
|
||||
return {
|
||||
wordId: (dictionary ?? topForm).id,
|
||||
headword: word.key,
|
||||
reading: reading ?? '',
|
||||
partOfSpeech: topForm.partOfSpeech,
|
||||
rank: word.rank,
|
||||
seen: word.seen,
|
||||
titles: titleCounts.get(word.key) ?? 0,
|
||||
firstSeen: word.firstSeen,
|
||||
lastSeen: word.lastSeen,
|
||||
known: isKnown(word, knownWords),
|
||||
isName: word.isName,
|
||||
streamOnly: streamOnlyWords.has(word.key),
|
||||
formCount: forms.length,
|
||||
forms: forms.slice(0, MAX_FORMS_PER_ROW),
|
||||
};
|
||||
}
|
||||
|
||||
/** One page of dictionary words, folded across their forms and filtered on the server. */
|
||||
export function getDictionaryWordPage(
|
||||
db: DatabaseSync,
|
||||
knownWords: ReadonlySet<string> | null,
|
||||
query: StatsDictionaryWordQuery,
|
||||
): DictionaryWordPage {
|
||||
const needle = query.search ? normalizeSearchText(query.search) : '';
|
||||
const posGroup = query.partOfSpeech ? PART_OF_SPEECH_GROUPS[query.partOfSpeech] : null;
|
||||
const needsAllTitleCounts = query.sort === 'titles' || query.minTitles !== undefined;
|
||||
const allTitleCounts = needsAllTitleCounts ? getAllTitleCounts(db) : null;
|
||||
|
||||
const matches = loadDictionaryWords(db).filter((word) => {
|
||||
if (query.hideNames && word.isName) return false;
|
||||
if (query.hideKana && word.isKana) return false;
|
||||
if (query.maxRank !== undefined && (word.rank === null || word.rank > query.maxRank)) {
|
||||
return false;
|
||||
}
|
||||
if (posGroup && !posGroup.has(word.forms[0]?.partOfSpeech ?? '')) return false;
|
||||
if (needle && !word.searchText.includes(needle)) return false;
|
||||
if (query.minTitles !== undefined && (allTitleCounts?.get(word.key) ?? 0) < query.minTitles) {
|
||||
return false;
|
||||
}
|
||||
return !(query.hideKnown && isKnown(word, knownWords));
|
||||
});
|
||||
|
||||
matches.sort((left, right) => compareWords(left, right, query, allTitleCounts));
|
||||
const pageWords = matches.slice(query.offset, query.offset + query.limit);
|
||||
const titleCounts = allTitleCounts ?? getTitleCountsForWords(db, pageWords);
|
||||
const streamOnlyWords = getStreamOnlyWords(db, pageWords);
|
||||
return {
|
||||
rows: pageWords.map((word) => toRow(word, knownWords, titleCounts, streamOnlyWords)),
|
||||
total: matches.length,
|
||||
maxSeen: matches.reduce((max, word) => Math.max(max, word.seen), 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** How many seen words in each frequency band are known. Names don't count. */
|
||||
export function getVocabularyCoverage(
|
||||
db: DatabaseSync,
|
||||
knownWords: ReadonlySet<string>,
|
||||
): VocabularyCoverageBand[] {
|
||||
const bands = COVERAGE_BAND_LIMITS.map(({ band }) => ({ band, seen: 0, known: 0 }));
|
||||
for (const word of loadDictionaryWords(db)) {
|
||||
if (word.isName || word.rank === null) continue;
|
||||
const index = COVERAGE_BAND_LIMITS.findIndex(({ maxRank }) => word.rank! <= maxRank);
|
||||
const band = bands[index];
|
||||
if (!band) continue;
|
||||
band.seen += 1;
|
||||
if (isKnown(word, knownWords)) band.known += 1;
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
@@ -624,7 +624,14 @@ export interface WordDetailRow {
|
||||
pos1: string | null;
|
||||
pos2: string | null;
|
||||
pos3: string | null;
|
||||
/** Times seen across every form of this dictionary word. */
|
||||
frequency: number;
|
||||
frequencyRank: number | null;
|
||||
dictionaryReading: string;
|
||||
formCount: number;
|
||||
forms: Array<{ wordId: number; word: string; reading: string; seen: number }>;
|
||||
lineCount: number;
|
||||
streamLineCount: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ function createControllerConfigFixture() {
|
||||
holdSecondarySub: { kind: 'none' as const },
|
||||
previousEntry: { kind: 'none' as const },
|
||||
nextEntry: { kind: 'none' as const },
|
||||
toggleCharacterNavigation: { kind: 'none' as const },
|
||||
previousSubtitle: { kind: 'none' as const },
|
||||
nextSubtitle: { kind: 'none' as const },
|
||||
replaySubtitle: { kind: 'none' as const },
|
||||
|
||||
@@ -38,7 +38,7 @@ const DEFAULT_WINDOW_HEIGHT = 720;
|
||||
export const PLUGIN_DEACTIVATE_TIMEOUT_MS = 2_000;
|
||||
export const PLUGIN_ACTIVATE_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface MpvClientEvents {
|
||||
export interface MpvClientEvents {
|
||||
currentVideoPath?: string;
|
||||
currentMediaTitle?: string | null;
|
||||
on: <E extends PluginMpvEventName>(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
enrichSessionsWithKnownWordMetrics,
|
||||
loadKnownWordsSet,
|
||||
parseEventTypesQuery,
|
||||
parseDayRange,
|
||||
parseIntQuery,
|
||||
parsePositiveId,
|
||||
parseTrendFillEmpty,
|
||||
@@ -64,17 +65,26 @@ export function registerStatsAnalyticsRoutes(
|
||||
});
|
||||
|
||||
app.get('/api/stats/trends/dashboard', async (c) => {
|
||||
const range = parseTrendRange(c.req.query('range'));
|
||||
const range = parseTrendRange(
|
||||
c.req.query('range'),
|
||||
c.req.query('fromDay'),
|
||||
c.req.query('toDay'),
|
||||
);
|
||||
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
|
||||
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
|
||||
const title = c.req.query('title')?.trim() || null;
|
||||
return c.json(
|
||||
statsJson('trendsDashboard', await tracker.getTrendsDashboard(range, groupBy, fillEmpty)),
|
||||
statsJson(
|
||||
'trendsDashboard',
|
||||
await tracker.getTrendsDashboard(range, groupBy, fillEmpty, title),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||
const rawSessions = await tracker.getSessionSummaries(limit);
|
||||
const dayRange = parseDayRange(c.req.query('fromDay'), c.req.query('toDay')) ?? undefined;
|
||||
const rawSessions = await tracker.getSessionSummaries(limit, dayRange);
|
||||
const sessions = await enrichSessionsWithKnownWordMetrics(
|
||||
tracker,
|
||||
rawSessions,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
parseBooleanQuery,
|
||||
parseDuplicateLineCleanupBody,
|
||||
parseExcludedWordsBody,
|
||||
parseDictionaryWordQuery,
|
||||
parseIntQuery,
|
||||
parsePositiveId,
|
||||
parsePositiveIdList,
|
||||
@@ -49,6 +50,20 @@ export function registerStatsLibraryRoutes(
|
||||
return c.json(statsJson('vocabularyCharts', await tracker.getVocabularyChartData()));
|
||||
});
|
||||
|
||||
app.get('/api/stats/vocabulary/words', async (c) => {
|
||||
const page = await tracker.getDictionaryWords(
|
||||
loadKnownWordsSet(options?.knownWordCachePath),
|
||||
parseDictionaryWordQuery((name) => c.req.query(name)),
|
||||
);
|
||||
return c.json(statsJson('dictionaryWords', page));
|
||||
});
|
||||
|
||||
app.get('/api/stats/vocabulary/coverage', async (c) => {
|
||||
const knownWords = loadKnownWordsSet(options?.knownWordCachePath);
|
||||
const coverage = knownWords ? await tracker.getVocabularyCoverage(knownWords) : [];
|
||||
return c.json(statsJson('vocabularyCoverage', coverage));
|
||||
});
|
||||
|
||||
app.get('/api/stats/excluded-words', async (c) => {
|
||||
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
|
||||
});
|
||||
@@ -78,12 +93,23 @@ export function registerStatsLibraryRoutes(
|
||||
const headword = (c.req.query('headword') ?? '').trim();
|
||||
const word = (c.req.query('word') ?? '').trim();
|
||||
const reading = (c.req.query('reading') ?? '').trim();
|
||||
if (!headword || !word) {
|
||||
const allForms = c.req.query('allForms') === 'true';
|
||||
// Legacy rows can lack a headword or form, so an all-forms lookup falls back the same
|
||||
// way the word list does. An exact lookup can only match a row that has both.
|
||||
const dictionaryWord = headword || word || reading;
|
||||
if (allForms ? !dictionaryWord : !headword || !word) {
|
||||
return c.json(statsJson('wordOccurrences', []), 400);
|
||||
}
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
||||
const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset);
|
||||
const occurrences = await tracker.getWordOccurrences(
|
||||
dictionaryWord,
|
||||
word,
|
||||
reading,
|
||||
limit,
|
||||
offset,
|
||||
allForms,
|
||||
);
|
||||
return c.json(statsJson('wordOccurrences', occurrences));
|
||||
});
|
||||
|
||||
@@ -102,7 +128,7 @@ export function registerStatsLibraryRoutes(
|
||||
});
|
||||
|
||||
app.get('/api/stats/kanji', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 100, 10_000);
|
||||
const kanji = await tracker.getKanjiStats(limit);
|
||||
return c.json(statsJson('kanji', kanji));
|
||||
});
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
} from '../../../anki-integration/known-word-cache-format.js';
|
||||
import { createLogger } from '../../../logger.js';
|
||||
import type { AnkiConnectConfig } from '../../../types.js';
|
||||
import type {
|
||||
StatsDayRange,
|
||||
StatsDictionaryWordQuery,
|
||||
StatsDictionaryWordSort,
|
||||
StatsTrendGroupBy,
|
||||
StatsTrendRange,
|
||||
} from '../../../types/stats-http-contract.js';
|
||||
import type { StatsExcludedWord } from '../../../types/stats-wire.js';
|
||||
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
||||
import { splitSentenceSearchTerms } from '../immersion-tracker/query-lexical.js';
|
||||
@@ -58,14 +65,61 @@ export function parsePositiveId(raw: string | undefined): number | null {
|
||||
return isPositiveSafeInteger(value) && String(value) === raw ? value : null;
|
||||
}
|
||||
|
||||
export function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
|
||||
/** Parses an inclusive `fromDay`/`toDay` pair; anything malformed or reversed means no range. */
|
||||
export function parseDayRange(
|
||||
rawFrom: string | undefined,
|
||||
rawTo: string | undefined,
|
||||
): StatsDayRange | null {
|
||||
const startDay = parsePositiveId(rawFrom);
|
||||
const endDay = parsePositiveId(rawTo);
|
||||
return startDay !== null && endDay !== null && startDay <= endDay ? { startDay, endDay } : null;
|
||||
}
|
||||
|
||||
export function parseTrendRange(
|
||||
raw: string | undefined,
|
||||
rawFrom?: string,
|
||||
rawTo?: string,
|
||||
): StatsTrendRange {
|
||||
if (raw === 'custom') return parseDayRange(rawFrom, rawTo) ?? '30d';
|
||||
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
|
||||
? raw
|
||||
: '30d';
|
||||
}
|
||||
|
||||
export function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
||||
return raw === 'month' ? 'month' : 'day';
|
||||
export function parseTrendGroupBy(raw: string | undefined): StatsTrendGroupBy {
|
||||
return raw === 'month' || raw === 'week' ? raw : 'day';
|
||||
}
|
||||
|
||||
const DICTIONARY_WORD_SORTS: readonly StatsDictionaryWordSort[] = [
|
||||
'seen',
|
||||
'rank',
|
||||
'titles',
|
||||
'firstSeen',
|
||||
'lastSeen',
|
||||
'headword',
|
||||
];
|
||||
const DICTIONARY_WORD_PAGE_LIMIT = 200;
|
||||
|
||||
/** Reads the word list query string. Anything unrecognized falls back to the default. */
|
||||
export function parseDictionaryWordQuery(
|
||||
read: (name: string) => string | undefined,
|
||||
): StatsDictionaryWordQuery {
|
||||
const sort = DICTIONARY_WORD_SORTS.find((value) => value === read('sort')) ?? 'seen';
|
||||
const pos = read('pos');
|
||||
const flag = (name: string): boolean => read(name) === 'true';
|
||||
return {
|
||||
search: read('search')?.trim() || undefined,
|
||||
sort,
|
||||
descending: read('order') !== 'asc',
|
||||
hideKnown: flag('hideKnown'),
|
||||
hideNames: flag('hideNames'),
|
||||
hideKana: flag('hideKana'),
|
||||
partOfSpeech: pos === 'noun' || pos === 'verb' || pos === 'adjective' ? pos : undefined,
|
||||
maxRank: parsePositiveId(read('maxRank')) ?? undefined,
|
||||
minTitles: parsePositiveId(read('minTitles')) ?? undefined,
|
||||
limit: parseIntQuery(read('limit'), 25, DICTIONARY_WORD_PAGE_LIMIT),
|
||||
offset: parseIntQuery(read('offset'), 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTrendFillEmpty(raw: string | undefined): boolean {
|
||||
|
||||
+95
-691
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ import test, { type TestContext } from 'node:test';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { HyprlandPlacementStatus } from '../../core/services/hyprland-window-placement';
|
||||
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
||||
import { stopLinuxOverlayZOrderKeepAliveLoop } from '../runtime/linux-overlay-zorder-keepalive';
|
||||
import { createAppState } from '../state';
|
||||
import { createOverlayAssembly, type OverlayAssemblyDeps } from './overlay';
|
||||
|
||||
@@ -73,26 +74,36 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
|
||||
}),
|
||||
setOverlayWindowBounds: spy('overlay.setOverlayWindowBounds', () => NO_PLACEMENT),
|
||||
setModalWindowBounds: spy('overlay.setModalWindowBounds', () => NO_PLACEMENT),
|
||||
getOverlayWindows: spy('overlay.getOverlayWindows', () => []),
|
||||
broadcastToOverlayWindows: spy('overlay.broadcast', noop),
|
||||
},
|
||||
overlayModalInputState: { getModalInputExclusive: spy('modal.getInputExclusive', () => false) },
|
||||
overlayModalRuntime: {
|
||||
notifyOverlayModalOpened: spy('modal.notifyOpened', noop),
|
||||
handleOverlayModalClosed: spy('modal.handleClosed', noop),
|
||||
sendToActiveOverlayWindow: spy('modal.sendToActive', () => false),
|
||||
openRuntimeOptionsPalette: spy('modal.openRuntimeOptionsPalette', noop),
|
||||
},
|
||||
overlayContentMeasurementStore: {
|
||||
report: spy('measurement.report', () =>
|
||||
state.measurementAccepted ? ACCEPTED_MEASUREMENT : null,
|
||||
),
|
||||
clear: spy('measurement.clear', noop),
|
||||
getLatestByLayer: spy('measurement.getLatest', () => null),
|
||||
},
|
||||
autoplayReadyGate: {
|
||||
getAutoplayReadyGate: spy('getAutoplayReadyGate', () => ({
|
||||
markCurrentMediaAutoplayReady: spy('autoplay.markCurrentMediaReady', noop),
|
||||
flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop),
|
||||
},
|
||||
})),
|
||||
screen: {
|
||||
getCursorScreenPoint: spy('screen.getCursorScreenPoint', () => ({ x: 0, y: 0 })),
|
||||
getDisplayNearestPoint: spy('screen.getDisplayNearestPoint', (): never => {
|
||||
throw new Error('display fallback used');
|
||||
}),
|
||||
getDisplayMatching: spy('screen.getDisplayMatching', (): never => {
|
||||
throw new Error('display matching used');
|
||||
}),
|
||||
screenToDipPoint: spy('screen.screenToDipPoint', (point: { x: number; y: number }) => point),
|
||||
},
|
||||
isDev: false,
|
||||
createOverlayWindowCore: spy('createOverlayWindowCore', (_kind, options): BrowserWindow => {
|
||||
@@ -101,47 +112,6 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
|
||||
return state.window;
|
||||
}),
|
||||
getYomitanSession: spy('getYomitanSession', () => null),
|
||||
getVisibleOverlayInteraction: spy('getVisibleOverlayInteraction', () => ({
|
||||
getLinuxOverlayInputShapeActive: spy('interaction.getLinuxInputShapeActive', () => false),
|
||||
getMacOSVisibleOverlayForegroundProbeActive: spy('interaction.getMacProbe', () => false),
|
||||
getVisibleOverlayInteractionActive: spy('interaction.getActive', () => false),
|
||||
setVisibleOverlayInteractionActive: spy('interaction.setActive', noop),
|
||||
setLinuxOverlayInteractiveHint: spy('interaction.setLinuxInteractiveHint', noop),
|
||||
applyLinuxOverlayInputShapeFromLatestMeasurement: spy(
|
||||
'interaction.applyLinuxShape',
|
||||
() => false,
|
||||
),
|
||||
tickLinuxOverlayPointerInteractionNow: spy('interaction.tickLinuxNow', noop),
|
||||
tickWindowsOverlayPointerInteractionNow: spy('interaction.tickWindowsNow', noop),
|
||||
getWindowsNativeWindowHandleNumber: spy('interaction.getWindowsHandle', () => 0),
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement: spy(
|
||||
'interaction.primePointerAfterFirstMeasurement',
|
||||
noop,
|
||||
),
|
||||
requestLinuxOverlayZOrderFollow: spy('interaction.requestLinuxZOrderFollow', noop),
|
||||
requestWindowsVisibleOverlayZOrderSync: spy('interaction.requestWindowsZOrderSync', noop),
|
||||
resetLinuxVisibleOverlayStartupInputPrimer: spy('interaction.resetStartupPrimer', noop),
|
||||
resetVisibleOverlayInputState: spy('interaction.resetInputState', noop),
|
||||
restoreVisibleOverlayWindowShapeForShow: spy('interaction.restoreShapeForShow', noop),
|
||||
scheduleVisibleOverlayBlurRefresh: spy('interaction.scheduleBlurRefresh', noop),
|
||||
startLinuxVisibleOverlayStartupInputGrace: spy('interaction.startStartupGrace', noop),
|
||||
})),
|
||||
getOverlayGeometry: spy('getOverlayGeometry', () => ({
|
||||
enforceOverlayLayerOrder: spy('geometry.enforceLayerOrder', noop),
|
||||
ensureOverlayWindowLevel: spy('geometry.ensureWindowLevel', noop),
|
||||
getCurrentOverlayGeometry: spy('geometry.getCurrentOverlayGeometry', () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
})),
|
||||
getCurrentTrackedOverlayGeometry: spy(
|
||||
'geometry.getCurrentTrackedOverlayGeometry',
|
||||
() => state.trackedGeometry,
|
||||
),
|
||||
syncPrimaryOverlayWindowLayer: spy('geometry.syncPrimaryLayer', noop),
|
||||
updateVisibleOverlayBounds: spy('geometry.updateVisibleBounds', () => 'not-applicable'),
|
||||
})),
|
||||
getOverlayShortcuts: spy('getOverlayShortcuts', () => ({
|
||||
syncOverlayShortcuts: spy('shortcuts.sync', noop),
|
||||
tryHandleOverlayShortcutLocalFallback: spy('shortcuts.localFallback', () => false),
|
||||
@@ -152,10 +122,9 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
|
||||
flushQueuedOverlayNotifications: spy('notifications.flushQueued', noop),
|
||||
maybeStartOverlayLoadingOsd: spy('notifications.maybeStartLoadingOsd', noop),
|
||||
})),
|
||||
getOverlayVisibilityComposer: spy('getOverlayVisibilityComposer', () => ({
|
||||
broadcastRuntimeOptionsChanged: spy('composer.broadcastRuntimeOptions', noop),
|
||||
setOverlayDebugVisualizationEnabled: spy('composer.setDebugVisualization', noop),
|
||||
})),
|
||||
getRuntimeOptionsState: spy('getRuntimeOptionsState', () => []),
|
||||
getOverlayForegroundSeparateWindows: spy('getOverlayForegroundSeparateWindows', () => []),
|
||||
promoteStatsOverlayAbovePlayback: spy('promoteStatsOverlayAbovePlayback', noop),
|
||||
getMediaTimingReviewRuntime: spy('getMediaTimingReviewRuntime', () => ({
|
||||
dispose: spy('mediaTimingReview.dispose', resolved),
|
||||
})),
|
||||
@@ -176,8 +145,6 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
|
||||
})),
|
||||
setSubtitleSidebarRequestedOpen: spy('setSubtitleSidebarRequestedOpen', noop),
|
||||
stealAppFocus: spy('stealAppFocus', noop),
|
||||
ensureOverlayMpvSubtitlesHidden: spy('ensureOverlayMpvSubtitlesHidden', resolved),
|
||||
syncOverlayMpvSubtitleSuppression: spy('syncOverlayMpvSubtitleSuppression', noop),
|
||||
initializeOverlayRuntime: spy('initializeOverlayRuntime', noop),
|
||||
};
|
||||
return { deps, windowOptions };
|
||||
@@ -201,7 +168,61 @@ function setup(t: TestContext, options: { platform?: NodeJS.Platform; visible?:
|
||||
};
|
||||
const { deps, windowOptions } = makeDeps(recorder, state);
|
||||
const overlay = createOverlayAssembly(deps);
|
||||
return { recorder, state, overlay, windowOptions };
|
||||
// Building the assembly on Linux starts the shared z-order keep-alive loop.
|
||||
t.after(() => stopLinuxOverlayZOrderKeepAliveLoop());
|
||||
stubRuntimeMethods(recorder, state, overlay);
|
||||
return { recorder, state, overlay, windowOptions, deps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the assembly's own pointer-interaction and geometry methods with recording stubs,
|
||||
* so these tests pin the order of the assembly's calls into them, not the runtimes' internals.
|
||||
* The assembly looks these methods up at call time, so stubbing after construction works.
|
||||
*/
|
||||
function stubRuntimeMethods(recorder: Recorder, state: FixtureState, overlay: Overlay): void {
|
||||
const { spy } = recorder;
|
||||
const noop = () => {};
|
||||
Object.assign(overlay.interaction, {
|
||||
getLinuxOverlayInputShapeActive: spy('interaction.getLinuxInputShapeActive', () => false),
|
||||
getMacOSVisibleOverlayForegroundProbeActive: spy('interaction.getMacProbe', () => false),
|
||||
getVisibleOverlayInteractionActive: spy('interaction.getActive', () => false),
|
||||
setVisibleOverlayInteractionActive: spy('interaction.setActive', noop),
|
||||
setLinuxOverlayInteractiveHint: spy('interaction.setLinuxInteractiveHint', noop),
|
||||
applyLinuxOverlayInputShapeFromLatestMeasurement: spy(
|
||||
'interaction.applyLinuxShape',
|
||||
() => false,
|
||||
),
|
||||
tickLinuxOverlayPointerInteractionNow: spy('interaction.tickLinuxNow', noop),
|
||||
tickWindowsOverlayPointerInteractionNow: spy('interaction.tickWindowsNow', noop),
|
||||
getWindowsNativeWindowHandleNumber: spy('interaction.getWindowsHandle', () => 0),
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement: spy(
|
||||
'interaction.primePointerAfterFirstMeasurement',
|
||||
noop,
|
||||
),
|
||||
requestLinuxOverlayZOrderFollow: spy('interaction.requestLinuxZOrderFollow', noop),
|
||||
requestWindowsVisibleOverlayZOrderSync: spy('interaction.requestWindowsZOrderSync', noop),
|
||||
resetLinuxVisibleOverlayStartupInputPrimer: spy('interaction.resetStartupPrimer', noop),
|
||||
resetVisibleOverlayInputState: spy('interaction.resetInputState', noop),
|
||||
restoreVisibleOverlayWindowShapeForShow: spy('interaction.restoreShapeForShow', noop),
|
||||
scheduleVisibleOverlayBlurRefresh: spy('interaction.scheduleBlurRefresh', noop),
|
||||
startLinuxVisibleOverlayStartupInputGrace: spy('interaction.startStartupGrace', noop),
|
||||
} satisfies Partial<Overlay['interaction']>);
|
||||
Object.assign(overlay.geometry, {
|
||||
enforceOverlayLayerOrder: spy('geometry.enforceLayerOrder', noop),
|
||||
ensureOverlayWindowLevel: spy('geometry.ensureWindowLevel', noop),
|
||||
getCurrentOverlayGeometry: spy('geometry.getCurrentOverlayGeometry', () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
})),
|
||||
getCurrentTrackedOverlayGeometry: spy(
|
||||
'geometry.getCurrentTrackedOverlayGeometry',
|
||||
() => state.trackedGeometry,
|
||||
),
|
||||
syncPrimaryOverlayWindowLayer: spy('geometry.syncPrimaryLayer', noop),
|
||||
updateVisibleOverlayBounds: spy('geometry.updateVisibleBounds', noop),
|
||||
} satisfies Partial<Overlay['geometry']>);
|
||||
}
|
||||
|
||||
/** Asserts each name was called, in this relative order. */
|
||||
@@ -341,8 +362,13 @@ test('visible overlay content-ready flushes notifications and autoplay without t
|
||||
|
||||
for (const trackedGeometry of [null, { x: 10, y: 20, width: 1280, height: 720 }]) {
|
||||
test(`Linux fullscreen recreation resets input first and ${trackedGeometry ? 'applies tracked mpv bounds' : 'keeps bounds without tracked geometry'}`, (t) => {
|
||||
const { recorder, state, overlay } = setup(t, { platform: 'linux', visible: true });
|
||||
const { recorder, state, overlay, deps } = setup(t, { platform: 'linux', visible: true });
|
||||
state.trackedGeometry = trackedGeometry;
|
||||
deps.appState.mpvClient = {
|
||||
connected: true,
|
||||
requestProperty: async () => 'yes',
|
||||
setSubVisibility: recorder.spy('mpv.setSubVisibility', () => {}),
|
||||
} as unknown as typeof deps.appState.mpvClient;
|
||||
// An opaque token: the assembly only hands the created window to the overlay manager.
|
||||
state.window = {} as BrowserWindow;
|
||||
|
||||
@@ -353,7 +379,7 @@ for (const trackedGeometry of [null, { x: 10, y: 20, width: 1280, height: 720 }]
|
||||
'interaction.resetInputState',
|
||||
'createOverlayWindowCore',
|
||||
'geometry.getCurrentTrackedOverlayGeometry',
|
||||
'ensureOverlayMpvSubtitlesHidden',
|
||||
'mpv.setSubVisibility',
|
||||
]);
|
||||
const boundsCalls = recorder.calls.filter(
|
||||
(call) => call.name === 'overlay.setOverlayWindowBounds',
|
||||
|
||||
+245
-109
@@ -1,8 +1,11 @@
|
||||
import type { BrowserWindow, Screen, Session } from 'electron';
|
||||
import {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
type createOverlayContentMeasurementStore,
|
||||
type createOverlayWindow as createOverlayWindowCoreFn,
|
||||
sendMpvCommandRuntime,
|
||||
setMpvSubVisibilityRuntime,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||
type createOverlayManager,
|
||||
} from '../../core/services';
|
||||
@@ -14,7 +17,7 @@ import type { createOverlayShortcutsRuntimeService } from '../overlay-shortcuts-
|
||||
import type { OverlayModalRuntime } from '../overlay-runtime';
|
||||
import { createOverlayVisibilityRuntimeService } from '../overlay-visibility-runtime';
|
||||
import type { createAutoplayReadyGate } from '../runtime/autoplay-ready-gate';
|
||||
import type { composeOverlayVisibilityRuntime } from '../runtime/composers';
|
||||
import { composeOverlayVisibilityRuntime } from '../runtime/composers';
|
||||
import { createOverlayVisibilityRuntime } from '../runtime/domains/overlay';
|
||||
import {
|
||||
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
@@ -27,10 +30,14 @@ import { focusMacOSOverlayWindow } from '../runtime/macos-overlay-window-focus';
|
||||
import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review';
|
||||
import type { createJimakuAutoOpenRuntime } from '../runtime/jimaku-auto-open';
|
||||
import type { createOverlayModalInputState } from '../runtime/overlay-modal-input-state';
|
||||
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
|
||||
import {
|
||||
createEnsureOverlayMpvSubtitlesHiddenHandler,
|
||||
createRestoreOverlayMpvSubtitlesHandler,
|
||||
} from '../runtime/overlay-mpv-sub-visibility';
|
||||
import { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import { createOverlayWindowRuntimeHandlers } from '../runtime/overlay-window-runtime-handlers';
|
||||
import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime';
|
||||
import { createVisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime';
|
||||
import type { AppState } from '../state';
|
||||
import type { PlaybackAssembly } from './playback';
|
||||
import type { IpcSlice } from './slices';
|
||||
@@ -55,6 +62,8 @@ export interface OverlayAssemblyDeps {
|
||||
| 'setVisibleOverlayVisible'
|
||||
| 'setOverlayWindowBounds'
|
||||
| 'setModalWindowBounds'
|
||||
| 'getOverlayWindows'
|
||||
| 'broadcastToOverlayWindows'
|
||||
>;
|
||||
overlayModalInputState: Pick<
|
||||
ReturnType<typeof createOverlayModalInputState>,
|
||||
@@ -62,55 +71,35 @@ export interface OverlayAssemblyDeps {
|
||||
>;
|
||||
overlayModalRuntime: Pick<
|
||||
OverlayModalRuntime,
|
||||
'notifyOverlayModalOpened' | 'handleOverlayModalClosed'
|
||||
| 'notifyOverlayModalOpened'
|
||||
| 'handleOverlayModalClosed'
|
||||
| 'sendToActiveOverlayWindow'
|
||||
| 'openRuntimeOptionsPalette'
|
||||
>;
|
||||
overlayContentMeasurementStore: Pick<
|
||||
ReturnType<typeof createOverlayContentMeasurementStore>,
|
||||
'report'
|
||||
'report' | 'clear' | 'getLatestByLayer'
|
||||
>;
|
||||
autoplayReadyGate: Pick<
|
||||
getAutoplayReadyGate: () => Pick<
|
||||
ReturnType<typeof createAutoplayReadyGate>,
|
||||
'markCurrentMediaAutoplayReady' | 'flushPendingAutoplayReadySignal'
|
||||
>;
|
||||
screen: Pick<Screen, 'getCursorScreenPoint' | 'getDisplayNearestPoint'>;
|
||||
screen: Pick<
|
||||
Screen,
|
||||
'getCursorScreenPoint' | 'getDisplayNearestPoint' | 'getDisplayMatching' | 'screenToDipPoint'
|
||||
>;
|
||||
isDev: boolean;
|
||||
/** Builds an Electron overlay window; injected so the assembly stays free of Electron calls. */
|
||||
createOverlayWindowCore: typeof createOverlayWindowCoreFn;
|
||||
getYomitanSession: () => Session | null;
|
||||
/** Pointer interaction and geometry stay in `main.ts`: both import Electron at module load. */
|
||||
getVisibleOverlayInteraction: () => Pick<
|
||||
VisibleOverlayInteractionRuntime,
|
||||
| 'getLinuxOverlayInputShapeActive'
|
||||
| 'getMacOSVisibleOverlayForegroundProbeActive'
|
||||
| 'getVisibleOverlayInteractionActive'
|
||||
| 'setVisibleOverlayInteractionActive'
|
||||
| 'setLinuxOverlayInteractiveHint'
|
||||
| 'applyLinuxOverlayInputShapeFromLatestMeasurement'
|
||||
| 'tickLinuxOverlayPointerInteractionNow'
|
||||
| 'tickWindowsOverlayPointerInteractionNow'
|
||||
| 'getWindowsNativeWindowHandleNumber'
|
||||
| 'primeLinuxOverlayPointerInteractionAfterFirstMeasurement'
|
||||
| 'requestLinuxOverlayZOrderFollow'
|
||||
| 'requestWindowsVisibleOverlayZOrderSync'
|
||||
| 'resetLinuxVisibleOverlayStartupInputPrimer'
|
||||
| 'resetVisibleOverlayInputState'
|
||||
| 'restoreVisibleOverlayWindowShapeForShow'
|
||||
| 'scheduleVisibleOverlayBlurRefresh'
|
||||
| 'startLinuxVisibleOverlayStartupInputGrace'
|
||||
>;
|
||||
getOverlayGeometry: () => Pick<
|
||||
ReturnType<typeof createOverlayGeometryRuntime>,
|
||||
| 'enforceOverlayLayerOrder'
|
||||
| 'ensureOverlayWindowLevel'
|
||||
| 'getCurrentOverlayGeometry'
|
||||
| 'getCurrentTrackedOverlayGeometry'
|
||||
| 'syncPrimaryOverlayWindowLayer'
|
||||
| 'updateVisibleOverlayBounds'
|
||||
>;
|
||||
getOverlayShortcuts: () => Pick<
|
||||
ReturnType<typeof createOverlayShortcutsRuntimeService>,
|
||||
'syncOverlayShortcuts' | 'tryHandleOverlayShortcutLocalFallback'
|
||||
>;
|
||||
/** Settings/setup windows the overlay must not raise above. */
|
||||
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
|
||||
/** Keeps the stats window above playback after the overlay re-asserts its level. */
|
||||
promoteStatsOverlayAbovePlayback: () => void;
|
||||
getOverlayNotifications: () => Pick<
|
||||
ReturnType<typeof createOverlayNotificationsRuntime>,
|
||||
| 'showOverlayLoadingStatusNotification'
|
||||
@@ -118,9 +107,11 @@ export interface OverlayAssemblyDeps {
|
||||
| 'flushQueuedOverlayNotifications'
|
||||
| 'maybeStartOverlayLoadingOsd'
|
||||
>;
|
||||
getOverlayVisibilityComposer: () => Pick<
|
||||
ReturnType<typeof composeOverlayVisibilityRuntime>,
|
||||
'broadcastRuntimeOptionsChanged' | 'setOverlayDebugVisualizationEnabled'
|
||||
/** Runtime option snapshot broadcast to overlay windows when an option changes. */
|
||||
getRuntimeOptionsState: () => ReturnType<
|
||||
Parameters<
|
||||
typeof composeOverlayVisibilityRuntime
|
||||
>[0]['broadcastRuntimeOptionsChangedMainDeps']['getRuntimeOptionsState']
|
||||
>;
|
||||
getMediaTimingReviewRuntime: () => Pick<
|
||||
ReturnType<typeof createMediaTimingReviewRuntime>,
|
||||
@@ -146,20 +137,19 @@ export interface OverlayAssemblyDeps {
|
||||
setSubtitleSidebarRequestedOpen: (open: boolean) => void;
|
||||
/** Brings SubMiner to the foreground (`app.focus({ steal: true })`). */
|
||||
stealAppFocus: () => void;
|
||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void>;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
}
|
||||
|
||||
export type OverlayAssembly = ReturnType<typeof createOverlayAssembly>;
|
||||
|
||||
/**
|
||||
* Owns overlay core: the visible/modal windows, their visibility, the Linux fullscreen window
|
||||
* mode and refresh burst, and the show/hide paths. `main.ts` builds it once and reaches the
|
||||
* runtimes through the result; overlay bootstrap, pointer interaction and geometry stay there.
|
||||
* Owns overlay core: the visible/modal windows, their visibility, geometry and pointer
|
||||
* interaction, the Linux fullscreen window mode and refresh burst, mpv subtitle suppression
|
||||
* while the overlay shows, and the show/hide paths. `main.ts` builds it once and reaches the
|
||||
* runtimes through the result; overlay bootstrap stays in the startup assembly.
|
||||
*/
|
||||
export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
const { appState, logger, overlayManager, overlayModalInputState, autoplayReadyGate } = deps;
|
||||
const { appState, logger, overlayManager, overlayModalInputState } = deps;
|
||||
|
||||
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
|
||||
null;
|
||||
@@ -169,14 +159,14 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
getWindow: () => overlayManager.getMainWindow(),
|
||||
clearWindow: () => overlayManager.setMainWindow(null),
|
||||
createWindow: () => {
|
||||
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
|
||||
interaction.resetVisibleOverlayInputState();
|
||||
createMainWindow();
|
||||
},
|
||||
refreshWindow: () => {
|
||||
const trackedGeometry = deps.getOverlayGeometry().getCurrentTrackedOverlayGeometry();
|
||||
const trackedGeometry = geometry.getCurrentTrackedOverlayGeometry();
|
||||
if (trackedGeometry) overlayManager.setOverlayWindowBounds(trackedGeometry);
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
void deps.ensureOverlayMpvSubtitlesHidden();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
if (appState.currentSubText.trim()) {
|
||||
deps.getSubtitleProcessingController().refreshCurrentSubtitle(appState.currentSubText);
|
||||
}
|
||||
@@ -195,31 +185,28 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
||||
getNonNativeInputRegionActive: () =>
|
||||
process.platform === 'linux' &&
|
||||
deps.getVisibleOverlayInteraction().getLinuxOverlayInputShapeActive(),
|
||||
process.platform === 'linux' && interaction.getLinuxOverlayInputShapeActive(),
|
||||
getSuspendVisibleOverlay: () => appState.statsOverlayVisible,
|
||||
getOverlayInteractionActive: () =>
|
||||
deps.getVisibleOverlayInteraction().getVisibleOverlayInteractionActive(),
|
||||
getOverlayInteractionActive: () => interaction.getVisibleOverlayInteractionActive(),
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
getMacOSForegroundProbeActive: () =>
|
||||
deps.getVisibleOverlayInteraction().getMacOSVisibleOverlayForegroundProbeActive(),
|
||||
getMacOSForegroundProbeActive: () => interaction.getMacOSVisibleOverlayForegroundProbeActive(),
|
||||
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
appState.trackerNotReadyWarningShown = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.getOverlayGeometry().updateVisibleOverlayBounds(geometry),
|
||||
updateVisibleOverlayBounds: (bounds: WindowGeometry) =>
|
||||
geometry.updateVisibleOverlayBounds(bounds),
|
||||
ensureOverlayWindowLevel: (window) => {
|
||||
deps.getOverlayGeometry().ensureOverlayWindowLevel(window);
|
||||
geometry.ensureOverlayWindowLevel(window);
|
||||
},
|
||||
syncWindowsOverlayToMpvZOrder: (_window) => {
|
||||
deps.getVisibleOverlayInteraction().requestWindowsVisibleOverlayZOrderSync();
|
||||
interaction.requestWindowsVisibleOverlayZOrderSync();
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: (layer) => {
|
||||
deps.getOverlayGeometry().syncPrimaryOverlayWindowLayer(layer);
|
||||
geometry.syncPrimaryOverlayWindowLayer(layer);
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
deps.getOverlayGeometry().enforceOverlayLayerOrder();
|
||||
geometry.enforceOverlayLayerOrder();
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
deps.getOverlayShortcuts().syncOverlayShortcuts();
|
||||
@@ -254,16 +241,14 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
return existingWindow;
|
||||
}
|
||||
const window = createModalWindowHandler();
|
||||
overlayManager.setModalWindowBounds(deps.getOverlayGeometry().getCurrentOverlayGeometry());
|
||||
overlayManager.setModalWindowBounds(geometry.getCurrentOverlayGeometry());
|
||||
return window;
|
||||
}
|
||||
|
||||
function createMainWindow(): BrowserWindow {
|
||||
const window = createMainWindowHandler();
|
||||
if (process.platform === 'win32') {
|
||||
const overlayHwnd = deps
|
||||
.getVisibleOverlayInteraction()
|
||||
.getWindowsNativeWindowHandleNumber(window);
|
||||
const overlayHwnd = interaction.getWindowsNativeWindowHandleNumber(window);
|
||||
if (!ensureWindowsOverlayTransparency(overlayHwnd)) {
|
||||
logger.warn('Failed to eagerly extend Windows overlay transparency via koffi');
|
||||
}
|
||||
@@ -292,12 +277,10 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
createOverlayWindowDeps: {
|
||||
createOverlayWindowCore: (kind, options) => deps.createOverlayWindowCore(kind, options),
|
||||
isDev: deps.isDev,
|
||||
ensureOverlayWindowLevel: (window) =>
|
||||
deps.getOverlayGeometry().ensureOverlayWindowLevel(window),
|
||||
onRuntimeOptionsChanged: () =>
|
||||
deps.getOverlayVisibilityComposer().broadcastRuntimeOptionsChanged(),
|
||||
ensureOverlayWindowLevel: (window) => geometry.ensureOverlayWindowLevel(window),
|
||||
onRuntimeOptionsChanged: () => visibilityComposer.broadcastRuntimeOptionsChanged(),
|
||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
||||
deps.getOverlayVisibilityComposer().setOverlayDebugVisualizationEnabled(enabled),
|
||||
visibilityComposer.setOverlayDebugVisualizationEnabled(enabled),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
getYomitanSession: () => deps.getYomitanSession(),
|
||||
@@ -308,10 +291,8 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
shouldRunLinuxOverlayZOrderKeepAlive() &&
|
||||
linuxOverlayModeRuntime.fullscreen &&
|
||||
linuxOverlayModeRuntime.mode === 'fullscreen-override',
|
||||
onVisibleWindowBlurred: () =>
|
||||
deps.getVisibleOverlayInteraction().scheduleVisibleOverlayBlurRefresh(),
|
||||
onVisibleWindowFocused: () =>
|
||||
deps.getVisibleOverlayInteraction().requestLinuxOverlayZOrderFollow(),
|
||||
onVisibleWindowBlurred: () => interaction.scheduleVisibleOverlayBlurRefresh(),
|
||||
onVisibleWindowFocused: () => interaction.requestLinuxOverlayZOrderFollow(),
|
||||
onWindowDidFinishLoad: () => {
|
||||
deps.getOverlayNotifications().flushQueuedOverlayNotifications();
|
||||
},
|
||||
@@ -319,10 +300,8 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
|
||||
deps.getOverlayNotifications().flushQueuedOverlayNotifications();
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
deps
|
||||
.getVisibleOverlayInteraction()
|
||||
.primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
interaction.primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
deps.getAutoplayReadyGate().flushPendingAutoplayReadySignal();
|
||||
},
|
||||
onWindowClosed: (windowKind, window) => {
|
||||
if (windowKind === 'visible') {
|
||||
@@ -371,22 +350,22 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
deps.getAutoplayReadyGate().markCurrentMediaAutoplayReady();
|
||||
deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
|
||||
interaction.resetVisibleOverlayInputState();
|
||||
}
|
||||
if (visible) {
|
||||
deps.getOverlayNotifications().maybeStartOverlayLoadingOsd();
|
||||
deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace();
|
||||
deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow();
|
||||
void deps.ensureOverlayMpvSubtitlesHidden();
|
||||
interaction.resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
interaction.startLinuxVisibleOverlayStartupInputGrace();
|
||||
interaction.restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay();
|
||||
}
|
||||
setVisibleOverlayVisibleHandler(visible);
|
||||
notifyMpvPluginVisibleOverlayVisibility(visible);
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
@@ -394,41 +373,41 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
||||
if (!nextVisible) {
|
||||
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
deps.getAutoplayReadyGate().markCurrentMediaAutoplayReady();
|
||||
deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
|
||||
interaction.resetVisibleOverlayInputState();
|
||||
} else {
|
||||
deps.getOverlayNotifications().maybeStartOverlayLoadingOsd();
|
||||
deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace();
|
||||
deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow();
|
||||
void deps.ensureOverlayMpvSubtitlesHidden();
|
||||
interaction.resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
interaction.startLinuxVisibleOverlayStartupInputGrace();
|
||||
interaction.restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay();
|
||||
}
|
||||
toggleVisibleOverlayHandler();
|
||||
notifyMpvPluginVisibleOverlayVisibility(nextVisible);
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
if (!visible) {
|
||||
deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification();
|
||||
deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
interaction.resetVisibleOverlayInputState();
|
||||
deps.getAutoplayReadyGate().markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
deps.getOverlayNotifications().maybeStartOverlayLoadingOsd();
|
||||
deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace();
|
||||
deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow();
|
||||
void deps.ensureOverlayMpvSubtitlesHidden();
|
||||
interaction.resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
interaction.startLinuxVisibleOverlayStartupInputGrace();
|
||||
interaction.restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay();
|
||||
}
|
||||
setOverlayVisibleHandler(visible);
|
||||
notifyMpvPluginVisibleOverlayVisibility(visible);
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function handleMpvFullscreenChange(fullscreen: boolean): void {
|
||||
@@ -443,10 +422,9 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) =>
|
||||
linuxOverlayModeRuntime.sync(nextFullscreen),
|
||||
getOverlayInteractionActive: () =>
|
||||
deps.getVisibleOverlayInteraction().getVisibleOverlayInteractionActive() ||
|
||||
deps.getVisibleOverlayInteraction().getLinuxOverlayInputShapeActive(),
|
||||
ensureOverlayWindowLevel: (window) =>
|
||||
deps.getOverlayGeometry().ensureOverlayWindowLevel(window),
|
||||
interaction.getVisibleOverlayInteractionActive() ||
|
||||
interaction.getLinuxOverlayInputShapeActive(),
|
||||
ensureOverlayWindowLevel: (window) => geometry.ensureOverlayWindowLevel(window),
|
||||
},
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
);
|
||||
@@ -459,6 +437,162 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
}
|
||||
|
||||
const geometry = createOverlayGeometryRuntime({
|
||||
screen: deps.screen,
|
||||
promoteStatsOverlayAbovePlayback: () => deps.promoteStatsOverlayAbovePlayback(),
|
||||
overlayManager: {
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getModalWindow: () => overlayManager.getModalWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
setOverlayWindowBounds: (bounds) => overlayManager.setOverlayWindowBounds(bounds),
|
||||
setModalWindowBounds: (bounds) => overlayManager.setModalWindowBounds(bounds),
|
||||
},
|
||||
getTrackedWindowGeometry: () => appState.windowTracker?.getGeometry() ?? null,
|
||||
getTrackedWindowMediaSourceId: () => appState.windowTracker?.getTargetWindowMediaSourceId?.(),
|
||||
getTrackedWindowNativeId: () => appState.windowTracker?.getTargetWindowNativeId?.(),
|
||||
getStatsOverlayVisible: () => appState.statsOverlayVisible,
|
||||
getOverlayForegroundSeparateWindows: () => deps.getOverlayForegroundSeparateWindows(),
|
||||
getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
|
||||
getLinuxTrackedMpvFullscreen: () => linuxOverlayModeRuntime.fullscreen,
|
||||
getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxOverlayModeRuntime.fullscreenChangedAtMs,
|
||||
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen) =>
|
||||
linuxOverlayModeRuntime.sync(fullscreen),
|
||||
getLinuxVisibleOverlayOwnerBindingKey: () => linuxOverlayModeRuntime.ownerBindingKey,
|
||||
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
|
||||
linuxOverlayModeRuntime.ownerBindingKey = key;
|
||||
},
|
||||
clearVisibleOverlayX11OwnerBinding: (window) =>
|
||||
interaction.clearVisibleOverlayX11OwnerBinding(window),
|
||||
getNativeWindowHandleDecimal: (window) => interaction.getNativeWindowHandleDecimal(window),
|
||||
enqueueVisibleOverlayX11OwnerBindingOperation: (window, args, onError) =>
|
||||
interaction.enqueueVisibleOverlayX11OwnerBindingOperation(window, args, onError),
|
||||
scheduleWindowsVisibleOverlayZOrderSyncBurst: () =>
|
||||
interaction.scheduleWindowsVisibleOverlayZOrderSyncBurst(),
|
||||
logDebug: (message, ...args) => logger.debug(message, ...args),
|
||||
});
|
||||
|
||||
// Starts the Windows foreground poll and Linux z-order keep-alive loops on those platforms.
|
||||
const interaction = createVisibleOverlayInteractionRuntime({
|
||||
screen: deps.screen,
|
||||
overlayManager: {
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
},
|
||||
overlayContentMeasurementStore: {
|
||||
clear: (layer) => deps.overlayContentMeasurementStore.clear(layer),
|
||||
getLatestByLayer: (layer) => deps.overlayContentMeasurementStore.getLatestByLayer(layer),
|
||||
},
|
||||
logger: {
|
||||
info: (message, ...args) => logger.info(message, ...args),
|
||||
warn: (message, ...args) => logger.warn(message, ...args),
|
||||
debug: (message, ...args) => logger.debug(message, ...args),
|
||||
},
|
||||
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
getModalInputExclusive: () => overlayModalInputState.getModalInputExclusive(),
|
||||
getStatsOverlayVisible: () => appState.statsOverlayVisible,
|
||||
setStatsOverlayVisible: (visible) => {
|
||||
appState.statsOverlayVisible = visible;
|
||||
},
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
setWindowTracker: (tracker) => {
|
||||
appState.windowTracker = tracker;
|
||||
},
|
||||
setTrackerNotReadyWarningShown: (shown) => {
|
||||
appState.trackerNotReadyWarningShown = shown;
|
||||
},
|
||||
getMpvSocketPath: () => appState.mpvSocketPath,
|
||||
getBackendOverride: () => appState.backendOverride,
|
||||
getInitialArgs: () => appState.initialArgs,
|
||||
getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
|
||||
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
|
||||
linuxOverlayModeRuntime.ownerBindingKey = key;
|
||||
},
|
||||
bindVisibleOverlayToTrackedX11Window: (window) =>
|
||||
geometry.bindVisibleOverlayToTrackedX11Window(window),
|
||||
updateVisibleOverlayBounds: (bounds) => geometry.updateVisibleOverlayBounds(bounds),
|
||||
refreshCurrentSubtitle: () => {
|
||||
deps.getSubtitleProcessingController().refreshCurrentSubtitle(appState.currentSubText);
|
||||
},
|
||||
getOverlayWindows: () => overlayManager.getOverlayWindows(),
|
||||
syncOverlayShortcuts: () => deps.getOverlayShortcuts().syncOverlayShortcuts(),
|
||||
resetLastOverlayWindowGeometry: () => geometry.resetLastOverlayWindowGeometry(),
|
||||
enforceOverlayLayerOrder: () => {
|
||||
geometry.enforceOverlayLayerOrder();
|
||||
},
|
||||
getOverlayForegroundSeparateWindows: () => deps.getOverlayForegroundSeparateWindows(),
|
||||
});
|
||||
|
||||
const visibilityComposer = composeOverlayVisibilityRuntime({
|
||||
overlayVisibilityRuntime,
|
||||
restorePreviousSecondarySubVisibilityMainDeps: {
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
},
|
||||
broadcastRuntimeOptionsChangedMainDeps: {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
getRuntimeOptionsState: () => deps.getRuntimeOptionsState(),
|
||||
broadcastToOverlayWindows: (channel, ...args) =>
|
||||
overlayManager.broadcastToOverlayWindows(channel, ...args),
|
||||
},
|
||||
sendToActiveOverlayWindowMainDeps: {
|
||||
sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) =>
|
||||
deps.overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
},
|
||||
setOverlayDebugVisualizationEnabledMainDeps: {
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
getCurrentEnabled: () => appState.overlayDebugVisualizationEnabled,
|
||||
setCurrentEnabled: (next) => {
|
||||
appState.overlayDebugVisualizationEnabled = next;
|
||||
},
|
||||
},
|
||||
openRuntimeOptionsPaletteMainDeps: {
|
||||
openRuntimeOptionsPaletteRuntime: () => deps.overlayModalRuntime.openRuntimeOptionsPalette(),
|
||||
},
|
||||
});
|
||||
|
||||
// mpv's own subtitles stay hidden while the visible overlay shows and come back when it hides.
|
||||
const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility,
|
||||
setSavedSubVisibility: (visible) => {
|
||||
appState.overlaySavedMpvSubVisibility = visible;
|
||||
},
|
||||
getRevision: () => appState.overlayMpvSubVisibilityRevision,
|
||||
setRevision: (revision) => {
|
||||
appState.overlayMpvSubVisibilityRevision = revision;
|
||||
},
|
||||
setMpvSubVisibility: (visible) => {
|
||||
setMpvSubVisibilityRuntime(appState.mpvClient, visible);
|
||||
},
|
||||
logWarn: (message, error) => {
|
||||
logger.warn(message, error);
|
||||
},
|
||||
});
|
||||
const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({
|
||||
getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility,
|
||||
setSavedSubVisibility: (visible) => {
|
||||
appState.overlaySavedMpvSubVisibility = visible;
|
||||
},
|
||||
getRevision: () => appState.overlayMpvSubVisibilityRevision,
|
||||
setRevision: (revision) => {
|
||||
appState.overlayMpvSubVisibilityRevision = revision;
|
||||
},
|
||||
isMpvConnected: () => Boolean(appState.mpvClient?.connected),
|
||||
shouldKeepSuppressedFromVisibleOverlayBinding: () => overlayManager.getVisibleOverlayVisible(),
|
||||
setMpvSubVisibility: (visible) => {
|
||||
setMpvSubVisibilityRuntime(appState.mpvClient, visible);
|
||||
},
|
||||
});
|
||||
|
||||
function syncOverlayMpvSubtitleSuppression(): void {
|
||||
if (overlayManager.getVisibleOverlayVisible()) {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
return;
|
||||
}
|
||||
|
||||
restoreOverlayMpvSubtitles();
|
||||
}
|
||||
|
||||
/** Renderer reports about overlay windows: focus, modal lifecycle, pointer and measurement. */
|
||||
const ipc: IpcSlice<
|
||||
| 'getMainWindow'
|
||||
@@ -490,14 +624,14 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
activatePlaybackWindowForOverlayInteraction: async () => {
|
||||
try {
|
||||
const raised = (await appState.windowTracker?.raiseTargetWindow?.()) ?? false;
|
||||
deps.getOverlayGeometry().enforceOverlayLayerOrder();
|
||||
geometry.enforceOverlayLayerOrder();
|
||||
return raised;
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
'Failed to raise tracked mpv window for overlay interaction:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
deps.getOverlayGeometry().enforceOverlayLayerOrder();
|
||||
geometry.enforceOverlayLayerOrder();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -545,7 +679,6 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
if (!mainWindow || senderWindow !== mainWindow) {
|
||||
return;
|
||||
}
|
||||
const interaction = deps.getVisibleOverlayInteraction();
|
||||
const previousActive = interaction.getVisibleOverlayInteractionActive();
|
||||
interaction.setVisibleOverlayInteractionActive(active);
|
||||
if (previousActive === active) {
|
||||
@@ -561,7 +694,6 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
if (!mainWindow || senderWindow !== mainWindow) {
|
||||
return;
|
||||
}
|
||||
const interaction = deps.getVisibleOverlayInteraction();
|
||||
interaction.setLinuxOverlayInteractiveHint(interactive);
|
||||
interaction.applyLinuxOverlayInputShapeFromLatestMeasurement();
|
||||
},
|
||||
@@ -569,11 +701,10 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
// then schedules the first-paint subtitle refresh.
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
if (deps.overlayContentMeasurementStore.report(payload)) {
|
||||
const interaction = deps.getVisibleOverlayInteraction();
|
||||
interaction.tickLinuxOverlayPointerInteractionNow();
|
||||
interaction.tickWindowsOverlayPointerInteractionNow();
|
||||
interaction.primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
deps.autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
deps.getAutoplayReadyGate().flushPendingAutoplayReadySignal();
|
||||
deps.getAutoplaySubtitlePriming().scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
}
|
||||
},
|
||||
@@ -581,6 +712,11 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
|
||||
|
||||
return {
|
||||
ipc,
|
||||
geometry,
|
||||
interaction,
|
||||
visibilityComposer,
|
||||
restoreOverlayMpvSubtitles,
|
||||
syncOverlayMpvSubtitleSuppression,
|
||||
linuxOverlayModeRuntime,
|
||||
overlayVisibilityRuntime,
|
||||
createMainWindow,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DEFAULT_CONFIG } from '../../config/definitions';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../../core/services/overlay-window-flags';
|
||||
import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import type { OverlayContentMeasurement, SubtitleData } from '../../types';
|
||||
import { createAppState } from '../state';
|
||||
import { createPlaybackAssembly, type PlaybackAssemblyDeps } from './playback';
|
||||
|
||||
@@ -53,18 +54,13 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
|
||||
overlayManager: {
|
||||
broadcastToOverlayWindows: spy('overlay.broadcast', noop),
|
||||
getVisibleOverlayVisible: spy('overlay.getVisibleOverlayVisible', () => false),
|
||||
getMainWindow: spy('overlay.getMainWindow', () => null),
|
||||
},
|
||||
overlayContentMeasurementStore: {
|
||||
getLatestByLayer: spy('measurement.getLatest', () => null),
|
||||
},
|
||||
subtitleWsService: { broadcast: spy('subtitleWs.broadcast', noop) },
|
||||
annotationSubtitleWsService: { broadcast: spy('annotationWs.broadcast', noop) },
|
||||
autoplayReadyGate: {
|
||||
maybeSignalPluginAutoplayReady: spy('autoplay.maybeSignal', noop),
|
||||
flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop),
|
||||
invalidatePendingAutoplayReadyFallbacks: spy('autoplay.invalidateFallbacks', noop),
|
||||
},
|
||||
currentMediaTokenizationGate: {
|
||||
updateCurrentMediaPath: spy('tokenizationGate.updateCurrentMediaPath', noop),
|
||||
markReady: spy('tokenizationGate.markReady', noop),
|
||||
},
|
||||
startupOsdSequencer: {
|
||||
reset: spy('startupOsd.reset', noop),
|
||||
markTokenizationReady: spy('startupOsd.markTokenizationReady', noop),
|
||||
@@ -72,13 +68,6 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
|
||||
showAnnotationLoading: spy('startupOsd.showAnnotationLoading', noop),
|
||||
markAnnotationLoadingComplete: spy('startupOsd.markAnnotationLoadingComplete', noop),
|
||||
},
|
||||
managedLocalSubtitleSelectionRuntime: {
|
||||
handleMediaPathChange: spy('localSubtitles.handleMediaPathChange', noop),
|
||||
handleSubtitleTrackListChange: spy('localSubtitles.handleSubtitleTrackListChange', noop),
|
||||
},
|
||||
jimakuAutoOpenRuntime: {
|
||||
handleMediaPathChange: spy('jimakuAutoOpen.handleMediaPathChange', noop),
|
||||
},
|
||||
mediaTimingReviewRuntime: {
|
||||
deferPlaybackResume: spy('timingReview.deferPlaybackResume', () => false),
|
||||
cancelPlaybackResume: spy('timingReview.cancelPlaybackResume', noop),
|
||||
@@ -103,6 +92,7 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
|
||||
handleMediaPathChange: spy('youtube.notification.handleMediaPathChange', noop),
|
||||
handleSubtitleTrackChange: spy('youtube.notification.handleSubtitleTrackChange', noop),
|
||||
handleSubtitleTrackListChange: spy('youtube.notification.handleTrackListChange', noop),
|
||||
isAppOwnedFlowInFlight: spy('youtube.notification.isAppOwnedFlowInFlight', () => false),
|
||||
},
|
||||
mediaCachePlayback: { handleMediaPathChange: spy('youtube.mediaCache', resolved) },
|
||||
browserPlayback: {
|
||||
@@ -168,6 +158,8 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
|
||||
// Every annotation off, so tokenization warmups need no MeCab or dictionaries.
|
||||
getRuntimeBooleanOption: spy('getRuntimeBooleanOption', () => false),
|
||||
onFullscreenChange: spy('onFullscreenChange', noop),
|
||||
stealAppFocus: spy('stealAppFocus', noop),
|
||||
openJimaku: spy('overlayModals.openJimaku', async () => false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,10 +178,32 @@ function bindMpvEvents(playback: ReturnType<typeof createPlaybackAssembly>) {
|
||||
};
|
||||
}
|
||||
|
||||
function setup(overrides: Partial<PlaybackAssemblyDeps> = {}) {
|
||||
/**
|
||||
* Replaces the assembly's autoplay-ready and tokenization gates with recording stubs, so tests
|
||||
* pin playback's calls into them. Playback looks these methods up at call time.
|
||||
*/
|
||||
function stubGates(recorder: Recorder, playback: ReturnType<typeof createPlaybackAssembly>) {
|
||||
const { spy } = recorder;
|
||||
const noop = () => {};
|
||||
Object.assign(playback.autoplayReadyGate, {
|
||||
maybeSignalPluginAutoplayReady: spy('autoplay.maybeSignal', noop),
|
||||
flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop),
|
||||
invalidatePendingAutoplayReadyFallbacks: spy('autoplay.invalidateFallbacks', noop),
|
||||
} satisfies Partial<typeof playback.autoplayReadyGate>);
|
||||
Object.assign(playback.currentMediaTokenizationGate, {
|
||||
updateCurrentMediaPath: spy('tokenizationGate.updateCurrentMediaPath', noop),
|
||||
markReady: spy('tokenizationGate.markReady', noop),
|
||||
} satisfies Partial<typeof playback.currentMediaTokenizationGate>);
|
||||
}
|
||||
|
||||
function setup(
|
||||
overrides: Partial<PlaybackAssemblyDeps> = {},
|
||||
options: { realGates?: boolean } = {},
|
||||
) {
|
||||
const recorder = createRecorder();
|
||||
const deps = { ...makeDeps(recorder), ...overrides };
|
||||
const playback = createPlaybackAssembly(deps);
|
||||
if (!options.realGates) stubGates(recorder, playback);
|
||||
const emit = bindMpvEvents(playback);
|
||||
return { recorder, deps, appState: deps.appState, playback, emit };
|
||||
}
|
||||
@@ -451,3 +465,80 @@ test('sidebar snapshot falls back to cached cues while mpv is disconnected', asy
|
||||
assert.equal(snapshot.config, DEFAULT_CONFIG.subtitleSidebar);
|
||||
assert.equal(recorder.names().includes('loadSubtitleSourceText'), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Real autoplay gate over a paused, connected mpv client. `overlayVisible` and `measurement`
|
||||
* control the visible-overlay readiness the gate waits for.
|
||||
*/
|
||||
function setupAutoplayGate(fixture: {
|
||||
overlayVisible: boolean;
|
||||
measurement?: OverlayContentMeasurement | null;
|
||||
}) {
|
||||
const overlayWindow = {
|
||||
isDestroyed: () => false,
|
||||
// Focused already, so the macOS pointer recovery on release does not steal focus.
|
||||
isVisible: () => true,
|
||||
isFocused: () => true,
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]: true,
|
||||
};
|
||||
const ctx = setup(
|
||||
{
|
||||
overlayManager: {
|
||||
broadcastToOverlayWindows: () => {},
|
||||
getVisibleOverlayVisible: () => fixture.overlayVisible,
|
||||
getMainWindow: () => overlayWindow as unknown as Electron.BrowserWindow,
|
||||
},
|
||||
overlayContentMeasurementStore: { getLatestByLayer: () => fixture.measurement ?? null },
|
||||
},
|
||||
{ realGates: true },
|
||||
);
|
||||
let paused = true;
|
||||
const scriptMessages: string[] = [];
|
||||
ctx.appState.currentMediaPath = '/videos/a.mkv';
|
||||
ctx.appState.mpvClient = {
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? paused : null),
|
||||
send: ({ command }: { command: unknown[] }) => {
|
||||
if (command[0] === 'script-message') scriptMessages.push(String(command[1]));
|
||||
if (command[0] === 'set_property' && command[1] === 'pause') paused = command[2] === true;
|
||||
},
|
||||
} as unknown as typeof ctx.appState.mpvClient;
|
||||
const released = () => scriptMessages.includes('subminer-autoplay-ready');
|
||||
return { ...ctx, released };
|
||||
}
|
||||
|
||||
const TOKENIZED_LINE: SubtitleData = { text: '一行目', tokens: [] };
|
||||
|
||||
test('autoplay stays paused until the visible overlay has measured the subtitle', () => {
|
||||
const fixture: { overlayVisible: boolean; measurement: OverlayContentMeasurement | null } = {
|
||||
overlayVisible: true,
|
||||
measurement: null,
|
||||
};
|
||||
const { playback, released } = setupAutoplayGate(fixture);
|
||||
|
||||
playback.autoplayReadyGate.maybeSignalPluginAutoplayReady(TOKENIZED_LINE);
|
||||
assert.equal(released(), false);
|
||||
|
||||
fixture.measurement = {
|
||||
layer: 'visible',
|
||||
measuredAtMs: Date.now() + 1_000,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 100, y: 900, width: 600, height: 60 },
|
||||
} as OverlayContentMeasurement;
|
||||
playback.autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
assert.equal(released(), true);
|
||||
});
|
||||
|
||||
// Untokenized lines (startup priming, tokenizer fallbacks) must not release the startup pause
|
||||
// while tokenization warms up; a tokenized line must not wait on the warmup flag.
|
||||
test('autoplay ignores an untokenized line during tokenization warmup but releases a tokenized one', () => {
|
||||
const { playback, released } = setupAutoplayGate({ overlayVisible: false });
|
||||
assert.equal(playback.isTokenizationWarmupReady(), false);
|
||||
|
||||
playback.autoplayReadyGate.maybeSignalPluginAutoplayReady({ text: '一行目', tokens: null });
|
||||
playback.autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
assert.equal(released(), false);
|
||||
|
||||
playback.autoplayReadyGate.maybeSignalPluginAutoplayReady(TOKENIZED_LINE);
|
||||
assert.equal(released(), true);
|
||||
});
|
||||
+151
-39
@@ -2,6 +2,7 @@ import {
|
||||
MpvIpcClient,
|
||||
applyMpvSubtitleRenderMetricsPatch,
|
||||
createTokenizerDepsRuntime,
|
||||
isOverlayWindowContentReady,
|
||||
isSubtitleAnnotationUpgrade,
|
||||
resolveSanitizedSubtitleSeekCommand,
|
||||
sendMpvCommandRuntime,
|
||||
@@ -14,14 +15,19 @@ import { resolveAniSkipMetadataForFile } from '../runtime/aniskip-metadata';
|
||||
import { createAniSkipRuntime } from '../runtime/aniskip-runtime';
|
||||
import { resolveCurrentSubtitleForRenderer } from '../runtime/current-subtitle-snapshot';
|
||||
import { resolveFreshPlaybackPaused } from '../runtime/playback-paused-state';
|
||||
import { createAutoplayReadyGate, type AutoplayReadySignal } from '../runtime/autoplay-ready-gate';
|
||||
import { createAutoplaySubtitlePrimingRuntime } from '../runtime/autoplay-subtitle-priming-runtime';
|
||||
import { createAutoplayTokenizationWarmRelease } from '../runtime/autoplay-tokenization-warm-release';
|
||||
import { composeMpvRuntimeHandlers } from '../runtime/composers';
|
||||
import { createCurrentMediaTokenizationGate } from '../runtime/current-media-tokenization-gate';
|
||||
import { createCachedInternalSubtitleTrackExtractor } from '../runtime/internal-subtitle-extraction';
|
||||
import {
|
||||
markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState,
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||
} from '../runtime/jellyfin-remote-playback';
|
||||
import { createJimakuAutoOpenRuntime } from '../runtime/jimaku-auto-open';
|
||||
import { createManagedLocalSubtitleSelectionRuntime } from '../runtime/local-subtitle-selection';
|
||||
import { focusMacOSOverlayWindow } from '../runtime/macos-overlay-window-focus';
|
||||
import { buildSubtitleTrackDiagnostics } from '../runtime/mpv-track-diagnostics';
|
||||
import { createRemoteMediaPathDetector } from '../runtime/network-media-path';
|
||||
import { resolveCanonicalPrimarySubtitle } from '../runtime/primary-subtitle-text';
|
||||
@@ -33,6 +39,7 @@ import {
|
||||
createResolveActiveSubtitleSidebarSourceHandler,
|
||||
} from '../runtime/subtitle-prefetch-runtime';
|
||||
import { createPrefetchAwareSubtitleProcessingController } from '../runtime/subtitle-processing-runtime';
|
||||
import { isVisibleOverlayAutoplayTargetReady } from '../runtime/visible-overlay-autoplay-readiness';
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
isYoutubeMediaPath,
|
||||
@@ -48,25 +55,25 @@ import type {
|
||||
} from '../../types';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { ConfigService } from '../../config/service';
|
||||
import type { createOverlayManager, SubtitleWebSocket } from '../../core/services';
|
||||
import type {
|
||||
createOverlayContentMeasurementStore,
|
||||
createOverlayManager,
|
||||
SubtitleWebSocket,
|
||||
} from '../../core/services';
|
||||
import type {
|
||||
SubtitlePrefetchService,
|
||||
SubtitlePrefetchServiceDeps,
|
||||
} from '../../core/services/subtitle-prefetch';
|
||||
import type { Logger } from '../../logger';
|
||||
import type { createMediaRuntimeService } from '../media-runtime';
|
||||
import type { createAutoplayReadyGate } from '../runtime/autoplay-ready-gate';
|
||||
import type { createCurrentMediaTokenizationGate } from '../runtime/current-media-tokenization-gate';
|
||||
import type { createDiscordPresenceRuntime } from '../runtime/discord-presence-runtime';
|
||||
import type { createImmersionMediaRuntime } from '../runtime/domains/startup';
|
||||
import type { loadSubtitleSourceText } from '../runtime/internal-subtitle-extraction';
|
||||
import type { createManagedLocalSubtitleSelectionRuntime } from '../runtime/local-subtitle-selection';
|
||||
import type { createJimakuAutoOpenRuntime } from '../runtime/jimaku-auto-open';
|
||||
import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import type { createStartupOsdSequencer } from '../runtime/startup-osd-sequencer';
|
||||
import type { createSubtitleGenerationRuntime } from '../runtime/subtitle-generation-runtime';
|
||||
import type { RuntimeOptionId } from '../../types/runtime-options';
|
||||
import type { AnnotationRuntimeOptionId } from '../runtime/annotation-options';
|
||||
import type { OverlayModalRuntime } from '../overlay-runtime';
|
||||
import type { AppState } from '../state';
|
||||
import type { AnilistAssembly } from './anilist';
|
||||
@@ -78,13 +85,6 @@ import type { YoutubeAssembly } from './youtube';
|
||||
|
||||
type SubtitleWsBroadcaster = Pick<SubtitleWebSocket, 'broadcast'>;
|
||||
type OverlayNotificationsRuntime = ReturnType<typeof createOverlayNotificationsRuntime>;
|
||||
type AnnotationRuntimeOptionId = Extract<
|
||||
RuntimeOptionId,
|
||||
| 'subtitle.annotation.knownWords.highlightEnabled'
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency'
|
||||
>;
|
||||
|
||||
/**
|
||||
* What the playback assembly needs from the rest of `main.ts`. Everything here except the
|
||||
@@ -97,20 +97,14 @@ export interface PlaybackAssemblyDeps {
|
||||
logger: Pick<Logger, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
overlayManager: Pick<
|
||||
ReturnType<typeof createOverlayManager>,
|
||||
'broadcastToOverlayWindows' | 'getVisibleOverlayVisible'
|
||||
'broadcastToOverlayWindows' | 'getVisibleOverlayVisible' | 'getMainWindow'
|
||||
>;
|
||||
overlayContentMeasurementStore: Pick<
|
||||
ReturnType<typeof createOverlayContentMeasurementStore>,
|
||||
'getLatestByLayer'
|
||||
>;
|
||||
subtitleWsService: SubtitleWsBroadcaster;
|
||||
annotationSubtitleWsService: SubtitleWsBroadcaster;
|
||||
autoplayReadyGate: Pick<
|
||||
ReturnType<typeof createAutoplayReadyGate>,
|
||||
| 'maybeSignalPluginAutoplayReady'
|
||||
| 'flushPendingAutoplayReadySignal'
|
||||
| 'invalidatePendingAutoplayReadyFallbacks'
|
||||
>;
|
||||
currentMediaTokenizationGate: Pick<
|
||||
ReturnType<typeof createCurrentMediaTokenizationGate>,
|
||||
'updateCurrentMediaPath' | 'markReady'
|
||||
>;
|
||||
startupOsdSequencer: Pick<
|
||||
ReturnType<typeof createStartupOsdSequencer>,
|
||||
| 'reset'
|
||||
@@ -119,14 +113,6 @@ export interface PlaybackAssemblyDeps {
|
||||
| 'showAnnotationLoading'
|
||||
| 'markAnnotationLoadingComplete'
|
||||
>;
|
||||
managedLocalSubtitleSelectionRuntime: Pick<
|
||||
ReturnType<typeof createManagedLocalSubtitleSelectionRuntime>,
|
||||
'handleMediaPathChange' | 'handleSubtitleTrackListChange'
|
||||
>;
|
||||
jimakuAutoOpenRuntime: Pick<
|
||||
ReturnType<typeof createJimakuAutoOpenRuntime>,
|
||||
'handleMediaPathChange'
|
||||
>;
|
||||
mediaTimingReviewRuntime: Pick<
|
||||
ReturnType<typeof createMediaTimingReviewRuntime>,
|
||||
'deferPlaybackResume' | 'cancelPlaybackResume'
|
||||
@@ -154,7 +140,10 @@ export interface PlaybackAssemblyDeps {
|
||||
playback: Pick<YoutubeAssembly['playback'], 'getQuitOnDisconnectArmed'>;
|
||||
primarySubtitleNotification: Pick<
|
||||
YoutubeAssembly['primarySubtitleNotification'],
|
||||
'handleMediaPathChange' | 'handleSubtitleTrackChange' | 'handleSubtitleTrackListChange'
|
||||
| 'handleMediaPathChange'
|
||||
| 'handleSubtitleTrackChange'
|
||||
| 'handleSubtitleTrackListChange'
|
||||
| 'isAppOwnedFlowInFlight'
|
||||
>;
|
||||
mediaCachePlayback: Pick<YoutubeAssembly['mediaCachePlayback'], 'handleMediaPathChange'>;
|
||||
browserPlayback: Pick<
|
||||
@@ -223,6 +212,10 @@ export interface PlaybackAssemblyDeps {
|
||||
getRuntimeBooleanOption: (id: AnnotationRuntimeOptionId, fallback: boolean) => boolean;
|
||||
/** Linux fullscreen overlay refresh lives with the overlay window code in `main.ts`. */
|
||||
onFullscreenChange: (fullscreen: boolean) => void;
|
||||
/** Brings SubMiner to the foreground (`app.focus({ steal: true })`). */
|
||||
stealAppFocus: () => void;
|
||||
/** Opens the Jimaku modal; resolves to whether the modal acknowledged opening. */
|
||||
openJimaku: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export type PlaybackAssembly = ReturnType<typeof createPlaybackAssembly>;
|
||||
@@ -230,8 +223,9 @@ export type PlaybackAssembly = ReturnType<typeof createPlaybackAssembly>;
|
||||
/**
|
||||
* Owns the subtitle pipeline around mpv playback: the mpv client runtime and its event
|
||||
* handlers, subtitle tokenization and warmups, subtitle prefetch, the subtitle sidebar's cached
|
||||
* playback state, secondary subtitles and autoplay subtitle priming. `main.ts` builds it once and
|
||||
* starts warmups and the mpv client from its startup code.
|
||||
* playback state, secondary subtitles, managed local subtitle selection, Jimaku auto-open, and the
|
||||
* autoplay-ready gate that releases mpv's startup pause with its subtitle priming. `main.ts` builds
|
||||
* it once and starts warmups and the mpv client from its startup code.
|
||||
*/
|
||||
export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
|
||||
const {
|
||||
@@ -241,11 +235,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
|
||||
overlayManager,
|
||||
subtitleWsService,
|
||||
annotationSubtitleWsService,
|
||||
autoplayReadyGate,
|
||||
currentMediaTokenizationGate,
|
||||
startupOsdSequencer,
|
||||
managedLocalSubtitleSelectionRuntime,
|
||||
jimakuAutoOpenRuntime,
|
||||
mediaTimingReviewRuntime,
|
||||
discordPresenceRuntime,
|
||||
immersionMediaRuntime,
|
||||
@@ -269,6 +259,125 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]);
|
||||
}
|
||||
|
||||
const currentMediaTokenizationGate = createCurrentMediaTokenizationGate();
|
||||
const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelectionRuntime({
|
||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getPrimarySubtitleLanguages: () => configService.getConfig().youtube.primarySubLanguages,
|
||||
getSecondarySubtitleLanguages: () =>
|
||||
configService.getConfig().secondarySub.secondarySubLanguages,
|
||||
sendMpvCommand: (command) => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
},
|
||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearScheduled: (timer) => clearTimeout(timer),
|
||||
});
|
||||
|
||||
const POST_WARM_AUTOPLAY_SUBTITLE_PRIME_DELAYS_MS = [0, 75, 200, 500, 1_000] as const;
|
||||
|
||||
/** After a warm-tokenization release, re-primes the current line while the overlay shows it. */
|
||||
function schedulePostWarmAutoplaySubtitlePrime(signal: AutoplayReadySignal): void {
|
||||
if (signal.payload.text.trim() !== '__warm__') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaPath = signal.mediaPath;
|
||||
for (const delayMs of POST_WARM_AUTOPLAY_SUBTITLE_PRIME_DELAYS_MS) {
|
||||
const timer = setTimeout(() => {
|
||||
const currentMediaPath =
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
|
||||
if (currentMediaPath !== mediaPath || !overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
void autoplaySubtitlePrimingRuntime
|
||||
.primeCurrentSubtitleForAutoplay(mediaPath)
|
||||
.catch((error) => {
|
||||
logger.debug(
|
||||
`[autoplay-subtitle-prime] failed to prime current subtitle after warm readiness: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}, delayMs);
|
||||
timer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
const autoplayReadyGate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => youtube.primarySubtitleNotification.isAppOwnedFlowInFlight(),
|
||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath ?? null,
|
||||
getPlaybackPaused: () => appState.playbackPaused,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
signalPluginAutoplayReady: () => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
// Untokenized signals wait for warmup here; the target-readiness predicate below must stay
|
||||
// warmup-free so warm and tokenized releases are never deferred behind the warmup flag.
|
||||
isTokenizationReady: () => isTokenizationWarmupReady(),
|
||||
// Deferred: jimakuAutoOpenRuntime is created right after this gate.
|
||||
isPlaybackHeld: () => jimakuAutoOpenRuntime.isHoldingPlayback(),
|
||||
requestOverlayPointerRecovery: () => {
|
||||
if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
// Renderer-side recovery alone cannot wake subtitle hover on macOS: the overlay only
|
||||
// receives mouse-moved events while it is the key window of the frontmost app, and mpv
|
||||
// is frontmost once playback starts. Activate the overlay window first so the broadcast's
|
||||
// setIgnoreMouseEvents toggling actually takes effect, mirroring a manual subtitle click.
|
||||
focusMacOSOverlayWindow({
|
||||
platform: process.platform,
|
||||
getOverlayWindow: () => overlayManager.getMainWindow(),
|
||||
stealAppFocus: () => deps.stealAppFocus(),
|
||||
warn: (message, details) => logger.warn(message, details),
|
||||
});
|
||||
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
|
||||
},
|
||||
onAutoplayReadyReleased: (signal) => {
|
||||
schedulePostWarmAutoplaySubtitlePrime(signal);
|
||||
},
|
||||
// Hold the release until the visible overlay has painted and measured its content.
|
||||
isSignalTargetReady: (signal) =>
|
||||
isVisibleOverlayAutoplayTargetReady(
|
||||
{
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
isOverlayWindowReady: () => {
|
||||
const overlayWindow = overlayManager.getMainWindow();
|
||||
return Boolean(overlayWindow && isOverlayWindowContentReady(overlayWindow));
|
||||
},
|
||||
getLatestVisibleMeasurement: () =>
|
||||
deps.overlayContentMeasurementStore.getLatestByLayer('visible'),
|
||||
},
|
||||
signal,
|
||||
),
|
||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
});
|
||||
|
||||
const jimakuAutoOpenRuntime = createJimakuAutoOpenRuntime({
|
||||
isEnabled: () => configService.getConfig().jimaku.autoOpenWhenPrimarySubtitleMissing,
|
||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getPrimarySubtitleLanguages: () => configService.getConfig().youtube.primarySubLanguages,
|
||||
isPlaybackPaused: () => appState.playbackPaused,
|
||||
pausePlayback: () => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
|
||||
},
|
||||
holdPluginAutoplayGate: () => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-hold']);
|
||||
},
|
||||
endHold: ({ undoPause }) => {
|
||||
// Readiness deferred during the hold goes through the normal release (plugin + unpause).
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
if (undoPause) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'no']);
|
||||
}
|
||||
},
|
||||
openJimaku: () => deps.openJimaku(),
|
||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearScheduled: (timer) => clearTimeout(timer),
|
||||
});
|
||||
|
||||
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||
const canonical = resolveCanonicalPrimarySubtitle({
|
||||
liveText: payload.text,
|
||||
@@ -1164,6 +1273,9 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
|
||||
|
||||
return {
|
||||
ipc,
|
||||
autoplayReadyGate,
|
||||
currentMediaTokenizationGate,
|
||||
jimakuAutoOpenRuntime,
|
||||
createMpvClient,
|
||||
aniSkipRuntime,
|
||||
setSubtitleSidebarRequestedOpen: (open: boolean): void => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAppState } from '../state';
|
||||
import { createPluginsAssembly, type PluginsAssemblyDeps } from './plugins';
|
||||
|
||||
type MpvClient = NonNullable<ReturnType<typeof createAppState>['mpvClient']>;
|
||||
|
||||
function lazy(name: string) {
|
||||
return (): never => {
|
||||
throw new Error(`${name} called unexpectedly`);
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds deps whose every function throws, so construction must not call any of them. */
|
||||
function makeDeps(): PluginsAssemblyDeps {
|
||||
const thrower = <T extends object>(name: string): T =>
|
||||
new Proxy({}, { get: (_target, key: string) => lazy(`${name}.${key}`) }) as T;
|
||||
return {
|
||||
appState: createAppState({ mpvSocketPath: '/tmp/subminer-plugins.sock', texthookerPort: 0 }),
|
||||
configService: thrower('configService'),
|
||||
overlayManager: thrower('overlayManager'),
|
||||
logger: thrower('logger'),
|
||||
createLogger: lazy('createLogger'),
|
||||
configDir: '/tmp/subminer-plugins-test',
|
||||
userDataPath: '/tmp/subminer-plugins-test',
|
||||
overlayModalRuntime: thrower('overlayModalRuntime'),
|
||||
overlayModals: thrower('overlayModals'),
|
||||
getOverlayNotifications: lazy('getOverlayNotifications'),
|
||||
createBrowserWindow: lazy('createBrowserWindow'),
|
||||
promoteAboveOverlay: lazy('promoteAboveOverlay'),
|
||||
ensureMpvRunning: lazy('ensureMpvRunning'),
|
||||
dispatchSessionAction: lazy('dispatchSessionAction'),
|
||||
};
|
||||
}
|
||||
|
||||
test('createPluginsAssembly does not call its deps while constructing', () => {
|
||||
const plugins = createPluginsAssembly(makeDeps());
|
||||
assert.equal(typeof plugins.host.loadAll, 'function');
|
||||
});
|
||||
|
||||
test('plugins see the current line with its parsed cue timing', () => {
|
||||
const deps = makeDeps();
|
||||
deps.appState.currentSubText = '一行目';
|
||||
deps.appState.activeParsedSubtitleCues = [{ startTime: 10, endTime: 12, text: '一行目' }];
|
||||
deps.appState.mpvClient = {
|
||||
currentSubText: '一行目',
|
||||
currentTimePos: 11,
|
||||
currentSubDelay: 0,
|
||||
} as MpvClient;
|
||||
|
||||
assert.deepEqual(createPluginsAssembly(deps).getCurrentSubtitle(), {
|
||||
text: '一行目',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
});
|
||||
});
|
||||
|
||||
test('without parsed cues plugins get the live line, timed only while mpv reports a span', () => {
|
||||
const deps = makeDeps();
|
||||
const plugins = createPluginsAssembly(deps);
|
||||
deps.appState.currentSubText = 'ライブ';
|
||||
deps.appState.mpvClient = {
|
||||
currentSubText: 'ライブ',
|
||||
currentSubStart: 3,
|
||||
currentSubEnd: 5,
|
||||
} as MpvClient;
|
||||
assert.deepEqual(plugins.getCurrentSubtitle(), { text: 'ライブ', startTime: 3, endTime: 5 });
|
||||
|
||||
// A cleared sub-start reads as zero; a span that does not move forward is not a timing.
|
||||
deps.appState.mpvClient = {
|
||||
currentSubText: 'ライブ',
|
||||
currentSubStart: 0,
|
||||
currentSubEnd: 0,
|
||||
} as MpvClient;
|
||||
assert.deepEqual(plugins.getCurrentSubtitle(), {
|
||||
text: 'ライブ',
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
});
|
||||
|
||||
deps.appState.currentSubText = ' ';
|
||||
assert.equal(plugins.getCurrentSubtitle(), null);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import path from 'node:path';
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
||||
import { AnkiConnectClient } from '../../anki-connect';
|
||||
import type { ConfigService } from '../../config/service';
|
||||
import {
|
||||
sendMpvCommandRuntime,
|
||||
toSubtitleClock,
|
||||
type createOverlayManager,
|
||||
} from '../../core/services';
|
||||
import { createPluginHost } from '../../core/services/plugins/plugin-host';
|
||||
import { buildPluginSubtitleContext } from '../../core/services/plugins/subtitle-context';
|
||||
import type { PluginSubtitleLine } from '../../core/services/plugins/types';
|
||||
import type { Logger } from '../../logger';
|
||||
import type { SessionActionDispatchRequest } from '../../types/runtime';
|
||||
import type { OverlayModalRuntime } from '../overlay-runtime';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import { createPluginPanelRuntime, openPluginPanelModal } from '../runtime/plugin-panel-runtime';
|
||||
import { createPluginWindowRuntime } from '../runtime/plugin-window-runtime';
|
||||
import { resolvePrimarySubtitle } from '../runtime/primary-subtitle-text';
|
||||
import type { AppState } from '../state';
|
||||
import type { OverlayModalsAssembly } from './overlay-modals';
|
||||
|
||||
type OverlayManager = ReturnType<typeof createOverlayManager>;
|
||||
|
||||
/**
|
||||
* What the plugin host needs from the rest of `main.ts`. `appState`, `configService` and
|
||||
* `overlayManager` are also handed to plugins whole as the `unstable` API.
|
||||
*/
|
||||
export interface PluginsAssemblyDeps {
|
||||
appState: AppState;
|
||||
configService: ConfigService;
|
||||
overlayManager: OverlayManager;
|
||||
logger: Pick<Logger, 'error'>;
|
||||
createLogger: (scope: string) => Logger;
|
||||
configDir: string;
|
||||
userDataPath: string;
|
||||
overlayModalRuntime: Pick<OverlayModalRuntime, 'handleOverlayModalClosed'>;
|
||||
overlayModals: Pick<OverlayModalsAssembly, 'createOverlayHostedModalOpenDeps'>;
|
||||
getOverlayNotifications: () => Pick<
|
||||
ReturnType<typeof createOverlayNotificationsRuntime>,
|
||||
'showConfiguredStatusNotification'
|
||||
>;
|
||||
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
|
||||
/** Keeps a plugin window above the overlay, like the settings windows. */
|
||||
promoteAboveOverlay: (window: BrowserWindow) => void;
|
||||
/** Starts mpv for a plugin that needs playback (the Jellyfin launch path). */
|
||||
ensureMpvRunning: () => Promise<boolean>;
|
||||
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export type PluginsAssembly = ReturnType<typeof createPluginsAssembly>;
|
||||
|
||||
/**
|
||||
* Owns user plugins: the plugin host, the overlay-hosted plugin panel and plugin windows.
|
||||
* `main.ts` builds it once; startup loads the plugins and quit cleanup unloads them.
|
||||
*/
|
||||
export function createPluginsAssembly(deps: PluginsAssemblyDeps) {
|
||||
const { appState, configService, overlayManager, logger } = deps;
|
||||
|
||||
const panel = createPluginPanelRuntime({
|
||||
openModal: (getSnapshot, signal) =>
|
||||
openPluginPanelModal(
|
||||
deps.overlayModals.createOverlayHostedModalOpenDeps(),
|
||||
getSnapshot,
|
||||
signal,
|
||||
),
|
||||
closeModal: () => deps.overlayModalRuntime.handleOverlayModalClosed('plugin-panel'),
|
||||
// The panel may live in either window; broadcastToOverlayWindows only reaches the main one.
|
||||
broadcast: (channel, payload) => {
|
||||
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
||||
if (window && !window.isDestroyed()) window.webContents.send(channel, payload);
|
||||
}
|
||||
},
|
||||
onOpenFailed: () =>
|
||||
deps.getOverlayNotifications().showConfiguredStatusNotification('Plugin panel unavailable.', {
|
||||
variant: 'warning',
|
||||
}),
|
||||
});
|
||||
|
||||
/** The line on screen, with cue timings on the subtitle file's timeline (as mining sees it). */
|
||||
function getCurrentSubtitle(): PluginSubtitleLine | null {
|
||||
const text = appState.currentSubText.trim();
|
||||
if (!text) return null;
|
||||
const resolved = resolvePrimarySubtitle({
|
||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||
currentTimeSec: toSubtitleClock(
|
||||
Number(appState.mpvClient?.currentTimePos),
|
||||
appState.mpvClient?.currentSubDelay ?? 0,
|
||||
),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
|
||||
if (
|
||||
resolved &&
|
||||
resolvedText &&
|
||||
Number.isFinite(resolved.startTime) &&
|
||||
Number.isFinite(resolved.endTime) &&
|
||||
resolved.endTime > resolved.startTime
|
||||
) {
|
||||
return { text: resolvedText, startTime: resolved.startTime, endTime: resolved.endTime };
|
||||
}
|
||||
const client = appState.mpvClient;
|
||||
// The client reports a cleared sub-start as zero, so only trust timing for a live line.
|
||||
const hasTiming = client !== null && client.currentSubEnd > client.currentSubStart;
|
||||
return {
|
||||
text,
|
||||
startTime: hasTiming ? client.currentSubStart : null,
|
||||
endTime: hasTiming ? client.currentSubEnd : null,
|
||||
};
|
||||
}
|
||||
|
||||
const windows = createPluginWindowRuntime({
|
||||
createBrowserWindow: (options) => deps.createBrowserWindow(options),
|
||||
promoteAboveOverlay: (window) => deps.promoteAboveOverlay(window),
|
||||
logError: (message) => logger.error(message),
|
||||
});
|
||||
|
||||
const host = createPluginHost({
|
||||
pluginsDir: path.join(deps.configDir, 'plugins'),
|
||||
pluginDataDir: path.join(deps.userDataPath, 'plugin-data'),
|
||||
getPluginConfig: () => configService.getConfig().plugins,
|
||||
createLogger: deps.createLogger,
|
||||
isMpvConnected: () => appState.mpvClient?.connected === true,
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||
requestMpvProperty: async (name) => {
|
||||
if (!appState.mpvClient) throw new Error('mpv is not connected.');
|
||||
return await appState.mpvClient.requestProperty(name);
|
||||
},
|
||||
requestMpvCommand: async (command) => {
|
||||
if (!appState.mpvClient) throw new Error('mpv is not connected.');
|
||||
return await appState.mpvClient.requestCommand(command);
|
||||
},
|
||||
ensureMpvRunning: () => deps.ensureMpvRunning(),
|
||||
invokeAnkiConnect: (action, params, options) => {
|
||||
const ankiConfig = configService.getConfig().ankiConnect;
|
||||
const url = (
|
||||
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(ankiConfig) ?? ankiConfig
|
||||
).url;
|
||||
if (!url) return Promise.reject(new Error('ankiConnect.url is not configured.'));
|
||||
// No retries: a retried `sync` or `addNote` could run twice.
|
||||
return new AnkiConnectClient(url).invoke(action, params, {
|
||||
timeout: options.timeoutMs,
|
||||
maxRetries: 0,
|
||||
});
|
||||
},
|
||||
dispatchSessionAction: (request) => deps.dispatchSessionAction(request),
|
||||
getCurrentSubtitle: () => getCurrentSubtitle(),
|
||||
getSubtitleContext: (options) =>
|
||||
buildPluginSubtitleContext({
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
current: getCurrentSubtitle(),
|
||||
recentLines: (appState.subtitleTimingTracker?.getRecentEntries(40) ?? []).map((entry) => ({
|
||||
text: entry.displayText,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
})),
|
||||
...options,
|
||||
}),
|
||||
notify: (message, variant) =>
|
||||
deps.getOverlayNotifications().showConfiguredStatusNotification(message, { variant }),
|
||||
openPanel: (content) => panel.open(content),
|
||||
openWindow: (request) => windows.open(request),
|
||||
unstable: { appState, configService, overlayManager },
|
||||
});
|
||||
|
||||
return { host, panel, getCurrentSubtitle };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DEFAULT_CONFIG } from '../../config/definitions';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import { createAppState } from '../state';
|
||||
import { createSessionActionsAssembly, type SessionActionsAssemblyDeps } from './session-actions';
|
||||
|
||||
function lazy(name: string) {
|
||||
return (): never => {
|
||||
throw new Error(`${name} called unexpectedly`);
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds deps whose every function throws unless a test overrides it. */
|
||||
function makeDeps(overrides: Partial<SessionActionsAssemblyDeps> = {}): SessionActionsAssemblyDeps {
|
||||
const thrower = <T extends object>(name: string): T =>
|
||||
new Proxy({}, { get: (_target, key: string) => lazy(`${name}.${key}`) }) as T;
|
||||
return {
|
||||
appState: createAppState({ mpvSocketPath: '/tmp/subminer-session.sock', texthookerPort: 0 }),
|
||||
getConfig: lazy('getConfig'),
|
||||
logger: thrower('logger'),
|
||||
overlayManager: thrower('overlayManager'),
|
||||
getOverlayNotifications: lazy('getOverlayNotifications'),
|
||||
stats: thrower('stats'),
|
||||
mining: thrower('mining'),
|
||||
overlayModals: thrower('overlayModals'),
|
||||
youtube: thrower('youtube'),
|
||||
plugins: { host: thrower('plugins.host') },
|
||||
toggleVisibleOverlay: lazy('toggleVisibleOverlay'),
|
||||
triggerSubsyncFromConfig: lazy('triggerSubsyncFromConfig'),
|
||||
readClipboardText: lazy('readClipboardText'),
|
||||
showMpvOsd: lazy('showMpvOsd'),
|
||||
sendRendererMpvCommand: lazy('sendRendererMpvCommand'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function configWithSubtitleSelection(enabled: boolean): ResolvedConfig {
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
subtitleSelection: { ...DEFAULT_CONFIG.subtitleSelection, enabled },
|
||||
};
|
||||
}
|
||||
|
||||
test('createSessionActionsAssembly does not call its deps while constructing', () => {
|
||||
const sessionActions = createSessionActionsAssembly(makeDeps());
|
||||
assert.equal(typeof sessionActions.dispatchSessionAction, 'function');
|
||||
});
|
||||
|
||||
test('the subtitle selection action opens its modal only when subtitle selection is enabled', async () => {
|
||||
let enabled = false;
|
||||
const opened: string[] = [];
|
||||
const sessionActions = createSessionActionsAssembly(
|
||||
makeDeps({
|
||||
getConfig: () => configWithSubtitleSelection(enabled),
|
||||
overlayModals: {
|
||||
openOverlayHostedModalWithOsd: (_open: unknown, unavailableMessage: string) => {
|
||||
opened.push(unavailableMessage);
|
||||
},
|
||||
} as unknown as SessionActionsAssemblyDeps['overlayModals'],
|
||||
}),
|
||||
);
|
||||
|
||||
await sessionActions.dispatchSessionAction({ actionId: 'openSubtitleSelection' });
|
||||
assert.deepEqual(opened, []);
|
||||
|
||||
enabled = true;
|
||||
await sessionActions.dispatchSessionAction({ actionId: 'openSubtitleSelection' });
|
||||
assert.deepEqual(opened, ['Subtitle selection overlay unavailable.']);
|
||||
});
|
||||
|
||||
test('an unregistered plugin action warns and tells the user it is unavailable', async () => {
|
||||
const warnings: string[] = [];
|
||||
const feedback: string[] = [];
|
||||
const sessionActions = createSessionActionsAssembly(
|
||||
makeDeps({
|
||||
logger: { warn: (message: string) => warnings.push(message) },
|
||||
getOverlayNotifications: () =>
|
||||
({
|
||||
showConfiguredPlaybackFeedback: (text: string) => feedback.push(text),
|
||||
}) as unknown as ReturnType<SessionActionsAssemblyDeps['getOverlayNotifications']>,
|
||||
plugins: { host: { runAction: async () => false } },
|
||||
}),
|
||||
);
|
||||
|
||||
await sessionActions.dispatchSessionAction({
|
||||
actionId: 'runPluginAction',
|
||||
payload: { pluginActionId: 'example.missing' },
|
||||
});
|
||||
|
||||
assert.deepEqual(warnings, ['Plugin action is not registered: example.missing']);
|
||||
assert.deepEqual(feedback, ['Plugin action not available: example.missing']);
|
||||
});
|
||||
|
||||
test('renderer mpv commands cycle runtime options only once the options manager exists', () => {
|
||||
const sessionActions = createSessionActionsAssembly(makeDeps());
|
||||
|
||||
assert.equal(sessionActions.mpvCommandDeps.hasRuntimeOptionsManager(), false);
|
||||
assert.deepEqual(
|
||||
sessionActions.mpvCommandDeps.cycleRuntimeOption('subtitle.annotation.jlpt', 1),
|
||||
{
|
||||
ok: false,
|
||||
error: 'Runtime options manager unavailable',
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
cycleSecondarySubMode as cycleSecondarySubModeCore,
|
||||
playNextSubtitleRuntime,
|
||||
replayCurrentSubtitleRuntime,
|
||||
sendMpvCommandRuntime,
|
||||
type createOverlayManager,
|
||||
} from '../../core/services';
|
||||
import { applyRuntimeOptionResultRuntime } from '../../core/services/runtime-options-ipc';
|
||||
import { dispatchSessionAction as dispatchSessionActionCore } from '../../core/services/session-actions';
|
||||
import type { Logger } from '../../logger';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { ResolvedConfig, SecondarySubMode } from '../../types';
|
||||
import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../../types/runtime-options';
|
||||
import type { SessionActionDispatchRequest } from '../../types/runtime';
|
||||
import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command';
|
||||
import { appendClipboardVideoToQueueRuntime } from '../runtime/clipboard-queue';
|
||||
import { createAppendClipboardVideoToQueueHandler } from '../runtime/overlay-main-actions';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import { createCycleSecondarySubModeRuntimeHandler } from '../runtime/secondary-sub-mode-runtime-handler';
|
||||
import { openSubtitleSelectionModal } from '../runtime/subtitle-selection';
|
||||
import type { AppState } from '../state';
|
||||
import type { MiningAssembly } from './mining';
|
||||
import type { OverlayModalsAssembly } from './overlay-modals';
|
||||
import type { PluginsAssembly } from './plugins';
|
||||
import type { StatsAssembly } from './stats';
|
||||
import type { YoutubeAssembly } from './youtube';
|
||||
|
||||
/** What session actions call into; every sibling here is built before the assembly. */
|
||||
export interface SessionActionsAssemblyDeps {
|
||||
appState: AppState;
|
||||
getConfig: () => ResolvedConfig;
|
||||
logger: Pick<Logger, 'warn'>;
|
||||
overlayManager: Pick<ReturnType<typeof createOverlayManager>, 'broadcastToOverlayWindows'>;
|
||||
getOverlayNotifications: () => Pick<
|
||||
ReturnType<typeof createOverlayNotificationsRuntime>,
|
||||
| 'showConfiguredPlaybackFeedback'
|
||||
| 'showConfiguredStatusNotification'
|
||||
| 'toggleNotificationHistoryPanel'
|
||||
>;
|
||||
stats: Pick<StatsAssembly, 'toggleStatsOverlay' | 'markActiveVideoWatched'>;
|
||||
mining: Pick<
|
||||
MiningAssembly,
|
||||
| 'copyCurrentSubtitle'
|
||||
| 'copySubtitleCount'
|
||||
| 'updateLastCardFromClipboard'
|
||||
| 'triggerFieldGrouping'
|
||||
| 'mineSentenceCard'
|
||||
| 'mineSentenceCount'
|
||||
| 'markLastCardAsAudioCard'
|
||||
>;
|
||||
overlayModals: Pick<
|
||||
OverlayModalsAssembly,
|
||||
| 'openRuntimeOptionsPalette'
|
||||
| 'openJimakuOverlay'
|
||||
| 'openTsukihimeOverlay'
|
||||
| 'openSessionHelpOverlay'
|
||||
| 'openOverlayHostedModalWithOsd'
|
||||
| 'openSubtitleGenerationOverlay'
|
||||
| 'openCharacterDictionaryManagerOverlay'
|
||||
| 'openControllerSelectOverlay'
|
||||
| 'openControllerDebugOverlay'
|
||||
| 'openPlaylistBrowser'
|
||||
>;
|
||||
youtube: Pick<YoutubeAssembly, 'openTrackPickerFromPlayback'>;
|
||||
plugins: { host: Pick<PluginsAssembly['host'], 'runAction'> };
|
||||
toggleVisibleOverlay: () => void;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
readClipboardText: () => string;
|
||||
/** Raw mpv OSD, bypassing the configured notification type. */
|
||||
showMpvOsd: (text: string) => void;
|
||||
/** Renderer-issued mpv commands; playback sanitizes subtitle seeks. */
|
||||
sendRendererMpvCommand: (command: (string | number)[]) => void;
|
||||
}
|
||||
|
||||
export type SessionActionsAssembly = ReturnType<typeof createSessionActionsAssembly>;
|
||||
|
||||
/**
|
||||
* Owns what a shortcut, controller button, CLI command or plugin can trigger in the running
|
||||
* session: the session action dispatcher, the secondary-subtitle mode cycle, queueing the
|
||||
* clipboard's video, and the mpv commands the overlay renderer sends.
|
||||
*/
|
||||
export function createSessionActionsAssembly(deps: SessionActionsAssemblyDeps) {
|
||||
const { appState, overlayManager, overlayModals, mining } = deps;
|
||||
|
||||
function setSecondarySubMode(mode: SecondarySubMode): void {
|
||||
appState.secondarySubMode = mode;
|
||||
}
|
||||
|
||||
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
|
||||
cycleSecondarySubModeMainDeps: {
|
||||
getSecondarySubMode: () => appState.secondarySubMode,
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
||||
setSecondarySubMode(mode);
|
||||
},
|
||||
getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs,
|
||||
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
|
||||
appState.lastSecondarySubToggleAtMs = timestampMs;
|
||||
},
|
||||
broadcastSecondarySubMode: (mode) => {
|
||||
overlayManager.broadcastToOverlayWindows('secondary-subtitle:mode', mode);
|
||||
},
|
||||
showMpvOsd: (text: string) =>
|
||||
deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text),
|
||||
},
|
||||
cycleSecondarySubMode: (cycleDeps) => cycleSecondarySubModeCore(cycleDeps),
|
||||
});
|
||||
|
||||
const appendClipboardVideoToQueue = createAppendClipboardVideoToQueueHandler({
|
||||
appendClipboardVideoToQueueRuntime,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
readClipboardText: () => deps.readClipboardText(),
|
||||
showMpvOsd: (text) => deps.getOverlayNotifications().showConfiguredStatusNotification(text),
|
||||
sendMpvCommand: (command) => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
},
|
||||
});
|
||||
|
||||
/** Steps a runtime option and reports the new value as playback feedback. */
|
||||
function cycleRuntimeOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult {
|
||||
if (!appState.runtimeOptionsManager) {
|
||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||
}
|
||||
return applyRuntimeOptionResultRuntime(
|
||||
appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||
(text) => deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text),
|
||||
);
|
||||
}
|
||||
|
||||
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
||||
await dispatchSessionActionCore(request, {
|
||||
toggleStatsOverlay: async () => await deps.stats.toggleStatsOverlay(),
|
||||
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
copyCurrentSubtitle: () => mining.copyCurrentSubtitle(),
|
||||
copySubtitleCount: (count) => mining.copySubtitleCount(count),
|
||||
updateLastCardFromClipboard: () => mining.updateLastCardFromClipboard(),
|
||||
triggerFieldGrouping: () => mining.triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
mineSentenceCard: () => mining.mineSentenceCard(),
|
||||
mineSentenceCount: (count) => mining.mineSentenceCount(count),
|
||||
toggleSecondarySub: () => cycleSecondarySubMode(),
|
||||
toggleSubtitleSidebar: () =>
|
||||
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle),
|
||||
toggleNotificationHistory: () =>
|
||||
deps.getOverlayNotifications().toggleNotificationHistoryPanel(),
|
||||
appendClipboardVideoToQueue: () => {
|
||||
appendClipboardVideoToQueue();
|
||||
},
|
||||
markLastCardAsAudioCard: () => mining.markLastCardAsAudioCard(),
|
||||
markActiveVideoWatched: () => deps.stats.markActiveVideoWatched(),
|
||||
openRuntimeOptionsPalette: () => overlayModals.openRuntimeOptionsPalette(),
|
||||
openJimaku: () => overlayModals.openJimakuOverlay(),
|
||||
openTsukihime: () => overlayModals.openTsukihimeOverlay(),
|
||||
openSessionHelp: () => overlayModals.openSessionHelpOverlay(),
|
||||
openSubtitleSelection: () => {
|
||||
if (!deps.getConfig().subtitleSelection.enabled) return;
|
||||
overlayModals.openOverlayHostedModalWithOsd(
|
||||
openSubtitleSelectionModal,
|
||||
'Subtitle selection overlay unavailable.',
|
||||
'Failed to open subtitle selection overlay.',
|
||||
);
|
||||
},
|
||||
openSubtitleGeneration: () => overlayModals.openSubtitleGenerationOverlay(),
|
||||
openCharacterDictionaryManager: () => overlayModals.openCharacterDictionaryManagerOverlay(),
|
||||
openControllerSelect: () => overlayModals.openControllerSelectOverlay(),
|
||||
openControllerDebug: () => overlayModals.openControllerDebugOverlay(),
|
||||
openYoutubeTrackPicker: () => deps.youtube.openTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => overlayModals.openPlaylistBrowser(),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||
cycleRuntimeOption,
|
||||
playNextPlaylistItem: () =>
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
||||
runPluginAction: async (pluginActionId) => {
|
||||
const ran = await deps.plugins.host.runAction(pluginActionId);
|
||||
if (!ran) deps.logger.warn(`Plugin action is not registered: ${pluginActionId}`);
|
||||
return ran;
|
||||
},
|
||||
showMpvOsd: (text) => deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text),
|
||||
});
|
||||
}
|
||||
|
||||
/** The mpv commands the overlay renderer sends over IPC (`mpv:command`). */
|
||||
const mpvCommandDeps: MpvCommandFromIpcRuntimeDeps = {
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => overlayModals.openRuntimeOptionsPalette(),
|
||||
openJimaku: () => overlayModals.openJimakuOverlay(),
|
||||
openTsukihime: () => overlayModals.openTsukihimeOverlay(),
|
||||
openYoutubeTrackPicker: () => deps.youtube.openTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => overlayModals.openPlaylistBrowser(),
|
||||
cycleRuntimeOption,
|
||||
showMpvOsd: (text: string) =>
|
||||
deps.getOverlayNotifications().showConfiguredStatusNotification(text),
|
||||
showRawMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
showPlaybackFeedback: (text: string) =>
|
||||
deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||
sendMpvCommand: (rawCommand: (string | number)[]) => deps.sendRendererMpvCommand(rawCommand),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
|
||||
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
|
||||
};
|
||||
|
||||
return { dispatchSessionAction, cycleSecondarySubMode, setSecondarySubMode, mpvCommandDeps };
|
||||
}
|
||||
@@ -43,7 +43,6 @@ function makeDeps(overrides: Partial<StartupAssemblyDeps> = {}): StartupAssembly
|
||||
hasMpvWebsocketPlugin: lazy('hasMpvWebsocketPlugin'),
|
||||
loadSubtitlePosition: lazy('loadSubtitlePosition'),
|
||||
setSecondarySubMode: lazy('setSecondarySubMode'),
|
||||
syncOverlayMpvSubtitleSuppression: lazy('syncOverlayMpvSubtitleSuppression'),
|
||||
yomitan: thrower('yomitan'),
|
||||
stats: { ensureImmersionTrackerStarted: lazy('ensureImmersionTrackerStarted') } as never,
|
||||
firstRunSetup: thrower('firstRunSetup'),
|
||||
@@ -57,9 +56,6 @@ function makeDeps(overrides: Partial<StartupAssemblyDeps> = {}): StartupAssembly
|
||||
getPlayback: lazy('getPlayback'),
|
||||
getPluginHost: lazy('getPluginHost'),
|
||||
getOverlayNotifications: lazy('getOverlayNotifications'),
|
||||
getOverlayVisibilityComposer: lazy('getOverlayVisibilityComposer'),
|
||||
getVisibleOverlayInteraction: lazy('getVisibleOverlayInteraction'),
|
||||
getOverlayGeometry: lazy('getOverlayGeometry'),
|
||||
handleInitialArgs: lazy('handleInitialArgs'),
|
||||
...overrides,
|
||||
};
|
||||
@@ -89,9 +85,11 @@ function makePrereqDeps(calls: string[]): Partial<StartupAssemblyDeps> {
|
||||
getSubtitlePrefetchService: () => ({ onSeek: record('prefetch.onSeek') }),
|
||||
getLastObservedTimePos: () => 12,
|
||||
}) as unknown as ReturnType<StartupAssemblyDeps['getPlayback']>,
|
||||
getOverlayVisibilityComposer: () => ({
|
||||
broadcastRuntimeOptionsChanged: record('broadcastRuntimeOptionsChanged'),
|
||||
}),
|
||||
overlay: {
|
||||
visibilityComposer: {
|
||||
broadcastRuntimeOptionsChanged: record('broadcastRuntimeOptionsChanged'),
|
||||
},
|
||||
} as unknown as StartupAssemblyDeps['overlay'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,9 @@ import { SubtitleTimingTracker } from '../../subtitle-timing-tracker';
|
||||
import type { SecondarySubMode, WindowGeometry } from '../../types';
|
||||
import { mergeAiConfig } from '../../ai/config';
|
||||
import type { OverlayModalRuntime } from '../overlay-runtime';
|
||||
import {
|
||||
composeAppReadyRuntime,
|
||||
type AppReadyComposerOptions,
|
||||
type composeOverlayVisibilityRuntime,
|
||||
} from '../runtime/composers';
|
||||
import { composeAppReadyRuntime, type AppReadyComposerOptions } from '../runtime/composers';
|
||||
import type { createConfigDerivedRuntime } from '../runtime/domains/startup';
|
||||
import { shouldAutoOpenFirstRunSetup } from '../runtime/first-run-setup-service';
|
||||
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
|
||||
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
|
||||
import { createOverlayRuntimeBootstrapHandlers } from '../runtime/overlay-runtime-bootstrap-handlers';
|
||||
import {
|
||||
@@ -32,7 +27,6 @@ import {
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||
shouldRefreshAnilistOnConfigReload,
|
||||
} from '../runtime/startup-mode-flags';
|
||||
import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime';
|
||||
import type { AppState } from '../state';
|
||||
import type { AnilistAssembly } from './anilist';
|
||||
import type { FirstRunSetupAssembly } from './first-run-setup';
|
||||
@@ -82,7 +76,6 @@ export interface StartupAssemblyDeps {
|
||||
hasMpvWebsocketPlugin: () => boolean;
|
||||
loadSubtitlePosition: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
yomitan: Pick<YomitanAssembly, 'loadExtension' | 'ensureExtensionLoaded'>;
|
||||
stats: Pick<StatsAssembly, 'ensureImmersionTrackerStarted' | 'immersionTrackerStartupDeps'>;
|
||||
firstRunSetup: Pick<FirstRunSetupAssembly, 'service' | 'openWindow'>;
|
||||
@@ -98,7 +91,18 @@ export interface StartupAssemblyDeps {
|
||||
| 'createMainWindow'
|
||||
| 'setVisibleOverlayVisible'
|
||||
| 'ensureOverlayWindowsReadyForVisibilityActions'
|
||||
>;
|
||||
| 'syncOverlayMpvSubtitleSuppression'
|
||||
> & {
|
||||
visibilityComposer: Pick<
|
||||
OverlayAssembly['visibilityComposer'],
|
||||
'broadcastRuntimeOptionsChanged'
|
||||
>;
|
||||
interaction: Pick<
|
||||
OverlayAssembly['interaction'],
|
||||
'createOverlayWindowTracker' | 'bindVisibleOverlayOwner' | 'releaseVisibleOverlayOwner'
|
||||
>;
|
||||
geometry: Pick<OverlayAssembly['geometry'], 'updateVisibleOverlayBounds'>;
|
||||
};
|
||||
shortcuts: Pick<
|
||||
ShortcutsAssembly,
|
||||
| 'overlayShortcutsRuntime'
|
||||
@@ -125,19 +129,6 @@ export interface StartupAssemblyDeps {
|
||||
ReturnType<typeof createOverlayNotificationsRuntime>,
|
||||
'showOverlayNotification' | 'dismissOverlayNotification'
|
||||
>;
|
||||
getOverlayVisibilityComposer: () => Pick<
|
||||
ReturnType<typeof composeOverlayVisibilityRuntime>,
|
||||
'broadcastRuntimeOptionsChanged'
|
||||
>;
|
||||
/** Pointer interaction and geometry stay in `main.ts`: both import Electron at module load. */
|
||||
getVisibleOverlayInteraction: () => Pick<
|
||||
VisibleOverlayInteractionRuntime,
|
||||
'createOverlayWindowTracker' | 'bindVisibleOverlayOwner' | 'releaseVisibleOverlayOwner'
|
||||
>;
|
||||
getOverlayGeometry: () => Pick<
|
||||
ReturnType<typeof createOverlayGeometryRuntime>,
|
||||
'updateVisibleOverlayBounds'
|
||||
>;
|
||||
/** Runs the CLI command the app was launched with. */
|
||||
handleInitialArgs: () => void;
|
||||
}
|
||||
@@ -167,7 +158,7 @@ export function createStartupAssembly(deps: StartupAssemblyDeps) {
|
||||
const playback = deps.getPlayback();
|
||||
playback.subtitleProcessingController.invalidateTokenizationCache();
|
||||
playback.getSubtitlePrefetchService()?.onSeek(playback.getLastObservedTimePos());
|
||||
deps.getOverlayVisibilityComposer().broadcastRuntimeOptionsChanged();
|
||||
deps.overlay.visibilityComposer.broadcastRuntimeOptionsChanged();
|
||||
deps.shortcuts.refreshOverlayShortcuts();
|
||||
},
|
||||
});
|
||||
@@ -233,13 +224,11 @@ export function createStartupAssembly(deps: StartupAssemblyDeps) {
|
||||
deps.shortcuts.registerGlobalShortcuts();
|
||||
},
|
||||
createWindowTracker: (override, targetMpvSocketPath) =>
|
||||
deps
|
||||
.getVisibleOverlayInteraction()
|
||||
.createOverlayWindowTracker(override, targetMpvSocketPath),
|
||||
deps.overlay.interaction.createOverlayWindowTracker(override, targetMpvSocketPath),
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.getOverlayGeometry().updateVisibleOverlayBounds(geometry),
|
||||
bindOverlayOwner: () => deps.getVisibleOverlayInteraction().bindVisibleOverlayOwner(),
|
||||
releaseOverlayOwner: () => deps.getVisibleOverlayInteraction().releaseVisibleOverlayOwner(),
|
||||
deps.overlay.geometry.updateVisibleOverlayBounds(geometry),
|
||||
bindOverlayOwner: () => deps.overlay.interaction.bindVisibleOverlayOwner(),
|
||||
releaseOverlayOwner: () => deps.overlay.interaction.releaseVisibleOverlayOwner(),
|
||||
getOverlayWindows: () => deps.overlayManager.getOverlayWindows(),
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
showDesktopNotification,
|
||||
@@ -280,7 +269,7 @@ export function createStartupAssembly(deps: StartupAssemblyDeps) {
|
||||
if (appState.ankiIntegration) {
|
||||
deps.mining.attachToAnkiIntegration(appState.ankiIntegration);
|
||||
}
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
deps.overlay.syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
/** Creates whatever overlay commands need that app-ready skipped on a deferred start. */
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { BrowserWindow, IpcMainInvokeEvent, WebContents } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { createAppState } from '../state';
|
||||
import {
|
||||
createSubtitleGenerationAssembly,
|
||||
type SubtitleGenerationAssemblyDeps,
|
||||
} from './subtitle-generation';
|
||||
|
||||
type Handler = (event: IpcMainInvokeEvent, payload: unknown) => unknown;
|
||||
|
||||
function lazy(name: string) {
|
||||
return (): never => {
|
||||
throw new Error(`${name} called unexpectedly`);
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<SubtitleGenerationAssemblyDeps> = {}) {
|
||||
const thrower = <T extends object>(name: string): T =>
|
||||
new Proxy({}, { get: (_target, key: string) => lazy(`${name}.${key}`) }) as T;
|
||||
const deps: SubtitleGenerationAssemblyDeps = {
|
||||
appState: createAppState({ mpvSocketPath: '/tmp/subminer-subgen.sock', texthookerPort: 0 }),
|
||||
getConfig: lazy('getConfig'),
|
||||
getModelDirectory: lazy('getModelDirectory'),
|
||||
overlayManager: thrower('overlayManager'),
|
||||
overlayModals: thrower('overlayModals'),
|
||||
youtube: {
|
||||
whisperSubtitles: thrower('youtube.whisperSubtitles'),
|
||||
flow: thrower('youtube.flow'),
|
||||
playback: thrower('youtube.playback'),
|
||||
},
|
||||
jellyfin: thrower('jellyfin'),
|
||||
ipcMain: thrower('ipcMain'),
|
||||
...overrides,
|
||||
};
|
||||
return deps;
|
||||
}
|
||||
|
||||
test('createSubtitleGenerationAssembly does not call its deps while constructing', () => {
|
||||
const subtitleGeneration = createSubtitleGenerationAssembly(makeDeps());
|
||||
assert.equal(typeof subtitleGeneration.registerIpc, 'function');
|
||||
});
|
||||
|
||||
test('subtitle generation IPC answers the overlay windows and refuses other senders', () => {
|
||||
const overlayContents = {} as WebContents;
|
||||
const overlayWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: overlayContents,
|
||||
} as unknown as BrowserWindow;
|
||||
const handlers = new Map<string, Handler>();
|
||||
const subtitleGeneration = createSubtitleGenerationAssembly(
|
||||
makeDeps({
|
||||
overlayManager: { getMainWindow: () => overlayWindow, getModalWindow: () => null },
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: Handler) => {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
subtitleGeneration.registerIpc();
|
||||
const cancel = handlers.get(IPC_CHANNELS.request.cancelSubtitleGeneration);
|
||||
assert.ok(cancel);
|
||||
|
||||
assert.throws(
|
||||
() => cancel({ sender: {} as WebContents } as IpcMainInvokeEvent, undefined),
|
||||
/only available from the overlay/,
|
||||
);
|
||||
assert.doesNotThrow(() => cancel({ sender: overlayContents } as IpcMainInvokeEvent, undefined));
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import path from 'node:path';
|
||||
import type { IpcMain } from 'electron';
|
||||
import { toJellyfinOriginalStreamUrl, type createOverlayManager } from '../../core/services';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { registerSubtitleGenerationIpc } from '../runtime/subtitle-generation-ipc';
|
||||
import { openSubtitleGenerationModal } from '../runtime/subtitle-generation-open';
|
||||
import { createSubtitleGenerationRuntime } from '../runtime/subtitle-generation-runtime';
|
||||
import type { AppState } from '../state';
|
||||
import type { JellyfinAssembly } from './jellyfin';
|
||||
import type { OverlayModalsAssembly } from './overlay-modals';
|
||||
import type { YoutubeAssembly } from './youtube';
|
||||
|
||||
export interface SubtitleGenerationAssemblyDeps {
|
||||
appState: Pick<AppState, 'mpvClient'>;
|
||||
getConfig: () => ResolvedConfig;
|
||||
/** Managed Whisper models live next to the config file. */
|
||||
getModelDirectory: () => string;
|
||||
overlayManager: Pick<ReturnType<typeof createOverlayManager>, 'getMainWindow' | 'getModalWindow'>;
|
||||
overlayModals: Pick<OverlayModalsAssembly, 'createOverlayHostedModalOpenDeps'>;
|
||||
youtube: {
|
||||
whisperSubtitles: Pick<YoutubeAssembly['whisperSubtitles'], 'generate'>;
|
||||
flow: Pick<YoutubeAssembly['flow'], 'createSubtitleTempDir'>;
|
||||
playback: Pick<YoutubeAssembly['playback'], 'getYoutubeSourceUrlForStream'>;
|
||||
};
|
||||
jellyfin: Pick<JellyfinAssembly, 'getActivePlayback'>;
|
||||
ipcMain: Pick<IpcMain, 'handle'>;
|
||||
}
|
||||
|
||||
export type SubtitleGenerationAssembly = ReturnType<typeof createSubtitleGenerationAssembly>;
|
||||
|
||||
/**
|
||||
* Owns Whisper subtitle generation for the playing media (local files, YouTube and Jellyfin
|
||||
* streams): the generation runtime, its overlay modal and its renderer IPC.
|
||||
*/
|
||||
export function createSubtitleGenerationAssembly(deps: SubtitleGenerationAssemblyDeps) {
|
||||
const { appState, overlayManager, youtube } = deps;
|
||||
|
||||
function getOverlayWindows() {
|
||||
return [overlayManager.getMainWindow(), overlayManager.getModalWindow()];
|
||||
}
|
||||
|
||||
const runtime = createSubtitleGenerationRuntime({
|
||||
getConfig: () => deps.getConfig().subtitleGeneration,
|
||||
getModelDirectory: () => deps.getModelDirectory(),
|
||||
generateYoutube: async ({ url, config, signal, onProgress }) =>
|
||||
youtube.whisperSubtitles.generate({
|
||||
url,
|
||||
config,
|
||||
signal,
|
||||
onProgress,
|
||||
outputPath: path.join(await youtube.flow.createSubtitleTempDir(), 'youtube-whisper.ja.srt'),
|
||||
}),
|
||||
resolveYoutubeSourceUrl: (mediaPath) =>
|
||||
youtube.playback.getYoutubeSourceUrlForStream(mediaPath),
|
||||
resolveJellyfinMedia: (mediaPath) => {
|
||||
const playback = deps.jellyfin.getActivePlayback();
|
||||
const sourceUrl = playback && toJellyfinOriginalStreamUrl(mediaPath, playback.itemId);
|
||||
return sourceUrl
|
||||
? {
|
||||
sourceUrl,
|
||||
transcoded: playback.playMethod === 'Transcode',
|
||||
audioStreamIndex: playback.audioStreamIndex ?? null,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
onProgress: (progress) => {
|
||||
for (const window of getOverlayWindows()) {
|
||||
if (window && !window.isDestroyed())
|
||||
window.webContents.send(IPC_CHANNELS.event.subtitleGenerationProgress, progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function openModal(): Promise<boolean> {
|
||||
return openSubtitleGenerationModal(deps.overlayModals.createOverlayHostedModalOpenDeps());
|
||||
}
|
||||
|
||||
/** Registers the generation IPC; only the overlay windows may call it. */
|
||||
function registerIpc(): void {
|
||||
registerSubtitleGenerationIpc({
|
||||
ipc: deps.ipcMain,
|
||||
isAllowedSender: (sender) =>
|
||||
getOverlayWindows().some(
|
||||
(window) => window && !window.isDestroyed() && window.webContents === sender,
|
||||
),
|
||||
runtime,
|
||||
openModal,
|
||||
});
|
||||
}
|
||||
|
||||
return { runtime, openModal, registerIpc };
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// Interim source-text guards: each test regexes src/main.ts (or a runtime file) to
|
||||
// pin a startup/overlay invariant that main.ts closures make hard to exercise.
|
||||
// Replace each with a behavioral test as the logic moves out of main.ts closures
|
||||
// (see docs/workflow/testing.md, Known Debt). Do not add new tests here.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function readMainSource(): string {
|
||||
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
|
||||
}
|
||||
|
||||
function readSource(relPath: string): string {
|
||||
return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8');
|
||||
}
|
||||
|
||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||
const actionBlock = source.match(
|
||||
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
||||
);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('deps.initSubtitlePrefetch(') <
|
||||
actionBlock.indexOf('deps.setActiveParsedSubtitleMediaPath(nextMediaPath);'),
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
|
||||
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||
const actionBlock = source.match(
|
||||
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/const cachedPayload = subtitleProcessingController\.consumeCachedSubtitle\(text\);/,
|
||||
);
|
||||
assert.match(actionBlock, /if \(cachedPayload\) \{/);
|
||||
assert.match(actionBlock, /emitSubtitlePayload\(cachedPayload\);/);
|
||||
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('consumeCachedSubtitle(text)') <
|
||||
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
|
||||
);
|
||||
});
|
||||
|
||||
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
|
||||
const source = readMainSource();
|
||||
const gateBlock = source.match(
|
||||
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
assert.ok(gateBlock);
|
||||
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
|
||||
// Untokenized signals are filtered by the gate's isTokenizationReady dep, not
|
||||
// by the target-readiness predicate (which must stay warmup-free so warm and
|
||||
// tokenized releases are never deferred behind the global warmup flag).
|
||||
assert.match(gateBlock, /isTokenizationReady:\s*\(\) => isTokenizationWarmupReady\(\)/);
|
||||
const signalTargetReadyBlock = gateBlock.match(
|
||||
/isSignalTargetReady:\s*\(signal\) =>(?<body>[\s\S]*?)\n schedule:/,
|
||||
)?.groups?.body;
|
||||
assert.ok(signalTargetReadyBlock);
|
||||
assert.doesNotMatch(signalTargetReadyBlock, /isTokenizationWarmupReady\(\)/);
|
||||
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
|
||||
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
|
||||
});
|
||||
|
||||
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||
const source = readSource('src/main/runtime/stats-server-runtime.ts');
|
||||
const statsServerConfigBlock = source.match(
|
||||
/const buildStatsServerConfig[\s\S]*?return \{(?<body>[\s\S]*?)\n \};\n \};/,
|
||||
)?.groups?.body;
|
||||
const addYomitanNoteBlock = statsServerConfigBlock?.match(
|
||||
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(addYomitanNoteBlock);
|
||||
assert.match(
|
||||
addYomitanNoteBlock,
|
||||
/const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/,
|
||||
);
|
||||
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||
});
|
||||
|
||||
test('visible overlay input reset clears the visible content measurement', () => {
|
||||
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||
const resetBlock = runtimeSource.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(resetBlock);
|
||||
assert.match(resetBlock, /overlayContentMeasurementStore\.clear\('visible'\);/);
|
||||
});
|
||||
|
||||
test('Linux visible overlay startup reapplies passive passthrough after input reset', () => {
|
||||
const pointerSource = readSource('src/main/runtime/linux-overlay-pointer-interaction.ts');
|
||||
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||
const resetPrimerBlock = runtimeSource.match(
|
||||
/function resetLinuxVisibleOverlayStartupInputPrimer\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
const startGraceBlock = runtimeSource.match(
|
||||
/function startLinuxVisibleOverlayStartupInputGrace\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
const depsBlock = runtimeSource.match(
|
||||
/const linuxOverlayPointerInteractionDeps = \{(?<body>[\s\S]*?)\n \};/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(resetPrimerBlock);
|
||||
assert.ok(startGraceBlock);
|
||||
assert.ok(depsBlock);
|
||||
assert.match(resetPrimerBlock, /visibleOverlayInteractionActive = false;/);
|
||||
assert.match(resetPrimerBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
|
||||
assert.match(
|
||||
startGraceBlock,
|
||||
/linuxVisibleOverlayStartupInputGraceUntilMs =\s+Date\.now\(\) \+ LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;/,
|
||||
);
|
||||
assert.match(startGraceBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
|
||||
assert.match(
|
||||
depsBlock,
|
||||
/isInteractionStateApplied:\s*\(\) => linuxOverlayPointerInteractionStateApplied/,
|
||||
);
|
||||
assert.match(
|
||||
pointerSource,
|
||||
/deps\.getInteractionActive\(\) === desired && deps\.isInteractionStateApplied\?\.\(\) !== false/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
|
||||
const source = readSource('src/main/runtime/overlay-geometry-runtime.ts');
|
||||
const afterBoundsBlock = source.match(
|
||||
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(afterBoundsBlock);
|
||||
assert.match(afterBoundsBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||
assert.ok(
|
||||
afterBoundsBlock.indexOf('restoreLinuxOverlayWindowShape(mainWindow);') <
|
||||
afterBoundsBlock.indexOf('ensureOverlayWindowLevel(mainWindow);'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DEFAULT_CONFIG } from '../../config/definitions';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import type { RuntimeOptionValue } from '../../types/runtime-options';
|
||||
import { createAnnotationOptions, type AnnotationRuntimeOptionId } from './annotation-options';
|
||||
|
||||
/** Every annotation off in config; `runtime` holds the options the user has cycled. */
|
||||
function setup(runtime: Partial<Record<AnnotationRuntimeOptionId, RuntimeOptionValue>> = {}) {
|
||||
const config: ResolvedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
ankiConnect: {
|
||||
...DEFAULT_CONFIG.ankiConnect,
|
||||
knownWords: { ...DEFAULT_CONFIG.ankiConnect.knownWords, highlightEnabled: false },
|
||||
nPlusOne: { ...DEFAULT_CONFIG.ankiConnect.nPlusOne, enabled: false },
|
||||
},
|
||||
subtitleStyle: {
|
||||
...DEFAULT_CONFIG.subtitleStyle,
|
||||
enableJlpt: false,
|
||||
nameMatchEnabled: true,
|
||||
frequencyDictionary: { ...DEFAULT_CONFIG.subtitleStyle.frequencyDictionary, enabled: false },
|
||||
},
|
||||
};
|
||||
return createAnnotationOptions({
|
||||
getConfig: () => config,
|
||||
getRuntimeOptionValue: (id) => runtime[id],
|
||||
isCharacterDictionaryEnabled: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
test('MeCab starts only when some annotation, from config or a runtime option, needs tokens', () => {
|
||||
assert.equal(setup().shouldInitializeMecabForAnnotations(), false);
|
||||
assert.equal(
|
||||
setup({ 'subtitle.annotation.jlpt': true }).shouldInitializeMecabForAnnotations(),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a runtime option overrides config only when it holds a boolean', () => {
|
||||
const options = setup({ 'subtitle.annotation.nPlusOne': true, 'subtitle.annotation.jlpt': 'on' });
|
||||
|
||||
assert.equal(options.getRuntimeBooleanOption('subtitle.annotation.nPlusOne', false), true);
|
||||
assert.equal(options.getRuntimeBooleanOption('subtitle.annotation.jlpt', false), false);
|
||||
assert.equal(options.getRuntimeBooleanOption('subtitle.annotation.frequency', true), true);
|
||||
});
|
||||
|
||||
test('the texthooker colors names only while the character dictionary is enabled', () => {
|
||||
const settings = setup({
|
||||
'subtitle.annotation.knownWords.highlightEnabled': true,
|
||||
}).getTexthookerBootstrapSettings();
|
||||
|
||||
assert.equal(settings.enableKnownWordColoring, true);
|
||||
assert.equal(settings.enableNPlusOneColoring, false);
|
||||
assert.equal(settings.characterDictionaryEnabled, false);
|
||||
assert.equal(settings.enableNameMatchColoring, false);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { TexthookerBootstrapSettings } from '../../core/services/texthooker';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-options';
|
||||
|
||||
/** The subtitle annotation toggles the user can flip at runtime from the options palette. */
|
||||
export type AnnotationRuntimeOptionId = Extract<
|
||||
RuntimeOptionId,
|
||||
| 'subtitle.annotation.knownWords.highlightEnabled'
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency'
|
||||
>;
|
||||
|
||||
export interface AnnotationOptionsDeps {
|
||||
getConfig: () => ResolvedConfig;
|
||||
/** The runtime option's current value; undefined before the options manager exists. */
|
||||
getRuntimeOptionValue: (id: AnnotationRuntimeOptionId) => RuntimeOptionValue | undefined;
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which subtitle annotations are on. A runtime option the user cycled wins over its
|
||||
* config value; the texthooker page and the MeCab startup decision both read through here.
|
||||
*/
|
||||
export function createAnnotationOptions(deps: AnnotationOptionsDeps) {
|
||||
function getRuntimeBooleanOption(id: AnnotationRuntimeOptionId, fallback: boolean): boolean {
|
||||
const value = deps.getRuntimeOptionValue(id);
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
/** MeCab is only worth starting when at least one annotation needs tokens. */
|
||||
function shouldInitializeMecabForAnnotations(): boolean {
|
||||
const config = deps.getConfig();
|
||||
const knownWordsEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
config.ankiConnect.knownWords.highlightEnabled,
|
||||
);
|
||||
const nPlusOneEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
config.ankiConnect.nPlusOne.enabled,
|
||||
);
|
||||
const jlptEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.jlpt',
|
||||
config.subtitleStyle.enableJlpt,
|
||||
);
|
||||
const frequencyEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
config.subtitleStyle.frequencyDictionary.enabled,
|
||||
);
|
||||
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
}
|
||||
|
||||
/** Annotation colors and toggles the texthooker page renders with. */
|
||||
function getTexthookerBootstrapSettings(): TexthookerBootstrapSettings {
|
||||
const config = deps.getConfig();
|
||||
const characterDictionaryEnabled =
|
||||
config.subtitleStyle.nameMatchEnabled && deps.isCharacterDictionaryEnabled();
|
||||
const knownWordColoringEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
config.ankiConnect.knownWords.highlightEnabled,
|
||||
);
|
||||
const nPlusOneColoringEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
config.ankiConnect.nPlusOne.enabled,
|
||||
);
|
||||
|
||||
return {
|
||||
enableKnownWordColoring: knownWordColoringEnabled,
|
||||
enableNPlusOneColoring: nPlusOneColoringEnabled,
|
||||
enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled,
|
||||
enableFrequencyColoring: getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
config.subtitleStyle.frequencyDictionary.enabled,
|
||||
),
|
||||
enableJlptColoring: getRuntimeBooleanOption(
|
||||
'subtitle.annotation.jlpt',
|
||||
config.subtitleStyle.enableJlpt,
|
||||
),
|
||||
characterDictionaryEnabled,
|
||||
knownWordColor: config.subtitleStyle.knownWordColor,
|
||||
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
|
||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
|
||||
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
|
||||
jlptColors: config.subtitleStyle.jlptColors,
|
||||
frequencyDictionary: {
|
||||
singleColor: config.subtitleStyle.frequencyDictionary.singleColor,
|
||||
bandedColors: config.subtitleStyle.frequencyDictionary.bandedColors,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getRuntimeBooleanOption,
|
||||
shouldInitializeMecabForAnnotations,
|
||||
getTexthookerBootstrapSettings,
|
||||
};
|
||||
}
|
||||
@@ -465,3 +465,121 @@ test('prefetch stays paused until tokenization of an uncached line completes', a
|
||||
'prefetch:resume',
|
||||
]);
|
||||
});
|
||||
|
||||
/** Deps whose calls append to `calls`; tests override what they exercise. */
|
||||
function makeRecordingDeps(
|
||||
calls: string[],
|
||||
overrides: Partial<Parameters<typeof createAutoplaySubtitlePrimingRuntime>[0]> = {},
|
||||
): Parameters<typeof createAutoplaySubtitlePrimingRuntime>[0] {
|
||||
let currentSubText = '';
|
||||
return {
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getMpvClient: () => null,
|
||||
setCurrentSubText: (text) => {
|
||||
currentSubText = text;
|
||||
calls.push(`set:${text}`);
|
||||
},
|
||||
getCurrentSubText: () => currentSubText,
|
||||
getCurrentSubtitleData: () => null,
|
||||
getActiveParsedSubtitleCues: () => [],
|
||||
setActiveParsedSubtitleMediaPath: (mediaPath) => calls.push(`tag:${mediaPath}`),
|
||||
subtitleProcessingController: {
|
||||
consumeCachedSubtitle: (text) => {
|
||||
calls.push(`consume:${text}`);
|
||||
return null;
|
||||
},
|
||||
onSubtitleChange: (text) => {
|
||||
calls.push(`change:${text}`);
|
||||
return true;
|
||||
},
|
||||
refreshCurrentSubtitle: (text) => {
|
||||
calls.push(`refresh:${text}`);
|
||||
return true;
|
||||
},
|
||||
notePlainSubtitleEmitted: () => {},
|
||||
},
|
||||
emitSubtitlePayload: (payload) =>
|
||||
calls.push(`emit:${payload.text}:${payload.tokens ? 'annotated' : 'raw'}`),
|
||||
getSubtitlePrefetchService: () => null,
|
||||
getLastObservedTimePos: () => 12,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
emitSecondarySubtitle: () => {},
|
||||
initSubtitlePrefetch: async (sourcePath) => {
|
||||
calls.push(`init:${sourcePath}`);
|
||||
},
|
||||
refreshSubtitlePrefetchFromActiveTrack: async () => {},
|
||||
logDebug: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('a cached line primes with its annotated payload and never paints it raw first', async () => {
|
||||
const calls: string[] = [];
|
||||
const mediaPath = '/media/video.mkv';
|
||||
const cached: SubtitleData = { text: '起動字幕', tokens: [] };
|
||||
const runtime = createAutoplaySubtitlePrimingRuntime(
|
||||
makeRecordingDeps(calls, {
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: mediaPath,
|
||||
requestProperty: async (name) => (name === 'sub-text' ? '起動字幕' : null),
|
||||
}),
|
||||
subtitleProcessingController: {
|
||||
consumeCachedSubtitle: (text) => {
|
||||
calls.push(`consume:${text}`);
|
||||
return cached;
|
||||
},
|
||||
onSubtitleChange: (text) => {
|
||||
calls.push(`change:${text}`);
|
||||
return false;
|
||||
},
|
||||
refreshCurrentSubtitle: () => true,
|
||||
notePlainSubtitleEmitted: () => {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'set:起動字幕',
|
||||
// Consumed before the controller hears about the line, which would otherwise schedule
|
||||
// its own tokenization of it.
|
||||
'consume:起動字幕',
|
||||
'change:起動字幕',
|
||||
'emit:起動字幕:annotated',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the subtitle sidebar tags its media path only once prefetch has loaded the source', async () => {
|
||||
const calls: string[] = [];
|
||||
const runtime = createAutoplaySubtitlePrimingRuntime(makeRecordingDeps(calls));
|
||||
|
||||
await runtime.refreshSubtitleSidebarFromSource('/subs/episode.srt', ' /media/other.mkv ');
|
||||
await runtime.refreshSubtitleSidebarFromSource('/subs/episode.srt');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'init:/subs/episode.srt',
|
||||
'tag:/media/other.mkv',
|
||||
'init:/subs/episode.srt',
|
||||
// Without an explicit media path the tag falls back to the current media.
|
||||
'tag:/media/video.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a failed subtitle sidebar prefetch leaves the media path untagged', async () => {
|
||||
const calls: string[] = [];
|
||||
const runtime = createAutoplaySubtitlePrimingRuntime(
|
||||
makeRecordingDeps(calls, {
|
||||
initSubtitlePrefetch: async () => {
|
||||
throw new Error('unreadable subtitle file');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
runtime.refreshSubtitleSidebarFromSource('/subs/episode.srt', '/media/other.mkv'),
|
||||
/unreadable subtitle file/,
|
||||
);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
import type { BrowserWindow, Display } from 'electron';
|
||||
import type { HyprlandPlacementStatus } from '../../core/services/hyprland-window-placement';
|
||||
import {
|
||||
createOverlayGeometryRuntime,
|
||||
type OverlayGeometryRuntimeDeps,
|
||||
} from './overlay-geometry-runtime';
|
||||
|
||||
const NO_PLACEMENT: HyprlandPlacementStatus = {
|
||||
applicable: false,
|
||||
clientFound: false,
|
||||
dispatched: false,
|
||||
};
|
||||
const DISPLAY = { bounds: { x: 0, y: 0, width: 1920, height: 1080 } } as Display;
|
||||
|
||||
/** Builds the runtime with `process.platform` set and a visible overlay window that logs calls. */
|
||||
function setup(t: TestContext, platform: NodeJS.Platform) {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
||||
t.after(() => Object.defineProperty(process, 'platform', { value: originalPlatform }));
|
||||
|
||||
const windowCalls: string[] = [];
|
||||
const record =
|
||||
(name: string) =>
|
||||
(..._args: unknown[]) => {
|
||||
windowCalls.push(name);
|
||||
};
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
getTitle: () => 'SubMiner Overlay',
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
setShape: record('setShape'),
|
||||
setAlwaysOnTop: record('setAlwaysOnTop'),
|
||||
setVisibleOnAllWorkspaces: record('setVisibleOnAllWorkspaces'),
|
||||
setFullScreenable: record('setFullScreenable'),
|
||||
moveTop: record('moveTop'),
|
||||
} as unknown as BrowserWindow;
|
||||
const noop = () => {};
|
||||
const deps: OverlayGeometryRuntimeDeps = {
|
||||
screen: {
|
||||
getCursorScreenPoint: () => ({ x: 0, y: 0 }),
|
||||
getDisplayNearestPoint: () => DISPLAY,
|
||||
getDisplayMatching: () => DISPLAY,
|
||||
},
|
||||
promoteStatsOverlayAbovePlayback: noop,
|
||||
overlayManager: {
|
||||
getMainWindow: () => mainWindow,
|
||||
getModalWindow: () => null,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
setOverlayWindowBounds: () => NO_PLACEMENT,
|
||||
setModalWindowBounds: () => NO_PLACEMENT,
|
||||
},
|
||||
getTrackedWindowGeometry: () => null,
|
||||
getTrackedWindowMediaSourceId: () => null,
|
||||
getTrackedWindowNativeId: () => null,
|
||||
getStatsOverlayVisible: () => false,
|
||||
getOverlayForegroundSeparateWindows: () => [],
|
||||
getLinuxVisibleOverlayWindowMode: () => 'managed',
|
||||
getLinuxTrackedMpvFullscreen: () => false,
|
||||
getLinuxTrackedMpvFullscreenChangedAtMs: () => 0,
|
||||
syncLinuxVisibleOverlayMpvFullscreenMode: noop,
|
||||
getLinuxVisibleOverlayOwnerBindingKey: () => null,
|
||||
setLinuxVisibleOverlayOwnerBindingKey: noop,
|
||||
clearVisibleOverlayX11OwnerBinding: noop,
|
||||
getNativeWindowHandleDecimal: () => '0',
|
||||
enqueueVisibleOverlayX11OwnerBindingOperation: noop,
|
||||
scheduleWindowsVisibleOverlayZOrderSyncBurst: noop,
|
||||
logDebug: noop,
|
||||
};
|
||||
return { geometry: createOverlayGeometryRuntime(deps), windowCalls };
|
||||
}
|
||||
|
||||
// New mpv bounds can leave the X11 input shape sized for the old window; restore it to the full
|
||||
// window before re-asserting the overlay's level so the whole overlay stays clickable.
|
||||
test('a Linux bounds update restores the overlay window shape before raising it', (t) => {
|
||||
const { geometry, windowCalls } = setup(t, 'linux');
|
||||
|
||||
geometry.updateVisibleOverlayBounds({ x: 10, y: 20, width: 1280, height: 720 });
|
||||
|
||||
const shapeIndex = windowCalls.indexOf('setShape');
|
||||
assert.ok(shapeIndex >= 0, 'window shape was not restored');
|
||||
assert.ok(shapeIndex < windowCalls.indexOf('setAlwaysOnTop'));
|
||||
});
|
||||
|
||||
test('a macOS bounds update raises the overlay without touching the window shape', (t) => {
|
||||
const { geometry, windowCalls } = setup(t, 'darwin');
|
||||
|
||||
geometry.updateVisibleOverlayBounds({ x: 10, y: 20, width: 1280, height: 720 });
|
||||
|
||||
assert.ok(windowCalls.includes('setAlwaysOnTop'));
|
||||
assert.ok(!windowCalls.includes('setShape'));
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type BrowserWindow, screen } from 'electron';
|
||||
import type { BrowserWindow, Screen } from 'electron';
|
||||
import type { WindowGeometry } from '../../types';
|
||||
import { hasHyprlandWindowPlacementBoundsMismatch } from '../../core/services/hyprland-window-placement';
|
||||
import { normalizeOverlayWindowBoundsForPlatform } from '../../core/services/overlay-window-bounds';
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
|
||||
syncOverlayWindowLayer,
|
||||
} from '../../core/services/overlay-window';
|
||||
import { promoteStatsOverlayAbovePlayback } from '../../core/services/stats-window.js';
|
||||
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
|
||||
import { shouldRunLinuxOverlayZOrderKeepAlive } from './linux-overlay-zorder-keepalive';
|
||||
import {
|
||||
@@ -25,6 +24,9 @@ import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-w
|
||||
const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
|
||||
|
||||
export interface OverlayGeometryRuntimeDeps {
|
||||
/** Injected so this module loads without Electron (and under `bun test`). */
|
||||
screen: Pick<Screen, 'getCursorScreenPoint' | 'getDisplayNearestPoint' | 'getDisplayMatching'>;
|
||||
promoteStatsOverlayAbovePlayback: () => void;
|
||||
overlayManager: {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
@@ -60,8 +62,8 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
|
||||
let lastOverlayWindowGeometry: WindowGeometry | null = null;
|
||||
|
||||
function getOverlayGeometryFallback(): WindowGeometry {
|
||||
const cursorPoint = screen.getCursorScreenPoint();
|
||||
const display = screen.getDisplayNearestPoint(cursorPoint);
|
||||
const cursorPoint = deps.screen.getCursorScreenPoint();
|
||||
const display = deps.screen.getDisplayNearestPoint(cursorPoint);
|
||||
const bounds = display.workArea;
|
||||
return {
|
||||
x: bounds.x,
|
||||
@@ -106,7 +108,7 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const displayBounds = screen.getDisplayMatching(geometry).bounds;
|
||||
const displayBounds = deps.screen.getDisplayMatching(geometry).bounds;
|
||||
return shouldExitFullscreenOverrideForTrackedGeometry({
|
||||
currentMode: deps.getLinuxVisibleOverlayWindowMode(),
|
||||
trackedFullscreen: deps.getLinuxTrackedMpvFullscreen(),
|
||||
@@ -137,7 +139,8 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
|
||||
}
|
||||
return hasHyprlandWindowPlacementBoundsMismatch({
|
||||
title: window.getTitle(),
|
||||
bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen, window),
|
||||
// Linux bounds come from the window's content insets; the DIP converter is Windows-only.
|
||||
bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, null, window),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -190,7 +193,7 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow);
|
||||
}
|
||||
promoteStatsOverlayAbovePlayback();
|
||||
deps.promoteStatsOverlayAbovePlayback();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,10 @@ function createRuntimeHarness(
|
||||
return { runtime, appStateValues };
|
||||
}
|
||||
|
||||
test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoint', async () => {
|
||||
/** Mines a word from the stats dashboard; returns the Anki settings sent to Hachidori. */
|
||||
async function mineDashboardWord(
|
||||
ankiConnect: Partial<(typeof DEFAULT_CONFIG)['ankiConnect']>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const settings: unknown[] = [];
|
||||
const context = vm.createContext({
|
||||
chrome: {},
|
||||
@@ -95,25 +98,40 @@ test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoin
|
||||
...createDeps(async (script) => structuredClone(await vm.runInContext(script, context))),
|
||||
getResolvedConfig: () => ({
|
||||
...DEFAULT_CONFIG,
|
||||
ankiConnect: {
|
||||
...DEFAULT_CONFIG.ankiConnect,
|
||||
enabled: true,
|
||||
url: 'http://127.0.0.1:8765',
|
||||
proxy: {
|
||||
...DEFAULT_CONFIG.ankiConnect.proxy,
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
},
|
||||
},
|
||||
ankiConnect: { ...DEFAULT_CONFIG.ankiConnect, ...ankiConnect },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await runtime.ensureStatsServerStarted();
|
||||
assert.equal(await configs[0]?.addYomitanNote?.('入れる'), 123);
|
||||
assert.ok(settings[0] && typeof settings[0] === 'object' && 'server' in settings[0]);
|
||||
assert.equal(settings[0].server, 'http://127.0.0.1:8766');
|
||||
await runtime.stopStatsServer();
|
||||
const sent = settings[0];
|
||||
assert.ok(sent && typeof sent === 'object');
|
||||
return sent as Record<string, unknown>;
|
||||
}
|
||||
|
||||
test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoint', async () => {
|
||||
const sent = await mineDashboardWord({
|
||||
enabled: true,
|
||||
url: 'http://127.0.0.1:8765',
|
||||
proxy: { ...DEFAULT_CONFIG.ankiConnect.proxy, enabled: true, host: '127.0.0.1', port: 8766 },
|
||||
});
|
||||
|
||||
assert.equal(sent.server, 'http://127.0.0.1:8766');
|
||||
assert.equal(sent.forceOverride, true);
|
||||
});
|
||||
|
||||
// Without the proxy SubMiner does not own the endpoint, so a server the user set in
|
||||
// Yomitan/Hachidori must survive dashboard mining.
|
||||
test('dashboard word mining without the proxy does not override the Hachidori Anki server', async () => {
|
||||
const sent = await mineDashboardWord({
|
||||
enabled: true,
|
||||
url: 'http://127.0.0.1:8765',
|
||||
proxy: { ...DEFAULT_CONFIG.ankiConnect.proxy, enabled: false },
|
||||
});
|
||||
|
||||
assert.equal(sent.server, 'http://127.0.0.1:8765');
|
||||
assert.equal(sent.forceOverride, false);
|
||||
});
|
||||
|
||||
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { stopLinuxOverlayPointerInteractionLoop } from './linux-overlay-pointer-interaction';
|
||||
import { stopLinuxOverlayZOrderKeepAliveLoop } from './linux-overlay-zorder-keepalive';
|
||||
import {
|
||||
createVisibleOverlayInteractionRuntime,
|
||||
type VisibleOverlayInteractionRuntimeDeps,
|
||||
} from './visible-overlay-interaction-runtime';
|
||||
|
||||
type MouseEventsCall = { ignore: boolean; options?: { forward?: boolean } };
|
||||
|
||||
/** Builds the runtime with `process.platform` set, a visible overlay and a fake main window. */
|
||||
function setup(t: TestContext, platform: NodeJS.Platform) {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
||||
t.after(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
// Construction on Linux starts the shared pointer poll and z-order keep-alive loops.
|
||||
stopLinuxOverlayPointerInteractionLoop();
|
||||
stopLinuxOverlayZOrderKeepAliveLoop();
|
||||
});
|
||||
|
||||
const mouseEvents: MouseEventsCall[] = [];
|
||||
const clearedLayers: string[] = [];
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
isFocused: () => true,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
|
||||
setShape: () => {},
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
mouseEvents.push({ ignore, options });
|
||||
},
|
||||
} as unknown as BrowserWindow;
|
||||
const noop = () => {};
|
||||
const deps: VisibleOverlayInteractionRuntimeDeps = {
|
||||
screen: {
|
||||
getCursorScreenPoint: () => ({ x: 5, y: 5 }),
|
||||
screenToDipPoint: (point) => point,
|
||||
},
|
||||
overlayManager: {
|
||||
getMainWindow: () => mainWindow,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
overlayContentMeasurementStore: {
|
||||
clear: (layer) => {
|
||||
clearedLayers.push(layer);
|
||||
},
|
||||
getLatestByLayer: () => null,
|
||||
},
|
||||
logger: { info: noop, warn: noop, debug: noop },
|
||||
updateVisibleOverlayVisibility: noop,
|
||||
getModalInputExclusive: () => false,
|
||||
getStatsOverlayVisible: () => false,
|
||||
setStatsOverlayVisible: noop,
|
||||
getWindowTracker: () => null,
|
||||
setWindowTracker: noop,
|
||||
setTrackerNotReadyWarningShown: noop,
|
||||
getMpvSocketPath: () => '/tmp/subminer-interaction-test.sock',
|
||||
getBackendOverride: () => null,
|
||||
getInitialArgs: () => null,
|
||||
getOverlayRuntimeInitialized: () => true,
|
||||
getLinuxVisibleOverlayWindowMode: () => 'managed',
|
||||
setLinuxVisibleOverlayOwnerBindingKey: noop,
|
||||
bindVisibleOverlayToTrackedX11Window: noop,
|
||||
updateVisibleOverlayBounds: noop,
|
||||
refreshCurrentSubtitle: noop,
|
||||
getOverlayWindows: () => [mainWindow],
|
||||
syncOverlayShortcuts: noop,
|
||||
resetLastOverlayWindowGeometry: noop,
|
||||
enforceOverlayLayerOrder: noop,
|
||||
getOverlayForegroundSeparateWindows: () => [],
|
||||
};
|
||||
const runtime = createVisibleOverlayInteractionRuntime(deps);
|
||||
return { runtime, mouseEvents, clearedLayers };
|
||||
}
|
||||
|
||||
test('resetting visible overlay input clears the visible content measurement', (t) => {
|
||||
const { runtime, clearedLayers } = setup(t, 'darwin');
|
||||
|
||||
runtime.resetVisibleOverlayInputState();
|
||||
|
||||
assert.deepEqual(clearedLayers, ['visible']);
|
||||
});
|
||||
|
||||
// With no subtitle under the cursor the poll wants passthrough. An input reset leaves the
|
||||
// window's real state unknown, so the next poll must reapply it even though the desired state
|
||||
// matches the remembered one. Hiding resets all input; showing resets only the startup primer.
|
||||
for (const reset of [
|
||||
'resetVisibleOverlayInputState',
|
||||
'resetLinuxVisibleOverlayStartupInputPrimer',
|
||||
] as const) {
|
||||
test(`the Linux pointer poll reapplies passive passthrough after ${reset}`, (t) => {
|
||||
const { runtime, mouseEvents } = setup(t, 'linux');
|
||||
const passthrough: MouseEventsCall = { ignore: true, options: { forward: true } };
|
||||
|
||||
runtime.tickLinuxOverlayPointerInteractionNow();
|
||||
runtime.tickLinuxOverlayPointerInteractionNow();
|
||||
assert.deepEqual(mouseEvents, [passthrough]);
|
||||
|
||||
runtime[reset]();
|
||||
runtime.tickLinuxOverlayPointerInteractionNow();
|
||||
runtime.tickLinuxOverlayPointerInteractionNow();
|
||||
assert.deepEqual(mouseEvents, [passthrough, passthrough]);
|
||||
});
|
||||
}
|
||||
|
||||
test('the Linux startup input grace makes the overlay interactive on the next poll', (t) => {
|
||||
const { runtime, mouseEvents } = setup(t, 'linux');
|
||||
runtime.tickLinuxOverlayPointerInteractionNow();
|
||||
|
||||
runtime.startLinuxVisibleOverlayStartupInputGrace();
|
||||
runtime.tickLinuxOverlayPointerInteractionNow();
|
||||
|
||||
assert.deepEqual(mouseEvents.at(-1), { ignore: false, options: undefined });
|
||||
assert.equal(runtime.getVisibleOverlayInteractionActive(), true);
|
||||
});
|
||||
|
||||
test('the Linux startup input grace reasserts input on an overlay that is already interactive', (t) => {
|
||||
const { runtime, mouseEvents } = setup(t, 'linux');
|
||||
const interactive: MouseEventsCall = { ignore: false, options: undefined };
|
||||
runtime.setLinuxOverlayInteractiveHint(true);
|
||||
runtime.tickLinuxOverlayPointerInteractionNow();
|
||||
runtime.tickLinuxOverlayPointerInteractionNow();
|
||||
assert.deepEqual(mouseEvents, [interactive]);
|
||||
|
||||
runtime.startLinuxVisibleOverlayStartupInputGrace();
|
||||
runtime.tickLinuxOverlayPointerInteractionNow();
|
||||
|
||||
assert.deepEqual(mouseEvents, [interactive, interactive]);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type BrowserWindow, screen } from 'electron';
|
||||
import type { BrowserWindow, Screen } from 'electron';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
|
||||
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
|
||||
@@ -36,6 +36,8 @@ import { hasLiveSeparateWindow } from './settings-window-z-order';
|
||||
import { tickWindowsOverlayPointerInteraction } from './windows-overlay-pointer-interaction';
|
||||
|
||||
export interface VisibleOverlayInteractionRuntimeDeps {
|
||||
/** Injected so this module loads without Electron (and under `bun test`). */
|
||||
screen: Pick<Screen, 'getCursorScreenPoint' | 'screenToDipPoint'>;
|
||||
overlayManager: {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
@@ -289,7 +291,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
return null;
|
||||
}
|
||||
return createWindowTrackerCore(override, targetMpvSocketPath, (point) =>
|
||||
screen.screenToDipPoint(point),
|
||||
deps.screen.screenToDipPoint(point),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -597,7 +599,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
const windowsOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getCursorScreenPoint: () => screen.getCursorScreenPoint(),
|
||||
getCursorScreenPoint: () => deps.screen.getCursorScreenPoint(),
|
||||
getSubtitleMeasurement: () => overlayContentMeasurementStore.getLatestByLayer('visible'),
|
||||
shouldSuspend: shouldSuspendWindowsOverlayPointerInteraction,
|
||||
getInteractionActive: () => windowsOverlayPointerInteractionActive,
|
||||
@@ -617,7 +619,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||
|
||||
const linuxX11CursorPointReader = createLinuxX11CursorPointReader({
|
||||
screenToDipPoint: (point) => screen.screenToDipPoint(point),
|
||||
screenToDipPoint: (point) => deps.screen.screenToDipPoint(point),
|
||||
});
|
||||
|
||||
function getLinuxOverlayPointerMeasurement() {
|
||||
@@ -805,7 +807,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getCursorScreenPoint: () =>
|
||||
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
|
||||
linuxX11CursorPointReader.getCursorScreenPoint(deps.screen.getCursorScreenPoint()),
|
||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||
getRendererInteractiveHint: () =>
|
||||
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
|
||||
|
||||
@@ -69,6 +69,7 @@ const VOID_ACTIONS = [
|
||||
'playCurrentAudio',
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'toggleCharacterNavigation',
|
||||
'toggleMpvPause',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
@@ -117,6 +118,7 @@ function createHarness(
|
||||
playCurrentAudio: () => {},
|
||||
previousEntry: () => {},
|
||||
nextEntry: () => {},
|
||||
toggleCharacterNavigation: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
previousSubtitle: () => {},
|
||||
nextSubtitle: () => {},
|
||||
@@ -648,6 +650,33 @@ test('gamepad controller waits for a fresh press when the popup opens under a he
|
||||
assert.deepEqual(harness.calls, []);
|
||||
});
|
||||
|
||||
test('gamepad controller switches character navigation once per press in keyboard-only mode', () => {
|
||||
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings.toggleCharacterNavigation, {
|
||||
kind: 'none',
|
||||
});
|
||||
let buttons: Record<number, TestButton> = {};
|
||||
let keyboardMode = true;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [createGamepad('pad-1', { buttons })],
|
||||
config: createControllerConfig({
|
||||
bindings: { toggleCharacterNavigation: { kind: 'button', buttonIndex: 10 } },
|
||||
}),
|
||||
deps: { getKeyboardModeEnabled: () => keyboardMode },
|
||||
});
|
||||
|
||||
harness.poll(0);
|
||||
buttons = { 10: PRESSED };
|
||||
harness.poll(16, 32, 400);
|
||||
buttons = {};
|
||||
harness.poll(416);
|
||||
// Like the other lookup actions, it waits for keyboard-only mode.
|
||||
keyboardMode = false;
|
||||
buttons = { 10: PRESSED };
|
||||
harness.poll(432);
|
||||
|
||||
assert.deepEqual(harness.calls, ['toggleCharacterNavigation']);
|
||||
});
|
||||
|
||||
test('gamepad controller ignores popup entry navigation while the lookup is closed', () => {
|
||||
const harness = createHarness({
|
||||
gamepads: [createGamepad('pad-1', { buttons: { 12: PRESSED, 13: PRESSED } })],
|
||||
|
||||
@@ -57,6 +57,8 @@ type GamepadControllerOptions = {
|
||||
playCurrentAudio: () => void;
|
||||
previousEntry: () => void;
|
||||
nextEntry: () => void;
|
||||
/** Switches keyboard-only mode between moving by word and by character. */
|
||||
toggleCharacterNavigation: () => void;
|
||||
toggleMpvPause: () => void;
|
||||
previousSubtitle: () => void;
|
||||
nextSubtitle: () => void;
|
||||
@@ -419,6 +421,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
['playCurrentAudio', config.bindings.playCurrentAudio],
|
||||
['previousEntry', config.bindings.previousEntry],
|
||||
['nextEntry', config.bindings.nextEntry],
|
||||
['toggleCharacterNavigation', config.bindings.toggleCharacterNavigation],
|
||||
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
|
||||
] as const;
|
||||
|
||||
@@ -580,6 +583,13 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
activeConfig,
|
||||
options.quitMpv,
|
||||
);
|
||||
handleActionEdge(
|
||||
'toggleCharacterNavigation',
|
||||
activeConfig.bindings.toggleCharacterNavigation,
|
||||
activeGamepad,
|
||||
activeConfig,
|
||||
options.toggleCharacterNavigation,
|
||||
);
|
||||
|
||||
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
clampCharacterCursor,
|
||||
countCharacters,
|
||||
moveCharacterCursor,
|
||||
} from './keyboard-character-cursor.js';
|
||||
|
||||
// もっとハ | キ | ハキ: the split that hides ハキハキ from word navigation.
|
||||
const LENGTHS = [4, 1, 2];
|
||||
|
||||
test('the character cursor walks a word and crosses into the next one', () => {
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 0, charIndex: 2 }, 1), {
|
||||
position: { wordIndex: 0, charIndex: 3 },
|
||||
result: 'moved',
|
||||
});
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 0, charIndex: 3 }, 1), {
|
||||
position: { wordIndex: 1, charIndex: 0 },
|
||||
result: 'moved',
|
||||
});
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 2, charIndex: 0 }, -1), {
|
||||
position: { wordIndex: 1, charIndex: 0 },
|
||||
result: 'moved',
|
||||
});
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 1, charIndex: 0 }, -1), {
|
||||
position: { wordIndex: 0, charIndex: 3 },
|
||||
result: 'moved',
|
||||
});
|
||||
});
|
||||
|
||||
test('the character cursor reports the line edges and stays put there', () => {
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 0, charIndex: 0 }, -1), {
|
||||
position: { wordIndex: 0, charIndex: 0 },
|
||||
result: 'start-boundary',
|
||||
});
|
||||
assert.deepEqual(moveCharacterCursor(LENGTHS, { wordIndex: 2, charIndex: 1 }, 1), {
|
||||
position: { wordIndex: 2, charIndex: 1 },
|
||||
result: 'end-boundary',
|
||||
});
|
||||
assert.equal(moveCharacterCursor([], { wordIndex: 0, charIndex: 0 }, 1).result, 'no-words');
|
||||
});
|
||||
|
||||
test('words without characters are skipped', () => {
|
||||
assert.deepEqual(moveCharacterCursor([1, 0, 2], { wordIndex: 0, charIndex: 0 }, 1).position, {
|
||||
wordIndex: 2,
|
||||
charIndex: 0,
|
||||
});
|
||||
assert.deepEqual(moveCharacterCursor([1, 0, 2], { wordIndex: 2, charIndex: 0 }, -1).position, {
|
||||
wordIndex: 0,
|
||||
charIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('a position past the end lands on the last character', () => {
|
||||
assert.deepEqual(
|
||||
clampCharacterCursor(LENGTHS, { wordIndex: 9, charIndex: Number.MAX_SAFE_INTEGER }),
|
||||
{ wordIndex: 2, charIndex: 1 },
|
||||
);
|
||||
assert.equal(clampCharacterCursor([], { wordIndex: 0, charIndex: 0 }), null);
|
||||
});
|
||||
|
||||
test('characters are counted as code points', () => {
|
||||
assert.equal(countCharacters('𠮷野家'), 3);
|
||||
assert.equal(countCharacters('ハキハキ'), 4);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Character navigation for keyboard-only mode: instead of the word boxes, the cursor sits on
|
||||
* one character, and a lookup searches from it. That reaches words the subtitle split cuts in
|
||||
* two (もっとハ|キ|ハキ… hides ハキハキ from word navigation).
|
||||
*/
|
||||
|
||||
/** The marked character, an element wrapping it inside its word span. */
|
||||
export const KEYBOARD_SELECTED_CHAR_CLASS = 'keyboard-selected-char';
|
||||
/** On the subtitle root while the character cursor is shown. */
|
||||
export const KEYBOARD_CHARACTER_NAVIGATION_CLASS = 'keyboard-character-navigation';
|
||||
|
||||
/** A word span (by index among the line's words) and a character in it. */
|
||||
export type CharacterCursorPosition = { wordIndex: number; charIndex: number };
|
||||
export type CharacterCursorMoveResult = 'moved' | 'start-boundary' | 'end-boundary' | 'no-words';
|
||||
|
||||
/** Characters are counted as code points, so a surrogate pair is one character. */
|
||||
export function countCharacters(text: string): number {
|
||||
return Array.from(text).length;
|
||||
}
|
||||
|
||||
/** The position kept inside the line; `charIndex` past the end lands on the last character. */
|
||||
export function clampCharacterCursor(
|
||||
wordLengths: readonly number[],
|
||||
position: CharacterCursorPosition,
|
||||
): CharacterCursorPosition | null {
|
||||
if (wordLengths.length === 0) return null;
|
||||
const wordIndex = Math.min(Math.max(position.wordIndex, 0), wordLengths.length - 1);
|
||||
const lastChar = Math.max((wordLengths[wordIndex] ?? 0) - 1, 0);
|
||||
const charIndex = Math.min(Math.max(position.charIndex, 0), lastChar);
|
||||
return { wordIndex, charIndex };
|
||||
}
|
||||
|
||||
/** One character left or right, crossing into the neighbouring word at its edges. */
|
||||
export function moveCharacterCursor(
|
||||
wordLengths: readonly number[],
|
||||
position: CharacterCursorPosition,
|
||||
delta: -1 | 1,
|
||||
): { position: CharacterCursorPosition; result: CharacterCursorMoveResult } {
|
||||
const start = clampCharacterCursor(wordLengths, position);
|
||||
if (!start) return { position, result: 'no-words' };
|
||||
|
||||
let { wordIndex } = start;
|
||||
let charIndex = start.charIndex + delta;
|
||||
for (;;) {
|
||||
if (charIndex >= 0 && charIndex < (wordLengths[wordIndex] ?? 0)) {
|
||||
return { position: { wordIndex, charIndex }, result: 'moved' };
|
||||
}
|
||||
wordIndex += delta;
|
||||
if (wordIndex < 0) return { position: start, result: 'start-boundary' };
|
||||
if (wordIndex >= wordLengths.length) return { position: start, result: 'end-boundary' };
|
||||
// Empty words (an image only) have no character to stop on and are skipped.
|
||||
charIndex = delta > 0 ? 0 : (wordLengths[wordIndex] ?? 0) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks and unmarks characters in the rendered subtitle. */
|
||||
export type CharacterCursorPainter = {
|
||||
getLength: (word: HTMLElement) => number;
|
||||
/** Wraps one character of `word` in a marker element and returns it. */
|
||||
paint: (word: HTMLElement, charIndex: number) => HTMLElement | null;
|
||||
/** Removes every marker under `root`, restoring the plain text; true if there was one. */
|
||||
clear: (root: HTMLElement) => boolean;
|
||||
/** Range from the marker to the end of `lastWord`: the text a lookup searches. */
|
||||
rangeToEnd: (marker: HTMLElement, lastWord: HTMLElement) => Range;
|
||||
};
|
||||
|
||||
function findCharacter(
|
||||
word: HTMLElement,
|
||||
charIndex: number,
|
||||
): { node: Text; start: number; end: number } | null {
|
||||
const walker = document.createTreeWalker(word, NodeFilter.SHOW_TEXT);
|
||||
let remaining = charIndex;
|
||||
for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {
|
||||
const chars = Array.from(node.data);
|
||||
if (remaining < chars.length) {
|
||||
const start = chars.slice(0, remaining).join('').length;
|
||||
return { node, start, end: start + (chars[remaining]?.length ?? 1) };
|
||||
}
|
||||
remaining -= chars.length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createDomCharacterCursorPainter(): CharacterCursorPainter {
|
||||
return {
|
||||
getLength: (word) => countCharacters(word.textContent ?? ''),
|
||||
paint: (word, charIndex) => {
|
||||
const found = findCharacter(word, charIndex);
|
||||
if (!found) return null;
|
||||
const range = document.createRange();
|
||||
range.setStart(found.node, found.start);
|
||||
range.setEnd(found.node, found.end);
|
||||
const marker = document.createElement('span');
|
||||
marker.className = KEYBOARD_SELECTED_CHAR_CLASS;
|
||||
range.surroundContents(marker);
|
||||
return marker;
|
||||
},
|
||||
clear: (root) => {
|
||||
const markers = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(`.${KEYBOARD_SELECTED_CHAR_CLASS}`),
|
||||
);
|
||||
for (const marker of markers) {
|
||||
const parent = marker.parentNode;
|
||||
if (!parent) continue;
|
||||
while (marker.firstChild) parent.insertBefore(marker.firstChild, marker);
|
||||
parent.removeChild(marker);
|
||||
parent.normalize();
|
||||
}
|
||||
return markers.length > 0;
|
||||
},
|
||||
rangeToEnd: (marker, lastWord) => {
|
||||
const range = document.createRange();
|
||||
range.setStart(marker, 0);
|
||||
range.setEnd(lastWord, lastWord.childNodes.length);
|
||||
return range;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -152,6 +152,7 @@ function installKeyboardTestGlobals() {
|
||||
const openedModalNotifications: string[] = [];
|
||||
let selectionClearCount = 0;
|
||||
let selectionAddCount = 0;
|
||||
let mutationRecordsTaken = 0;
|
||||
|
||||
let popupVisible = false;
|
||||
let hachidoriPaneOpen = false;
|
||||
@@ -311,6 +312,10 @@ function installKeyboardTestGlobals() {
|
||||
configurable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
mutationRecordsTaken += 1;
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -522,6 +527,7 @@ function installKeyboardTestGlobals() {
|
||||
},
|
||||
selectionClearCount: () => selectionClearCount,
|
||||
selectionAddCount: () => selectionAddCount,
|
||||
mutationRecordsTaken: () => mutationRecordsTaken,
|
||||
restore,
|
||||
};
|
||||
}
|
||||
@@ -540,8 +546,9 @@ function createKeyboardHandlerHarness() {
|
||||
|
||||
// Synthetic pointer/mouse events dispatched on words: the lookup's fallback scan.
|
||||
let wordEventCount = 0;
|
||||
const createWordNode = (left: number) => ({
|
||||
const createWordNode = (left: number, textContent = 'ab') => ({
|
||||
classList: createClassList(),
|
||||
textContent,
|
||||
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
|
||||
dispatchEvent: () => {
|
||||
wordEventCount += 1;
|
||||
@@ -549,6 +556,23 @@ function createKeyboardHandlerHarness() {
|
||||
},
|
||||
});
|
||||
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
|
||||
// Records the character cursor instead of wrapping text in a real DOM.
|
||||
const characterCursorLog: string[] = [];
|
||||
const characterCursor = {
|
||||
getLength: (word: { textContent: string }) => Array.from(word.textContent).length,
|
||||
paint: (word: unknown, charIndex: number) => {
|
||||
characterCursorLog.push(`paint:${wordNodes.indexOf(word as never)}:${charIndex}`);
|
||||
return {
|
||||
getBoundingClientRect: () => ({ left: 0, top: 10, width: 10, height: 20 }),
|
||||
dispatchEvent: () => true,
|
||||
};
|
||||
},
|
||||
clear: () => false,
|
||||
rangeToEnd: (_marker: unknown, lastWord: unknown) => {
|
||||
characterCursorLog.push(`rangeToEnd:${wordNodes.indexOf(lastWord as never)}`);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
@@ -610,12 +634,18 @@ function createKeyboardHandlerHarness() {
|
||||
openControllerDebugCount += 1;
|
||||
},
|
||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||
characterCursor: characterCursor as never,
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
handlers,
|
||||
testGlobals,
|
||||
characterCursorLog,
|
||||
setWordTexts: (texts: string[]) => {
|
||||
wordNodes = texts.map((text, index) => createWordNode(10 + index * 70, text));
|
||||
},
|
||||
wordNodes: () => wordNodes,
|
||||
mediaTimingReviewKeydownCount: () => mediaTimingReviewKeydownCount,
|
||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||
openControllerSelectCount: () => openControllerSelectCount,
|
||||
@@ -913,6 +943,120 @@ test('keyboard mode: left and right move token selection while popup remains ope
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: C switches between moving by word and by character', async () => {
|
||||
const { ctx, handlers, testGlobals, characterCursorLog, setWordTexts, wordNodes } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
setWordTexts(['もっとハ', 'キ', 'ハキ']);
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
assert.equal(testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' }), true);
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, true);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('keyboard-character-navigation'), true);
|
||||
assert.equal(wordNodes()[0]!.classList.contains('keyboard-selected'), false);
|
||||
// The box colour is the only sign: no message on screen.
|
||||
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||
assert.equal(characterCursorLog.at(-1), 'paint:0:0');
|
||||
// Marking the character edits the subtitle, which the subtitle observer answers with
|
||||
// another sync: its own edits must be dropped, or the two loop forever.
|
||||
assert.ok(testGlobals.mutationRecordsTaken() > 0);
|
||||
|
||||
// The cursor walks the characters, crossing into the next word at its end.
|
||||
for (let i = 0; i < 4; i += 1) testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
|
||||
assert.deepEqual(
|
||||
[ctx.state.keyboardSelectedWordIndex, ctx.state.keyboardSelectedCharIndex],
|
||||
[1, 0],
|
||||
);
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||
assert.equal(characterCursorLog.at(-1), 'paint:0:3');
|
||||
|
||||
// Back to words: the box covers the word that holds the character.
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, false);
|
||||
assert.equal(wordNodes()[0]!.classList.contains('keyboard-selected'), true);
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('keyboard-character-navigation'), false);
|
||||
assert.deepEqual(testGlobals.mpvCommands, []);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: character navigation lasts until toggled, even across keyboard mode', async () => {
|
||||
const { ctx, handlers, testGlobals, characterCursorLog, setWordTexts } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
setWordTexts(['もっとハ', 'キ', 'ハキ']);
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
|
||||
// Leave the mode with the cursor in the middle of the first word.
|
||||
for (let i = 0; i < 3; i += 1) testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
|
||||
assert.equal(characterCursorLog.at(-1), 'paint:0:3');
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('keyboard-character-navigation'), false);
|
||||
|
||||
// Coming back starts at the beginning of the line, as word mode does.
|
||||
characterCursorLog.length = 0;
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, true);
|
||||
assert.equal(characterCursorLog.at(-1), 'paint:0:0');
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('C only switches navigation in keyboard mode, without modifiers or a pending chord', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, false, 'outside keyboard mode');
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
testGlobals.dispatchKeydown({ key: 'C', code: 'KeyC', shiftKey: true });
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC', ctrlKey: true });
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, false, 'with a modifier');
|
||||
|
||||
// `C` is also the second key of a chord.
|
||||
ctx.state.chordPending = true;
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
|
||||
assert.equal(ctx.state.keyboardCharacterNavigation, false, 'during a chord');
|
||||
assert.equal(ctx.state.chordPending, false);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: a lookup on a character searches from it to the end of the line', async () => {
|
||||
const { ctx, handlers, testGlobals, characterCursorLog, setWordTexts } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
setWordTexts(['もっとハ', 'キ', 'ハキ']);
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
|
||||
for (let i = 0; i < 3; i += 1) testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
|
||||
const added = testGlobals.selectionAddCount();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
|
||||
|
||||
assert.deepEqual(characterCursorLog.slice(-2), ['paint:0:3', 'rangeToEnd:2']);
|
||||
assert.equal(testGlobals.selectionAddCount(), added + 1);
|
||||
assert.equal(testGlobals.commandEvents.at(-1)?.type, 'scanSelectedText');
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: up/down/j/k do not open or close lookup when popup is closed', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
isYomitanPopupVisible,
|
||||
isYomitanPopupIframe,
|
||||
} from '../yomitan-popup.js';
|
||||
import {
|
||||
KEYBOARD_CHARACTER_NAVIGATION_CLASS,
|
||||
createDomCharacterCursorPainter,
|
||||
moveCharacterCursor,
|
||||
type CharacterCursorPainter,
|
||||
} from './keyboard-character-cursor.js';
|
||||
|
||||
export function createKeyboardHandlers(
|
||||
ctx: RendererContext,
|
||||
@@ -44,8 +50,15 @@ export function createKeyboardHandlers(
|
||||
/** Keyboard-only mode turned on or off (keyboard shortcut or controller). */
|
||||
onKeyboardDrivenModeChanged?: (enabled: boolean) => void;
|
||||
toggleSubtitleSidebarModal?: () => void;
|
||||
/** Marks the character cursor in the subtitle; replaced in tests. */
|
||||
characterCursor?: CharacterCursorPainter;
|
||||
},
|
||||
) {
|
||||
const characterCursor = options.characterCursor ?? createDomCharacterCursorPainter();
|
||||
/** The marked character of the character cursor, while it is shown. */
|
||||
let characterCursorMarker: HTMLElement | null = null;
|
||||
/** Re-syncs the selection when the subtitle changes; set up with the other listeners. */
|
||||
let subtitleMutationObserver: MutationObserver | null = null;
|
||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||
const CHORD_TIMEOUT_MS = 1000;
|
||||
const MPV_INPUT_FORWARDING_CONFIG_LOAD_TIMEOUT_MS = 50;
|
||||
@@ -502,9 +515,29 @@ export function createKeyboardHandlers(
|
||||
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
||||
}
|
||||
|
||||
function isCharacterNavigationActive(): boolean {
|
||||
return ctx.state.keyboardDrivenModeEnabled && ctx.state.keyboardCharacterNavigation;
|
||||
}
|
||||
|
||||
function syncKeyboardTokenSelection(): void {
|
||||
// Marking a character edits the subtitle, which the observer below answers with another
|
||||
// sync; drop the notifications for our own edits so the two do not loop.
|
||||
if (applyKeyboardTokenSelection()) subtitleMutationObserver?.takeRecords();
|
||||
}
|
||||
|
||||
/** Shows the word box or the character cursor; true if the subtitle markup changed. */
|
||||
function applyKeyboardTokenSelection(): boolean {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
clearKeyboardSelectedWordClasses(wordNodes);
|
||||
const clearedCursor = characterCursor.clear(ctx.dom.subtitleRoot);
|
||||
characterCursorMarker = null;
|
||||
// Lookups select from the character to the end of the line; this class keeps that
|
||||
// selection from recolouring the rest of the line, so only the cursor box shows.
|
||||
if (isCharacterNavigationActive()) {
|
||||
ctx.dom.subtitleRoot.classList.add(KEYBOARD_CHARACTER_NAVIGATION_CLASS);
|
||||
} else {
|
||||
ctx.dom.subtitleRoot.classList.remove(KEYBOARD_CHARACTER_NAVIGATION_CLASS);
|
||||
}
|
||||
|
||||
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
@@ -515,12 +548,14 @@ export function createKeyboardHandlers(
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
return;
|
||||
return clearedCursor;
|
||||
}
|
||||
|
||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||
ctx.state.keyboardSelectedWordIndex =
|
||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
||||
ctx.state.keyboardSelectedCharIndex =
|
||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : Number.MAX_SAFE_INTEGER;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
@@ -536,6 +571,7 @@ export function createKeyboardHandlers(
|
||||
|
||||
if (resetSelectionToStartOnNextSubtitleSync) {
|
||||
ctx.state.keyboardSelectedWordIndex = 0;
|
||||
ctx.state.keyboardSelectedCharIndex = 0;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
}
|
||||
@@ -546,9 +582,21 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||
const selectedWordNode = wordNodes[selectedIndex];
|
||||
if (selectedWordNode && ctx.state.keyboardSelectionVisible) {
|
||||
if (!selectedWordNode || !ctx.state.keyboardSelectionVisible) return clearedCursor;
|
||||
if (!isCharacterNavigationActive()) {
|
||||
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
||||
return clearedCursor;
|
||||
}
|
||||
const length = characterCursor.getLength(selectedWordNode);
|
||||
ctx.state.keyboardSelectedCharIndex = Math.min(
|
||||
Math.max(ctx.state.keyboardSelectedCharIndex, 0),
|
||||
Math.max(length - 1, 0),
|
||||
);
|
||||
characterCursorMarker =
|
||||
length > 0
|
||||
? characterCursor.paint(selectedWordNode, ctx.state.keyboardSelectedCharIndex)
|
||||
: null;
|
||||
return clearedCursor || characterCursorMarker !== null;
|
||||
}
|
||||
|
||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||
@@ -557,6 +605,9 @@ export function createKeyboardHandlers(
|
||||
ctx.state.keyboardSelectionVisible = enabled;
|
||||
if (!enabled) {
|
||||
ctx.state.keyboardSelectedWordIndex = null;
|
||||
// Character navigation itself stays on, but like the word cursor it restarts at the
|
||||
// beginning of the line next time.
|
||||
ctx.state.keyboardSelectedCharIndex = 0;
|
||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||
resetSelectionToStartOnNextSubtitleSync = false;
|
||||
@@ -583,6 +634,24 @@ export function createKeyboardHandlers(
|
||||
return 'no-words';
|
||||
}
|
||||
|
||||
if (isCharacterNavigationActive()) {
|
||||
const { position, result } = moveCharacterCursor(
|
||||
wordNodes.map((wordNode) => characterCursor.getLength(wordNode)),
|
||||
{
|
||||
wordIndex: ctx.state.keyboardSelectedWordIndex ?? 0,
|
||||
charIndex: ctx.state.keyboardSelectedCharIndex,
|
||||
},
|
||||
delta,
|
||||
);
|
||||
if (result === 'moved') {
|
||||
ctx.state.keyboardSelectedWordIndex = position.wordIndex;
|
||||
ctx.state.keyboardSelectedCharIndex = position.charIndex;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const currentIndex = Math.min(
|
||||
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||
wordNodes.length - 1,
|
||||
@@ -679,15 +748,40 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function selectWordNodeText(wordNode: HTMLElement): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(wordNode);
|
||||
selectSubtitleRange(range);
|
||||
}
|
||||
|
||||
function selectSubtitleRange(range: Range): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
ctx.dom.subtitleRoot.classList.add('has-selection');
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches keyboard-only mode between moving by word and by character (`C`, or the
|
||||
* controller binding). The cursor starts on the first character of the selected word, and
|
||||
* switching back selects the word holding the character. Kept until toggled back.
|
||||
*/
|
||||
function toggleCharacterNavigation(): boolean {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled) return false;
|
||||
ctx.state.keyboardCharacterNavigation = !ctx.state.keyboardCharacterNavigation;
|
||||
ctx.state.keyboardSelectedCharIndex = 0;
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
if (isLookupPopupOpen(ctx.state, document)) triggerLookupForSelectedWord();
|
||||
return true;
|
||||
}
|
||||
|
||||
function isCharacterNavigationToggle(e: KeyboardEvent): boolean {
|
||||
if (!ctx.state.keyboardDrivenModeEnabled || ctx.state.chordPending) return false;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false;
|
||||
return e.code === 'KeyC';
|
||||
}
|
||||
|
||||
function triggerLookupForSelectedWord(): boolean {
|
||||
const wordNodes = getSubtitleWordNodes();
|
||||
if (wordNodes.length === 0) {
|
||||
@@ -704,9 +798,18 @@ export function createKeyboardHandlers(
|
||||
|
||||
ctx.state.keyboardSelectionVisible = true;
|
||||
syncKeyboardTokenSelection();
|
||||
selectWordNodeText(selectedWordNode);
|
||||
// On a character, search from it to the end of the line: Yomitan takes the longest word
|
||||
// that starts there, whatever the word boxes say.
|
||||
const marker = characterCursorMarker;
|
||||
const lastWordNode = wordNodes[wordNodes.length - 1];
|
||||
const scanTarget = marker ?? selectedWordNode;
|
||||
if (marker && lastWordNode) {
|
||||
selectSubtitleRange(characterCursor.rangeToEnd(marker, lastWordNode));
|
||||
} else {
|
||||
selectWordNodeText(selectedWordNode);
|
||||
}
|
||||
|
||||
const rect = selectedWordNode.getBoundingClientRect();
|
||||
const rect = scanTarget.getBoundingClientRect();
|
||||
const clientX = rect.left + rect.width / 2;
|
||||
const clientY = rect.top + rect.height / 2;
|
||||
|
||||
@@ -727,7 +830,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
// Dispatch directly on the selected token span; when overlay pointer-events are disabled,
|
||||
// elementFromPoint may resolve to the underlying video surface instead.
|
||||
emitLookupScanFallback(selectedWordNode, clientX, clientY);
|
||||
emitLookupScanFallback(scanTarget, clientX, clientY);
|
||||
}, 60);
|
||||
return true;
|
||||
}
|
||||
@@ -1113,7 +1216,7 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
document.addEventListener('keyup', importedMpvBindings.keyup, true);
|
||||
|
||||
const subtitleMutationObserver = new MutationObserver(() => {
|
||||
subtitleMutationObserver = new MutationObserver(() => {
|
||||
syncKeyboardTokenSelection();
|
||||
});
|
||||
subtitleMutationObserver.observe(ctx.dom.subtitleRoot, {
|
||||
@@ -1291,6 +1394,13 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
// Before popup keybinds, so it also works while the popup is open.
|
||||
if (isCharacterNavigationToggle(e)) {
|
||||
e.preventDefault();
|
||||
if (!e.repeat) toggleCharacterNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLookupPopupOpen(ctx.state, document)) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
@@ -1413,6 +1523,7 @@ export function createKeyboardHandlers(
|
||||
handleLookupWindowToggleRequested,
|
||||
closeLookupWindow,
|
||||
moveSelectionForController,
|
||||
toggleCharacterNavigationForController: toggleCharacterNavigation,
|
||||
forwardPopupKeydownForController,
|
||||
mineSelectedFromController,
|
||||
cyclePopupAudioSourceForController,
|
||||
|
||||
@@ -24,6 +24,7 @@ const BINDINGS: ResolvedControllerConfig['bindings'] = {
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
@@ -166,12 +167,12 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
findOne(row, 'controller-config-badge').textContent,
|
||||
]),
|
||||
[
|
||||
['Toggle Lookup', 'A / Cross'],
|
||||
['Close Lookup', 'B / Circle'],
|
||||
['Toggle Lookup', 'A'],
|
||||
['Close Lookup', 'B'],
|
||||
['Previous Entry', 'None'],
|
||||
['Next Entry', 'None'],
|
||||
['Mine Card', 'X / Square'],
|
||||
['Toggle Keyboard-Only Mode', 'Y / Triangle'],
|
||||
['Mine Card', 'X'],
|
||||
['Toggle Keyboard-Only Mode', 'Y'],
|
||||
['Toggle MPV Pause', 'R3 / RS'],
|
||||
['Show Secondary Subtitle (Hold)', 'None'],
|
||||
['Quit MPV', 'Back / Select'],
|
||||
@@ -182,6 +183,7 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
['Previous Audio', 'None'],
|
||||
['Next Audio', 'RB / R1'],
|
||||
['Play Current Audio', 'LB / L1'],
|
||||
['Toggle Character Navigation', 'None'],
|
||||
['Token Move (Stick)', 'Left Stick X'],
|
||||
['Token Move (D-pad)', 'D-pad ↔'],
|
||||
['Popup Scroll (Stick)', 'Left Stick Y'],
|
||||
@@ -194,7 +196,7 @@ test('controller config form renders grouped rows with friendly binding labels',
|
||||
);
|
||||
assert.deepEqual(
|
||||
findAll(container, 'disabled').map((badge) => badge.textContent),
|
||||
['None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None'],
|
||||
['None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None'],
|
||||
);
|
||||
assert.deepEqual(findAll(container, 'expanded'), []);
|
||||
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
|
||||
|
||||
@@ -156,6 +156,13 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'button', buttonIndex: 4 },
|
||||
},
|
||||
{
|
||||
id: 'toggleCharacterNavigation',
|
||||
label: 'Toggle Character Navigation',
|
||||
group: 'Navigation',
|
||||
bindingType: 'discrete',
|
||||
defaultBinding: { kind: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'leftStickHorizontal',
|
||||
label: 'Token Move',
|
||||
@@ -212,10 +219,6 @@ export function getDefaultDpadFallback(
|
||||
// Names for pads that do not report the W3C "standard" mapping. Raw layouts
|
||||
// vary by driver, so these are a best guess.
|
||||
const RAW_LAYOUT_BUTTON_NAMES: Record<number, string> = {
|
||||
0: 'A / Cross',
|
||||
1: 'B / Circle',
|
||||
2: 'X / Square',
|
||||
3: 'Y / Triangle',
|
||||
4: 'LB / L1',
|
||||
5: 'RB / R1',
|
||||
6: 'Back / Select',
|
||||
@@ -244,10 +247,6 @@ const RAW_LAYOUT_AXIS_NAMES: Record<number, string> = {
|
||||
// Switch and 8BitDo pads). Index meanings are fixed by the W3C Gamepad spec:
|
||||
// https://w3c.github.io/gamepad/#remapping
|
||||
const W3C_STANDARD_BUTTON_NAMES: Record<number, string> = {
|
||||
0: 'A / Cross',
|
||||
1: 'B / Circle',
|
||||
2: 'X / Square',
|
||||
3: 'Y / Triangle',
|
||||
4: 'LB / L1',
|
||||
5: 'RB / R1',
|
||||
6: 'LT / L2',
|
||||
@@ -284,13 +283,10 @@ export function getFriendlyButtonName(
|
||||
mapping?: ControllerMapping,
|
||||
faceButtonLabels?: FaceButtonLabels,
|
||||
): string {
|
||||
// The default Xbox style keeps the shared "A / Cross" names; the others print their own.
|
||||
// Face positions 0-3 are the same on raw and standard layouts.
|
||||
const styledName =
|
||||
faceButtonLabels && faceButtonLabels !== 'xbox'
|
||||
? getControllerFaceButtonName(buttonIndex, faceButtonLabels)
|
||||
: null;
|
||||
if (styledName) return styledName;
|
||||
// Face buttons print as on the pad's Button Style (Xbox when none is set): A, not
|
||||
// "A / Cross". Face positions 0-3 are the same on raw and standard layouts.
|
||||
const faceName = getControllerFaceButtonName(buttonIndex, faceButtonLabels ?? 'xbox');
|
||||
if (faceName) return faceName;
|
||||
const names = mapping === 'standard' ? W3C_STANDARD_BUTTON_NAMES : RAW_LAYOUT_BUTTON_NAMES;
|
||||
return names[buttonIndex] ?? `Button ${buttonIndex}`;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
@@ -202,6 +203,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
@@ -326,6 +328,7 @@ test('controller debug modal stays closed and notifies when controller support i
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
|
||||
@@ -163,6 +163,7 @@ function buildContext() {
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
|
||||
@@ -184,6 +184,7 @@ export function createControllerSelectModal(
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
|
||||
@@ -576,6 +576,7 @@ function startControllerPolling(): void {
|
||||
holdSecondarySub: { kind: 'none' },
|
||||
previousEntry: { kind: 'none' },
|
||||
nextEntry: { kind: 'none' },
|
||||
toggleCharacterNavigation: { kind: 'none' },
|
||||
previousSubtitle: { kind: 'none' },
|
||||
nextSubtitle: { kind: 'none' },
|
||||
replaySubtitle: { kind: 'none' },
|
||||
@@ -635,6 +636,9 @@ function startControllerPolling(): void {
|
||||
nextEntry: () => {
|
||||
keyboardHandlers.movePopupEntryForController(1);
|
||||
},
|
||||
toggleCharacterNavigation: () => {
|
||||
keyboardHandlers.toggleCharacterNavigationForController();
|
||||
},
|
||||
toggleMpvPause: () => {
|
||||
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
|
||||
},
|
||||
|
||||
@@ -166,6 +166,10 @@ export type RendererState = {
|
||||
keyboardDrivenModeEnabled: boolean;
|
||||
keyboardSelectionVisible: boolean;
|
||||
keyboardSelectedWordIndex: number | null;
|
||||
/** Keyboard-only mode moves by character instead of by word; kept until toggled back. */
|
||||
keyboardCharacterNavigation: boolean;
|
||||
/** Character of the selected word that the cursor is on, in character navigation. */
|
||||
keyboardSelectedCharIndex: number;
|
||||
yomitanPopupVisible: boolean;
|
||||
// Set once Hachidori's attention events identify it as the reader. Its attention
|
||||
// also covers presses that may start a selection, so yomitanPopupVisible alone
|
||||
@@ -309,6 +313,8 @@ export function createRendererState(): RendererState {
|
||||
keyboardDrivenModeEnabled: false,
|
||||
keyboardSelectionVisible: false,
|
||||
keyboardSelectedWordIndex: null,
|
||||
keyboardCharacterNavigation: false,
|
||||
keyboardSelectedCharIndex: 0,
|
||||
yomitanPopupVisible: false,
|
||||
hachidoriReaderSeen: false,
|
||||
isOverYomitanPopup: false,
|
||||
|
||||
@@ -2282,6 +2282,26 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
0 0 18px rgba(120, 188, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Character navigation in keyboard-only mode: the cursor box sits on one character, in
|
||||
orange so it reads apart from the blue word box. */
|
||||
#subtitleRoot .keyboard-selected-char {
|
||||
outline: 2px solid rgba(245, 169, 127, 0.95);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(12, 18, 28, 0.68),
|
||||
0 0 18px rgba(245, 169, 127, 0.45);
|
||||
}
|
||||
|
||||
/* A lookup on a character selects from it to the end of the line; keep that selection from
|
||||
recolouring the text (the doubled id outranks the selection colours below). */
|
||||
#subtitleRoot#subtitleRoot.keyboard-character-navigation::selection,
|
||||
#subtitleRoot#subtitleRoot.keyboard-character-navigation ::selection {
|
||||
background: transparent !important;
|
||||
color: currentcolor !important;
|
||||
-webkit-text-fill-color: currentcolor !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word[data-frequency-rank]::before {
|
||||
content: attr(data-frequency-rank);
|
||||
position: absolute;
|
||||
|
||||
@@ -222,6 +222,7 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
|
||||
'holdSecondarySub',
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'toggleCharacterNavigation',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
|
||||
@@ -242,6 +242,7 @@ export interface ControllerBindingsConfig {
|
||||
holdSecondarySub?: ControllerDiscreteBindingConfig;
|
||||
previousEntry?: ControllerDiscreteBindingConfig;
|
||||
nextEntry?: ControllerDiscreteBindingConfig;
|
||||
toggleCharacterNavigation?: ControllerDiscreteBindingConfig;
|
||||
previousSubtitle?: ControllerDiscreteBindingConfig;
|
||||
nextSubtitle?: ControllerDiscreteBindingConfig;
|
||||
replaySubtitle?: ControllerDiscreteBindingConfig;
|
||||
@@ -265,6 +266,7 @@ export interface ResolvedControllerBindingsConfig {
|
||||
holdSecondarySub?: ResolvedControllerDiscreteBinding;
|
||||
previousEntry?: ResolvedControllerDiscreteBinding;
|
||||
nextEntry?: ResolvedControllerDiscreteBinding;
|
||||
toggleCharacterNavigation?: ResolvedControllerDiscreteBinding;
|
||||
previousSubtitle?: ResolvedControllerDiscreteBinding;
|
||||
nextSubtitle?: ResolvedControllerDiscreteBinding;
|
||||
replaySubtitle?: ResolvedControllerDiscreteBinding;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AnimeLibraryItem,
|
||||
AnimeWord,
|
||||
DailyRollup,
|
||||
DictionaryWordPage,
|
||||
EpisodeDetailData,
|
||||
EpisodesPerDay,
|
||||
KanjiDetailData,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
StatsExcludedWord,
|
||||
StreakCalendarDay,
|
||||
TrendsDashboardData,
|
||||
VocabularyCoverageBand,
|
||||
VocabularyEntry,
|
||||
VocabularyOccurrenceEntry,
|
||||
WatchTimePerAnime,
|
||||
@@ -29,8 +31,14 @@ import type {
|
||||
} from './stats-wire';
|
||||
import type { TmdbMediaType } from '../shared/media-kind';
|
||||
|
||||
export type StatsTrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||
export type StatsTrendGroupBy = 'day' | 'month';
|
||||
export type StatsTrendPresetRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||
/** Inclusive local calendar days since the Unix epoch. */
|
||||
export interface StatsDayRange {
|
||||
startDay: number;
|
||||
endDay: number;
|
||||
}
|
||||
export type StatsTrendRange = StatsTrendPresetRange | StatsDayRange;
|
||||
export type StatsTrendGroupBy = 'day' | 'week' | 'month';
|
||||
export type StatsMineMode = 'word' | 'sentence' | 'audio';
|
||||
|
||||
/** Body of `POST /api/stats/maintenance/duplicate-lines`. */
|
||||
@@ -63,12 +71,37 @@ export interface StatsVocabularySummary {
|
||||
|
||||
export interface StatsVocabularyCharts {
|
||||
ready: boolean;
|
||||
topWords: Array<{ wordId: number; headword: string; frequency: number }>;
|
||||
topWordsWithoutNames: Array<{ wordId: number; headword: string; frequency: number }>;
|
||||
newWordsTimeline: Array<{ epochDay: number; wordCount: number }>;
|
||||
newWordsTimelineWithoutNames: Array<{ epochDay: number; wordCount: number }>;
|
||||
}
|
||||
|
||||
export type StatsDictionaryWordSort =
|
||||
| 'seen'
|
||||
| 'rank'
|
||||
| 'titles'
|
||||
| 'firstSeen'
|
||||
| 'lastSeen'
|
||||
| 'headword';
|
||||
export type StatsDictionaryWordPos = 'noun' | 'verb' | 'adjective';
|
||||
|
||||
/** Query for `GET /api/stats/vocabulary/words`. */
|
||||
export interface StatsDictionaryWordQuery {
|
||||
/** Matches any form or reading, treating katakana and hiragana alike. */
|
||||
search?: string;
|
||||
sort: StatsDictionaryWordSort;
|
||||
descending: boolean;
|
||||
hideKnown: boolean;
|
||||
hideNames: boolean;
|
||||
hideKana: boolean;
|
||||
partOfSpeech?: StatsDictionaryWordPos;
|
||||
/** Keep only ranked words at or better than this frequency rank. */
|
||||
maxRank?: number;
|
||||
/** Keep only words seen in at least this many titles. */
|
||||
minTitles?: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface StatsAnilistSearchResult {
|
||||
id: number;
|
||||
episodes: number | null;
|
||||
@@ -204,6 +237,8 @@ export interface StatsJsonResponseMap {
|
||||
vocabulary: VocabularyEntry[];
|
||||
vocabularySummary: StatsVocabularySummary;
|
||||
vocabularyCharts: StatsVocabularyCharts;
|
||||
dictionaryWords: DictionaryWordPage;
|
||||
vocabularyCoverage: VocabularyCoverageBand[];
|
||||
excludedWords: StatsExcludedWord[];
|
||||
setExcludedWords: StatsOkResponse;
|
||||
duplicateLineCleanup: StatsDuplicateLineCleanupResult;
|
||||
@@ -259,13 +294,15 @@ export interface StatsHttpClient {
|
||||
getOverview: () => Promise<OverviewData>;
|
||||
getDailyRollups: (limit?: number) => Promise<DailyRollup[]>;
|
||||
getMonthlyRollups: (limit?: number) => Promise<MonthlyRollup[]>;
|
||||
getSessions: (limit?: number) => Promise<SessionSummary[]>;
|
||||
getSessions: (limit?: number, dayRange?: StatsDayRange) => Promise<SessionSummary[]>;
|
||||
getSessionTimeline: (id: number, limit?: number) => Promise<SessionTimelinePoint[]>;
|
||||
getSessionEvents: (id: number, limit?: number, eventTypes?: number[]) => Promise<SessionEvent[]>;
|
||||
getSessionKnownWordsTimeline: (id: number) => Promise<StatsSessionKnownWordsTimelinePoint[]>;
|
||||
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
|
||||
getVocabularySummary: () => Promise<StatsVocabularySummary>;
|
||||
getVocabularyCharts: () => Promise<StatsVocabularyCharts>;
|
||||
getDictionaryWords: (query: StatsDictionaryWordQuery) => Promise<DictionaryWordPage>;
|
||||
getVocabularyCoverage: () => Promise<VocabularyCoverageBand[]>;
|
||||
getExcludedWords: () => Promise<StatsExcludedWord[]>;
|
||||
setExcludedWords: (words: StatsExcludedWord[]) => Promise<void>;
|
||||
cleanupDuplicateLines: (
|
||||
@@ -277,6 +314,7 @@ export interface StatsHttpClient {
|
||||
reading: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
allForms?: boolean,
|
||||
) => Promise<VocabularyOccurrenceEntry[]>;
|
||||
searchSentences: (
|
||||
query: string,
|
||||
@@ -306,6 +344,7 @@ export interface StatsHttpClient {
|
||||
range: StatsTrendRange,
|
||||
groupBy: StatsTrendGroupBy,
|
||||
fillEmpty?: boolean,
|
||||
title?: string | null,
|
||||
) => Promise<TrendsDashboardData>;
|
||||
getWordDetail: (wordId: number) => Promise<WordDetailData>;
|
||||
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
|
||||
|
||||
+126
-16
@@ -78,6 +78,55 @@ export interface VocabularyEntry {
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
/** One written form of a dictionary word, such as 見て for 見る. */
|
||||
export interface DictionaryWordForm {
|
||||
wordId: number;
|
||||
word: string;
|
||||
reading: string;
|
||||
seen: number;
|
||||
}
|
||||
|
||||
/** A dictionary word with every form it was seen in folded together. */
|
||||
export interface DictionaryWordRow {
|
||||
/** The form to open: the dictionary form when it was seen, otherwise the most seen one. */
|
||||
wordId: number;
|
||||
headword: string;
|
||||
/** Reading of the dictionary form, or empty when only conjugated forms were seen. */
|
||||
reading: string;
|
||||
partOfSpeech: string | null;
|
||||
rank: number | null;
|
||||
seen: number;
|
||||
/** Titles it appeared in. Computed for the returned rows. */
|
||||
titles: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
known: boolean;
|
||||
isName: boolean;
|
||||
/** Every line with this word came from a stream, so it can't be mined. */
|
||||
streamOnly: boolean;
|
||||
formCount: number;
|
||||
/** The most seen forms, capped so a word with hundreds of forms stays small. */
|
||||
forms: DictionaryWordForm[];
|
||||
}
|
||||
|
||||
export interface DictionaryWordPage {
|
||||
rows: DictionaryWordRow[];
|
||||
/** Words matching the query across all pages. */
|
||||
total: number;
|
||||
/** Highest `seen` across all pages, for scaling inline bars. */
|
||||
maxSeen: number;
|
||||
}
|
||||
|
||||
export const VOCABULARY_COVERAGE_BANDS = ['top1k', 'top5k', 'top10k', 'top20k'] as const;
|
||||
export type VocabularyCoverageBandId = (typeof VOCABULARY_COVERAGE_BANDS)[number];
|
||||
|
||||
/** Words seen in a frequency band, and how many of those are known. Names are left out. */
|
||||
export interface VocabularyCoverageBand {
|
||||
band: VocabularyCoverageBandId;
|
||||
seen: number;
|
||||
known: number;
|
||||
}
|
||||
|
||||
export interface StatsExcludedWord {
|
||||
headword: string;
|
||||
word: string;
|
||||
@@ -343,16 +392,59 @@ export interface TrendChartPoint {
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** A rate over a bucket. `null` means too little data to rate, which charts draw as a gap. */
|
||||
export interface TrendRatePoint {
|
||||
label: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
/** One x-axis bucket, clipped to the requested date range. Epoch days are local calendar days. */
|
||||
export interface TrendBucket {
|
||||
label: string;
|
||||
startDay: number;
|
||||
endDay: number;
|
||||
}
|
||||
|
||||
export interface TrendPerAnimePoint {
|
||||
epochDay: number;
|
||||
animeTitle: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const TREND_FREQUENCY_BANDS = [
|
||||
'top1k',
|
||||
'top5k',
|
||||
'top10k',
|
||||
'top20k',
|
||||
'beyond20k',
|
||||
'unranked',
|
||||
] as const;
|
||||
export type TrendFrequencyBand = (typeof TREND_FREQUENCY_BANDS)[number];
|
||||
|
||||
export interface TrendFrequencyBandPoint {
|
||||
label: string;
|
||||
counts: Record<TrendFrequencyBand, number>;
|
||||
}
|
||||
|
||||
export interface TrendSummaryTotals {
|
||||
watchTimeMin: number;
|
||||
words: number;
|
||||
/** Null when the dashboard is filtered to one title: new words are not tracked per title. */
|
||||
newWords: number | null;
|
||||
cards: number;
|
||||
episodes: number;
|
||||
activeDays: number;
|
||||
}
|
||||
|
||||
export interface LibrarySummaryRow {
|
||||
title: string;
|
||||
/** Null for videos that are not linked to a library title. */
|
||||
animeId: number | null;
|
||||
/** Most recently watched video, the click-through target when `animeId` is null. */
|
||||
videoId: number | null;
|
||||
watchTimeMin: number;
|
||||
videos: number;
|
||||
episodesFinished: number;
|
||||
sessions: number;
|
||||
cards: number;
|
||||
words: number;
|
||||
@@ -363,15 +455,24 @@ export interface LibrarySummaryRow {
|
||||
}
|
||||
|
||||
export interface TrendsDashboardData {
|
||||
/** The days the dashboard covers. For `all` this is the span of recorded data. */
|
||||
window: { startDay: number; endDay: number };
|
||||
/** Axis shared by the `activity`, `progress`, and `ratios` series, index for index. */
|
||||
buckets: TrendBucket[];
|
||||
summary: {
|
||||
current: TrendSummaryTotals;
|
||||
/** The equally long period just before `window`; null for `all`. */
|
||||
previous: TrendSummaryTotals | null;
|
||||
};
|
||||
activity: {
|
||||
watchTime: TrendChartPoint[];
|
||||
cards: TrendChartPoint[];
|
||||
words: TrendChartPoint[];
|
||||
sessions: TrendChartPoint[];
|
||||
episodes: TrendChartPoint[];
|
||||
};
|
||||
/** Running totals counted from the start of `window`, each point the total at its period's end. */
|
||||
progress: {
|
||||
watchTime: TrendChartPoint[];
|
||||
sessions: TrendChartPoint[];
|
||||
words: TrendChartPoint[];
|
||||
newWords: TrendChartPoint[];
|
||||
cards: TrendChartPoint[];
|
||||
@@ -379,22 +480,21 @@ export interface TrendsDashboardData {
|
||||
lookups: TrendChartPoint[];
|
||||
};
|
||||
ratios: {
|
||||
lookupsPerHundred: TrendChartPoint[];
|
||||
cardsPerHour: TrendChartPoint[];
|
||||
readingSpeed: TrendChartPoint[];
|
||||
lookupsPerHundred: TrendRatePoint[];
|
||||
cardsPerHour: TrendRatePoint[];
|
||||
readingSpeed: TrendRatePoint[];
|
||||
};
|
||||
/** Vocabulary growth signals. Null when filtered to one title. */
|
||||
learning: {
|
||||
newWordsPerThousand: TrendRatePoint[];
|
||||
frequencyBands: TrendFrequencyBandPoint[];
|
||||
} | null;
|
||||
/** Active minutes as `[dayOfWeek][hour]`, with 0 = Sunday and local-time hours. */
|
||||
watchTimeByDayHour: number[][];
|
||||
/** Every title in `window`, unaffected by the title filter. */
|
||||
librarySummary: LibrarySummaryRow[];
|
||||
/** Sparse per-title values for each active day; the client accumulates them for charts. */
|
||||
animeDaily: {
|
||||
watchTime: TrendPerAnimePoint[];
|
||||
episodes: TrendPerAnimePoint[];
|
||||
cards: TrendPerAnimePoint[];
|
||||
words: TrendPerAnimePoint[];
|
||||
};
|
||||
patterns: {
|
||||
watchTimeByDayOfWeek: TrendChartPoint[];
|
||||
watchTimeByHour: TrendChartPoint[];
|
||||
};
|
||||
/** Sparse active minutes per title per day, unaffected by the title filter. */
|
||||
titleTimeline: TrendPerAnimePoint[];
|
||||
}
|
||||
|
||||
export interface WordDetailData {
|
||||
@@ -407,7 +507,17 @@ export interface WordDetailData {
|
||||
pos1: string | null;
|
||||
pos2: string | null;
|
||||
pos3: string | null;
|
||||
/** Times seen across every form of this dictionary word. */
|
||||
frequency: number;
|
||||
frequencyRank: number | null;
|
||||
/** Reading of the dictionary form, or empty when only conjugated forms were seen. */
|
||||
dictionaryReading: string;
|
||||
formCount: number;
|
||||
forms: DictionaryWordForm[];
|
||||
/** Stored subtitle lines with this word, across every form. */
|
||||
lineCount: number;
|
||||
/** Those lines that came from a stream, which can't be mined. */
|
||||
streamLineCount: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
|
||||
+32
-4
@@ -10,10 +10,13 @@ import {
|
||||
createInitialStatsView,
|
||||
navigateToAnime as navigateToAnimeState,
|
||||
navigateToSession as navigateToSessionState,
|
||||
navigateToSessionsDays,
|
||||
openAnimeEpisodeDetail,
|
||||
openOverviewMediaDetail,
|
||||
openSessionsMediaDetail,
|
||||
openTrendsMediaDetail,
|
||||
switchTab,
|
||||
type SessionsDayFilter,
|
||||
} from './lib/stats-navigation';
|
||||
|
||||
const AnimeTab = lazy(() =>
|
||||
@@ -72,7 +75,8 @@ export function App() {
|
||||
const [mountedTabs, setMountedTabs] = useState<Set<TabId>>(() => new Set(['overview']));
|
||||
const [globalWordId, setGlobalWordId] = useState<number | null>(null);
|
||||
const { excluded, isExcluded, toggleExclusion, removeExclusion, clearAll } = useExcludedWords();
|
||||
const { activeTab, selectedAnimeId, focusedSessionId, mediaDetail } = viewState;
|
||||
const { activeTab, selectedAnimeId, focusedSessionId, sessionsDayFilter, mediaDetail } =
|
||||
viewState;
|
||||
|
||||
const activateTab = useCallback((tabId: TabId) => {
|
||||
setViewState((prev) => switchTab(prev, tabId));
|
||||
@@ -104,6 +108,16 @@ export function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openSessionsForDays = useCallback((filter: SessionsDayFilter) => {
|
||||
setViewState((prev) => navigateToSessionsDays(prev, filter));
|
||||
setMountedTabs((prev) => {
|
||||
if (prev.has('sessions')) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add('sessions');
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const navigateToEpisodeDetail = useCallback(
|
||||
(animeId: number, videoId: number, sessionId: number | null = null) => {
|
||||
setViewState((prev) => openAnimeEpisodeDetail(prev, animeId, videoId, sessionId));
|
||||
@@ -122,6 +136,10 @@ export function App() {
|
||||
setViewState((prev) => openSessionsMediaDetail(prev, videoId));
|
||||
}, []);
|
||||
|
||||
const navigateToTrendsMediaDetail = useCallback((videoId: number) => {
|
||||
setViewState((prev) => openTrendsMediaDetail(prev, videoId));
|
||||
}, []);
|
||||
|
||||
const openWordDetail = useCallback((wordId: number) => {
|
||||
setGlobalWordId(wordId);
|
||||
}, []);
|
||||
@@ -171,7 +189,9 @@ export function App() {
|
||||
? 'Back to Overview'
|
||||
: mediaDetail.origin.type === 'sessions'
|
||||
? 'Back to Sessions'
|
||||
: 'Back to Library'
|
||||
: mediaDetail.origin.type === 'trends'
|
||||
? 'Back to Trends'
|
||||
: 'Back to Library'
|
||||
}
|
||||
onNavigateToAnime={navigateToAnime}
|
||||
/>
|
||||
@@ -222,7 +242,11 @@ export function App() {
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<Suspense fallback={<LoadingSurface label="Loading trends..." />}>
|
||||
<TrendsTab />
|
||||
<TrendsTab
|
||||
onNavigateToAnime={navigateToAnime}
|
||||
onNavigateToMediaDetail={navigateToTrendsMediaDetail}
|
||||
onOpenSessionsForDays={openSessionsForDays}
|
||||
/>
|
||||
</Suspense>
|
||||
</section>
|
||||
) : null}
|
||||
@@ -239,7 +263,7 @@ export function App() {
|
||||
onNavigateToAnime={navigateToAnime}
|
||||
onOpenWordDetail={openWordDetail}
|
||||
excluded={excluded}
|
||||
isExcluded={isExcluded}
|
||||
onToggleExclusion={toggleExclusion}
|
||||
onRemoveExclusion={removeExclusion}
|
||||
onClearExclusions={clearAll}
|
||||
/>
|
||||
@@ -274,6 +298,10 @@ export function App() {
|
||||
setViewState((prev) => ({ ...prev, focusedSessionId: null }))
|
||||
}
|
||||
onNavigateToMediaDetail={navigateToSessionsMediaDetail}
|
||||
dayFilter={sessionsDayFilter}
|
||||
onClearDayFilter={() =>
|
||||
setViewState((prev) => ({ ...prev, sessionsDayFilter: null }))
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Stands in for the mine buttons when a line can't be mined, and says why. */
|
||||
export function MineUnavailableNote({ reason }: { reason: string }) {
|
||||
return <span className="text-xs text-ctp-peach">{reason}</span>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
interface SummaryTileProps {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string | null;
|
||||
/** Smaller value for text such as dates. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** One headline number. Group several in a grid for a summary strip. */
|
||||
export function SummaryTile({ label, value, detail, compact = false }: SummaryTileProps) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] text-ctp-subtext0">{label}</div>
|
||||
<div
|
||||
className={`font-semibold leading-tight text-ctp-text ${compact ? 'mt-1 text-sm' : 'text-2xl'}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{detail ? <div className="text-[11px] text-ctp-overlay2">{detail}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
renderSentenceWithMatches,
|
||||
} from '../../lib/sentence-search';
|
||||
import { buildStatsMineCardParams, getStatsMineCardError } from '../../lib/mining';
|
||||
import { MineUnavailableNote } from '../common/MineUnavailableNote';
|
||||
import type { SentenceSearchResult } from '../../types/stats';
|
||||
|
||||
const SEARCH_LIMIT = 50;
|
||||
@@ -215,51 +216,53 @@ export function SearchTab() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availability.exactMatch && (
|
||||
<button
|
||||
type="button"
|
||||
title={wordAudioDisabledReason || 'Create a word card from this sentence'}
|
||||
className="rounded-md border border-ctp-mauve/50 px-3 py-1.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-mauve/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||
disabled={wordStatus?.loading || !availability.canMineWordAudio}
|
||||
onClick={() => void handleMine(result, index, 'word')}
|
||||
>
|
||||
{buttonLabel(
|
||||
'word',
|
||||
wordStatus,
|
||||
availability.canMineWordAudio ? '' : 'Unavailable',
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title={sentenceDisabledReason || 'Create a sentence card from this line'}
|
||||
className="rounded-md border border-ctp-green/50 px-3 py-1.5 text-xs font-medium text-ctp-green transition hover:bg-ctp-green/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||
disabled={sentenceStatus?.loading || !availability.canMineSentence}
|
||||
onClick={() => void handleMine(result, index, 'sentence')}
|
||||
>
|
||||
{buttonLabel(
|
||||
'sentence',
|
||||
sentenceStatus,
|
||||
availability.canMineSentence ? '' : 'Unavailable',
|
||||
{availability.unavailableReason ? (
|
||||
<MineUnavailableNote reason={availability.unavailableReason} />
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availability.exactMatch && (
|
||||
<button
|
||||
type="button"
|
||||
title={wordAudioDisabledReason || 'Create a word card from this sentence'}
|
||||
className="rounded-md border border-ctp-mauve/50 px-3 py-1.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-mauve/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||
disabled={wordStatus?.loading || !availability.canMineWordAudio}
|
||||
onClick={() => void handleMine(result, index, 'word')}
|
||||
>
|
||||
{buttonLabel(
|
||||
'word',
|
||||
wordStatus,
|
||||
availability.canMineWordAudio ? '' : 'No exact match',
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
{availability.exactMatch && (
|
||||
<button
|
||||
type="button"
|
||||
title={wordAudioDisabledReason || 'Create an audio card from this sentence'}
|
||||
className="rounded-md border border-ctp-blue/50 px-3 py-1.5 text-xs font-medium text-ctp-blue transition hover:bg-ctp-blue/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||
disabled={audioStatus?.loading || !availability.canMineWordAudio}
|
||||
onClick={() => void handleMine(result, index, 'audio')}
|
||||
title={sentenceDisabledReason || 'Create a sentence card from this line'}
|
||||
className="rounded-md border border-ctp-green/50 px-3 py-1.5 text-xs font-medium text-ctp-green transition hover:bg-ctp-green/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||
disabled={sentenceStatus?.loading || !availability.canMineSentence}
|
||||
onClick={() => void handleMine(result, index, 'sentence')}
|
||||
>
|
||||
{buttonLabel(
|
||||
'audio',
|
||||
audioStatus,
|
||||
availability.canMineWordAudio ? '' : 'Unavailable',
|
||||
)}
|
||||
{buttonLabel('sentence', sentenceStatus, '')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{availability.exactMatch && (
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
wordAudioDisabledReason || 'Create an audio card from this sentence'
|
||||
}
|
||||
className="rounded-md border border-ctp-blue/50 px-3 py-1.5 text-xs font-medium text-ctp-blue transition hover:bg-ctp-blue/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||
disabled={audioStatus?.loading || !availability.canMineWordAudio}
|
||||
onClick={() => void handleMine(result, index, 'audio')}
|
||||
>
|
||||
{buttonLabel(
|
||||
'audio',
|
||||
audioStatus,
|
||||
availability.canMineWordAudio ? '' : 'No exact match',
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 rounded-lg bg-ctp-base/70 px-4 py-3 text-base leading-7 text-ctp-text">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiClient } from '../../lib/api-client';
|
||||
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
||||
import { groupSessionsByVideo, type SessionBucket } from '../../lib/session-grouping';
|
||||
import type { SessionsDayFilter } from '../../lib/stats-navigation';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||
@@ -79,18 +80,28 @@ export function decrementDeletingSessionCounts(
|
||||
return next;
|
||||
}
|
||||
|
||||
// Enough to show every session of any span a chart bar can open; the API caps limit at 500.
|
||||
const DAY_FILTER_SESSION_LIMIT = 500;
|
||||
|
||||
interface SessionsTabProps {
|
||||
initialSessionId?: number | null;
|
||||
onClearInitialSession?: () => void;
|
||||
onNavigateToMediaDetail?: (videoId: number) => void;
|
||||
dayFilter?: SessionsDayFilter | null;
|
||||
onClearDayFilter?: () => void;
|
||||
}
|
||||
|
||||
export function SessionsTab({
|
||||
initialSessionId,
|
||||
onClearInitialSession,
|
||||
onNavigateToMediaDetail,
|
||||
dayFilter,
|
||||
onClearDayFilter,
|
||||
}: SessionsTabProps = {}) {
|
||||
const { sessions, loading, error } = useSessions();
|
||||
const { sessions, loading, error } = useSessions(
|
||||
dayFilter ? DAY_FILTER_SESSION_LIMIT : undefined,
|
||||
dayFilter,
|
||||
);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [expandedBuckets, setExpandedBuckets] = useState<Set<string>>(() => new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -212,6 +223,20 @@ export function SessionsTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{dayFilter ? (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 px-3 py-2 text-sm">
|
||||
<span className="text-ctp-subtext0">
|
||||
Sessions from <span className="text-ctp-text">{dayFilter.label}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearDayFilter}
|
||||
className="ml-auto rounded-md border border-ctp-surface2 px-2 py-1 text-xs text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
>
|
||||
Show all sessions
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Search sessions by title"
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import type { TimeRange, GroupBy } from '../../hooks/useTrends';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GroupBy, PresetTimeRange, TimeRange } from '../../hooks/useTrends';
|
||||
import { epochDayToIsoDate, isoDateToEpochDay, todayLocalDay } from '../../lib/formatters';
|
||||
import type { StatsDayRange } from '../../types/stats';
|
||||
|
||||
interface DateRangeSelectorProps {
|
||||
range: TimeRange;
|
||||
groupBy: GroupBy;
|
||||
fillEmpty: boolean;
|
||||
/** The days currently shown, used to seed the date inputs when switching to Custom. */
|
||||
shownDays: { startDay: number; endDay: number } | null;
|
||||
onRangeChange: (r: TimeRange) => void;
|
||||
onGroupByChange: (g: GroupBy) => void;
|
||||
onFillEmptyChange: (fillEmpty: boolean) => void;
|
||||
}
|
||||
|
||||
const CUSTOM_RANGE = 'custom';
|
||||
type RangeOption = PresetTimeRange | typeof CUSTOM_RANGE;
|
||||
|
||||
const RANGE_OPTIONS: RangeOption[] = ['7d', '30d', '90d', '365d', 'all', CUSTOM_RANGE];
|
||||
const DEFAULT_CUSTOM_DAYS = 30;
|
||||
const DATE_INPUT_CLASS =
|
||||
'rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-xs text-ctp-text transition hover:border-ctp-blue';
|
||||
|
||||
function formatRangeOption(option: RangeOption): string {
|
||||
if (option === 'all') return 'All';
|
||||
return option === CUSTOM_RANGE ? 'Custom' : option;
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
label,
|
||||
options,
|
||||
@@ -29,6 +47,7 @@ function SegmentedControl<T extends string>({
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => onChange(opt)}
|
||||
aria-pressed={value === opt}
|
||||
className={`px-2.5 py-1 rounded-md text-xs transition-colors ${
|
||||
@@ -45,32 +64,109 @@ function SegmentedControl<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
interface DateDrafts {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
/** The range two date inputs describe, or null while either is incomplete or they are reversed. */
|
||||
export function parseDateDrafts(drafts: DateDrafts): StatsDayRange | null {
|
||||
const startDay = isoDateToEpochDay(drafts.start);
|
||||
const endDay = isoDateToEpochDay(drafts.end);
|
||||
return startDay !== null && endDay !== null && startDay <= endDay ? { startDay, endDay } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date inputs report every keystroke, so typing 2025 passes through years 0002 and
|
||||
* 0020. Each input shows its own draft, or it would snap back and lose the digits.
|
||||
* Both drafts are checked together on each edit, so a range can move forward by
|
||||
* editing either end first. They reset whenever the range changes.
|
||||
*/
|
||||
function CustomRangeInputs({
|
||||
range,
|
||||
onRangeChange,
|
||||
}: {
|
||||
range: StatsDayRange;
|
||||
onRangeChange: (range: StatsDayRange) => void;
|
||||
}) {
|
||||
const start = epochDayToIsoDate(range.startDay);
|
||||
const end = epochDayToIsoDate(range.endDay);
|
||||
const [drafts, setDrafts] = useState<DateDrafts>({ start, end });
|
||||
|
||||
useEffect(() => {
|
||||
setDrafts({ start, end });
|
||||
}, [start, end]);
|
||||
|
||||
const updateDrafts = (next: DateDrafts) => {
|
||||
setDrafts(next);
|
||||
const parsed = parseDateDrafts(next);
|
||||
if (parsed) onRangeChange(parsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-ctp-overlay2">
|
||||
<input
|
||||
type="date"
|
||||
aria-label="Range start"
|
||||
className={DATE_INPUT_CLASS}
|
||||
value={drafts.start}
|
||||
max={drafts.end}
|
||||
onChange={(event) => updateDrafts({ ...drafts, start: event.target.value })}
|
||||
/>
|
||||
to
|
||||
<input
|
||||
type="date"
|
||||
aria-label="Range end"
|
||||
className={DATE_INPUT_CLASS}
|
||||
value={drafts.end}
|
||||
min={drafts.start}
|
||||
max={epochDayToIsoDate(todayLocalDay())}
|
||||
onChange={(event) => updateDrafts({ ...drafts, end: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DateRangeSelector({
|
||||
range,
|
||||
groupBy,
|
||||
fillEmpty,
|
||||
shownDays,
|
||||
onRangeChange,
|
||||
onGroupByChange,
|
||||
onFillEmptyChange,
|
||||
}: DateRangeSelectorProps) {
|
||||
const selectRange = (option: RangeOption) => {
|
||||
if (option !== CUSTOM_RANGE) {
|
||||
onRangeChange(option);
|
||||
return;
|
||||
}
|
||||
if (typeof range !== 'string') return;
|
||||
const today = todayLocalDay();
|
||||
onRangeChange(shownDays ?? { startDay: today - (DEFAULT_CUSTOM_DAYS - 1), endDay: today });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<SegmentedControl
|
||||
label="Range"
|
||||
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
|
||||
value={range}
|
||||
onChange={onRangeChange}
|
||||
formatLabel={(r) => (r === 'all' ? 'All' : r)}
|
||||
options={RANGE_OPTIONS}
|
||||
value={typeof range === 'string' ? range : CUSTOM_RANGE}
|
||||
onChange={selectRange}
|
||||
formatLabel={formatRangeOption}
|
||||
/>
|
||||
{typeof range === 'string' ? null : (
|
||||
<CustomRangeInputs range={range} onRangeChange={onRangeChange} />
|
||||
)}
|
||||
<SegmentedControl
|
||||
label="Group by"
|
||||
options={['day', 'month'] as GroupBy[]}
|
||||
options={['day', 'week', 'month'] as GroupBy[]}
|
||||
value={groupBy}
|
||||
onChange={onGroupByChange}
|
||||
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Empty days"
|
||||
label={`Empty ${groupBy}s`}
|
||||
options={['show', 'hide']}
|
||||
value={fillEmpty ? 'show' : 'hide'}
|
||||
onChange={(v) => onFillEmptyChange(v === 'show')}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { formatHours } from '../../lib/formatters';
|
||||
|
||||
interface DayHourHeatmapProps {
|
||||
/** Active minutes as `[dayOfWeek][hour]`, with 0 = Sunday. */
|
||||
grid: number[][];
|
||||
}
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const DAY_ORDER = [1, 2, 3, 4, 5, 6, 0];
|
||||
// The day starts at 06:00 so an evening that runs past midnight stays one block
|
||||
// instead of being split across both ends of the chart.
|
||||
const FIRST_HOUR = 6;
|
||||
const HOUR_LABEL_EVERY = 3;
|
||||
const MIN_CELL_OPACITY = 0.18;
|
||||
|
||||
export const HEATMAP_HOURS = Array.from({ length: 24 }, (_, index) => (index + FIRST_HOUR) % 24);
|
||||
|
||||
function formatHourLabel(hour: number): string {
|
||||
return `${String(hour).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square-root scale, so a few very long sessions don't wash out the rest of the
|
||||
* grid. Returns 0 for an empty cell, which is drawn as background rather than color.
|
||||
*/
|
||||
export function heatmapCellOpacity(minutes: number, maxMinutes: number): number {
|
||||
if (minutes <= 0 || maxMinutes <= 0) return 0;
|
||||
return MIN_CELL_OPACITY + (1 - MIN_CELL_OPACITY) * Math.sqrt(minutes / maxMinutes);
|
||||
}
|
||||
|
||||
export function DayHourHeatmap({ grid }: DayHourHeatmapProps) {
|
||||
const maxMinutes = Math.max(0, ...grid.flat());
|
||||
const columns = `2.25rem repeat(24, minmax(0, 1fr)) 3rem`;
|
||||
|
||||
return (
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<h3 className="mb-2 text-xs font-semibold text-ctp-text">Watch Time by Day and Hour</h3>
|
||||
{maxMinutes === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2">No data</div>
|
||||
) : (
|
||||
<div className="grid items-center gap-0.5" style={{ gridTemplateColumns: columns }}>
|
||||
{DAY_ORDER.map((day) => {
|
||||
const hours = grid[day] ?? [];
|
||||
const total = hours.reduce((sum, minutes) => sum + minutes, 0);
|
||||
return (
|
||||
<div key={day} className="contents">
|
||||
<div className="text-[10px] text-ctp-subtext0">{DAY_NAMES[day]}</div>
|
||||
{HEATMAP_HOURS.map((hour) => {
|
||||
const minutes = hours[hour] ?? 0;
|
||||
const opacity = heatmapCellOpacity(minutes, maxMinutes);
|
||||
return (
|
||||
<div
|
||||
key={hour}
|
||||
title={`${DAY_NAMES[day]} ${formatHourLabel(hour)}: ${formatHours(minutes)}`}
|
||||
className={`h-6 rounded-sm ${opacity === 0 ? 'bg-ctp-surface1/30' : 'bg-ctp-blue'}`}
|
||||
style={opacity === 0 ? undefined : { opacity }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="text-right text-[10px] text-ctp-subtext0 tabular-nums">
|
||||
{formatHours(total)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div />
|
||||
{HEATMAP_HOURS.map((hour, index) => (
|
||||
<div key={hour} className="text-[10px] whitespace-nowrap text-ctp-overlay2">
|
||||
{index % HOUR_LABEL_EVERY === 0 ? formatHourLabel(hour) : ''}
|
||||
</div>
|
||||
))}
|
||||
<div />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import {
|
||||
TREND_FREQUENCY_BANDS,
|
||||
type TrendFrequencyBand,
|
||||
type TrendFrequencyBandPoint,
|
||||
} from '../../types/stats';
|
||||
|
||||
interface FrequencyBandChartProps {
|
||||
data: TrendFrequencyBandPoint[];
|
||||
}
|
||||
|
||||
const BAND_LABELS: Record<TrendFrequencyBand, string> = {
|
||||
top1k: 'Top 1k',
|
||||
top5k: '1k to 5k',
|
||||
top10k: '5k to 10k',
|
||||
top20k: '10k to 20k',
|
||||
beyond20k: 'Beyond 20k',
|
||||
unranked: 'Unranked',
|
||||
};
|
||||
|
||||
// Bands are ordered, so one hue runs from faint (common) to strong (rare): the bar
|
||||
// brightens as new vocabulary moves further into rare words. Unranked words have
|
||||
// no place in that order and get a neutral color.
|
||||
const BAND_FILLS: Record<TrendFrequencyBand, { fill: string; fillOpacity: number }> = {
|
||||
top1k: { fill: '#8aadf4', fillOpacity: 0.2 },
|
||||
top5k: { fill: '#8aadf4', fillOpacity: 0.36 },
|
||||
top10k: { fill: '#8aadf4', fillOpacity: 0.54 },
|
||||
top20k: { fill: '#8aadf4', fillOpacity: 0.74 },
|
||||
beyond20k: { fill: '#8aadf4', fillOpacity: 1 },
|
||||
unranked: { fill: '#6e738d', fillOpacity: 0.6 },
|
||||
};
|
||||
|
||||
interface BandTooltipEntry {
|
||||
dataKey?: unknown;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
function BandTooltip({
|
||||
active,
|
||||
label,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
label?: string | number;
|
||||
payload?: BandTooltipEntry[];
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const rows = payload.map((entry) => ({
|
||||
band: TREND_FREQUENCY_BANDS.find((band) => band === entry.dataKey),
|
||||
count: typeof entry.value === 'number' ? entry.value : 0,
|
||||
}));
|
||||
const total = rows.reduce((sum, row) => sum + row.count, 0);
|
||||
return (
|
||||
<div style={{ ...TOOLTIP_CONTENT_STYLE, padding: '6px 10px' }}>
|
||||
<div style={{ color: CHART_THEME.tooltipLabel, marginBottom: 4, fontWeight: 600 }}>
|
||||
{label}: {formatNumber(total)} new words
|
||||
</div>
|
||||
{rows.map(({ band, count }) =>
|
||||
band ? (
|
||||
<div key={band} className="flex justify-between gap-4">
|
||||
<span>{BAND_LABELS[band]}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatNumber(count)} ({total > 0 ? Math.round((count / total) * 100) : 0}%)
|
||||
</span>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FrequencyBandChart({ data }: FrequencyBandChartProps) {
|
||||
const rows = data.map((point) => ({ label: point.label, ...point.counts }));
|
||||
return (
|
||||
<div className="h-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<h3 className="mb-2 text-xs font-semibold text-ctp-text">New Words by Frequency Rank</h3>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2">No data</div>
|
||||
) : (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
||||
<BarChart data={rows} margin={CHART_DEFAULTS.margin} stackOffset="expand">
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
width={40}
|
||||
tickFormatter={(value: number) => `${Math.round(value * 100)}%`}
|
||||
/>
|
||||
<Tooltip content={<BandTooltip />} wrapperStyle={{ zIndex: 50 }} />
|
||||
{TREND_FREQUENCY_BANDS.map((band) => (
|
||||
<Bar key={band} dataKey={band} stackId="bands" {...BAND_FILLS[band]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1">
|
||||
{TREND_FREQUENCY_BANDS.map((band) => (
|
||||
<span key={band} className="flex items-center gap-1 text-[10px] text-ctp-subtext0">
|
||||
<span
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: BAND_FILLS[band].fill,
|
||||
opacity: BAND_FILLS[band].fillOpacity,
|
||||
}}
|
||||
/>
|
||||
{BAND_LABELS[band]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import type { LibrarySummaryRow } from '../../types/stats';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
import { epochDayToDate, formatDuration, formatNumber } from '../../lib/formatters';
|
||||
|
||||
interface LibrarySummarySectionProps {
|
||||
rows: LibrarySummaryRow[];
|
||||
hiddenTitles: ReadonlySet<string>;
|
||||
selectedTitle: string | null;
|
||||
onSelectTitle: (title: string) => void;
|
||||
onOpenTitle: (row: LibrarySummaryRow) => void;
|
||||
}
|
||||
|
||||
const LEADERBOARD_LIMIT = 10;
|
||||
const LEADERBOARD_HEIGHT = 260;
|
||||
const LEADERBOARD_BAR_COLOR = '#8aadf4';
|
||||
const TABLE_MAX_HEIGHT = 480;
|
||||
|
||||
type SortColumn =
|
||||
| 'title'
|
||||
| 'watchTimeMin'
|
||||
| 'videos'
|
||||
| 'episodesFinished'
|
||||
| 'sessions'
|
||||
| 'cards'
|
||||
| 'words'
|
||||
@@ -37,6 +35,7 @@ const COLUMNS: ColumnDef[] = [
|
||||
{ id: 'title', label: 'Title', align: 'left' },
|
||||
{ id: 'watchTimeMin', label: 'Watch Time', align: 'right' },
|
||||
{ id: 'videos', label: 'Videos', align: 'right' },
|
||||
{ id: 'episodesFinished', label: 'Finished', align: 'right' },
|
||||
{ id: 'sessions', label: 'Sessions', align: 'right' },
|
||||
{ id: 'cards', label: 'Cards', align: 'right' },
|
||||
{ id: 'words', label: 'Words', align: 'right' },
|
||||
@@ -45,11 +44,6 @@ const COLUMNS: ColumnDef[] = [
|
||||
{ id: 'firstWatched', label: 'Date Range', align: 'right' },
|
||||
];
|
||||
|
||||
function truncateTitle(title: string, maxChars: number): string {
|
||||
if (title.length <= maxChars) return title;
|
||||
return `${title.slice(0, maxChars - 1)}…`;
|
||||
}
|
||||
|
||||
function formatDateRange(firstEpochDay: number, lastEpochDay: number): string {
|
||||
const fmt = (epochDay: number) =>
|
||||
epochDayToDate(epochDay).toLocaleDateString(undefined, {
|
||||
@@ -76,10 +70,6 @@ function compareRows(
|
||||
return a.title.localeCompare(b.title) * sign;
|
||||
}
|
||||
|
||||
if (column === 'firstWatched') {
|
||||
return (a.firstWatched - b.firstWatched) * sign;
|
||||
}
|
||||
|
||||
if (column === 'lookupsPerHundred') {
|
||||
const aVal = a.lookupsPerHundred;
|
||||
const bVal = b.lookupsPerHundred;
|
||||
@@ -89,39 +79,28 @@ function compareRows(
|
||||
return (aVal - bVal) * sign;
|
||||
}
|
||||
|
||||
const aVal = a[column] as number;
|
||||
const bVal = b[column] as number;
|
||||
return (aVal - bVal) * sign;
|
||||
return (a[column] - b[column]) * sign;
|
||||
}
|
||||
|
||||
export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySectionProps) {
|
||||
export function LibrarySummarySection({
|
||||
rows,
|
||||
selectedTitle,
|
||||
onSelectTitle,
|
||||
onOpenTitle,
|
||||
}: LibrarySummarySectionProps) {
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('watchTimeMin');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() => rows.filter((row) => !hiddenTitles.has(row.title)),
|
||||
[rows, hiddenTitles],
|
||||
);
|
||||
|
||||
const sortedRows = useMemo(
|
||||
() => [...visibleRows].sort((a, b) => compareRows(a, b, sortColumn, sortDirection)),
|
||||
[visibleRows, sortColumn, sortDirection],
|
||||
() => [...rows].sort((a, b) => compareRows(a, b, sortColumn, sortDirection)),
|
||||
[rows, sortColumn, sortDirection],
|
||||
);
|
||||
const maxWatchTime = useMemo(
|
||||
() => rows.reduce((max, row) => Math.max(max, row.watchTimeMin), 0),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const leaderboard = useMemo(
|
||||
() =>
|
||||
[...visibleRows]
|
||||
.sort((a, b) => b.watchTimeMin - a.watchTimeMin)
|
||||
.slice(0, LEADERBOARD_LIMIT)
|
||||
.map((row) => ({
|
||||
title: row.title,
|
||||
displayTitle: truncateTitle(row.title, 24),
|
||||
watchTimeMin: row.watchTimeMin,
|
||||
})),
|
||||
[visibleRows],
|
||||
);
|
||||
|
||||
if (visibleRows.length === 0) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<div className="text-xs text-ctp-overlay2">No library activity in the selected window.</div>
|
||||
@@ -139,86 +118,85 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
|
||||
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
|
||||
<BarChart
|
||||
data={leaderboard}
|
||||
layout="vertical"
|
||||
margin={{ top: 8, right: 16, bottom: 8, left: 8 }}
|
||||
>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="displayTitle"
|
||||
width={160}
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_CONTENT_STYLE}
|
||||
formatter={(value: number) => [`${value} min`, 'Watch Time']}
|
||||
labelFormatter={(_label, payload) => {
|
||||
const datum = payload?.[0]?.payload as { title?: string } | undefined;
|
||||
return datum?.title ?? '';
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="watchTimeMin" fill={LEADERBOARD_BAR_COLOR} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
|
||||
<div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-ctp-surface0">
|
||||
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
|
||||
{COLUMNS.map((column) => {
|
||||
const isActive = column.id === sortColumn;
|
||||
const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
return (
|
||||
<th
|
||||
key={column.id}
|
||||
scope="col"
|
||||
className={`px-2 py-2 font-medium select-none cursor-pointer hover:text-ctp-text ${
|
||||
column.align === 'right' ? 'text-right' : 'text-left'
|
||||
} ${isActive ? 'text-ctp-text' : ''}`}
|
||||
onClick={() => handleHeaderClick(column.id)}
|
||||
>
|
||||
{column.label}
|
||||
{indicator}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row) => (
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
|
||||
<div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-ctp-surface0">
|
||||
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
|
||||
{COLUMNS.map((column) => {
|
||||
const isActive = column.id === sortColumn;
|
||||
const indicator = isActive ? (sortDirection === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
return (
|
||||
<th
|
||||
key={column.id}
|
||||
scope="col"
|
||||
aria-sort={
|
||||
isActive ? (sortDirection === 'asc' ? 'ascending' : 'descending') : undefined
|
||||
}
|
||||
className={`px-2 py-2 font-medium select-none cursor-pointer hover:text-ctp-text ${
|
||||
column.align === 'right' ? 'text-right' : 'text-left'
|
||||
} ${isActive ? 'text-ctp-text' : ''}`}
|
||||
onClick={() => handleHeaderClick(column.id)}
|
||||
>
|
||||
{column.label}
|
||||
{indicator}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row) => {
|
||||
const isSelected = row.title === selectedTitle;
|
||||
return (
|
||||
<tr
|
||||
key={row.title}
|
||||
className="border-b border-ctp-surface1 last:border-b-0 hover:bg-ctp-surface1/40"
|
||||
className={`border-b border-ctp-surface1 last:border-b-0 hover:bg-ctp-surface1/40 ${
|
||||
isSelected ? 'bg-ctp-surface1/60' : ''
|
||||
}`}
|
||||
>
|
||||
<td
|
||||
className="px-2 py-2 text-left text-ctp-text max-w-[240px] truncate"
|
||||
title={row.title}
|
||||
>
|
||||
{row.title}
|
||||
<td className="px-2 py-2 text-left max-w-[240px]">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
title={`Filter the page to ${row.title}`}
|
||||
className={`min-w-0 truncate text-left transition hover:text-ctp-blue ${
|
||||
isSelected ? 'font-semibold text-ctp-blue' : 'text-ctp-text'
|
||||
}`}
|
||||
onClick={() => onSelectTitle(row.title)}
|
||||
>
|
||||
{row.title}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${row.title}`}
|
||||
title="Open this title"
|
||||
className="shrink-0 text-ctp-overlay2 transition hover:text-ctp-blue"
|
||||
onClick={() => onOpenTitle(row)}
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatWatchTime(row.watchTimeMin)}
|
||||
<div>{formatWatchTime(row.watchTimeMin)}</div>
|
||||
<div className="ml-auto mt-1 h-1 w-20 overflow-hidden rounded-full bg-ctp-surface1/60">
|
||||
<div
|
||||
className="ml-auto h-full rounded-full bg-ctp-blue"
|
||||
style={{
|
||||
width: `${maxWatchTime > 0 ? (row.watchTimeMin / maxWatchTime) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.videos)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.episodesFinished)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
|
||||
{formatNumber(row.sessions)}
|
||||
</td>
|
||||
@@ -238,11 +216,11 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
|
||||
{formatDateRange(row.firstWatched, row.lastWatched)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildLineData,
|
||||
sortTooltipEntries,
|
||||
tooltipColumnCount,
|
||||
type PerAnimeDataPoint,
|
||||
} from './StackedTrendChart';
|
||||
|
||||
function makePoints(titleCount: number): PerAnimeDataPoint[] {
|
||||
const points: PerAnimeDataPoint[] = [];
|
||||
for (let i = 0; i < titleCount; i += 1) {
|
||||
points.push({ epochDay: 20_000 + i, animeTitle: `Title ${i}`, value: titleCount - i });
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
test('sortTooltipEntries orders rows by value descending, tolerating string values', () => {
|
||||
const sorted = sortTooltipEntries([
|
||||
{ name: 'A', value: 5 },
|
||||
{ name: 'B', value: 20 },
|
||||
{ name: 'C', value: '12.5' },
|
||||
{ name: 'D', value: undefined },
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
sorted.map((entry) => entry.name),
|
||||
['B', 'C', 'A', 'D'],
|
||||
);
|
||||
});
|
||||
|
||||
test('tooltipColumnCount wraps into extra columns as items grow, capped at 3', () => {
|
||||
assert.equal(tooltipColumnCount(1), 1);
|
||||
assert.equal(tooltipColumnCount(8), 1);
|
||||
assert.equal(tooltipColumnCount(9), 2);
|
||||
assert.equal(tooltipColumnCount(16), 2);
|
||||
assert.equal(tooltipColumnCount(17), 3);
|
||||
assert.equal(tooltipColumnCount(40), 3);
|
||||
});
|
||||
|
||||
test('buildLineData keeps every title as a series instead of capping at the top 7', () => {
|
||||
const { points, seriesKeys } = buildLineData(makePoints(17));
|
||||
|
||||
assert.equal(seriesKeys.length, 17);
|
||||
assert.ok(seriesKeys.includes('Title 16'));
|
||||
for (const row of points) {
|
||||
for (const key of seriesKeys) {
|
||||
assert.ok(key in row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('buildLineData caps series at maxSeries when set, keeping the top titles', () => {
|
||||
const { points, seriesKeys } = buildLineData(makePoints(17), 7);
|
||||
|
||||
assert.equal(seriesKeys.length, 7);
|
||||
assert.deepEqual(
|
||||
seriesKeys,
|
||||
Array.from({ length: 7 }, (_, i) => `Title ${i}`),
|
||||
);
|
||||
for (const row of points) {
|
||||
assert.ok(!('Title 16' in row));
|
||||
}
|
||||
});
|
||||
|
||||
test('buildLineData orders series by total value descending', () => {
|
||||
const { seriesKeys } = buildLineData([
|
||||
{ epochDay: 20_000, animeTitle: 'Small', value: 1 },
|
||||
{ epochDay: 20_000, animeTitle: 'Big', value: 10 },
|
||||
{ epochDay: 20_001, animeTitle: 'Medium', value: 5 },
|
||||
]);
|
||||
|
||||
assert.deepEqual(seriesKeys, ['Big', 'Medium', 'Small']);
|
||||
});
|
||||
|
||||
test('buildLineData total mode ranks by final cumulative value, not the sum of snapshots', () => {
|
||||
// "Early" plateaus at 10 across four days (snapshot sum 40); "Late" reaches a
|
||||
// larger final value of 12 in a single day. Ranking by final value keeps Late
|
||||
// first; the old snapshot-sum ranking would have put Early first.
|
||||
const raw: PerAnimeDataPoint[] = [
|
||||
{ epochDay: 20_000, animeTitle: 'Early', value: 10 },
|
||||
{ epochDay: 20_001, animeTitle: 'Early', value: 10 },
|
||||
{ epochDay: 20_002, animeTitle: 'Early', value: 10 },
|
||||
{ epochDay: 20_003, animeTitle: 'Early', value: 10 },
|
||||
{ epochDay: 20_003, animeTitle: 'Late', value: 12 },
|
||||
];
|
||||
|
||||
const { seriesKeys } = buildLineData(raw, undefined, 'total');
|
||||
assert.deepEqual(seriesKeys, ['Late', 'Early']);
|
||||
});
|
||||
|
||||
test('buildLineData recent mode keeps the most recently active titles over the largest', () => {
|
||||
// "Old Giant" has a huge cumulative total but stopped growing early; "Fresh"
|
||||
// is small but its cumulative value increased on the latest day.
|
||||
const raw: PerAnimeDataPoint[] = [
|
||||
{ epochDay: 20_000, animeTitle: 'Old Giant', value: 500 },
|
||||
{ epochDay: 20_001, animeTitle: 'Old Giant', value: 500 },
|
||||
{ epochDay: 20_002, animeTitle: 'Old Giant', value: 500 },
|
||||
{ epochDay: 20_000, animeTitle: 'Fresh', value: 1 },
|
||||
{ epochDay: 20_001, animeTitle: 'Fresh', value: 2 },
|
||||
{ epochDay: 20_002, animeTitle: 'Fresh', value: 5 },
|
||||
];
|
||||
|
||||
const total = buildLineData(raw, 1, 'total');
|
||||
assert.deepEqual(total.seriesKeys, ['Old Giant']);
|
||||
|
||||
const recent = buildLineData(raw, 1, 'recent');
|
||||
assert.deepEqual(recent.seriesKeys, ['Fresh']);
|
||||
});
|
||||
|
||||
test('buildLineData recent mode breaks ties by total then name', () => {
|
||||
// Both titles last increased on day 20_002; the larger total wins the tie.
|
||||
const raw: PerAnimeDataPoint[] = [
|
||||
{ epochDay: 20_001, animeTitle: 'A', value: 2 },
|
||||
{ epochDay: 20_002, animeTitle: 'A', value: 4 },
|
||||
{ epochDay: 20_001, animeTitle: 'B', value: 3 },
|
||||
{ epochDay: 20_002, animeTitle: 'B', value: 9 },
|
||||
];
|
||||
|
||||
const { seriesKeys } = buildLineData(raw, 1, 'recent');
|
||||
assert.deepEqual(seriesKeys, ['B']);
|
||||
});
|
||||
@@ -1,275 +0,0 @@
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
|
||||
export interface PerAnimeDataPoint {
|
||||
epochDay: number;
|
||||
animeTitle: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface StackedTrendChartProps {
|
||||
title: string;
|
||||
data: PerAnimeDataPoint[];
|
||||
colorPalette?: string[];
|
||||
maxSeries?: number | null;
|
||||
maxSeriesMode?: SeriesRankMode;
|
||||
}
|
||||
|
||||
const DEFAULT_LINE_COLORS = [
|
||||
'#8aadf4',
|
||||
'#c6a0f6',
|
||||
'#a6da95',
|
||||
'#f5a97f',
|
||||
'#f5bde6',
|
||||
'#91d7e3',
|
||||
'#ee99a0',
|
||||
'#f4dbd6',
|
||||
];
|
||||
|
||||
// Wrap the tooltip into extra columns once a single column would get too tall,
|
||||
// keeping it compact instead of overflowing behind the charts below it.
|
||||
const TOOLTIP_ROWS_PER_COLUMN = 8;
|
||||
const TOOLTIP_MAX_COLUMNS = 3;
|
||||
|
||||
export function tooltipColumnCount(itemCount: number): number {
|
||||
const columns = Math.ceil(itemCount / TOOLTIP_ROWS_PER_COLUMN);
|
||||
return Math.min(TOOLTIP_MAX_COLUMNS, Math.max(1, columns));
|
||||
}
|
||||
|
||||
interface TooltipEntry {
|
||||
name?: string | number;
|
||||
value?: string | number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface StackedTooltipProps {
|
||||
active?: boolean;
|
||||
label?: string | number;
|
||||
payload?: TooltipEntry[];
|
||||
}
|
||||
|
||||
function tooltipEntryValue(entry: TooltipEntry): number {
|
||||
const numeric = typeof entry.value === 'number' ? entry.value : Number(entry.value);
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
}
|
||||
|
||||
// Tooltip rows are sorted by value (largest first) so the biggest contributors
|
||||
// read top-down, independent of the series/stacking order.
|
||||
export function sortTooltipEntries<T extends TooltipEntry>(entries: readonly T[]): T[] {
|
||||
return [...entries].sort((a, b) => tooltipEntryValue(b) - tooltipEntryValue(a));
|
||||
}
|
||||
|
||||
function StackedTooltip({ active, label, payload }: StackedTooltipProps) {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sorted = sortTooltipEntries(payload);
|
||||
const columns = tooltipColumnCount(sorted.length);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...TOOLTIP_CONTENT_STYLE,
|
||||
padding: '6px 10px',
|
||||
maxWidth: '90vw',
|
||||
}}
|
||||
>
|
||||
{label !== undefined && (
|
||||
<div style={{ color: CHART_THEME.tooltipLabel, marginBottom: 4, fontWeight: 600 }}>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, max-content)`,
|
||||
columnGap: 16,
|
||||
rowGap: 2,
|
||||
}}
|
||||
>
|
||||
{sorted.map((entry, index) => (
|
||||
<div
|
||||
key={`${entry.name ?? index}`}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 9999,
|
||||
background: entry.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span>{entry.name}</span>
|
||||
<span style={{ marginLeft: 16, color: CHART_THEME.tooltipLabel }}>{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type SeriesRankMode = 'total' | 'recent';
|
||||
|
||||
// Per-title ranking key. The data is cumulative per title, so `total` is the
|
||||
// final (latest) cumulative value — not the sum of daily snapshots, which would
|
||||
// over-weight titles that plateaued high early. `recentDay` is the latest epoch
|
||||
// day the value increased, i.e. its last day of real activity — used to keep
|
||||
// the most recently watched titles.
|
||||
function rankTitles(raw: PerAnimeDataPoint[]): Map<string, { total: number; recentDay: number }> {
|
||||
const pointsByTitle = new Map<string, PerAnimeDataPoint[]>();
|
||||
for (const entry of raw) {
|
||||
const list = pointsByTitle.get(entry.animeTitle) ?? [];
|
||||
list.push(entry);
|
||||
pointsByTitle.set(entry.animeTitle, list);
|
||||
}
|
||||
|
||||
const stats = new Map<string, { total: number; recentDay: number }>();
|
||||
for (const [title, points] of pointsByTitle) {
|
||||
const sorted = [...points].sort((a, b) => a.epochDay - b.epochDay);
|
||||
let previous = 0;
|
||||
let recentDay = Number.NEGATIVE_INFINITY;
|
||||
for (const point of sorted) {
|
||||
if (point.value > previous) {
|
||||
recentDay = point.epochDay;
|
||||
}
|
||||
previous = point.value;
|
||||
}
|
||||
const total = sorted.length > 0 ? sorted[sorted.length - 1]!.value : 0;
|
||||
stats.set(title, { total, recentDay });
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function buildLineData(
|
||||
raw: PerAnimeDataPoint[],
|
||||
maxSeries?: number | null,
|
||||
mode: SeriesRankMode = 'total',
|
||||
) {
|
||||
const stats = rankTitles(raw);
|
||||
|
||||
let seriesKeys = [...stats.entries()]
|
||||
.sort((a, b) => {
|
||||
if (mode === 'recent') {
|
||||
return (
|
||||
b[1].recentDay - a[1].recentDay || b[1].total - a[1].total || a[0].localeCompare(b[0])
|
||||
);
|
||||
}
|
||||
return b[1].total - a[1].total || a[0].localeCompare(b[0]);
|
||||
})
|
||||
.map(([title]) => title);
|
||||
if (typeof maxSeries === 'number' && maxSeries > 0) {
|
||||
seriesKeys = seriesKeys.slice(0, maxSeries);
|
||||
}
|
||||
const seriesSet = new Set(seriesKeys);
|
||||
|
||||
const byDay = new Map<number, Record<string, number>>();
|
||||
for (const entry of raw) {
|
||||
if (!seriesSet.has(entry.animeTitle)) continue;
|
||||
const row = byDay.get(entry.epochDay) ?? {};
|
||||
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
|
||||
byDay.set(entry.epochDay, row);
|
||||
}
|
||||
|
||||
const points = [...byDay.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([epochDay, values]) => {
|
||||
const row: Record<string, string | number> = {
|
||||
label: epochDayToDate(epochDay).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
};
|
||||
for (const title of seriesKeys) {
|
||||
row[title] = values[title] ?? 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return { points, seriesKeys };
|
||||
}
|
||||
|
||||
export function StackedTrendChart({
|
||||
title,
|
||||
data,
|
||||
colorPalette,
|
||||
maxSeries,
|
||||
maxSeriesMode,
|
||||
}: StackedTrendChartProps) {
|
||||
const { points, seriesKeys } = buildLineData(data, maxSeries, maxSeriesMode);
|
||||
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
||||
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<div className="text-xs text-ctp-overlay2">No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
||||
<AreaChart data={points} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<StackedTooltip />}
|
||||
wrapperStyle={{ zIndex: 50 }}
|
||||
allowEscapeViewBox={{ x: false, y: true }}
|
||||
/>
|
||||
{seriesKeys.map((key, i) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={colors[i % colors.length]}
|
||||
fill={colors[i % colors.length]}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{seriesKeys.map((key, i) => (
|
||||
<span
|
||||
key={key}
|
||||
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 whitespace-nowrap"
|
||||
title={key}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: colors[i % colors.length] }}
|
||||
/>
|
||||
<span>{key}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { TrendSummaryTotals, TrendsDashboardData } from '../../types/stats';
|
||||
import { formatCompactNumber, formatHours, formatNumber } from '../../lib/formatters';
|
||||
import { SummaryTile } from '../layout/SummaryTile';
|
||||
|
||||
interface SummaryStripProps {
|
||||
summary: TrendsDashboardData['summary'];
|
||||
days: number;
|
||||
}
|
||||
|
||||
type NumericKey = Exclude<keyof TrendSummaryTotals, 'activeDays'>;
|
||||
|
||||
const TILES: Array<{ key: NumericKey; label: string; format: (value: number) => string }> = [
|
||||
{ key: 'watchTimeMin', label: 'Watch time', format: formatHours },
|
||||
{ key: 'words', label: 'Words seen', format: formatCompactNumber },
|
||||
{ key: 'newWords', label: 'New words', format: formatNumber },
|
||||
{ key: 'cards', label: 'Cards mined', format: formatNumber },
|
||||
{ key: 'episodes', label: 'Episodes finished', format: formatNumber },
|
||||
];
|
||||
|
||||
/** Compares a total with the same-length period before it; null when both are zero. */
|
||||
export function describePeriodChange(
|
||||
current: number,
|
||||
previous: number,
|
||||
days: number,
|
||||
): string | null {
|
||||
const period = `previous ${days} day${days === 1 ? '' : 's'}`;
|
||||
if (previous === 0) return current === 0 ? null : `None in ${period}`;
|
||||
if (current === previous) return `Same as ${period}`;
|
||||
const percent = Math.round(((current - previous) / previous) * 100);
|
||||
if (percent === 0) return `Under 1% from ${period}`;
|
||||
return `${percent > 0 ? '+' : '\u2212'}${Math.abs(percent)}% vs ${period}`;
|
||||
}
|
||||
|
||||
function isEmptyPeriod(totals: TrendSummaryTotals): boolean {
|
||||
return totals.activeDays === 0 && TILES.every(({ key }) => !totals[key]);
|
||||
}
|
||||
|
||||
export function SummaryStrip({ summary, days }: SummaryStripProps) {
|
||||
const { current } = summary;
|
||||
// An empty period gets one note, because the same sentence on every tile reads as missing data.
|
||||
const previousIsEmpty = summary.previous !== null && isEmptyPeriod(summary.previous);
|
||||
const previous = previousIsEmpty ? null : summary.previous;
|
||||
return (
|
||||
<section
|
||||
aria-label="Period summary"
|
||||
className="grid grid-cols-2 gap-4 rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4 sm:grid-cols-3 lg:grid-cols-6"
|
||||
>
|
||||
{TILES.map(({ key, label, format }) => {
|
||||
const value = current[key];
|
||||
if (value === null) return null;
|
||||
const previousValue = previous?.[key] ?? null;
|
||||
return (
|
||||
<SummaryTile
|
||||
key={key}
|
||||
label={label}
|
||||
value={format(value)}
|
||||
detail={
|
||||
previousValue === null ? null : describePeriodChange(value, previousValue, days)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<SummaryTile
|
||||
label="Active days"
|
||||
value={formatNumber(current.activeDays)}
|
||||
detail={previous ? `${formatNumber(previous.activeDays)} in previous ${days}` : null}
|
||||
/>
|
||||
{previousIsEmpty ? (
|
||||
<p className="col-span-full text-[11px] text-ctp-overlay2">
|
||||
Not watched in the previous {days} day{days === 1 ? '' : 's'}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
interface TitleFilterProps {
|
||||
titles: string[];
|
||||
query: string;
|
||||
selectedTitle: string | null;
|
||||
onQueryChange: (query: string) => void;
|
||||
onSelectTitle: (title: string | null) => void;
|
||||
}
|
||||
|
||||
const TITLE_OPTIONS_ID = 'trends-title-options';
|
||||
|
||||
/**
|
||||
* Whether an input event came from picking a datalist option rather than typing.
|
||||
* Firefox reports insertReplacementText. Chromium, which the stats window runs on,
|
||||
* sends a plain Event with no inputType, while typing always reports one.
|
||||
*/
|
||||
export function isDatalistSelection(event: Event): boolean {
|
||||
const inputType = 'inputType' in event ? event.inputType : undefined;
|
||||
return inputType === undefined || inputType === 'insertReplacementText';
|
||||
}
|
||||
|
||||
/**
|
||||
* Typing narrows the title lists below. Picking a suggestion, or pressing Enter on
|
||||
* an exact title, filters the whole page to it, as choosing it from a list row does.
|
||||
* Typing alone never selects, because one title can be the start of another.
|
||||
*/
|
||||
export function TitleFilter({
|
||||
titles,
|
||||
query,
|
||||
selectedTitle,
|
||||
onQueryChange,
|
||||
onSelectTitle,
|
||||
}: TitleFilterProps) {
|
||||
const selectIfTitle = (value: string): boolean => {
|
||||
if (!titles.includes(value)) return false;
|
||||
onSelectTitle(value);
|
||||
onQueryChange('');
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs">
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Filter by title"
|
||||
placeholder="Filter by title..."
|
||||
list={TITLE_OPTIONS_ID}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (!isDatalistSelection(event.nativeEvent) || !selectIfTitle(value)) {
|
||||
onQueryChange(value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Enter during IME composition confirms the conversion, not the filter.
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
selectIfTitle(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
className="w-56 max-w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-2.5 py-1 text-xs text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-blue focus:outline-none"
|
||||
/>
|
||||
<datalist id={TITLE_OPTIONS_ID}>
|
||||
{titles.map((title) => (
|
||||
<option key={title} value={title} />
|
||||
))}
|
||||
</datalist>
|
||||
{selectedTitle ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Clear title filter: ${selectedTitle}`}
|
||||
title="Clear title filter"
|
||||
className="flex max-w-xs items-center gap-1.5 rounded-full border border-ctp-blue/60 bg-ctp-blue/12 px-2.5 py-1 text-ctp-blue transition hover:border-ctp-blue"
|
||||
onClick={() => onSelectTitle(null)}
|
||||
>
|
||||
<span className="truncate">{selectedTitle}</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { LibrarySummaryRow, TrendPerAnimePoint } from '../../types/stats';
|
||||
import { epochDayToDate, formatHours, formatNumber } from '../../lib/formatters';
|
||||
import { heatmapCellOpacity } from './DayHourHeatmap';
|
||||
import { buildTitleTimeline } from './title-timeline';
|
||||
|
||||
interface TitleTimelineProps {
|
||||
/** Titles to show, most watched first. */
|
||||
rows: LibrarySummaryRow[];
|
||||
points: TrendPerAnimePoint[];
|
||||
window: { startDay: number; endDay: number };
|
||||
selectedTitle: string | null;
|
||||
onSelectTitle: (title: string) => void;
|
||||
onOpenTitle: (row: LibrarySummaryRow) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_VISIBLE_ROWS = 12;
|
||||
|
||||
function formatCellDate(epochDay: number): string {
|
||||
return epochDayToDate(epochDay).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** One row per title, shaded on the days it was watched, so viewing bursts are visible at a glance. */
|
||||
export function TitleTimeline({
|
||||
rows,
|
||||
points,
|
||||
window,
|
||||
selectedTitle,
|
||||
onSelectTitle,
|
||||
onOpenTitle,
|
||||
}: TitleTimelineProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const model = useMemo(() => buildTitleTimeline(points, window), [points, window]);
|
||||
const visibleRows = showAll ? rows : rows.slice(0, DEFAULT_VISIBLE_ROWS);
|
||||
|
||||
return (
|
||||
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
|
||||
<h3 className="mb-2 text-xs font-semibold text-ctp-text">
|
||||
What You Watched and When
|
||||
{model.columnDays > 1 ? (
|
||||
<span className="ml-2 font-normal text-ctp-overlay2">One column per week</span>
|
||||
) : null}
|
||||
</h3>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2">No titles in this range.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[minmax(7rem,14rem)_minmax(0,1fr)_2.5rem_2.5rem] items-center gap-x-2 gap-y-px">
|
||||
<div />
|
||||
<div className="relative h-4">
|
||||
{model.ticks.map((tick) => (
|
||||
<span
|
||||
key={tick.leftPercent}
|
||||
className="absolute top-0 text-[10px] whitespace-nowrap text-ctp-overlay2"
|
||||
style={{ left: `${tick.leftPercent}%` }}
|
||||
>
|
||||
{tick.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-ctp-overlay2">Time</div>
|
||||
<div className="text-right text-[10px] text-ctp-overlay2">Cards</div>
|
||||
{visibleRows.map((row) => {
|
||||
const isSelected = row.title === selectedTitle;
|
||||
return (
|
||||
<div key={row.title} className="contents">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
title={`Filter the page to ${row.title}`}
|
||||
className={`min-w-0 truncate text-left text-[11px] transition hover:text-ctp-blue ${
|
||||
isSelected ? 'font-semibold text-ctp-blue' : 'text-ctp-text'
|
||||
}`}
|
||||
onClick={() => onSelectTitle(row.title)}
|
||||
>
|
||||
{row.title}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${row.title}`}
|
||||
title="Open this title"
|
||||
className="shrink-0 text-[11px] text-ctp-overlay2 transition hover:text-ctp-blue"
|
||||
onClick={() => onOpenTitle(row)}
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`relative h-4 rounded-sm ${isSelected ? 'bg-ctp-surface2/60' : 'bg-ctp-surface1/30'}`}
|
||||
>
|
||||
{model.ticks.map((tick) => (
|
||||
<span
|
||||
key={tick.leftPercent}
|
||||
className="absolute inset-y-0 w-px bg-ctp-surface2/60"
|
||||
style={{ left: `${tick.leftPercent}%` }}
|
||||
/>
|
||||
))}
|
||||
{(model.cellsByTitle.get(row.title) ?? []).map((cell) => (
|
||||
<span
|
||||
key={cell.startDay}
|
||||
title={`${row.title}\n${model.columnDays > 1 ? 'Week of ' : ''}${formatCellDate(cell.startDay)}: ${formatHours(cell.minutes)}`}
|
||||
className="absolute inset-y-px min-w-[2px] rounded-[1px] bg-ctp-blue"
|
||||
style={{
|
||||
left: `${cell.leftPercent}%`,
|
||||
width: `${cell.widthPercent}%`,
|
||||
opacity: heatmapCellOpacity(cell.minutes, model.maxCellMinutes),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-ctp-subtext0 tabular-nums">
|
||||
{formatHours(row.watchTimeMin)}
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-ctp-subtext0 tabular-nums">
|
||||
{formatNumber(row.cards)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{rows.length > DEFAULT_VISIBLE_ROWS ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-[11px] text-ctp-overlay2 transition hover:text-ctp-blue"
|
||||
onClick={() => setShowAll((value) => !value)}
|
||||
>
|
||||
{showAll ? 'Show fewer' : `Show all ${formatNumber(rows.length)} titles`}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,39 +10,85 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
|
||||
import { formatCompactNumber, formatNumber } from '../../lib/formatters';
|
||||
|
||||
interface TrendChartProps {
|
||||
title: string;
|
||||
data: Array<{ label: string; value: number }>;
|
||||
/** A null value is a gap: no data to plot, as opposed to a real zero. */
|
||||
data: Array<{ label: string; value: number | null }>;
|
||||
color: string;
|
||||
type: 'bar' | 'line';
|
||||
formatter?: (value: number) => string;
|
||||
onBarClick?: (label: string) => void;
|
||||
/** Formats axis ticks. Defaults to compact numbers such as 24K. */
|
||||
tickFormatter?: (value: number) => string;
|
||||
/** Formats the tooltip value. Defaults to the full number. */
|
||||
valueFormatter?: (value: number) => string;
|
||||
onBarClick?: (label: string, index: number) => void;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
|
||||
const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
|
||||
const Y_AXIS_WIDTH = 40;
|
||||
|
||||
/** A point with gaps on both sides draws no line segment, so it gets a dot instead. */
|
||||
export function isIsolatedPoint(values: ReadonlyArray<number | null>, index: number): boolean {
|
||||
return (
|
||||
values[index] !== null &&
|
||||
values[index] !== undefined &&
|
||||
(values[index - 1] ?? null) === null &&
|
||||
(values[index + 1] ?? null) === null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leads a running total with zero. Each point is the total at the end of its period, so
|
||||
* without this the line would begin at the first day's whole total.
|
||||
*/
|
||||
export function startFromZero<T extends { label: string; value: number }>(
|
||||
points: T[],
|
||||
): Array<T | { label: string; value: number }> {
|
||||
return points.length > 0 ? [{ label: 'Start', value: 0 }, ...points] : points;
|
||||
}
|
||||
|
||||
export function TrendChart({
|
||||
title,
|
||||
data,
|
||||
color,
|
||||
type,
|
||||
tickFormatter = formatCompactNumber,
|
||||
valueFormatter = formatNumber,
|
||||
onBarClick,
|
||||
height = CHART_DEFAULTS.height,
|
||||
}: TrendChartProps) {
|
||||
const formatValue = (value: unknown) =>
|
||||
typeof value === 'number' ? [valueFormatter(value), title] : ['Not enough data', title];
|
||||
const values = data.map((point) => point.value);
|
||||
|
||||
const xAxis = (
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
);
|
||||
const yAxis = (
|
||||
<YAxis
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
width={Y_AXIS_WIDTH}
|
||||
tickFormatter={tickFormatter}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="h-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={CHART_DEFAULTS.height}>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
{type === 'bar' ? (
|
||||
<BarChart data={data} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
tickFormatter={formatter}
|
||||
/>
|
||||
{xAxis}
|
||||
{yAxis}
|
||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
@@ -50,28 +96,32 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
|
||||
radius={[2, 2, 0, 0]}
|
||||
cursor={onBarClick ? 'pointer' : undefined}
|
||||
onClick={
|
||||
onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined
|
||||
onBarClick
|
||||
? (entry: { label: string }, index: number) => onBarClick(entry.label, index)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data} margin={CHART_DEFAULTS.margin}>
|
||||
<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }}
|
||||
axisLine={{ stroke: CHART_THEME.axisLine }}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
tickFormatter={formatter}
|
||||
/>
|
||||
{xAxis}
|
||||
{yAxis}
|
||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} formatter={formatValue} />
|
||||
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
||||
<Line
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={(props: { cx?: number; cy?: number; index?: number }) => (
|
||||
<circle
|
||||
key={props.index}
|
||||
cx={props.cx}
|
||||
cy={props.cy}
|
||||
r={props.index !== undefined && isIsolatedPoint(values, props.index) ? 2.5 : 0}
|
||||
fill={color}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -1,43 +1,138 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AnimeVisibilityFilter } from './TrendsTab';
|
||||
import { parseDateDrafts } from './DateRangeSelector';
|
||||
import { HEATMAP_HOURS, heatmapCellOpacity } from './DayHourHeatmap';
|
||||
import { describePeriodChange, SummaryStrip } from './SummaryStrip';
|
||||
import { isDatalistSelection } from './TitleFilter';
|
||||
import { isIsolatedPoint, startFromZero } from './TrendChart';
|
||||
|
||||
test('AnimeVisibilityFilter offers a per-chart title limit selector', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeVisibilityFilter
|
||||
animeTitles={['KonoSuba']}
|
||||
hiddenAnime={new Set()}
|
||||
maxTitles={7}
|
||||
maxTitlesMode="total"
|
||||
onShowAll={() => {}}
|
||||
onHideAll={() => {}}
|
||||
onToggleAnime={() => {}}
|
||||
onMaxTitlesChange={() => {}}
|
||||
onMaxTitlesModeChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /per chart/);
|
||||
assert.match(markup, /<option value="all">All<\/option>/);
|
||||
assert.match(markup, /<option value="7" selected="">/);
|
||||
test('heatmap hours start at 06:00 so a late evening is not split across the axis', () => {
|
||||
assert.equal(HEATMAP_HOURS[0], 6);
|
||||
assert.equal(HEATMAP_HOURS.indexOf(23) + 1, HEATMAP_HOURS.indexOf(0));
|
||||
assert.equal(HEATMAP_HOURS.length, 24);
|
||||
});
|
||||
|
||||
test('AnimeVisibilityFilter keeps the ranking mode selectable even when showing all titles', () => {
|
||||
test('heatmap opacity leaves empty cells uncolored and keeps small values visible', () => {
|
||||
assert.equal(heatmapCellOpacity(0, 100), 0);
|
||||
assert.ok(heatmapCellOpacity(1, 100) > 0.2);
|
||||
assert.equal(heatmapCellOpacity(100, 100), 1);
|
||||
});
|
||||
|
||||
for (const c of [
|
||||
{ name: 'a decrease', current: 88, previous: 100, expected: '\u221212% vs previous 30 days' },
|
||||
{ name: 'an increase', current: 150, previous: 100, expected: '+50% vs previous 30 days' },
|
||||
{ name: 'no change', current: 100, previous: 100, expected: 'Same as previous 30 days' },
|
||||
{
|
||||
name: 'a tiny change',
|
||||
current: 1001,
|
||||
previous: 1000,
|
||||
expected: 'Under 1% from previous 30 days',
|
||||
},
|
||||
{ name: 'nothing before', current: 5, previous: 0, expected: 'None in previous 30 days' },
|
||||
{ name: 'nothing at all', current: 0, previous: 0, expected: null },
|
||||
]) {
|
||||
test(`describePeriodChange reports ${c.name}`, () => {
|
||||
assert.equal(describePeriodChange(c.current, c.previous, 30), c.expected);
|
||||
});
|
||||
}
|
||||
|
||||
test('SummaryStrip hides new words while filtered to a title and comparison on all-time', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeVisibilityFilter
|
||||
animeTitles={['KonoSuba']}
|
||||
hiddenAnime={new Set()}
|
||||
maxTitles={null}
|
||||
maxTitlesMode="total"
|
||||
onShowAll={() => {}}
|
||||
onHideAll={() => {}}
|
||||
onToggleAnime={() => {}}
|
||||
onMaxTitlesChange={() => {}}
|
||||
onMaxTitlesModeChange={() => {}}
|
||||
<SummaryStrip
|
||||
days={231}
|
||||
summary={{
|
||||
current: {
|
||||
watchTimeMin: 90,
|
||||
words: 24_000,
|
||||
newWords: null,
|
||||
cards: 3,
|
||||
episodes: 1,
|
||||
activeDays: 2,
|
||||
},
|
||||
previous: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
assert.match(markup, /1\.5h/);
|
||||
assert.doesNotMatch(markup, /New words/);
|
||||
assert.doesNotMatch(markup, /previous/);
|
||||
});
|
||||
|
||||
assert.match(markup, /aria-label="Title ranking mode"/);
|
||||
assert.doesNotMatch(markup, /aria-label="Title ranking mode"[^>]*disabled/);
|
||||
const SUMMARY_CURRENT = {
|
||||
watchTimeMin: 210,
|
||||
words: 15_900,
|
||||
newWords: null,
|
||||
cards: 35,
|
||||
episodes: 11,
|
||||
activeDays: 3,
|
||||
};
|
||||
const EMPTY_PERIOD = {
|
||||
watchTimeMin: 0,
|
||||
words: 0,
|
||||
newWords: null,
|
||||
cards: 0,
|
||||
episodes: 0,
|
||||
activeDays: 0,
|
||||
};
|
||||
|
||||
test('SummaryStrip shows one note instead of repeating an empty previous period', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SummaryStrip days={30} summary={{ current: SUMMARY_CURRENT, previous: EMPTY_PERIOD }} />,
|
||||
);
|
||||
assert.equal(markup.match(/previous 30/g)?.length, 1);
|
||||
assert.match(markup, /Not watched in the previous 30 days/);
|
||||
assert.doesNotMatch(markup, /None in previous/);
|
||||
});
|
||||
|
||||
test('SummaryStrip still compares each tile when the previous period has any activity', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SummaryStrip
|
||||
days={30}
|
||||
summary={{
|
||||
current: SUMMARY_CURRENT,
|
||||
previous: { ...EMPTY_PERIOD, watchTimeMin: 105, words: 7_950, activeDays: 1 },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
assert.match(markup, /\+100% vs previous 30 days/);
|
||||
assert.match(markup, /None in previous 30 days/);
|
||||
assert.match(markup, /1 in previous 30/);
|
||||
assert.doesNotMatch(markup, /Not watched/);
|
||||
});
|
||||
|
||||
test('startFromZero leads a running total with a zero point', () => {
|
||||
const points = [
|
||||
{ label: 'Oct 24', value: 106 },
|
||||
{ label: 'Oct 25', value: 155 },
|
||||
];
|
||||
assert.deepEqual(startFromZero(points), [{ label: 'Start', value: 0 }, ...points]);
|
||||
assert.deepEqual(startFromZero([]), []);
|
||||
});
|
||||
|
||||
test('isIsolatedPoint is true only for a value with gaps on both sides', () => {
|
||||
const values = [null, 3, null, 4, 5, null];
|
||||
assert.equal(isIsolatedPoint(values, 1), true);
|
||||
assert.equal(isIsolatedPoint(values, 3), false);
|
||||
assert.equal(isIsolatedPoint(values, 2), false);
|
||||
});
|
||||
|
||||
test('isDatalistSelection separates picking a suggestion from typing', () => {
|
||||
const inputEvent = (inputType: string) => Object.assign(new Event('input'), { inputType });
|
||||
assert.equal(isDatalistSelection(new Event('input')), true);
|
||||
assert.equal(isDatalistSelection(inputEvent('insertReplacementText')), true);
|
||||
assert.equal(isDatalistSelection(inputEvent('insertText')), false);
|
||||
assert.equal(isDatalistSelection(inputEvent('deleteContentBackward')), false);
|
||||
});
|
||||
|
||||
test('parseDateDrafts waits for two complete dates in order', () => {
|
||||
const end = '2026-03-01';
|
||||
assert.deepEqual(parseDateDrafts({ start: '2026-02-01', end }), {
|
||||
startDay: 20_485,
|
||||
endDay: 20_513,
|
||||
});
|
||||
// What a date input reports partway through typing a year, or when cleared.
|
||||
assert.equal(parseDateDrafts({ start: '0002-02-01', end }), null);
|
||||
assert.equal(parseDateDrafts({ start: '', end }), null);
|
||||
assert.equal(parseDateDrafts({ start: '2026-03-02', end }), null);
|
||||
});
|
||||
@@ -1,368 +1,367 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTrends, type GroupBy, type TimeRange } from '../../hooks/useTrends';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import type { SessionsDayFilter } from '../../lib/stats-navigation';
|
||||
import type { LibrarySummaryRow, TrendBucket, TrendChartPoint } from '../../types/stats';
|
||||
import { DateRangeSelector } from './DateRangeSelector';
|
||||
import { TrendChart } from './TrendChart';
|
||||
import { StackedTrendChart } from './StackedTrendChart';
|
||||
import {
|
||||
MAX_TITLES_MODES,
|
||||
MAX_TITLES_OPTIONS,
|
||||
buildAnimeVisibilityOptions,
|
||||
filterHiddenAnimeData,
|
||||
loadHiddenTitles,
|
||||
loadMaxTitles,
|
||||
loadMaxTitlesMode,
|
||||
loadShowEmptyDays,
|
||||
pruneHiddenAnime,
|
||||
saveHiddenTitles,
|
||||
saveMaxTitles,
|
||||
saveMaxTitlesMode,
|
||||
saveShowEmptyDays,
|
||||
type MaxTitlesMode,
|
||||
} from './anime-visibility';
|
||||
import { DayHourHeatmap } from './DayHourHeatmap';
|
||||
import { FrequencyBandChart } from './FrequencyBandChart';
|
||||
import { LibrarySummarySection } from './LibrarySummarySection';
|
||||
import { buildAnimeCumulative } from './anime-cumulative';
|
||||
import { SummaryStrip } from './SummaryStrip';
|
||||
import { TitleFilter } from './TitleFilter';
|
||||
import { TitleTimeline } from './TitleTimeline';
|
||||
import { startFromZero, TrendChart } from './TrendChart';
|
||||
import {
|
||||
RUNNING_TOTAL_METRICS,
|
||||
loadGroupBy,
|
||||
loadRange,
|
||||
loadRunningTotalMetric,
|
||||
loadShowEmptyDays,
|
||||
saveGroupBy,
|
||||
saveRange,
|
||||
saveRunningTotalMetric,
|
||||
saveShowEmptyDays,
|
||||
type RunningTotalMetric,
|
||||
} from './trends-preferences';
|
||||
|
||||
const MAX_TITLES_MODE_LABELS: Record<MaxTitlesMode, string> = {
|
||||
recent: 'most recent',
|
||||
total: 'top',
|
||||
interface TrendsTabProps {
|
||||
onNavigateToAnime?: (animeId: number) => void;
|
||||
onNavigateToMediaDetail?: (videoId: number) => void;
|
||||
onOpenSessionsForDays?: (filter: SessionsDayFilter) => void;
|
||||
}
|
||||
|
||||
const WATCH_TIME_COLOR = '#8aadf4';
|
||||
const WORDS_COLOR = '#8bd5ca';
|
||||
const NEW_WORDS_COLOR = '#c6a0f6';
|
||||
const EPISODES_COLOR = '#91d7e3';
|
||||
const LOOKUPS_COLOR = '#f5a97f';
|
||||
const READING_SPEED_COLOR = '#a6da95';
|
||||
const CARDS_COLOR = 'var(--color-ctp-cards-mined)';
|
||||
|
||||
const RUNNING_TOTAL_LABELS: Record<RunningTotalMetric, string> = {
|
||||
watchTime: 'Watch time',
|
||||
words: 'Words seen',
|
||||
newWords: 'New words',
|
||||
cards: 'Cards mined',
|
||||
episodes: 'Episodes finished',
|
||||
lookups: 'Lookups',
|
||||
};
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
const RUNNING_TOTAL_COLORS: Record<RunningTotalMetric, string> = {
|
||||
watchTime: WATCH_TIME_COLOR,
|
||||
words: WORDS_COLOR,
|
||||
newWords: NEW_WORDS_COLOR,
|
||||
cards: CARDS_COLOR,
|
||||
episodes: EPISODES_COLOR,
|
||||
lookups: LOOKUPS_COLOR,
|
||||
};
|
||||
|
||||
function SectionHeader({ children, hint }: { children: ReactNode; hint?: string }) {
|
||||
return (
|
||||
<div className="col-span-full mt-6 mb-2 flex items-center gap-3">
|
||||
<h3 className="text-ctp-subtext0 text-xs font-semibold uppercase tracking-widest shrink-0">
|
||||
{children}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
||||
{hint ? <span className="shrink-0 text-[11px] text-ctp-overlay2">{hint}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnimeVisibilityFilterProps {
|
||||
animeTitles: string[];
|
||||
hiddenAnime: ReadonlySet<string>;
|
||||
maxTitles: number | null;
|
||||
maxTitlesMode: MaxTitlesMode;
|
||||
onShowAll: () => void;
|
||||
onHideAll: () => void;
|
||||
onToggleAnime: (title: string) => void;
|
||||
onMaxTitlesChange: (value: number | null) => void;
|
||||
onMaxTitlesModeChange: (mode: MaxTitlesMode) => void;
|
||||
/** Chart values in hours, so axis ticks land on round hours instead of odd minute counts. */
|
||||
function toHours(points: TrendChartPoint[]): TrendChartPoint[] {
|
||||
return points.map((point) => ({ ...point, value: point.value / 60 }));
|
||||
}
|
||||
|
||||
export function AnimeVisibilityFilter({
|
||||
animeTitles,
|
||||
hiddenAnime,
|
||||
maxTitles,
|
||||
maxTitlesMode,
|
||||
onShowAll,
|
||||
onHideAll,
|
||||
onToggleAnime,
|
||||
onMaxTitlesChange,
|
||||
onMaxTitlesModeChange,
|
||||
}: AnimeVisibilityFilterProps) {
|
||||
if (animeTitles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const formatHourTick = (hours: number): string => `${+hours.toFixed(2)}h`;
|
||||
const formatHourValue = (hours: number): string => formatDuration(hours * 3_600_000);
|
||||
|
||||
return (
|
||||
<div className="col-span-full -mt-1 mb-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-widest text-ctp-subtext0">
|
||||
Title Visibility
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-ctp-overlay1">
|
||||
Shared across all anime trend charts. Default: show everything. Selections are saved.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1 text-[11px] text-ctp-subtext0">
|
||||
Show
|
||||
<select
|
||||
aria-label="Title ranking mode"
|
||||
className="rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue"
|
||||
value={maxTitlesMode}
|
||||
onChange={(event) => onMaxTitlesModeChange(event.target.value as MaxTitlesMode)}
|
||||
>
|
||||
{MAX_TITLES_MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{MAX_TITLES_MODE_LABELS[mode]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Titles per chart"
|
||||
className="rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue"
|
||||
value={maxTitles === null ? 'all' : String(maxTitles)}
|
||||
onChange={(event) =>
|
||||
onMaxTitlesChange(event.target.value === 'all' ? null : Number(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
{MAX_TITLES_OPTIONS.map((count) => (
|
||||
<option key={count} value={count}>
|
||||
{count}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
per chart
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={onShowAll}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-peach hover:text-ctp-peach"
|
||||
onClick={onHideAll}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{animeTitles.map((title) => {
|
||||
const isVisible = !hiddenAnime.has(title);
|
||||
return (
|
||||
<button
|
||||
key={title}
|
||||
type="button"
|
||||
aria-pressed={isVisible}
|
||||
className={`max-w-full rounded-full border px-3 py-1 text-xs transition ${
|
||||
isVisible
|
||||
? 'border-ctp-blue/60 bg-ctp-blue/12 text-ctp-blue'
|
||||
: 'border-ctp-surface2 bg-transparent text-ctp-subtext0'
|
||||
}`}
|
||||
onClick={() => onToggleAnime(title)}
|
||||
title={title}
|
||||
>
|
||||
<span className="block truncate">{title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function bucketSessionsLabel(bucket: TrendBucket, groupBy: GroupBy): string {
|
||||
return groupBy === 'week' ? `the week of ${bucket.label}` : bucket.label;
|
||||
}
|
||||
|
||||
export function TrendsTab() {
|
||||
const [range, setRange] = useState<TimeRange>('30d');
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('day');
|
||||
export function TrendsTab({
|
||||
onNavigateToAnime,
|
||||
onNavigateToMediaDetail,
|
||||
onOpenSessionsForDays,
|
||||
}: TrendsTabProps = {}) {
|
||||
const [range, setRange] = useState<TimeRange>(() => loadRange());
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>(() => loadGroupBy());
|
||||
const [showEmptyDays, setShowEmptyDays] = useState(() => loadShowEmptyDays());
|
||||
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => loadHiddenTitles());
|
||||
const [maxTitles, setMaxTitles] = useState<number | null>(() => loadMaxTitles());
|
||||
const [maxTitlesMode, setMaxTitlesMode] = useState<MaxTitlesMode>(() => loadMaxTitlesMode());
|
||||
const { data, loading, error } = useTrends(range, groupBy, showEmptyDays);
|
||||
const animeCumulative = useMemo(
|
||||
() => (data ? buildAnimeCumulative(data.animeDaily) : null),
|
||||
[data],
|
||||
const [runningTotalMetric, setRunningTotalMetric] = useState(() => loadRunningTotalMetric());
|
||||
const [selectedTitle, setSelectedTitle] = useState<string | null>(null);
|
||||
const [titleQuery, setTitleQuery] = useState('');
|
||||
const { data, loading, refreshing, error } = useTrends(
|
||||
range,
|
||||
groupBy,
|
||||
showEmptyDays,
|
||||
selectedTitle,
|
||||
);
|
||||
|
||||
const updateHiddenAnime = (next: Set<string>) => {
|
||||
setHiddenAnime(next);
|
||||
saveHiddenTitles(next);
|
||||
const updateRange = (next: TimeRange) => {
|
||||
setRange(next);
|
||||
saveRange(next);
|
||||
};
|
||||
const updateMaxTitles = (value: number | null) => {
|
||||
setMaxTitles(value);
|
||||
saveMaxTitles(value);
|
||||
};
|
||||
const updateMaxTitlesMode = (mode: MaxTitlesMode) => {
|
||||
setMaxTitlesMode(mode);
|
||||
saveMaxTitlesMode(mode);
|
||||
const updateGroupBy = (next: GroupBy) => {
|
||||
setGroupBy(next);
|
||||
saveGroupBy(next);
|
||||
};
|
||||
const updateShowEmptyDays = (show: boolean) => {
|
||||
setShowEmptyDays(show);
|
||||
saveShowEmptyDays(show);
|
||||
};
|
||||
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
|
||||
const cardsMinedStackedColors = [
|
||||
cardsMinedColor,
|
||||
'#8aadf4',
|
||||
'#c6a0f6',
|
||||
'#f5a97f',
|
||||
'#f5bde6',
|
||||
'#91d7e3',
|
||||
'#ee99a0',
|
||||
'#f4dbd6',
|
||||
];
|
||||
const updateRunningTotalMetric = (metric: RunningTotalMetric) => {
|
||||
setRunningTotalMetric(metric);
|
||||
saveRunningTotalMetric(metric);
|
||||
};
|
||||
const toggleSelectedTitle = (title: string) => {
|
||||
setSelectedTitle((current) => (current === title ? null : title));
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
if (!data || !animeCumulative) return null;
|
||||
const openTitle = (row: LibrarySummaryRow) => {
|
||||
if (row.animeId !== null) {
|
||||
onNavigateToAnime?.(row.animeId);
|
||||
} else if (row.videoId !== null) {
|
||||
onNavigateToMediaDetail?.(row.videoId);
|
||||
}
|
||||
};
|
||||
|
||||
const animeTitles = buildAnimeVisibilityOptions([
|
||||
animeCumulative.episodes,
|
||||
animeCumulative.cards,
|
||||
animeCumulative.words,
|
||||
animeCumulative.watchTime,
|
||||
]);
|
||||
const activeHiddenAnime = pruneHiddenAnime(hiddenAnime, animeTitles);
|
||||
const allTitles = useMemo(() => data?.librarySummary.map((row) => row.title) ?? [], [data]);
|
||||
const visibleTitleRows = useMemo(() => {
|
||||
const rows = data?.librarySummary ?? [];
|
||||
const query = titleQuery.trim().toLowerCase();
|
||||
return query ? rows.filter((row) => row.title.toLowerCase().includes(query)) : rows;
|
||||
}, [data, titleQuery]);
|
||||
|
||||
const filteredAnimeProgress = filterHiddenAnimeData(animeCumulative.episodes, activeHiddenAnime);
|
||||
const filteredCardsProgress = filterHiddenAnimeData(animeCumulative.cards, activeHiddenAnime);
|
||||
const filteredWordsProgress = filterHiddenAnimeData(animeCumulative.words, activeHiddenAnime);
|
||||
const filteredWatchTimeProgress = filterHiddenAnimeData(
|
||||
animeCumulative.watchTime,
|
||||
activeHiddenAnime,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
const controls = (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<DateRangeSelector
|
||||
range={range}
|
||||
groupBy={groupBy}
|
||||
fillEmpty={showEmptyDays}
|
||||
onRangeChange={setRange}
|
||||
onGroupByChange={setGroupBy}
|
||||
shownDays={data?.window ?? null}
|
||||
onRangeChange={updateRange}
|
||||
onGroupByChange={updateGroupBy}
|
||||
onFillEmptyChange={updateShowEmptyDays}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
|
||||
<TrendChart
|
||||
title="Watch Time (min)"
|
||||
data={data.activity.watchTime}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Cards Mined"
|
||||
data={data.activity.cards}
|
||||
color={cardsMinedColor}
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
||||
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
||||
<TitleFilter
|
||||
titles={allTitles}
|
||||
query={titleQuery}
|
||||
selectedTitle={selectedTitle}
|
||||
onQueryChange={setTitleQuery}
|
||||
onSelectTitle={setSelectedTitle}
|
||||
/>
|
||||
{refreshing && data ? (
|
||||
<span role="status" className="text-[11px] text-ctp-overlay2">
|
||||
Updating...
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
<SectionHeader>Cumulative Totals</SectionHeader>
|
||||
<TrendChart
|
||||
title="Watch Time, cumulative (min)"
|
||||
data={data.progress.watchTime}
|
||||
color="#8aadf4"
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Words Seen (cumulative)"
|
||||
data={data.progress.words}
|
||||
color="#8bd5ca"
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart
|
||||
title="New Words Seen (cumulative)"
|
||||
data={data.progress.newWords}
|
||||
color="#c6a0f6"
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Cards Mined (cumulative)"
|
||||
data={data.progress.cards}
|
||||
color={cardsMinedColor}
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Episodes Watched (cumulative)"
|
||||
data={data.progress.episodes}
|
||||
color="#91d7e3"
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Sessions (cumulative)"
|
||||
data={data.progress.sessions}
|
||||
color="#b7bdf8"
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Lookups (cumulative)"
|
||||
data={data.progress.lookups}
|
||||
color="#f5bde6"
|
||||
type="line"
|
||||
/>
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{controls}
|
||||
<div className="text-ctp-overlay2 p-4">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{controls}
|
||||
{error ? <div className="text-ctp-red p-4">Error: {error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<SectionHeader>Efficiency</SectionHeader>
|
||||
<TrendChart
|
||||
title="Words / Min"
|
||||
data={data.ratios.readingSpeed}
|
||||
color="#a6da95"
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Cards / Hour"
|
||||
data={data.ratios.cardsPerHour}
|
||||
color={cardsMinedColor}
|
||||
type="line"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Lookups / 100 Words"
|
||||
data={data.ratios.lookupsPerHundred}
|
||||
color="#f5a97f"
|
||||
type="line"
|
||||
/>
|
||||
const openBucketSessions = (_label: string, index: number) => {
|
||||
const bucket = data.buckets[index];
|
||||
if (!bucket) return;
|
||||
onOpenSessionsForDays?.({
|
||||
startDay: bucket.startDay,
|
||||
endDay: bucket.endDay,
|
||||
label: bucketSessionsLabel(bucket, groupBy),
|
||||
});
|
||||
};
|
||||
const barClickProps = onOpenSessionsForDays ? { onBarClick: openBucketSessions } : {};
|
||||
|
||||
const windowDays = data.window.endDay - data.window.startDay + 1;
|
||||
const runningTotalOptions = RUNNING_TOTAL_METRICS.filter(
|
||||
(metric) => metric !== 'newWords' || data.learning !== null,
|
||||
);
|
||||
const activeRunningTotal = runningTotalOptions.includes(runningTotalMetric)
|
||||
? runningTotalMetric
|
||||
: 'watchTime';
|
||||
const isWatchTimeTotal = activeRunningTotal === 'watchTime';
|
||||
|
||||
return (
|
||||
<div className="space-y-4" aria-busy={refreshing}>
|
||||
{controls}
|
||||
{error ? <div className="text-sm text-ctp-red">Error: {error}</div> : null}
|
||||
<SummaryStrip summary={data.summary} days={windowDays} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-6 gap-4">
|
||||
<SectionHeader
|
||||
hint={onOpenSessionsForDays ? 'Click a bar to open its sessions' : undefined}
|
||||
>
|
||||
Activity per {groupBy}
|
||||
</SectionHeader>
|
||||
<div className="xl:col-span-3">
|
||||
<TrendChart
|
||||
title="Watch Time (hours)"
|
||||
data={toHours(data.activity.watchTime)}
|
||||
color={WATCH_TIME_COLOR}
|
||||
type="bar"
|
||||
tickFormatter={formatHourTick}
|
||||
valueFormatter={formatHourValue}
|
||||
{...barClickProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="xl:col-span-3">
|
||||
<TrendChart
|
||||
title="Cards Mined"
|
||||
data={data.activity.cards}
|
||||
color={CARDS_COLOR}
|
||||
type="bar"
|
||||
{...barClickProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="xl:col-span-3">
|
||||
<TrendChart
|
||||
title="Words Seen"
|
||||
data={data.activity.words}
|
||||
color={WORDS_COLOR}
|
||||
type="bar"
|
||||
{...barClickProps}
|
||||
/>
|
||||
</div>
|
||||
<div className="xl:col-span-3">
|
||||
<TrendChart
|
||||
title="Episodes Finished"
|
||||
data={data.activity.episodes}
|
||||
color={EPISODES_COLOR}
|
||||
type="bar"
|
||||
{...barClickProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.learning ? (
|
||||
<>
|
||||
<SectionHeader hint="Dictionary forms, names excluded">Learning</SectionHeader>
|
||||
<div className="xl:col-span-3">
|
||||
<TrendChart
|
||||
title={
|
||||
groupBy === 'day'
|
||||
? 'New Words per 1,000 Seen (rolling 7 days)'
|
||||
: 'New Words per 1,000 Seen'
|
||||
}
|
||||
data={data.learning.newWordsPerThousand}
|
||||
color={NEW_WORDS_COLOR}
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
<div className="xl:col-span-3">
|
||||
<FrequencyBandChart data={data.learning.frequencyBands} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="col-span-full text-xs text-ctp-overlay2">
|
||||
New words are tracked across your whole library, so the Learning charts are hidden while
|
||||
filtering to one title.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SectionHeader
|
||||
hint={
|
||||
groupBy === 'day'
|
||||
? 'Rolling 7 days, gaps mean too little data'
|
||||
: 'Gaps mean too little data'
|
||||
}
|
||||
>
|
||||
Efficiency
|
||||
</SectionHeader>
|
||||
<div className="xl:col-span-2">
|
||||
<TrendChart
|
||||
title="Words / Min"
|
||||
data={data.ratios.readingSpeed}
|
||||
color={READING_SPEED_COLOR}
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
<div className="xl:col-span-2">
|
||||
<TrendChart
|
||||
title="Cards / Hour"
|
||||
data={data.ratios.cardsPerHour}
|
||||
color={CARDS_COLOR}
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
<div className="xl:col-span-2">
|
||||
<TrendChart
|
||||
title="Lookups / 100 Words"
|
||||
data={data.ratios.lookupsPerHundred}
|
||||
color={LOOKUPS_COLOR}
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionHeader>Running Total</SectionHeader>
|
||||
<div className="col-span-full">
|
||||
<div className="mb-2 flex flex-wrap gap-1" role="group" aria-label="Running total metric">
|
||||
{runningTotalOptions.map((metric) => (
|
||||
<button
|
||||
key={metric}
|
||||
type="button"
|
||||
aria-pressed={metric === activeRunningTotal}
|
||||
className={`rounded-md border px-2 py-1 text-[11px] transition ${
|
||||
metric === activeRunningTotal
|
||||
? 'border-ctp-surface2 bg-ctp-surface2 text-ctp-text'
|
||||
: 'border-ctp-surface1 text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
onClick={() => updateRunningTotalMetric(metric)}
|
||||
>
|
||||
{RUNNING_TOTAL_LABELS[metric]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TrendChart
|
||||
title={`${RUNNING_TOTAL_LABELS[activeRunningTotal]}, total since the start of this range`}
|
||||
data={startFromZero(
|
||||
isWatchTimeTotal
|
||||
? toHours(data.progress.watchTime)
|
||||
: data.progress[activeRunningTotal],
|
||||
)}
|
||||
color={RUNNING_TOTAL_COLORS[activeRunningTotal]}
|
||||
type="line"
|
||||
{...(isWatchTimeTotal
|
||||
? { tickFormatter: formatHourTick, valueFormatter: formatHourValue }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionHeader>Patterns</SectionHeader>
|
||||
<TrendChart
|
||||
title="Watch Time by Day of Week (min)"
|
||||
data={data.patterns.watchTimeByDayOfWeek}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Watch Time by Hour (min)"
|
||||
data={data.patterns.watchTimeByHour}
|
||||
color="#c6a0f6"
|
||||
type="bar"
|
||||
/>
|
||||
<DayHourHeatmap grid={data.watchTimeByDayHour} />
|
||||
|
||||
<SectionHeader>Library — Cumulative</SectionHeader>
|
||||
<AnimeVisibilityFilter
|
||||
animeTitles={animeTitles}
|
||||
hiddenAnime={activeHiddenAnime}
|
||||
maxTitles={maxTitles}
|
||||
maxTitlesMode={maxTitlesMode}
|
||||
onShowAll={() => updateHiddenAnime(new Set())}
|
||||
onHideAll={() => updateHiddenAnime(new Set(animeTitles))}
|
||||
onToggleAnime={(title) => {
|
||||
const next = new Set(hiddenAnime);
|
||||
if (next.has(title)) {
|
||||
next.delete(title);
|
||||
} else {
|
||||
next.add(title);
|
||||
}
|
||||
updateHiddenAnime(next);
|
||||
}}
|
||||
onMaxTitlesChange={updateMaxTitles}
|
||||
onMaxTitlesModeChange={updateMaxTitlesMode}
|
||||
<SectionHeader
|
||||
hint={`${formatNumber(data.librarySummary.length)} titles. Click a title to filter the page, ↗ to open it`}
|
||||
>
|
||||
Titles
|
||||
</SectionHeader>
|
||||
<TitleTimeline
|
||||
rows={visibleTitleRows}
|
||||
points={data.titleTimeline}
|
||||
window={data.window}
|
||||
selectedTitle={selectedTitle}
|
||||
onSelectTitle={toggleSelectedTitle}
|
||||
onOpenTitle={openTitle}
|
||||
/>
|
||||
<StackedTrendChart
|
||||
title="Watch Time Progress (min)"
|
||||
data={filteredWatchTimeProgress}
|
||||
maxSeries={maxTitles}
|
||||
maxSeriesMode={maxTitlesMode}
|
||||
<LibrarySummarySection
|
||||
rows={visibleTitleRows}
|
||||
selectedTitle={selectedTitle}
|
||||
onSelectTitle={toggleSelectedTitle}
|
||||
onOpenTitle={openTitle}
|
||||
/>
|
||||
<StackedTrendChart
|
||||
title="Episodes Progress"
|
||||
data={filteredAnimeProgress}
|
||||
maxSeries={maxTitles}
|
||||
maxSeriesMode={maxTitlesMode}
|
||||
/>
|
||||
<StackedTrendChart
|
||||
title="Cards Mined Progress"
|
||||
data={filteredCardsProgress}
|
||||
colorPalette={cardsMinedStackedColors}
|
||||
maxSeries={maxTitles}
|
||||
maxSeriesMode={maxTitlesMode}
|
||||
/>
|
||||
<StackedTrendChart
|
||||
title="Words Seen Progress"
|
||||
data={filteredWordsProgress}
|
||||
maxSeries={maxTitles}
|
||||
maxSeriesMode={maxTitlesMode}
|
||||
/>
|
||||
|
||||
<SectionHeader>Library — Summary</SectionHeader>
|
||||
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user