mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1865f4a6
|
||
|
|
6fd55053d4 | ||
|
|
0360270e81
|
||
|
|
a8e929eb26
|
||
|
|
b31ddce627 | ||
|
|
79e5535823
|
||
|
|
5140b5e9be
|
||
|
|
c2dac537da
|
||
|
|
bc7e82fee6
|
||
|
|
c3e672efdb
|
||
|
|
269c0708cb | ||
|
|
f89f78b861 | ||
|
|
14ef7e823c | ||
|
|
a8bc585f75
|
No files matched your search
@@ -32,6 +32,9 @@ jobs:
|
||||
- name: Verify native mpv process launch
|
||||
run: bun test src/main/runtime/mpv-process.test.ts
|
||||
|
||||
- name: Verify mpv PID lookup and Windows tracker
|
||||
run: bun test src/window-trackers/mpv-process-id.test.ts src/window-trackers/windows-tracker.test.ts
|
||||
|
||||
- name: Verify POSIX launcher bootstrap
|
||||
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts
|
||||
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## v0.22.0 (2026-11-06)
|
||||
|
||||
### Added
|
||||
- **Plugins**:
|
||||
- JavaScript plugins load at startup from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
|
||||
- Plugins can react to playback and mined cards, add bindable actions, run programs, and show results in overlay panels or their own windows.
|
||||
- Example plugins cover AI line explanations (`ai-explain`), AnkiWeb sync (`anki-sync`), and show and episode tags on mined cards (`episode-tags`).
|
||||
- Plugin authors get a versioned API with standalone types.
|
||||
- **Subtitle Filters**: Remove text in parentheses, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
|
||||
- **Controller Combos**: Controller actions can now be combos, such as holding `L1` and pressing D-pad right. Click **Learn** in `Alt+C`, then hold one button and press another.
|
||||
- **Controller Actions**: New bindings cover previous, next, replay, and play-next subtitle, hold-to-show secondary subtitle, and previous or next Yomitan entry. All are unbound by default and can be assigned in `Alt+C`.
|
||||
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be used with a controller and show button hints while one is connected. Remap both layouts per controller in `Alt+C`.
|
||||
- **Controller Button Style**: Choose Xbox, Nintendo, or PlayStation button names per controller in `Alt+C`, or set a default with `controller.faceButtonLabels`.
|
||||
- **Controller Clash Warnings**: `Alt+C` now warns when two actions share the same input.
|
||||
- **Keyboard-Only Mode**:
|
||||
- Press `C` or use the new controller binding to move by character, reaching words split by the subtitle parser.
|
||||
- The new **Pause Video In Keyboard-Only Mode** setting pauses on entry and resumes on exit.
|
||||
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Reopening Jimaku after the video changes now refreshes the title and episode.
|
||||
|
||||
### Changed
|
||||
- **Stats Trends and Vocabulary**:
|
||||
- Trends adds a learning section, heatmap, title timeline, custom date ranges, and a page-wide title filter.
|
||||
- Vocabulary now has one searchable word table, known words by frequency band, a growth chart, and a full kanji view.
|
||||
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
|
||||
- Stats now explains when streamed words or lines can't be mined.
|
||||
- **Bundled Hachidori**: Updated to upstream 0.4.0, adding every pitch accent in popup entries, per-kanji headword furigana, and Yomitan-style definition tags.
|
||||
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Connected and disabled messages now follow your `ankiConnect.behavior.notificationType` setting.
|
||||
- **Dictionary Popup**: Turning keyboard-only mode off now also closes the popup.
|
||||
|
||||
### Fixed
|
||||
- **Controller Settings (`Alt+C`)**:
|
||||
- Remaps now apply right away, and Learn works on **(D-pad)** rows.
|
||||
- Clicking a row shows **Learn**, **Clear**, and **Reset** again.
|
||||
- The window previews your bindings until a press, then selects the pressed controller.
|
||||
- Button names are now correct on standard-layout controllers.
|
||||
- **Controller Input on macOS and Windows**: One press no longer runs its action twice. On macOS, buttons now work while mpv is in front.
|
||||
- **Dictionary Popup**: Keyboard and controller lookups now pause and resume playback reliably. Moving between words right after closing a popup no longer reopens it.
|
||||
- **Playback**: Reloading the current file no longer leaves playback paused. The "Overlay loading" message no longer sticks or turns into a false error when a playlist advances.
|
||||
- **Stats**:
|
||||
- The dashboard, Vocabulary tab, and sentence search load much faster on large databases, and Trends downloads far less data.
|
||||
- Trends now counts each finished episode once, and its Library totals match the selected range.
|
||||
- Word totals, readings, Hide Names, and rate charts are now accurate.
|
||||
- Date handling is now correct in time zones ahead of UTC.
|
||||
- **AniList**: Progress sync now adds a missing show to Watching, or Completed on the final episode, when the title matches exactly or is pinned. Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
|
||||
- **Anki Sentence Audio**: Generated sentence audio no longer overwrites Yomitan's word audio on default configs. If you relied on the old default, set `ankiConnect.fields.audio` to `ExpressionAudio`.
|
||||
- **AppImage**: Fixed "Service Crash" notifications on quit on Ubuntu 26.04 and newer.
|
||||
|
||||
### Docs
|
||||
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
|
||||
|
||||
## v0.21.1 (2026-10-26)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -104,7 +104,7 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Plugins</b></td>
|
||||
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. The bundled example explains the current line with Codex. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
|
||||
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. Bundled examples explain the current line with Codex, sync Anki to AnkiWeb, and tag mined cards by episode. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- The controller settings window (`Alt+C`) no longer edits a controller it cannot see. A newly opened window cannot see any controller until you press one of its buttons (browsers hide a pad from a new window until then); it used to show and edit the global defaults meanwhile, and its status could stay stuck on "No controllers detected". Now it shows the bindings of the controller the overlay is using (or the saved one), labelled as a preview, and Learn, Clear, Reset and the button style are locked until the press.
|
||||
- With several controllers connected, `Alt+C` now selects the one you just pressed, and Learn listens to that controller.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- AniList progress sync now adds a show to your Watching list (or Completed on the final episode) when it is not on your list yet, instead of failing with "AniList update not possible". This only happens when the file title exactly matches an AniList title or the entry is pinned with the character dictionary override; loose matches still require adding the show yourself.
|
||||
- Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- `ankiConnect.fields.audio` now defaults to `SentenceAudio` instead of `ExpressionAudio`, so generated sentence audio no longer overwrites Yomitan's word audio on default configs. `fields.wordAudio` still defaults to `ExpressionAudio`. If you relied on the old default, set `fields.audio` to `ExpressionAudio` explicitly.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: linux
|
||||
|
||||
- Fixed AppImage builds on Ubuntu 26.04+ (uutils coreutils) releasing their mount before Chromium child processes finished shutting down, which brought back "Service Crash" notifications on quit.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Controller remaps made in `Alt+C` now apply right away; before, the overlay kept the old bindings until SubMiner restarted.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- `Alt+C` now warns when two controller actions share an input, as the dialog layouts already did. Both rows show "⚠ Same as …", including a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- `Alt+C` has a **Button Style** choice (Xbox, Nintendo or PlayStation), saved per controller. PlayStation is new: buttons and the review and duplicate card shortcuts show ╳ ○ □ △. `controller.faceButtonLabels` accepts `playstation` and stays the default for controllers without their own style.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- Controller connected and controller-disabled messages are now regular notifications that follow `ankiConnect.behavior.notificationType` (overlay, system, both or none) instead of a separate pill in the corner of the overlay. Connected notices only appear when `controller.enabled` is on.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Clicking a row in `Alt+C` shows its **Learn**, **Clear** and **Reset** buttons again. The panel opened but was squeezed to nothing once the list was taller than the window, so **Clear** (set a binding to None) could not be reached.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- The duplicate card (field grouping) dialog has a controller layout: the D-pad picks the original or new card, `A` continues and confirms the merge, `B` cancels or goes back from the preview, `X` turns "Delete duplicate" on or off, `Y` switches Compact / Full, and the D-pad scrolls the preview. Each control shows its shortcut, and the layout can be changed per controller in the new **Duplicate card (field grouping)** group of `Alt+C`.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added a controller layout for the media timing review dialog covering every button, with L1 / R1 + D-pad combos for the clip start and end, and D-pad actions that repeat while held. Each button shows its controller shortcut while controller support is on, and the first press in a newly opened dialog already counts. The controller's usual actions pause while the dialog is open, including in the hidden subtitle overlay behind it.
|
||||
- Added `controller.faceButtonLabels` (`xbox` or `nintendo`) so fixed controller layouts and the `Alt+C` button names follow the letters printed on the pad. Use `nintendo` for controllers with A on the right and B at the bottom.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- The controller's **Toggle MPV Pause** button now works without keyboard-only mode, so you can pause while just watching.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- The media timing review controller layout can now be changed in the new **Review media timing** group of `Alt+C`. Learn records a single button or a combo (hold one button, press another), any button can be a hold button, changes are saved per controller, rows warn about clashing inputs, and **Reset all** restores the default layout. The review's button hints follow your bindings.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added a controller binding that shows the secondary subtitle only while a button is held (`controller.bindings.holdSecondarySub`). It is unbound by default; assign it with Learn in the `Alt+C` controller modal. It works with or without keyboard-only mode and does not change the saved secondary subtitle mode.
|
||||
- Added controller bindings to select the previous or next word entry in the open Yomitan popup (`controller.bindings.previousEntry` / `nextEntry`). Mine Card mines the selected entry. Both are unbound by default; assign them with Learn in the `Alt+C` controller modal.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- The `Alt+C` controller screen now shows the right button names for controllers that report the W3C standard layout (most Xbox, PlayStation, Switch and 8BitDo pads). Previously Select and Start showed as `L3` and `R3`, and the triggers as `Back` and `Start`. Bindings themselves were unaffected.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- New controller actions for subtitle lines, in the **Subtitles** group of `Alt+C`: **Previous Subtitle**, **Next Subtitle**, **Replay Subtitle** and **Play Next Subtitle**, matching `Shift+H`, `Shift+L`, `Ctrl+Shift+H` and `Ctrl+Shift+L`. They are unbound by default and work with or without keyboard-only mode.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: jimaku
|
||||
|
||||
- Added `jimaku.autoOpenWhenPrimarySubtitleMissing` (off by default). When a local video loads without a Japanese (primary-language) subtitle track, SubMiner pauses playback and opens the Jimaku modal; playback resumes when the modal closes.
|
||||
- Opening Jimaku while it is already open now reloads the title and episode if the video changed, instead of keeping the previous episode's search.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- New **Pause Video In Keyboard-Only Mode** setting (`subtitleStyle.autoPauseVideoOnKeyboardMode`, off by default): turning keyboard-only mode on, from the keyboard or a controller, pauses the video, and turning it off resumes it. A video that was already paused, or that you played or paused yourself in the meantime, is left as it is.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- Turning keyboard-only mode off, from the keyboard or a controller, now also closes the dictionary popup.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- On macOS, controller buttons now work while mpv is in front: a press brings the overlay forward and runs that button's action, instead of doing nothing until you click the overlay.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- On macOS and Windows, one controller press no longer runs its action twice, which cancelled out toggles like mpv pause.
|
||||
- On macOS, the press that brings the overlay forward from mpv no longer runs its action a second time.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: mpv-plugin
|
||||
|
||||
- Fixed the "Overlay loading" message staying on screen when mpv moves to the next file in a playlist, then turning into "Overlay did not become ready; check SubMiner logs" after 30 seconds even though the overlay was working. The overlay window is reused between files, so the plugin no longer restarts the loading message once the overlay has reported ready.
|
||||
@@ -1,10 +0,0 @@
|
||||
type: added
|
||||
area: plugins
|
||||
|
||||
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings or run with `--plugin-action`, run built-in actions, react to mined Anki cards and call AnkiConnect, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window.
|
||||
- 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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed controller and keyboard lookups sometimes pausing playback without a popup, leaving playback paused after the popup closed until the mouse moved, or not pausing when the popup opened.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Moving between words with the keyboard or a controller right after closing the dictionary popup no longer reopens it. A popup that closed under the mouse pointer was still treated as open.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: playback
|
||||
|
||||
- Reloading the same file in mpv (`loadfile <current path> replace`) could intermittently leave playback paused forever. SubMiner now treats that reload like any other newly loaded file, so it resets the subtitle state and resumes once the file is ready.
|
||||
@@ -1,8 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- The stats dashboard loads much faster on large databases. Overview, media and library detail, and trends no longer re-count every recorded word in the whole database on each request, so these views only cost as much as the sessions they show. On a ~1 GB database the overview went from ~1.4 s to ~0.14 s, media detail from ~1.5 s to ~0.08 s, the 30-day trends from ~1.5 s to ~0.06 s, and all-time trends from ~1.6 s to ~0.2 s.
|
||||
- The Vocabulary tab's summary and word list load several times faster (summary ~2 s to ~0.2 s, word list ~0.6 s to ~0.2 s).
|
||||
- The Trends tab downloads far less data: the per-title library charts now arrive as daily values and are totalled in the dashboard, so the all-time response shrank from ~3.9 MB to ~0.2 MB.
|
||||
- Sentence search with headword matching returns several times faster (~0.3 s to ~0.08 s per query).
|
||||
- Vocabulary totals and new-word charts stay correct after running vocabulary cleanup. Words it repaired could be counted or hidden by mistake; the first launch after updating corrects them in the background.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: subtitles
|
||||
|
||||
- Added subtitle filters (`subtitleFilter`) that remove unwanted text from primary subtitles before it reaches the overlay, sidebar, mined sentences and stats. Built-in presets cover text in parentheses (speaker names, readings, notes; whole-line inner thoughts are kept), square-bracket sound descriptions, speaker names before a colon, and music notes. Custom regex rules with a replacement can be added after the presets. Lines left with no words are hidden.
|
||||
- Configure them in the settings window under Behavior → Subtitle Filters; changes apply live to the current subtitle file.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: overlay
|
||||
|
||||
- Explain how to collect and interpret Windows overlay attachment and readiness warnings without enabling debug logging.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Windows overlay attachment now identifies mpv through its IPC process ID, removing the dependency on PowerShell command-line lookups and allowing the IPC socket to be configured in `mpv.conf`.
|
||||
- Revalidate cached Windows mpv process IDs and expire them after sustained IPC failures so PID reuse cannot keep the overlay attached to an unrelated window.
|
||||
- Diagnose stalled Windows overlays at the default warn log level, including tracking/PID state, renderer readiness, opacity, native binding errors, and recovery, with repeated warnings rate limited.
|
||||
+19
-21
@@ -128,59 +128,62 @@
|
||||
"toggleLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"closeLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"toggleKeyboardOnlyMode": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"mineCard": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"quitMpv": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"previousAudio": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"nextAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"playCurrentAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"toggleMpvPause": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. 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.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"holdSecondarySub": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that shows the secondary subtitle only while held. 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.
|
||||
}, // Controller binding descriptor that shows the secondary subtitle only while held. 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"previousEntry": {
|
||||
"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 previous 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.
|
||||
}, // Controller binding descriptor that selects the previous 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"nextSubtitle": {
|
||||
"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 next subtitle line, like Shift+L. 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.
|
||||
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"replaySubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+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.
|
||||
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"playNextSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. 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.
|
||||
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"leftStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||
@@ -191,11 +194,6 @@
|
||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickVertical": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||
|
||||
@@ -162,7 +162,7 @@ When you cancel, you can go back to editing, keep the original timing, create th
|
||||
|
||||
#### With a controller
|
||||
|
||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
|
||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut once SubMiner has seen a controller, which takes one button press after connecting it. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
|
||||
|
||||
`A`, `B`, `X` and `Y` mean the letters printed on your controller. Pick your controller's **Button Style** in `Alt+C`: choose Nintendo if A is on the right and B at the bottom (Switch-style and many 8BitDo pads). On PlayStation, `A` is ╳, `B` ○, `X` □ and `Y` △, and the shortcuts on screen show those symbols.
|
||||
|
||||
@@ -267,7 +267,7 @@ The manual dialog cancels itself after 90 seconds. Identical entries are not ded
|
||||
|
||||
### With a controller {#field-grouping-with-a-controller}
|
||||
|
||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
|
||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut once SubMiner has seen a controller. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
|
||||
|
||||
| Controller | Choosing the card | Merge preview |
|
||||
| ------------------ | --------------------------------- | ----------------------- |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+58
-3
@@ -1,6 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## v0.21.1 (2026-10-26)
|
||||
## v0.22.0 (2026-11-06)
|
||||
|
||||
**Added**
|
||||
- **Plugins**:
|
||||
- JavaScript plugins load at startup from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
|
||||
- Plugins can react to playback and mined cards, add bindable actions, run programs, and show results in overlay panels or their own windows.
|
||||
- Example plugins cover AI line explanations (`ai-explain`), AnkiWeb sync (`anki-sync`), and show and episode tags on mined cards (`episode-tags`).
|
||||
- Plugin authors get a versioned API with standalone types.
|
||||
- **Subtitle Filters**: Remove text in parentheses, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
|
||||
- **Controller Combos**: Controller actions can now be combos, such as holding `L1` and pressing D-pad right. Click **Learn** in `Alt+C`, then hold one button and press another.
|
||||
- **Controller Actions**: New bindings cover previous, next, replay, and play-next subtitle, hold-to-show secondary subtitle, and previous or next Yomitan entry. All are unbound by default and can be assigned in `Alt+C`.
|
||||
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be used with a controller and show button hints while one is connected. Remap both layouts per controller in `Alt+C`.
|
||||
- **Controller Button Style**: Choose Xbox, Nintendo, or PlayStation button names per controller in `Alt+C`, or set a default with `controller.faceButtonLabels`.
|
||||
- **Controller Clash Warnings**: `Alt+C` now warns when two actions share the same input.
|
||||
- **Keyboard-Only Mode**:
|
||||
- Press `C` or use the new controller binding to move by character, reaching words split by the subtitle parser.
|
||||
- The new **Pause Video In Keyboard-Only Mode** setting pauses on entry and resumes on exit.
|
||||
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Reopening Jimaku after the video changes now refreshes the title and episode.
|
||||
|
||||
**Changed**
|
||||
- **Stats Trends and Vocabulary**:
|
||||
- Trends adds a learning section, heatmap, title timeline, custom date ranges, and a page-wide title filter.
|
||||
- Vocabulary now has one searchable word table, known words by frequency band, a growth chart, and a full kanji view.
|
||||
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
|
||||
- Stats now explains when streamed words or lines can't be mined.
|
||||
- **Bundled Hachidori**: Updated to upstream 0.4.0, adding every pitch accent in popup entries, per-kanji headword furigana, and Yomitan-style definition tags.
|
||||
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Connected and disabled messages now follow your `ankiConnect.behavior.notificationType` setting.
|
||||
- **Dictionary Popup**: Turning keyboard-only mode off now also closes the popup.
|
||||
|
||||
**Fixed**
|
||||
- **Controller Settings (`Alt+C`)**:
|
||||
- Remaps now apply right away, and Learn works on **(D-pad)** rows.
|
||||
- Clicking a row shows **Learn**, **Clear**, and **Reset** again.
|
||||
- The window previews your bindings until a press, then selects the pressed controller.
|
||||
- Button names are now correct on standard-layout controllers.
|
||||
- **Controller Input on macOS and Windows**: One press no longer runs its action twice. On macOS, buttons now work while mpv is in front.
|
||||
- **Dictionary Popup**: Keyboard and controller lookups now pause and resume playback reliably. Moving between words right after closing a popup no longer reopens it.
|
||||
- **Playback**: Reloading the current file no longer leaves playback paused. The "Overlay loading" message no longer sticks or turns into a false error when a playlist advances.
|
||||
- **Stats**:
|
||||
- The dashboard, Vocabulary tab, and sentence search load much faster on large databases, and Trends downloads far less data.
|
||||
- Trends now counts each finished episode once, and its Library totals match the selected range.
|
||||
- Word totals, readings, Hide Names, and rate charts are now accurate.
|
||||
- Date handling is now correct in time zones ahead of UTC.
|
||||
- **AniList**: Progress sync now adds a missing show to Watching, or Completed on the final episode, when the title matches exactly or is pinned. Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
|
||||
- **Anki Sentence Audio**: Generated sentence audio no longer overwrites Yomitan's word audio on default configs. If you relied on the old default, set `ankiConnect.fields.audio` to `ExpressionAudio`.
|
||||
- **AppImage**: Fixed "Service Crash" notifications on quit on Ubuntu 26.04 and newer.
|
||||
|
||||
**Docs**
|
||||
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
|
||||
|
||||
## Previous Versions
|
||||
|
||||
<details>
|
||||
<summary>v0.21.x</summary>
|
||||
|
||||
<h2>v0.21.1 (2026-10-26)</h2>
|
||||
|
||||
**Changed**
|
||||
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||
@@ -23,7 +78,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## v0.21.0 (2026-10-24)
|
||||
<h2>v0.21.0 (2026-10-24)</h2>
|
||||
|
||||
**Added**
|
||||
- **Hachidori Dictionary Backend**:
|
||||
@@ -68,7 +123,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.20.x</summary>
|
||||
|
||||
@@ -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 |
|
||||
| ---------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -329,7 +329,11 @@ Gamepad input for the overlay, through the browser Gamepad API. Most actions onl
|
||||
| `controller.repeatDelayMs` | `320` | Delay before a held button repeats |
|
||||
| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons |
|
||||
|
||||
Bindings, including the media timing review and duplicate card dialog layouts, are set with `Alt+C` learn mode, which saves them per controller.
|
||||
Bindings, including the media timing review and duplicate card dialog layouts, are set with `Alt+C` learn mode, which saves them per controller. Learn can also record a [combo](/usage#controller-support), saved on the binding as an optional `hold` input in the same form:
|
||||
|
||||
```jsonc
|
||||
"nextAudio": { "kind": "button", "buttonIndex": 15, "hold": { "kind": "button", "buttonIndex": 4 } }
|
||||
```
|
||||
|
||||
## Anki integration
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -128,59 +128,62 @@
|
||||
"toggleLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"closeLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"toggleKeyboardOnlyMode": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"mineCard": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"quitMpv": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"previousAudio": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"nextAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"playCurrentAudio": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"toggleMpvPause": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. 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.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"holdSecondarySub": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that shows the secondary subtitle only while held. 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.
|
||||
}, // Controller binding descriptor that shows the secondary subtitle only while held. 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"previousEntry": {
|
||||
"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 previous 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.
|
||||
}, // Controller binding descriptor that selects the previous 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"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.
|
||||
}, // 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"nextSubtitle": {
|
||||
"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 next subtitle line, like Shift+L. 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.
|
||||
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"replaySubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+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.
|
||||
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"playNextSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. 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.
|
||||
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. 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. Alt+C can also learn a combo, saved as an optional "hold" input.
|
||||
"leftStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||
@@ -191,11 +194,6 @@
|
||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
"rightStickVertical": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -51,6 +51,17 @@ When `connect ENOENT /tmp/subminer-socket` follows a cast in the app log, SubMin
|
||||
- Confirm SubMiner is running (`SubMiner.AppImage --start`, or check for the process).
|
||||
- Linux: Hyprland and Sway work natively. Any other compositor needs mpv and SubMiner under X11 or Xwayland, with `xdotool`, `xprop`, and `xwininfo` installed. See [KDE Plasma and other Wayland compositors](#kde-plasma-and-other-wayland-compositors).
|
||||
- macOS: grant Accessibility permission. See [macOS](#macos).
|
||||
- Windows: leave the affected mpv window open for at least five seconds, then collect the daily
|
||||
app log from the directory above. No debug setting is required: a stalled overlay logs
|
||||
`Windows overlay is not visible after startup or tracking loss` with its PID, native window
|
||||
handle, tracking state, geometry, opacity, and renderer readiness. The `reason` distinguishes
|
||||
a missing mpv window, an unready renderer, a hidden window, and a transparent window.
|
||||
This warning waits until mpv has answered on its IPC socket, so a requested overlay with no
|
||||
mpv running stays quiet. PID lookup failures and native ownership or transparency errors also
|
||||
appear at `warn`, and a PID lookup failure is the only trace if mpv never answers.
|
||||
Repeated visibility/native-operation warnings are limited to one every 30 seconds; recovery
|
||||
is recorded too. These logs diagnose attachment and readiness, but cannot prove that pixels
|
||||
were drawn correctly by the graphics driver.
|
||||
|
||||
## Overlay is on the wrong monitor or position
|
||||
|
||||
|
||||
+6
-1
@@ -164,7 +164,9 @@ You can drive the overlay with a gamepad.
|
||||
3. Press `Y` on the controller to turn on keyboard-only mode. Most controller actions only work in this mode.
|
||||
4. Move between words with the left stick, press `A` to look one up, and `X` to mine it.
|
||||
|
||||
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want. Set **Button Style** to Xbox, Nintendo or PlayStation so buttons are named as printed on your controller; it is saved per controller. Changes apply right away. When two actions share an input, both fire, so their rows show a ⚠ warning naming the other action; this includes a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action. The window only sees a controller after you press one of its buttons; until then it shows the bindings of the controller in use as a preview, and editing stays locked. With several controllers connected, it selects the one you pressed last. `Alt+Shift+C` shows raw input values for unusual pads.
|
||||
Press `Alt+C` to choose a controller and remap buttons. Click an action's **Learn** button, then press the button you want, or hold one button and press another to make a combo such as `L1` + D-pad right. Set **Button Style** to Xbox, Nintendo or PlayStation so buttons are named as printed on your controller; it is saved per controller. Changes apply right away. When two actions share an input, both fire, so their rows show a ⚠ warning naming the other action; this includes a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action. The window only sees a controller after you press one of its buttons; until then it shows the bindings of the controller in use as a preview, and editing stays locked. With several controllers connected, it selects the one you pressed last. `Alt+Shift+C` shows raw input values for unusual pads.
|
||||
|
||||
A combo fires each time you press its second button while the first is down, so holding `L1` and pressing right twice runs it twice. While a combo's hold button is down, other buttons only run combos that use it, and its press no longer drives a **(D-pad)** or stick row. A hold button can keep its own action: that action then runs when you let go, unless you pressed another bound button during the hold. The exception is **Show Secondary Subtitle (Hold)**, which can't wait for release, so its row warns if it shares a hold button.
|
||||
|
||||
| Button | Action |
|
||||
| --------------------- | -------------------------------------- |
|
||||
@@ -184,6 +186,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 +194,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,37 @@ 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`
|
||||
Windows window tracking: `src/window-trackers/windows-tracker.ts` queries the target mpv IPC
|
||||
socket for its PID through `mpv-process-id.ts`, then `win32.ts` matches native windows by PID.
|
||||
Visible matches are revalidated every 10 seconds; a PID expires after 30 seconds without a
|
||||
successful IPC response. Missing windows retry at one-second intervals. Custom executable
|
||||
names remain supported because matching uses the IPC PID.
|
||||
`src/core/services/overlay-visibility-diagnostics.ts` watches requested Windows overlays:
|
||||
after five seconds hidden or transparent it logs a warn-level snapshot, repeating at most
|
||||
every 30 seconds. Intentional hiding, minimization and having no mpv answer on the socket
|
||||
yet suppress these warnings; an identified mpv whose window never matches does not. PID lookup
|
||||
failures and native ownership/transparency failures also log at warn, including recovery.
|
||||
- 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 +73,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`.
|
||||
@@ -114,6 +114,45 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
// The other tests read the renderer over CDP, which works even if the window never appears.
|
||||
// Windows keeps the overlay hidden until the tracker identifies mpv's window.
|
||||
// Bun's node:test ignores the `skip` option, so this picks `test.skip` instead.
|
||||
const testOnWindows = process.platform === 'win32' ? test : test.skip;
|
||||
testOnWindows('visible overlay window appears over mpv', { timeout: TEST_TIMEOUT_MS }, async () => {
|
||||
const { session } = await useSession();
|
||||
await session.runAppCommand('--show-visible-overlay');
|
||||
const mpvPid = await session.mpv.command('get_property', 'pid');
|
||||
assert.equal(typeof mpvPid, 'number');
|
||||
const main = await session.mainProcess();
|
||||
await waitUntil(
|
||||
() =>
|
||||
main.evaluate<boolean>(
|
||||
`(() => {
|
||||
const electron = process.mainModule.require('electron');
|
||||
const overlay = electron.BrowserWindow.getAllWindows().find(
|
||||
(w) => w.webContents.getURL().includes(${JSON.stringify(OVERLAY_PAGE)}));
|
||||
if (!overlay?.isVisible() || overlay.getOpacity() !== 1 ||
|
||||
overlay.__subminerOverlayContentReady !== true) return false;
|
||||
// The inspector's mainModule can be Electron's bootstrap module. Resolve app
|
||||
// dependencies from the repository instead of that module's search paths.
|
||||
const appRequire = process.mainModule.require('node:module').createRequire(
|
||||
process.mainModule.require('node:path').resolve('package.json'));
|
||||
const koffi = appRequire('koffi');
|
||||
const getWindow = koffi.load('user32.dll').func(
|
||||
'intptr __stdcall GetWindow(intptr hwnd, uint uCmd)');
|
||||
const handle = overlay.getNativeWindowHandle();
|
||||
const hwnd = handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
|
||||
const owner = getWindow(hwnd, 4); // GW_OWNER
|
||||
const win32 = appRequire(
|
||||
process.mainModule.require('node:path').resolve('dist/window-trackers/win32.js'));
|
||||
return win32.findMpvWindows(${JSON.stringify(mpvPid)}).matches.some(
|
||||
(match) => match.hwnd === owner);
|
||||
})()`,
|
||||
),
|
||||
{ description: 'the visible overlay to be fully revealed and owned by the target mpv window' },
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'seeking to another cue replaces the line and deinflects verbs to their headword',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
@@ -331,6 +370,35 @@ testOutsideLinux(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'controller combos survive the real save path and can be rebound to one button',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { overlay } = await useSession();
|
||||
const L1 = { kind: 'button', buttonIndex: 4 };
|
||||
const DPAD_LEFT = { kind: 'button', buttonIndex: 14 };
|
||||
const DPAD_RIGHT = { kind: 'button', buttonIndex: 15 };
|
||||
const save = (bindings: Record<string, unknown>) =>
|
||||
overlay.evaluate<Record<string, unknown>>(
|
||||
`(async () => {
|
||||
await window.electronAPI.saveControllerConfig(${JSON.stringify({ bindings })});
|
||||
return (await window.electronAPI.getControllerConfig()).bindings;
|
||||
})()`,
|
||||
);
|
||||
|
||||
await save({ previousEntry: { ...DPAD_LEFT, hold: L1 } });
|
||||
const bothCombos = await save({ nextEntry: { ...DPAD_RIGHT, hold: L1 } });
|
||||
assert.deepEqual(bothCombos.previousEntry, { ...DPAD_LEFT, hold: L1 });
|
||||
assert.deepEqual(bothCombos.nextEntry, { ...DPAD_RIGHT, hold: L1 });
|
||||
|
||||
const rebound = await save({ previousEntry: DPAD_LEFT, nextEntry: { kind: 'none' } });
|
||||
assert.deepEqual(rebound.previousEntry, DPAD_LEFT);
|
||||
assert.deepEqual(rebound.nextEntry, { kind: 'none' });
|
||||
|
||||
await save({ previousEntry: { kind: 'none' } });
|
||||
},
|
||||
);
|
||||
|
||||
// Runs last: it leaves the lookup popup open over the overlay.
|
||||
test(
|
||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||
|
||||
@@ -543,8 +543,10 @@ test('launchAppBackgroundDetached starts background child directly', async () =>
|
||||
|
||||
launchAppBackgroundDetached(appPath, 'info');
|
||||
|
||||
// The shell creates each file before writing to it, so wait for content, not existence.
|
||||
const hasContent = (file: string) => fs.existsSync(file) && fs.statSync(file).size > 0;
|
||||
const deadline = Date.now() + 1000;
|
||||
while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) {
|
||||
while ((!hasContent(argsPath) || !hasContent(envPath)) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.21.1",
|
||||
"version": "0.22.1-beta.1",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -287,8 +287,5 @@
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,21 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-version: 0.20.1-beta.2; since: v0.20.1-beta.1 -->
|
||||
|
||||
## Changes since v0.20.1-beta.1
|
||||
|
||||
- Upgraded the desktop runtime to Electron 43.7.7.
|
||||
- SubMiner now refuses to start on an unsupported Electron runtime, or after an Electron major or minor downgrade, so your Yomitan profile data stays safe; patch downgrades are still allowed.
|
||||
- Automatic character-dictionary updates now stop if a Yomitan profile that had dictionaries suddenly reports none.
|
||||
- Development launches now use a separate `SubMiner-dev` profile unless you explicitly ask for the production profile.
|
||||
- Sentence mining (single and multi-line) no longer counts each card twice in session and lifetime stats.
|
||||
- Deleting sessions now updates word and kanji "first seen" and "last seen" dates correctly, and retention pruning keeps historical discovery dates and counts.
|
||||
- Words hidden by the vocabulary filter are no longer counted in the new-words charts, so the charts now match the vocabulary summary.
|
||||
- A one-time background repair on next launch fixes stale "last seen" dates and existing chart mismatches.
|
||||
- Overview totals, charts, the activity calendar, and the vocabulary summary now refresh after you delete sessions, and a failed refresh keeps the dashboard visible with a retry option.
|
||||
- Subtitle generation now works during Windows YouTube playback and loads the generated subtitles into the playing video.
|
||||
- On Windows, moving on to a video queued from the YouTube browser window no longer drops the rest of mpv's queue.
|
||||
- The stats docs now cover the automatic Overview refresh after deletions and how discovery dates are kept through retention pruning.
|
||||
<!-- prerelease-version: 0.22.1-beta.1 -->
|
||||
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **YouTube Browser Window**:
|
||||
- Open it with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in across restarts.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift-click, Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup when they start.
|
||||
- Closing the window quits SubMiner if you started it with `subminer youtube`. If a video is still playing, SubMiner waits until mpv closes.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions.
|
||||
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. You can close the modal to keep watching, or cancel.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including on Windows.
|
||||
- **Mouse and Scroll Wheel Bindings**:
|
||||
- You can now use scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`) in `keybindings` and record them in the settings key editor.
|
||||
- mpv's mouse button and wheel bindings now work while the cursor is over the overlay. This matters most on Hyprland, where these bindings did nothing before.
|
||||
- When a binding conflicts, SubMiner's own bindings and right-click pause take priority over mpv's.
|
||||
- **Session Help Commands**: During playback, press `Enter` or double-click a row in session help to run that command. Hovering over a row makes it the target for `Enter`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release Assets**: Releases no longer include package-size JSON reports.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Jellyfin Subtitles**:
|
||||
- The Japanese track is selected, and annotations start, as soon as it downloads. SubMiner no longer waits for the other tracks.
|
||||
- Japanese and English tracks are now picked automatically on episodes with image-based subtitles (PGS, DVD, DVB).
|
||||
- If one track fails to download, the other tracks are still selected.
|
||||
- **Jellyfin Audio Track**: On direct-play streams, timing review previews and mined card audio now use the audio track you're playing.
|
||||
- **Media Timing Review**:
|
||||
- Trim handles are now thin lines centered on the clip edges.
|
||||
- Original subtitle boundaries are now plain orange bars with no labels.
|
||||
- The preview cursor no longer stutters on macOS with Bluetooth headphones such as AirPods.
|
||||
- **Hyprland Fullscreen**: The overlay stays visible when mpv enters or leaves fullscreen. With `mpv.backend: x11`, subtitles and the sidebar stay clickable after going fullscreen.
|
||||
- **mpv Key Bindings in the Overlay**: mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus, even when the overlay loaded before mpv connected.
|
||||
- **YouTube Japanese Subtitles**: Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429". If YouTube refuses a direct download, SubMiner retries with yt-dlp.
|
||||
- **Stats Accuracy**:
|
||||
- Sentence mining no longer counts each card twice in session and lifetime stats.
|
||||
- Overview totals, charts, the activity calendar, and the vocabulary summary now update after you delete sessions.
|
||||
- Word and kanji dates stay correct after deleting sessions. New-word charts now match the vocabulary summary.
|
||||
- **Dictionary Safety**: Automatic character dictionary changes now stop if a Yomitan profile that had dictionaries suddenly reports none. SubMiner also refuses to load Yomitan data after a downgrade to an older Electron version.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window in sandboxes that mount the image themselves, such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes it with a "write EPIPE" error.
|
||||
- **Security Updates**: Electron is updated to 43.7.7 and undici to 7.29.1, which fixes known security advisories.
|
||||
- **Windows Overlay**:
|
||||
- The overlay now finds mpv more reliably, without relying on PowerShell lookups.
|
||||
- You can now configure the IPC socket in `mpv.conf`.
|
||||
- The overlay no longer stays attached to an unrelated window after mpv's process ID is reused.
|
||||
- Stalled overlays now log diagnostic warnings without debug logging enabled.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Docs Site Rewrite**: Pages are shorter and easier to scan. The configuration reference now has a short explanation and a table of keys and defaults for each config block. Pages that no longer matched current behavior are fixed.
|
||||
- **Arch Linux Install**: The MeCab install command now uses the AUR package `mecab-git`. The README now matches the installation guide.
|
||||
- **Stats**: The docs now cover the Overview refreshing after you delete sessions, and how first-seen dates are kept when old sessions are cleaned up.
|
||||
- **Windows Overlay Troubleshooting**: Added a guide to collecting and interpreting overlay attachment and readiness warnings without debug logging.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
|
||||
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
||||
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
||||
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
||||
- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274
|
||||
- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275
|
||||
- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276
|
||||
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
||||
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
||||
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
||||
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
|
||||
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
|
||||
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
|
||||
- fix(overlay): identify Windows mpv windows through IPC PID by @ksyasuda in #330
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,19 +1,73 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **Plugins**:
|
||||
- JavaScript plugins load from `<config dir>/plugins/<id>/` and are enabled under `plugins` in the config.
|
||||
- Plugins can react to mpv events and mined cards, call AnkiConnect, run programs, and show results in an overlay panel or window.
|
||||
- Three examples are included: `ai-explain` for line explanations, `anki-sync` for AnkiWeb syncing, and `episode-tags` for tagging cards by show and episode.
|
||||
- **Subtitle Filters**: Remove parenthesized text, sound descriptions, speaker names, music notes, or custom regex matches from primary subtitles. Configure them in Settings under Behavior → Subtitle Filters.
|
||||
- **Controller Dialog Layouts**: The media timing review and duplicate card dialogs can now be fully operated by controller and show button hints. Remap both layouts per controller in `Alt+C`.
|
||||
- **Controller Customization**:
|
||||
- `Alt+C` actions can be combos: click **Learn**, hold one button, and press another.
|
||||
- New unbound actions cover subtitle navigation, holding to show the secondary subtitle, Yomitan entry selection, and character navigation.
|
||||
- **Button Style** offers Xbox, Nintendo, or PlayStation names and symbols, saved per controller.
|
||||
- `Alt+C` now warns when two actions share an input.
|
||||
- **Keyboard-Only Mode**: Press `C` to move and look up by character, reaching words split by subtitle tokenization. **Pause Video In Keyboard-Only Mode** can also pause when the mode turns on and resume when it turns off.
|
||||
- **Jimaku Auto-Open**: Set `jimaku.autoOpenWhenPrimarySubtitleMissing` to pause and open Jimaku when a local video has no Japanese subtitle track. Playback resumes when the modal closes.
|
||||
|
||||
### Changed
|
||||
- **Stats Session View**: The expanded session view in the stats dashboard has been redesigned. It shows stat tiles for pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words with lookup and card-mined markers.
|
||||
- **Stats Trends**: Rebuilt with period comparisons, Learning charts, a heatmap, a title timeline, and a single running-total chart. Week grouping, custom ranges, a page-wide title filter, and click-through to sessions and library pages are also new.
|
||||
- **Stats Vocabulary**:
|
||||
- Rebuilt around one searchable, sortable word table with Worth mining, All words, Across titles, and Newest views.
|
||||
- Added known words by frequency band, a vocabulary growth chart, and a full kanji view.
|
||||
- New word counts now count dictionary words, so a word's conjugations count once and totals are lower than before.
|
||||
- Words and lines from streams now say they can't be mined, instead of showing Unavailable buttons.
|
||||
- **Controller Behavior**: **Toggle MPV Pause** now works outside keyboard-only mode. Controller connected and disabled messages now follow your regular notification setting instead of using a separate overlay pill.
|
||||
- **Keyboard-Only Mode Exit**: Turning the mode off now also closes the dictionary popup.
|
||||
- **Hachidori**: The bundled dictionary reader is updated to 0.4.0, adding every pitch accent in each popup entry, pitch drawn on the headword reading, per-kanji furigana, and Yomitan-style definition tags.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle Delay**: Retiming subtitles in mpv (z/x or sub-step) now carries through SubMiner. Mined card audio, images, and timestamps match the retimed line, and so do the subtitle sidebar, subtitle navigation, and replay.
|
||||
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||
- **Controller Settings (`Alt+C`)**:
|
||||
- Remaps now apply right away, without restarting SubMiner.
|
||||
- Editing stays locked until you press a button, so the window can't edit a controller it can't see.
|
||||
- With several controllers connected, it selects and listens to the one you just pressed.
|
||||
- **Learn**, **Clear**, and **Reset** are reachable again, D-pad **Learn** no longer errors, and button names are correct on standard-layout pads.
|
||||
- **Controller Input**: One press no longer runs its action twice on macOS and Windows. On macOS, buttons now work while mpv is in front.
|
||||
- **Dictionary Popup**: Controller and keyboard lookups pause and resume playback reliably, and moving between words right after closing the popup no longer reopens it.
|
||||
- **Anki Sentence Audio**: `ankiConnect.fields.audio` now defaults to `SentenceAudio`, so generated audio no longer overwrites Yomitan's word audio. If you relied on the old default, set it to `ExpressionAudio`.
|
||||
- **AniList**: Progress sync now adds an exactly matched or pinned show to your Watching or Completed list when it isn't on your list yet. Completed, Paused, Dropped, and Rewatching entries are never changed.
|
||||
- **Stats Dashboard**:
|
||||
- Overview, media, library, trends, vocabulary, and sentence search load several times faster on large databases.
|
||||
- Trends totals, episode counts, rate charts, axis labels, and date handling in time zones ahead of UTC are corrected.
|
||||
- Word lists now total every form of a word and show the dictionary form's reading.
|
||||
- Names no longer slip past Hide Names, and totals stay correct after vocabulary cleanup.
|
||||
- **Playback**: Reloading the current file in mpv no longer leaves playback paused, and the "Overlay loading" message no longer gets stuck when moving to the next playlist file.
|
||||
- **AppImage**: "Service Crash" notifications on quit no longer appear on Ubuntu 26.04 and newer.
|
||||
|
||||
### Docs
|
||||
- **README**: Refreshed the anime screenshots using the current build.
|
||||
- **Keyboard and Controller Reference**: Keyboard shortcuts now list the keyboard-only `C` key, and the configuration reference points to Controller support for default button actions.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300
|
||||
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302
|
||||
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304
|
||||
- feat(plugins): add JavaScript plugin system with overlay panel by @ksyasuda in #303
|
||||
- fix(anilist): add missing list entry on confident match by @dandascan in #305
|
||||
- Controller: hold to show the secondary subtitle, dictionary entry navigation, standard button names by @YashaNoos in #312
|
||||
- Controller: layouts for the media timing review and duplicate card dialogs by @YashaNoos in #313
|
||||
- Controller: subtitle line actions, Button Style, keyboard-only mode pause, Alt+C fixes by @YashaNoos in #316
|
||||
- fix(plugin): keep loading OSD off on warm playlist advance by @ksyasuda in #317
|
||||
- feat(subtitles): add regex subtitle filters with built-in presets by @ksyasuda in #318
|
||||
- feat(jimaku): auto-open modal when a video has no Japanese subtitle by @dandascan in #319
|
||||
- fix(controller): pick up macOS presses while mpv is in front by @ksyasuda in #321
|
||||
- perf(stats): speed up dashboard queries on large databases by @ksyasuda in #322
|
||||
- fix(mpv): resume playback after a same-file reload that skips the path clear by @ksyasuda in #323
|
||||
- fix(controller): keep popup pause and press handling reliable by @ksyasuda in #324
|
||||
- feat(stats): overhaul the Trends tab by @ksyasuda in #325
|
||||
- Controller: character navigation in keyboard-only mode, plain Xbox face names by @YashaNoos in #326
|
||||
- feat(controller): add hold + press combos to main bindings by @ksyasuda in #328
|
||||
|
||||
## New Contributors
|
||||
|
||||
- @dandascan made their first contribution in #305
|
||||
- @YashaNoos made their first contribution in #312
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -8,6 +8,9 @@ const http = require('node:http');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js'));
|
||||
const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js'));
|
||||
const { HACHIDORI_POPUP_OPEN_SELECTOR } = require(
|
||||
path.join(root, 'dist/renderer/yomitan-popup.js'),
|
||||
);
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-'));
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
@@ -43,6 +46,58 @@ function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') {
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
// The renderer's popup auto-pause and key routing read this marker because the
|
||||
// reader's shadow root is closed. Drives the real reader with real input, since
|
||||
// stubbed DOM tests cannot notice an upstream merge that changes what it exposes.
|
||||
async function assertPopupOpenMarkerFollowsPane(targetSession) {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
response.end(
|
||||
'<!doctype html><p id="word" style="font-size:48px;margin:300px 0 0 100px">食べた</p>',
|
||||
);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const page = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 600,
|
||||
show: true,
|
||||
webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false },
|
||||
});
|
||||
try {
|
||||
await page.loadURL(`http://127.0.0.1:${server.address().port}/`);
|
||||
const { webContents } = page;
|
||||
const word = await webContents.executeJavaScript(
|
||||
`(() => { const { x, y, height } = document.getElementById('word').getBoundingClientRect(); return { x, y, height }; })()`,
|
||||
);
|
||||
const repeatUntilMarker = async (expected, act) => {
|
||||
const query = `document.querySelector(${JSON.stringify(HACHIDORI_POPUP_OPEN_SELECTOR)}) !== null`;
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
// Content scripts load asynchronously, so keep acting until the reader reacts.
|
||||
act(attempt);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if ((await webContents.executeJavaScript(query)) === expected) return;
|
||||
}
|
||||
assert.fail(`Hachidori popup-open marker never became ${expected}`);
|
||||
};
|
||||
page.focus();
|
||||
webContents.focus();
|
||||
await repeatUntilMarker(true, (attempt) =>
|
||||
webContents.sendInputEvent({
|
||||
type: 'mouseMove',
|
||||
x: Math.round(word.x + 24) + (attempt % 2),
|
||||
y: Math.round(word.y + word.height / 2),
|
||||
}),
|
||||
);
|
||||
await repeatUntilMarker(false, () => {
|
||||
webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
|
||||
});
|
||||
} finally {
|
||||
page.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
@@ -211,6 +266,7 @@ app
|
||||
assert.deepEqual(unmatched, []);
|
||||
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
|
||||
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
|
||||
await assertPopupOpenMarkerFollowsPane(targetSession);
|
||||
assert.equal(
|
||||
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger),
|
||||
true,
|
||||
|
||||
@@ -1297,7 +1297,6 @@ test('parses controller settings with logical bindings and tuning knobs', () =>
|
||||
"toggleMpvPause": "leftStickPress",
|
||||
"leftStickHorizontal": "rightStickX",
|
||||
"leftStickVertical": "rightStickY",
|
||||
"rightStickHorizontal": "leftStickX",
|
||||
"rightStickVertical": "leftStickY"
|
||||
}
|
||||
}
|
||||
@@ -1383,6 +1382,59 @@ test('parses descriptor-based controller bindings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parses a hold input on discrete controller bindings and rejects an invalid one', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"bindings": {
|
||||
"nextAudio": {
|
||||
"kind": "button",
|
||||
"buttonIndex": 15,
|
||||
"hold": { "kind": "button", "buttonIndex": 4 }
|
||||
},
|
||||
"nextEntry": {
|
||||
"kind": "axis",
|
||||
"axisIndex": 0,
|
||||
"direction": "positive",
|
||||
"hold": { "kind": "axis", "axisIndex": 2, "direction": "positive" }
|
||||
},
|
||||
"mineCard": {
|
||||
"kind": "button",
|
||||
"buttonIndex": 12,
|
||||
"hold": { "kind": "button" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.deepEqual(config.controller.bindings.nextAudio, {
|
||||
kind: 'button',
|
||||
buttonIndex: 15,
|
||||
hold: { kind: 'button', buttonIndex: 4 },
|
||||
});
|
||||
assert.deepEqual(config.controller.bindings.nextEntry, {
|
||||
kind: 'axis',
|
||||
axisIndex: 0,
|
||||
direction: 'positive',
|
||||
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
|
||||
});
|
||||
// A broken hold must not silently become a plain press of button 12.
|
||||
assert.deepEqual(
|
||||
config.controller.bindings.mineCard,
|
||||
DEFAULT_CONFIG.controller.bindings.mineCard,
|
||||
);
|
||||
assert.ok(
|
||||
service.getWarnings().some((warning) => warning.path === 'controller.bindings.mineCard'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses controller profiles as per-gamepad binding overrides', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -85,13 +85,13 @@ 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' },
|
||||
playNextSubtitle: { kind: 'none' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
|
||||
@@ -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,
|
||||
@@ -119,11 +125,6 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
|
||||
description: 'Axis binding descriptor used for primary popup scrolling.',
|
||||
},
|
||||
{
|
||||
id: 'rightStickHorizontal',
|
||||
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
|
||||
description: 'Axis binding descriptor reserved for alternate right-stick mappings.',
|
||||
},
|
||||
{
|
||||
id: 'rightStickVertical',
|
||||
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
|
||||
@@ -466,7 +467,7 @@ export function buildCoreConfigOptionRegistry(
|
||||
path: `controller.bindings.${binding.id}`,
|
||||
kind: 'object' as const,
|
||||
defaultValue: binding.defaultValue,
|
||||
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.`,
|
||||
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. Alt+C can also learn a combo, saved as an optional "hold" input.`,
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.kind`,
|
||||
@@ -499,6 +500,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
description:
|
||||
'Axis direction captured for this discrete controller action. Required when kind is "axis".',
|
||||
},
|
||||
{
|
||||
path: `controller.bindings.${binding.id}.hold`,
|
||||
kind: 'object' as const,
|
||||
defaultValue: undefined,
|
||||
description:
|
||||
'Optional button or axis input, in the same form, that must be held down while pressing this one.',
|
||||
},
|
||||
]),
|
||||
...axisBindings.flatMap((binding) => [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerAxisDirection,
|
||||
ControllerButtonBinding,
|
||||
ControllerButtonIndicesConfig,
|
||||
ControllerDpadFallback,
|
||||
@@ -11,6 +10,7 @@ import type {
|
||||
import {
|
||||
isControllerFaceButtonLabels,
|
||||
isControllerScreenAction,
|
||||
parseControllerDiscreteBinding,
|
||||
parseControllerReviewBinding,
|
||||
type ControllerScreenBindings,
|
||||
type ControllerScreenDefinition,
|
||||
@@ -70,7 +70,6 @@ const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
||||
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
||||
leftStickHorizontal: 'horizontal',
|
||||
leftStickVertical: 'vertical',
|
||||
rightStickHorizontal: 'none',
|
||||
rightStickVertical: 'none',
|
||||
} as const satisfies Record<string, ControllerDpadFallback>;
|
||||
|
||||
@@ -101,6 +100,7 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
||||
'holdSecondarySub',
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'toggleCharacterNavigation',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
@@ -110,7 +110,6 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
||||
const CONTROLLER_AXIS_BINDING_KEYS = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
|
||||
@@ -119,10 +118,6 @@ const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'pr
|
||||
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
|
||||
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
|
||||
|
||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
||||
return value === 'negative' || value === 'positive';
|
||||
}
|
||||
|
||||
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
||||
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
||||
}
|
||||
@@ -151,29 +146,6 @@ function resolveLegacyAxisBinding(
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||
if (value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (value.kind === 'button') {
|
||||
return typeof value.buttonIndex === 'number' &&
|
||||
Number.isInteger(value.buttonIndex) &&
|
||||
value.buttonIndex >= 0
|
||||
? { kind: 'button', buttonIndex: value.buttonIndex }
|
||||
: null;
|
||||
}
|
||||
if (value.kind === 'axis') {
|
||||
return typeof value.axisIndex === 'number' &&
|
||||
Number.isInteger(value.axisIndex) &&
|
||||
value.axisIndex >= 0 &&
|
||||
isControllerAxisDirection(value.direction)
|
||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAxisBindingObject(
|
||||
value: unknown,
|
||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||
@@ -241,7 +213,7 @@ function applyControllerBindings(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
||||
const parsedObject = parseControllerDiscreteBinding(bindingValue);
|
||||
if (parsedObject) {
|
||||
target[key] = parsedObject;
|
||||
} else if (bindingValue !== undefined) {
|
||||
@@ -249,7 +221,7 @@ function applyControllerBindings(
|
||||
`${pathPrefix}.${key}`,
|
||||
bindingValue,
|
||||
target[key],
|
||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis', and an optional button or axis 'hold'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
@@ -86,7 +87,6 @@ function createControllerConfigFixture() {
|
||||
dpadFallback: 'horizontal' as const,
|
||||
},
|
||||
leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const },
|
||||
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
|
||||
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createOverlayVisibilityDiagnostics,
|
||||
readOverlayVisibilitySnapshot,
|
||||
type OverlayVisibilitySnapshot,
|
||||
} from './overlay-visibility-diagnostics';
|
||||
|
||||
type SnapshotArgs = Parameters<typeof readOverlayVisibilitySnapshot>[0];
|
||||
|
||||
function snapshotFor(
|
||||
options: {
|
||||
tracking?: boolean;
|
||||
targetPid?: number | null;
|
||||
minimized?: boolean;
|
||||
window?: Record<string, unknown>;
|
||||
noTracker?: boolean;
|
||||
} & Pick<SnapshotArgs, 'modalActive' | 'suspendVisibleOverlay'> & {
|
||||
visibleOverlayVisible?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const tracker = {
|
||||
isTracking: () => options.tracking ?? false,
|
||||
getGeometry: () => null,
|
||||
isTargetWindowFocused: () => false,
|
||||
isTargetWindowMinimized: () => options.minimized ?? false,
|
||||
getDiagnosticState: () => ({ targetPid: options.targetPid ?? null, socketPath: '/tmp/mpv' }),
|
||||
};
|
||||
return readOverlayVisibilitySnapshot({
|
||||
mainWindow: {
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => false,
|
||||
getOpacity: () => 0,
|
||||
...options.window,
|
||||
} as unknown as SnapshotArgs['mainWindow'],
|
||||
windowTracker: options.noTracker ? null : (tracker as unknown as SnapshotArgs['windowTracker']),
|
||||
visibleOverlayVisible: options.visibleOverlayVisible ?? true,
|
||||
modalActive: options.modalActive,
|
||||
suspendVisibleOverlay: options.suspendVisibleOverlay,
|
||||
});
|
||||
}
|
||||
|
||||
function harness() {
|
||||
let now = 0;
|
||||
let callback: (() => void) | null = null;
|
||||
let delay = 0;
|
||||
const warnings: Array<{ message: string; details: Record<string, unknown> }> = [];
|
||||
let snapshot: OverlayVisibilitySnapshot | null = {
|
||||
expectedVisible: true,
|
||||
visible: false,
|
||||
opacity: 0,
|
||||
contentReady: false,
|
||||
tracking: false,
|
||||
targetPid: 4242,
|
||||
pidLookupPending: false,
|
||||
};
|
||||
const diagnostics = createOverlayVisibilityDiagnostics({
|
||||
now: () => now,
|
||||
warn: (message, details) => warnings.push({ message, details }),
|
||||
schedule: (fn, ms) => {
|
||||
callback = fn;
|
||||
delay = ms;
|
||||
return { unref() {} } as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
cancel: () => {
|
||||
callback = null;
|
||||
},
|
||||
});
|
||||
const update = () => diagnostics.update(() => snapshot);
|
||||
return {
|
||||
warnings,
|
||||
update,
|
||||
setSnapshot: (value: typeof snapshot) => {
|
||||
snapshot = value;
|
||||
},
|
||||
tick: () => {
|
||||
assert.ok(callback, 'watchdog must be scheduled');
|
||||
now += delay;
|
||||
callback();
|
||||
},
|
||||
scheduled: () => callback !== null,
|
||||
};
|
||||
}
|
||||
|
||||
test('visibility watchdog warns without more events and rate limits repeated polls', () => {
|
||||
const h = harness();
|
||||
h.update();
|
||||
assert.equal(h.warnings.length, 0);
|
||||
for (let i = 0; i < 100; i += 1) h.update();
|
||||
h.tick();
|
||||
assert.equal(h.warnings.length, 1);
|
||||
assert.equal(h.warnings[0]?.details.reason, 'mpv-window-not-tracked');
|
||||
assert.equal(h.warnings[0]?.details.targetPid, 4242);
|
||||
assert.equal(h.warnings[0]?.details.elapsedMs, 5_000);
|
||||
for (let i = 0; i < 100; i += 1) h.update();
|
||||
assert.equal(h.warnings.length, 1);
|
||||
h.tick();
|
||||
assert.equal(h.warnings[1]?.details.elapsedMs, 35_000);
|
||||
});
|
||||
|
||||
test('visibility watchdog distinguishes renderer readiness, hidden windows and opacity', () => {
|
||||
for (const [contentReady, visible, opacity, reason] of [
|
||||
[false, false, 0, 'renderer-not-ready'],
|
||||
[true, false, 0, 'window-hidden'],
|
||||
[true, true, 0, 'window-transparent'],
|
||||
] as const) {
|
||||
const h = harness();
|
||||
h.setSnapshot({ expectedVisible: true, tracking: true, contentReady, visible, opacity });
|
||||
h.update();
|
||||
h.tick();
|
||||
assert.equal(h.warnings[0]?.details.reason, reason);
|
||||
}
|
||||
});
|
||||
|
||||
test('visibility watchdog reports recovery only after warning and stops', () => {
|
||||
const h = harness();
|
||||
h.update();
|
||||
h.tick();
|
||||
h.setSnapshot({
|
||||
expectedVisible: true,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
contentReady: true,
|
||||
tracking: true,
|
||||
});
|
||||
h.tick();
|
||||
assert.match(h.warnings[1]!.message, /recovered/);
|
||||
assert.equal(h.scheduled(), false);
|
||||
h.update();
|
||||
assert.equal(h.warnings.length, 2);
|
||||
});
|
||||
|
||||
test('visibility watchdog stays quiet after fast startup, intentional hide or window destruction', () => {
|
||||
for (const snapshot of [
|
||||
{ expectedVisible: true, visible: true, opacity: 1, contentReady: true, tracking: true },
|
||||
{ expectedVisible: false, visible: false, opacity: 0, contentReady: false, tracking: false },
|
||||
null,
|
||||
]) {
|
||||
const h = harness();
|
||||
h.update();
|
||||
h.setSnapshot(snapshot);
|
||||
h.tick();
|
||||
assert.deepEqual(h.warnings, []);
|
||||
assert.equal(h.scheduled(), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay snapshot expects visibility only once an mpv has been identified', () => {
|
||||
// Requested before any mpv exists: auto_start_overlay, tray or --show-visible-overlay.
|
||||
assert.equal(snapshotFor()?.expectedVisible, false);
|
||||
assert.equal(snapshotFor({ noTracker: true })?.expectedVisible, false);
|
||||
// mpv answered on the socket but its window never matched, so tracking never started.
|
||||
assert.equal(snapshotFor({ targetPid: 4242 })?.expectedVisible, true);
|
||||
// A tracker with no target socket matches windows without ever learning a PID.
|
||||
assert.equal(snapshotFor({ tracking: true })?.expectedVisible, true);
|
||||
});
|
||||
|
||||
test('overlay snapshot does not expect an intentionally hidden overlay', () => {
|
||||
const identified = { targetPid: 4242, tracking: true };
|
||||
assert.equal(snapshotFor(identified)?.expectedVisible, true);
|
||||
assert.equal(snapshotFor({ ...identified, modalActive: true })?.expectedVisible, false);
|
||||
assert.equal(snapshotFor({ ...identified, suspendVisibleOverlay: true })?.expectedVisible, false);
|
||||
assert.equal(snapshotFor({ ...identified, minimized: true })?.expectedVisible, false);
|
||||
assert.equal(
|
||||
snapshotFor({ ...identified, visibleOverlayVisible: false })?.expectedVisible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay snapshot carries tracker state, defaults opacity and ends with the window', () => {
|
||||
const snapshot = snapshotFor({ targetPid: 4242, window: { getOpacity: undefined } });
|
||||
assert.equal(snapshot?.targetPid, 4242);
|
||||
assert.equal(snapshot?.socketPath, '/tmp/mpv');
|
||||
assert.equal(snapshot?.opacity, 1);
|
||||
assert.equal(snapshotFor({ window: { isDestroyed: () => true } }), null);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { BaseWindowTracker } from '../../window-trackers';
|
||||
import { createLogger } from '../../logger';
|
||||
import {
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
||||
} from './overlay-window-flags';
|
||||
|
||||
const log = createLogger('overlay:visibility');
|
||||
|
||||
export type OverlayVisibilitySnapshot = {
|
||||
expectedVisible: boolean;
|
||||
visible: boolean;
|
||||
opacity: number;
|
||||
contentReady: boolean;
|
||||
tracking: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// One watchdog per window, independent of further visibility events: a renderer
|
||||
// that never becomes ready must still leave evidence at the default warn level.
|
||||
export function createOverlayVisibilityDiagnostics(deps: {
|
||||
warn: (message: string, details: Record<string, unknown>) => void;
|
||||
now: () => number;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
cancel: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
}) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let readSnapshot: () => OverlayVisibilitySnapshot | null = () => null;
|
||||
let stalledAtMs: number | null = null;
|
||||
let warned = false;
|
||||
let lastWarningAtMs = 0;
|
||||
|
||||
const check = () => {
|
||||
if (timer !== null) deps.cancel(timer);
|
||||
timer = null;
|
||||
const snapshot = readSnapshot();
|
||||
if (!snapshot?.expectedVisible) {
|
||||
stalledAtMs = null;
|
||||
warned = false;
|
||||
return;
|
||||
}
|
||||
if (snapshot.visible && snapshot.opacity > 0 && snapshot.contentReady) {
|
||||
if (warned) {
|
||||
deps.warn('Windows overlay visibility recovered', {
|
||||
...snapshot,
|
||||
elapsedMs: deps.now() - stalledAtMs!,
|
||||
});
|
||||
}
|
||||
stalledAtMs = null;
|
||||
warned = false;
|
||||
return;
|
||||
}
|
||||
stalledAtMs ??= deps.now();
|
||||
const elapsedMs = deps.now() - stalledAtMs;
|
||||
if (elapsedMs >= 5_000 && (!warned || deps.now() - lastWarningAtMs >= 30_000)) {
|
||||
const reason = !snapshot.tracking
|
||||
? 'mpv-window-not-tracked'
|
||||
: !snapshot.contentReady
|
||||
? 'renderer-not-ready'
|
||||
: !snapshot.visible
|
||||
? 'window-hidden'
|
||||
: 'window-transparent';
|
||||
deps.warn('Windows overlay is not visible after startup or tracking loss', {
|
||||
...snapshot,
|
||||
reason,
|
||||
elapsedMs,
|
||||
});
|
||||
warned = true;
|
||||
lastWarningAtMs = deps.now();
|
||||
}
|
||||
timer = deps.schedule(
|
||||
check,
|
||||
warned ? 30_000 - (deps.now() - lastWarningAtMs) : 5_000 - elapsedMs,
|
||||
);
|
||||
timer.unref?.();
|
||||
};
|
||||
|
||||
return {
|
||||
update(reader: () => OverlayVisibilitySnapshot | null) {
|
||||
readSnapshot = reader;
|
||||
// Frequent polls must not postpone a warning or bypass its rate limit.
|
||||
check();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const diagnosticsByWindow = new WeakMap<
|
||||
BrowserWindow,
|
||||
ReturnType<typeof createOverlayVisibilityDiagnostics>
|
||||
>();
|
||||
|
||||
type OverlayVisibilityWatchArgs = {
|
||||
mainWindow: BrowserWindow;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
visibleOverlayVisible: boolean;
|
||||
modalActive?: boolean;
|
||||
suspendVisibleOverlay?: boolean;
|
||||
};
|
||||
|
||||
export function readOverlayVisibilitySnapshot(
|
||||
args: OverlayVisibilityWatchArgs,
|
||||
): OverlayVisibilitySnapshot | null {
|
||||
const window = args.mainWindow;
|
||||
if (window.isDestroyed()) return null;
|
||||
const tracker = args.windowTracker as
|
||||
| (BaseWindowTracker & {
|
||||
getDiagnosticState?: () => { targetPid?: number | null } & Record<string, unknown>;
|
||||
})
|
||||
| null;
|
||||
const trackerState = tracker?.getDiagnosticState?.();
|
||||
const flags = window as unknown as Record<string, unknown>;
|
||||
const minimized = tracker?.isTargetWindowMinimized?.() ?? false;
|
||||
const tracking = tracker?.isTracking() ?? false;
|
||||
// The overlay may be requested with no mpv to attach to (auto_start_overlay, tray,
|
||||
// --show-visible-overlay), so it only counts as stalled once an mpv has answered on the
|
||||
// socket. That still covers an mpv whose window never matches, which never starts tracking.
|
||||
const mpvIdentified = tracking || (trackerState?.targetPid ?? null) !== null;
|
||||
return {
|
||||
...trackerState,
|
||||
expectedVisible:
|
||||
args.visibleOverlayVisible &&
|
||||
mpvIdentified &&
|
||||
!args.modalActive &&
|
||||
!args.suspendVisibleOverlay &&
|
||||
!minimized,
|
||||
visible: window.isVisible(),
|
||||
opacity: window.getOpacity?.() ?? 1,
|
||||
contentReady: flags[OVERLAY_WINDOW_CONTENT_READY_FLAG] === true,
|
||||
documentLoaded: flags[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === true,
|
||||
rendererLoading: window.webContents?.isLoading?.() ?? null,
|
||||
tracking,
|
||||
trackerInitialized: tracker !== null,
|
||||
geometry: tracker?.getGeometry() ?? null,
|
||||
focused: tracker?.isTargetWindowFocused?.() ?? false,
|
||||
minimized,
|
||||
};
|
||||
}
|
||||
|
||||
export function watchWindowsOverlayVisibility(args: OverlayVisibilityWatchArgs): void {
|
||||
const window = args.mainWindow;
|
||||
let diagnostics = diagnosticsByWindow.get(window);
|
||||
if (!diagnostics) {
|
||||
diagnostics = createOverlayVisibilityDiagnostics({
|
||||
warn: (message, details) => log.warn(message, details),
|
||||
now: Date.now,
|
||||
schedule: setTimeout,
|
||||
cancel: clearTimeout,
|
||||
});
|
||||
diagnosticsByWindow.set(window, diagnostics);
|
||||
}
|
||||
diagnostics.update(() => readOverlayVisibilitySnapshot(args));
|
||||
}
|
||||
@@ -589,6 +589,34 @@ runScenarios([
|
||||
},
|
||||
]);
|
||||
|
||||
test('only a live Windows overlay reads visibility diagnostics', () => {
|
||||
const diagnosticReads = (platform: Platform, overrides: Partial<VisibilityArgs> = {}): number => {
|
||||
let reads = 0;
|
||||
createHarness({ platform, tracker: tracked() }).run({
|
||||
windowTracker: {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => GEOMETRY,
|
||||
getDiagnosticState: () => {
|
||||
reads += 1;
|
||||
return { targetPid: 4242 };
|
||||
},
|
||||
} as unknown as VisibilityArgs['windowTracker'],
|
||||
...overrides,
|
||||
});
|
||||
return reads;
|
||||
};
|
||||
|
||||
assert.equal(diagnosticReads('windows'), 1);
|
||||
assert.equal(diagnosticReads('linux'), 0);
|
||||
assert.equal(diagnosticReads('macos'), 0);
|
||||
assert.equal(
|
||||
diagnosticReads('windows', {
|
||||
mainWindow: { isDestroyed: () => true } as unknown as VisibilityArgs['mainWindow'],
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows visible overlay restores opacity and rebinds after the deferred reveal delay', async () => {
|
||||
const h = createHarness({ platform: 'windows', tracker: tracked() });
|
||||
assertOutcome(h.run(), { opacity: 0, zOrderSyncs: 1 });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BaseWindowTracker } from '../../window-trackers';
|
||||
import { WindowGeometry } from '../../types';
|
||||
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
import { watchWindowsOverlayVisibility } from './overlay-visibility-diagnostics';
|
||||
|
||||
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
||||
const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
|
||||
@@ -65,7 +66,7 @@ function isOverlayWindowContentReady(window: BrowserWindow): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function updateVisibleOverlayVisibility(args: {
|
||||
function applyVisibleOverlayVisibility(args: {
|
||||
visibleOverlayVisible: boolean;
|
||||
modalActive?: boolean;
|
||||
forceMousePassthrough?: boolean;
|
||||
@@ -453,6 +454,15 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.syncOverlayShortcuts();
|
||||
}
|
||||
|
||||
export function updateVisibleOverlayVisibility(
|
||||
args: Parameters<typeof applyVisibleOverlayVisibility>[0],
|
||||
): void {
|
||||
applyVisibleOverlayVisibility(args);
|
||||
if (args.isWindowsPlatform && args.mainWindow && !args.mainWindow.isDestroyed()) {
|
||||
watchWindowsOverlayVisibility({ ...args, mainWindow: args.mainWindow });
|
||||
}
|
||||
}
|
||||
|
||||
export function setVisibleOverlayVisible(options: {
|
||||
visible: boolean;
|
||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+97
-696
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 };
|
||||
@@ -190,6 +157,22 @@ function setup(t: TestContext, options: { platform?: NodeJS.Platform; visible?:
|
||||
Object.defineProperty(process, 'platform', { value: options.platform, configurable: true });
|
||||
t.after(() => Object.defineProperty(process, 'platform', { value: originalPlatform }));
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
// Fullscreen recreation only runs outside Hyprland and Sway, so pin an X11
|
||||
// session rather than inheriting the compositor the tests happen to run under.
|
||||
for (const key of [
|
||||
'HYPRLAND_INSTANCE_SIGNATURE',
|
||||
'SWAYSOCK',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'XDG_SESSION_DESKTOP',
|
||||
]) {
|
||||
const original = process.env[key];
|
||||
delete process.env[key];
|
||||
t.after(() => {
|
||||
if (original !== undefined) process.env[key] = original;
|
||||
});
|
||||
}
|
||||
}
|
||||
const recorder = createRecorder();
|
||||
const state: FixtureState = {
|
||||
visible: options.visible ?? false,
|
||||
@@ -201,7 +184,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 +378,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 +395,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,7 +1,79 @@
|
||||
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 { applyControllerConfigUpdate } from './controller-config-update.js';
|
||||
import { ConfigService } from '../config/service';
|
||||
import { parseControllerConfigUpdate } from '../shared/ipc/validators';
|
||||
import {
|
||||
applyControllerConfigUpdate,
|
||||
saveControllerConfigUpdate,
|
||||
} from './controller-config-update.js';
|
||||
|
||||
const L1 = { kind: 'button', buttonIndex: 4 } as const;
|
||||
const L2 = { kind: 'button', buttonIndex: 6 } as const;
|
||||
const DPAD_LEFT = { kind: 'button', buttonIndex: 14 } as const;
|
||||
const DPAD_RIGHT = { kind: 'button', buttonIndex: 15 } as const;
|
||||
|
||||
/** One Alt+C save for a profile, through the same validator and merge as the real IPC handler. */
|
||||
function createSaveHarness() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-controller-save-'));
|
||||
const service = new ConfigService(dir);
|
||||
return {
|
||||
service,
|
||||
save: (action: string, binding: unknown) => {
|
||||
const update = parseControllerConfigUpdate({
|
||||
profiles: { 'pad-1': { bindings: { [action]: binding } } },
|
||||
});
|
||||
assert.ok(update, 'the validator accepts the update');
|
||||
saveControllerConfigUpdate(service, update);
|
||||
},
|
||||
savedBinding: (action: 'nextAudio' | 'previousAudio' | 'nextEntry') =>
|
||||
service.getConfig().controller.profiles['pad-1']?.bindings[action],
|
||||
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
test('combos sharing a hold button or a press button all survive later saves', () => {
|
||||
const harness = createSaveHarness();
|
||||
try {
|
||||
harness.save('nextAudio', { ...DPAD_LEFT, hold: L1 });
|
||||
harness.save('previousAudio', { ...DPAD_RIGHT, hold: L1 });
|
||||
harness.save('nextEntry', { ...DPAD_LEFT, hold: L2 });
|
||||
|
||||
assert.deepEqual(harness.savedBinding('nextAudio'), { ...DPAD_LEFT, hold: L1 });
|
||||
assert.deepEqual(harness.savedBinding('previousAudio'), { ...DPAD_RIGHT, hold: L1 });
|
||||
assert.deepEqual(harness.savedBinding('nextEntry'), { ...DPAD_LEFT, hold: L2 });
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('rebinding a combo to one button drops its hold', () => {
|
||||
const harness = createSaveHarness();
|
||||
try {
|
||||
harness.save('nextAudio', { ...DPAD_LEFT, hold: L1 });
|
||||
harness.save('nextAudio', DPAD_RIGHT);
|
||||
|
||||
assert.deepEqual(harness.savedBinding('nextAudio'), DPAD_RIGHT);
|
||||
assert.deepEqual(
|
||||
harness.service.getRawConfig().controller?.profiles?.['pad-1']?.bindings?.nextAudio,
|
||||
DPAD_RIGHT,
|
||||
);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('the validator rejects an invalid hold instead of saving a plain press', () => {
|
||||
assert.equal(
|
||||
parseControllerConfigUpdate({
|
||||
bindings: { nextAudio: { ...DPAD_LEFT, hold: { kind: 'face' } } },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
|
||||
const next = applyControllerConfigUpdate(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ConfigService } from '../config/service';
|
||||
import type { ControllerConfigUpdate, RawConfig } from '../types';
|
||||
|
||||
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
||||
@@ -96,6 +97,21 @@ function mergeControllerProfile(
|
||||
return nextProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an Alt+C update to the config file. The controller section is replaced, not patched:
|
||||
* `patchRawConfig` deep-merges, so a combo rebound to one button would keep its old `hold`.
|
||||
*/
|
||||
export function saveControllerConfigUpdate(
|
||||
configService: Pick<ConfigService, 'getRawConfig' | 'saveRawConfig'>,
|
||||
update: ControllerConfigUpdate,
|
||||
): void {
|
||||
const rawConfig = configService.getRawConfig();
|
||||
configService.saveRawConfig({
|
||||
...rawConfig,
|
||||
controller: applyControllerConfigUpdate(rawConfig.controller, update),
|
||||
});
|
||||
}
|
||||
|
||||
export function applyControllerConfigUpdate(
|
||||
currentController: RawConfig['controller'] | undefined,
|
||||
update: ControllerConfigUpdate,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user