Compare commits

..

11 Commits

Author SHA1 Message Date
sudacode 1858dae2c8 fix(overlay): use Lua dispatch syntax for Hyprland 0.55+ Lua configs
- Detect `configProvider: "lua"` via `hyprctl -j status` at placement time
- Emit `hl.dsp.window.*` Lua dispatcher calls instead of legacy hyprlang syntax
- Fall back to hyprlang if status call fails or returns non-lua provider
2026-05-28 23:54:21 -07:00
sudacode a1da3dcdc8 docs(troubleshooting): fix Hyprland rules, add character dictionary + see also
Rewrite Hyprland overlay window-rule guidance with current Lua (hl.window_rule)
config and legacy hyprland.conf syntax, and note SubMiner's automatic hyprctl
placement. Add a Character Dictionary troubleshooting section (no AniList auth
required) and a See Also index linking each feature's own troubleshooting page.
2026-05-28 23:53:07 -07:00
sudacode 9927ef1581 docs(character-dictionary): correct auth requirement and add portrait do
- AniList auth not required for character dictionary; uses public GraphQL
- Document nameMatchImagesEnabled and inline portrait behavior
- Clarify AniList auth is only for watch-progress sync
- Delete stale release/release-notes.md
2026-05-28 23:30:19 -07:00
sudacode 791c993870 docs: reformat changelog entries as nested bullet lists
- Convert flat prose entries in CHANGELOG.md and docs-site/changelog.md to bold headers + sub-bullets
- Scope artifact uploads in release/prerelease workflows to `latest*.yml` instead of `*.yml`
- Update release-notes and RELEASING docs to match
- Adjust workflow tests for new nested bullet format
2026-05-28 22:53:22 -07:00
sudacode 38dbce517c chore(release): 0.15.0 2026-05-28 19:46:05 -07:00
sudacode 889dc9c009 docs: reconcile docs-site with current config schema and defaults
- sidebar: migrate flat props to css object (font-family, color, bg, custom vars)
- frequencyDictionary.topX default: 1000 → 10000
- text-shadow default: updated to outline-style multi-shadow
- anki: reset ai model/prompt, imageMaxWidth/Height, animatedMaxHeight to 0; isLapis defaults
- troubleshooting: log default warn (not info), css["font-size"] usage
- shortcuts: add W markWatchedKey; clarify keybindings vs built-in overlay actions
- websocket: clarify all services off by default, fix enabled semantics
- usage: --anilist → --anilist-setup
- AGENTS.md: add Docs Upkeep trigger map, clarify CLAUDE.md symlink, expand PR notes
2026-05-28 19:21:58 -07:00
sudacode 097021072d chore(release): 0.15.0-beta.12 2026-05-28 02:16:55 -07:00
sudacode 91c8eb8faf feat(changelog): add nested bullet format for release notes
- Prompt now requests short top-level bullets with nested change/benefit/action sub-bullets in release-notes mode
- CHANGELOG mode keeps single-line bullets unchanged
- Tests assert new prompt constraints are present
2026-05-28 02:08:44 -07:00
sudacode eed0a6a243 feat: use cached annotations on subtitle change and skip pre-warmed cues (#97) 2026-05-28 00:50:41 -07:00
sudacode d33009d4a3 style: update subtitle text shadow, JLPT underlines, and frequency topX default (#96)
* style: update subtitle text shadow, JLPT underlines, and frequency topX

- Replace directional text-shadow with 4-corner outline shadow for sharper readability
- Increase JLPT level border-bottom from 2px to 3px; add drop-shadow filter
- Add margin-left 0.18em to character image token
- Raise frequencyDictionary topX default from 1000 to 10000

* docs: add subtitle style changelog fragment

* docs: update generated config examples

* style: wrap long textShadow strings and add blank line in CSS
2026-05-28 00:18:39 -07:00
sudacode 8d0535f3ca feat: add Anki deck dropdown with Yomitan auto-fill in settings (#95) 2026-05-27 23:13:43 -07:00
106 changed files with 1528 additions and 430 deletions
+5 -5
View File
@@ -148,7 +148,7 @@ jobs:
name: appimage name: appimage
path: | path: |
release/*.AppImage release/*.AppImage
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
if-no-files-found: error if-no-files-found: error
@@ -226,7 +226,7 @@ jobs:
path: | path: |
release/*.dmg release/*.dmg
release/*.zip release/*.zip
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
if-no-files-found: error if-no-files-found: error
@@ -279,7 +279,7 @@ jobs:
path: | path: |
release/*.exe release/*.exe
release/*.zip release/*.zip
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
if-no-files-found: error if-no-files-found: error
@@ -353,7 +353,7 @@ jobs:
- name: Generate checksums - name: Generate checksums
run: | run: |
shopt -s nullglob shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/*.yml release/*.blockmap dist/launcher/subminer) files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer)
if [ "${#files[@]}" -eq 0 ]; then if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation." echo "No release artifacts found for checksum generation."
exit 1 exit 1
@@ -389,7 +389,7 @@ jobs:
release/*.exe release/*.exe
release/*.zip release/*.zip
release/*.tar.gz release/*.tar.gz
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
release/SHA256SUMS.txt release/SHA256SUMS.txt
dist/launcher/subminer dist/launcher/subminer
+5 -5
View File
@@ -139,7 +139,7 @@ jobs:
name: appimage name: appimage
path: | path: |
release/*.AppImage release/*.AppImage
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
build-macos: build-macos:
@@ -216,7 +216,7 @@ jobs:
path: | path: |
release/*.dmg release/*.dmg
release/*.zip release/*.zip
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
build-windows: build-windows:
@@ -268,7 +268,7 @@ jobs:
path: | path: |
release/*.exe release/*.exe
release/*.zip release/*.zip
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
if-no-files-found: error if-no-files-found: error
@@ -342,7 +342,7 @@ jobs:
- name: Generate checksums - name: Generate checksums
run: | run: |
shopt -s nullglob shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/*.yml release/*.blockmap dist/launcher/subminer) files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer)
if [ "${#files[@]}" -eq 0 ]; then if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation." echo "No release artifacts found for checksum generation."
exit 1 exit 1
@@ -396,7 +396,7 @@ jobs:
release/*.exe release/*.exe
release/*.zip release/*.zip
release/*.tar.gz release/*.tar.gz
release/*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
release/SHA256SUMS.txt release/SHA256SUMS.txt
dist/launcher/subminer dist/launcher/subminer
+19 -2
View File
@@ -13,6 +13,8 @@ Start here, then leave this file.
`docs-site/` is user-facing. Do not treat it as the canonical internal source of truth. `docs-site/` is user-facing. Do not treat it as the canonical internal source of truth.
`CLAUDE.md` is a symlink to this file — there is one project instruction file, not two.
## Quick Start ## Quick Start
- Init workspace: `git submodule update --init --recursive` - Init workspace: `git submodule update --init --recursive`
@@ -42,6 +44,20 @@ Start here, then leave this file.
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat` - Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
- Docs-only: `bun run docs:test`, then `bun run docs:build` - Docs-only: `bun run docs:test`, then `bun run docs:build`
## Docs Upkeep
- Docs ship with the change, not after. If a change alters behavior, defaults, flags, shortcuts, ports, or APIs, update the matching docs in the same PR. Touching code without reconciling its docs is an incomplete change.
- Source of truth for config defaults is the generated `config.example.jsonc`. Never write a default value into prose you didn't read from it — and don't restate the same default across multiple docs; cite/link to one place so there's a single thing to update.
- Trigger map (touch left → update right):
- `src/config/definitions/**` (schema/defaults/template) → `bun run generate:config-example`, then reconcile `docs-site/configuration.md` + any feature doc that cites that default
- shortcuts/keybindings (`shortcuts.*`, `keybindings`, `stats.*Key`, `subtitleSidebar.toggleKey`, controller bindings) → `docs-site/shortcuts.md`
- CLI flags/subcommands (`src/cli/args.ts`, `launcher/**`) → `docs-site/usage.md` + relevant integration doc
- feature behavior (anki / jellyfin / jimaku / anilist / youtube / immersion / stats / websocket / sidebar / character-dictionary / annotations) → matching `docs-site/<feature>.md`
- architecture / IPC / workflow / internal process → internal `docs/` (system of record)
- feature set / requirements / install flow → `README.md`
- Removing or renaming a config key: grep `docs-site/` and `docs/` for the old key and any value it documented; legacy/hidden keys (`LEGACY_HIDDEN_CONFIG_PATHS`) should not appear in user docs as current settings.
- Verify after doc edits: `bun run verify:config-example` (if config touched), `bun run docs:test`, `bun run docs:build`.
## Sensitive Files ## Sensitive Files
- Launcher source of truth: `launcher/*.ts` - Launcher source of truth: `launcher/*.ts`
@@ -52,7 +68,8 @@ Start here, then leave this file.
## Release / PR Notes ## Release / PR Notes
- User-visible PRs need one fragment in `changes/*.md` - User-visible PRs need one fragment in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; apply the `skip-changelog` label to opt out)
- User-visible docs changes get a `type: docs` fragment
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check` - CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
- PR review helpers: - PR review helpers:
- `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'` - `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`
@@ -63,4 +80,4 @@ Start here, then leave this file.
- Use Codex background for long jobs; tmux only when persistence/interaction is required - Use Codex background for long jobs; tmux only when persistence/interaction is required
- CI red: `gh run list/view`, rerun, fix, repeat until green - CI red: `gh run list/view`, rerun, fix, repeat until green
- TypeScript: keep files small; follow existing patterns - TypeScript: keep files small; follow existing patterns
- Swift: use workspace helper/daemon; validate `swift build` + tests - Only Swift is the `scripts/get-mpv-window-macos.swift` helper (macOS mpv window detection); validate via `bun test scripts/get-mpv-window-macos.test.ts`
+152
View File
@@ -1,5 +1,157 @@
# Changelog # Changelog
## v0.15.0 (2026-05-29)
### Breaking Changes
- **Subsync:**
- The `subsync.defaultMode` config option has been removed
- Subsync now always opens the manual subtitle picker regardless of any previously set default mode
- **N+1 Highlighting:**
- N+1 highlighting now has its own dedicated `ankiConnect.nPlusOne.enabled` option, separate from known-word highlighting
- It is no longer enabled automatically when known-word highlighting is on — enable it explicitly to keep N+1 annotations
### Added
- **Auto-Updater:**
- Tray and `subminer -u` update checks with app update prompts
- Launcher and Linux rofi theme auto-updates
- Checksum verification and configurable notifications
- Opt-in prerelease channel via `updates.channel: "prerelease"`
- **Settings Window:**
- New dedicated Settings window via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections
- Click-to-learn keybinding controls
- AnkiConnect-backed deck, field, and note-type pickers that auto-fill from the configured Anki deck
- Cross-category search
- Live save for most options including subtitle CSS, stats keys, logging level, Jimaku, Subsync, and Anki mappings
- AI and translation settings remain config-file only
- **Inline Character Portraits:**
- Optional AniList character portraits appear inline for name-matched subtitle text
- Manual AniList overrides scoped per parent media directory so separate season folders maintain separate character dictionary selections
- **Character Dictionary Manager:** New `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded entries.
- **Log Export:** Sanitized log ZIP export from the tray menu and via `subminer logs -e`, with home-directory usernames redacted from exported contents.
- **Launcher CLI:**
- `subminer --version` / `subminer -v` prints the installed app version
- `mpv.profile` config and Settings support passes a named mpv profile to managed launches
- Bundled mpv plugin startup options are now configurable from SubMiner config
- **First-Run Setup:**
- Optional installer for Bun and the `subminer` CLI on Linux, macOS, and Windows
- Windows `subminer.cmd` PATH shim so `subminer` works without manually adding `SubMiner.exe` to PATH
- Setup recognizes existing Homebrew or user PATH installs and avoids writing into Homebrew-owned paths
- Includes an Open SubMiner Settings button
- Standalone setup app quits after completing, returning terminal control
- **Primary Subtitle Visibility on Yomitan Popup:** New `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan popup is open.
### Changed
- **Subtitle Appearance Config:**
- Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`
- Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`
- Subtitle font defaults updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`
- Existing configs migrate automatically; legacy Anki color keys still accepted with deprecation warnings
- **Subtitle Style Defaults:**
- Stronger outline-style text shadow
- Thicker JLPT underlines
- Frequency `topX` default raised to `10000`
- **Character Dictionary:**
- Entries scoped to the current AniList media for name matching and inline portraits
- Generates Japanese-only name aliases so raw romanized/English aliases no longer surface as separate results
- In-app AniList selector waits for an explicit search with the box prefilled from the current filename
- `subtitleStyle.nameMatchEnabled` is now the sole switch for dictionary sync and builds
- **Electron Runtime:** Updated from 39.8.6 to 42.2.0, returning SubMiner to a supported Electron release line.
- **Jellyfin Setup:**
- Removed the server presets dropdown
- Setup now shows a single editable server URL field
- **Jellyfin Cast Identity:**
- Device identity now derived from the OS hostname and always reported as SubMiner
- Previously configurable identity fields are ignored, preventing multiple installs from sharing a remote-session identity
- **Startup Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
- **Setup Appearance:** Removed the bundled mpv runtime plugin readiness card from the setup flow.
### Fixed
- **AniList Progress:**
- Progress updates fire correctly when playback reaches or skips past the watched threshold, using fresh mpv timing events
- Season-specific results preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching
- Repeated missing-token checks no longer exhaust retry attempts or duplicate dead-letter entries
- **Anki Mining:**
- Sentence-audio padding is opt-in by default
- Animated AVIF freeze-frame duration aligned to word audio length without double-counting
- Multi-line sentence alignment fixed for repeated subtitle text
- Kiku duplicate-card detection, auto-merge, modal acknowledgment race, and field/tag ordering corrected
- YouTube playback cards use mpv's resolved stream URLs
- Sentence cards refresh the secondary subtitle before saving
- **Jellyfin Discovery:**
- Startup, subtitle track selection, and duplicate ready-signal handling all fixed
- Paused mpv no longer misreported as playing
- Resume corrected when a remote play command sends `StartPositionTicks: 0` despite saved progress
- **Jellyfin Remote:**
- Tray checkbox stays in sync on Linux after tray, CLI, or startup changes
- Remote controller visibility and progress sync fixed for seeks, stops, startup path changes, and Linux websocket reconnect windows
- Play and Resume now behave correctly (Play from beginning, Resume from saved position)
- Final progress reports reuse SubMiner's last known position when mpv resets on stop
- Windows setup login flow fixed with an IPC bridge, immediate feedback, and a timeout with inline error for unreachable servers
- **Overlay (macOS):**
- Overlay hides when mpv loses focus, is minimized, or is no longer the foreground app
- Stays stable through transient window geometry disappearances from macOS APIs and when clicking from the overlay back into mpv
- Stats overlay opened inactive so it appears over fullscreen mpv without switching Spaces
- Passthrough fixed so mpv controls stay clickable before hovering a subtitle bar
- **Yomitan Sidebar:**
- Playback stays paused for sidebar-opened Yomitan popups when auto-pause is enabled
- Popups now open when startup races the Yomitan extension load
- Sidebar mining cards use audio and images from the clicked sidebar line instead of the current primary subtitle
- **Launcher:**
- `subminer app` on Linux returns terminal control immediately
- `subminer app --setup` opens the setup flow when SubMiner is already running in the background
- **YouTube Playback:**
- Selected subtitles downloaded to local temp files so the primary bar and sidebar read the same source, with cleanup on reload and quit
- False load-failure notifications suppressed
- Tray icon created on launcher-managed playback that attaches to an already-running process
- **Shortcuts:**
- Native mpv menu shortcuts disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus
- Custom session shortcuts including `stats.markWatchedKey` wired through mpv
- Multi-line copy/mine overlay correctly focused so number keys choose the line count on macOS and Windows
- **Controller Bindings:**
- Controller config and debug shortcuts stay closed while controller support is disabled
- Binding learn mode starts from the edit pencil
- Remaps saved per controller profile
- Binding badges also start learn mode
- Row reset buttons restore individual bindings to defaults
- **Logging:**
- `logging.level` forwarded to launcher-started and Windows shortcut-started mpv sessions, covering mpv log verbosity, plugin logging, and plugin-launched app logging
- `logging.rotation` (default 7 days) and per-component `logging.files` toggles added, with mpv logs disabled by default
- Repeated IPC socket warning spam suppressed while waiting for mpv to recreate the socket
- Windows mpv IPC, subtitle track, and Yomitan diagnostics added
- **In-Player Stats:**
- Layering fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window
- Jellyfin playback stats grouped by item metadata so watched episodes merge with matching local library titles and keep clean display names
- **WebSocket Annotations:**
- Annotation spans and token metadata stay on the annotation WebSocket
- The regular subtitle WebSocket is plain-text only
- **Subtitle Annotation Prefetching:** Cached colored annotations and character images ready sooner for live subtitle changes without delaying raw subtitle display.
- **Windows Startup Errors:** Fatal startup failures now show a native error dialog and write details to the app log instead of exiting silently.
### Docs
- **Documentation Site:**
- Published stable docs at the site root with current development docs under `/main/`
- Fixed versioned docs navigation, archived page link handling, and local dev version routing
- Documented all previously undocumented config options including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options
- Added Playback Startup Flow and Runtime Sockets diagrams to the architecture docs with cross-reference pointers in the MPV Plugin and Troubleshooting pages
<details>
<summary>Internal changes</summary>
### Internal
- **Release Tooling:**
- Release-note polishing treats pending fragments and reviewed prerelease notes as a cumulative final outcome, collapsing prerelease-only fixes into the final user-facing change
- Prerelease generation reuses existing reviewed notes and merges only new fragment material
- `make clean` preserves `release/prerelease-notes.md`
- **Tests:** Removed stale Yomitan vendor source-inspection assertions for changes that were not shipped.
</details>
## v0.14.0 (2026-05-12) ## v0.14.0 (2026-05-12)
### Added ### Added
+1
View File
@@ -35,6 +35,7 @@ How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that. - At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries. - The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
- GitHub release notes and prerelease notes use short top-level items with nested bullets for the change, user benefit, and any useful action note. The stable `CHANGELOG.md` can stay in compact single-line bullets.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely. - `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something. - The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
-6
View File
@@ -1,6 +0,0 @@
type: fixed
area: anilist
- Used fresh mpv time-position, duration, and subtitle timing events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
- Prefer season-specific AniList search results for multi-season files before falling back to the base title, and show a clear message when the matched season is not in Planning or Watching instead of silently queueing an impossible update.
- Prevent repeated missing-token checks from rapidly exhausting AniList retry attempts or duplicating dead-letter entries for the same episode.
-8
View File
@@ -1,8 +0,0 @@
type: fixed
area: anki
- Made sentence-audio padding opt-in by default, and kept animated AVIF freeze-frame duration aligned to the word audio length without double-counting sentence audio padding.
- Kept multi-line sentence mining aligned when repeated subtitle text appears in the selected history range.
- Fixed Kiku duplicate-card detection so local duplicate sentence cards trigger the manual modal or auto merge, modal-open acknowledgement races no longer cancel the flow, and merged fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics.
- Fixed manual clipboard card updates from YouTube playback so generated audio and images use mpv's resolved stream URLs instead of the YouTube page URL.
- Sentence cards now refresh the current secondary subtitle before saving, so the translation field uses the loaded subtitle instead of repeating the primary text.
-4
View File
@@ -1,4 +0,0 @@
type: added
area: updater
- Added tray and `subminer -u` update checks for SubMiner releases, including app update prompts, launcher and Linux rofi theme updates, checksum verification, configurable update notifications, and an opt-in prerelease channel. Set `updates.channel` to `"prerelease"` to receive beta/RC builds.
+6
View File
@@ -0,0 +1,6 @@
type: docs
area: character-dictionary
- Corrected character dictionary setup docs: AniList authentication is not required; the feature uses public GraphQL queries and only needs `subtitleStyle.nameMatchEnabled`.
- Added documentation for inline character portraits (`subtitleStyle.nameMatchImagesEnabled`).
- Clarified that AniList authentication is only needed for watch-progress sync, not the character dictionary.
-8
View File
@@ -1,8 +0,0 @@
type: fixed
area: character-dictionary
- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request.
- Block the character dictionary manager when annotations are disabled, with a notice through the configured OSD/system notification surfaces.
- Added surname honorific matches for Japanese localized character aliases embedded in AniList alternative names (e.g. Korean-source characters with Japanese names in parentheses), and refresh cached snapshots so those aliases are regenerated.
- Use `subtitleStyle.nameMatchEnabled` as the only switch for character-dictionary sync/builds, hiding the legacy `anilist.characterDictionary.enabled` option.
- Forward character dictionary manager session-action keybindings to the mpv plugin.
-6
View File
@@ -1,6 +0,0 @@
type: changed
area: character-dictionary
- Character dictionary entries are now scoped to the current AniList media for name matching and inline portraits, and generate Japanese name aliases only so raw romanized/English aliases no longer surface as separate results.
- Added a `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded dictionary entries.
- The in-app AniList selector now waits for an explicit title search, with the search box prefilled from the current filename guess so you can edit it before choosing an override.
-7
View File
@@ -1,7 +0,0 @@
type: added
area: subtitles
- Added optional inline AniList portraits for character-name subtitle matches, including automatic refresh of cached character dictionary snapshots that do not contain portrait data.
- Scoped manual AniList overrides by parent media directory, so separate season folders can keep separate character dictionary selections.
- Fixed large character dictionary imports by serving the merged ZIP through a local URL when supported, with a base64 fallback for older bundled Yomitan builds.
- Allowed subtitle overlay data image sources so inline character portraits render instead of showing a broken image icon.
@@ -1,6 +0,0 @@
type: fixed
area: overlay
- Controller config and debug shortcuts now stay closed while controller support is disabled and show a notice to enable `controller.enabled` manually.
- Controller binding rows now start learn mode from the edit pencil, so clicking edit and pressing a controller button saves the remap.
- Controller remaps are now saved per controller profile, binding badges also start learn mode, and row reset buttons restore individual bindings to their defaults.
@@ -1,4 +0,0 @@
type: changed
area: config
- Defaulted Jellyfin remote-session startup warmup and character-name subtitle highlighting to off.
-6
View File
@@ -1,6 +0,0 @@
type: docs
area: docs
- Published stable docs at the site root with current development docs under `/main/`, and fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests paths incorrectly, local dev version routes serve warmed archive files, and internal README files no longer break archived builds.
- Documented all previously undocumented config options, including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options.
- Added a Playback Startup Flow diagram and a Runtime Sockets section/diagram to the architecture docs, with cross-reference pointers in the MPV Plugin and Troubleshooting pages.
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: runtime
- Updated the bundled Electron runtime from 39.8.6 to 42.2.0, moving SubMiner back onto a supported Electron release line.
@@ -1,4 +0,0 @@
type: fixed
area: integrations
- Prevented Discord Rich Presence from falling back to Jellyfin stream URLs, and primed Jellyfin playback titles before loading tokenized streams so presence shows the show/episode title
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: setup
- Setup: Removed the bundled mpv runtime plugin readiness card; legacy mpv plugin removal still appears when needed.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed Hyprland overlay placement on Hyprland 0.55+ Lua configs by using Lua dispatcher syntax when Hyprland reports `configProvider: "lua"`.
-6
View File
@@ -1,6 +0,0 @@
type: fixed
area: jellyfin
- Fixed Jellyfin discovery playback: the active item is no longer reloaded on startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, duplicate ready signals no longer re-show the overlay, delayed Japanese subtitle selection is handled correctly, later-loading foreign tracks no longer steal the active Japanese track, and long-lived sidebar ffmpeg extractors no longer run against stream URLs.
- Fixed discovery resume when a remote play command sends `StartPositionTicks: 0` despite saved progress on the item.
- Kept Jellyfin picker library discovery working when the app log level is above info.
-8
View File
@@ -1,8 +0,0 @@
type: fixed
area: jellyfin
- Keep the discovery tray checkbox in sync on Linux after tray, CLI, or startup remote-session changes, and restart stale discovery sessions when the server no longer lists the SubMiner cast target.
- Fixed remote controller visibility and progress sync for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows.
- Kept Play and Resume distinct: Play starts from the beginning while Resume starts at the saved position, and final progress reports reuse SubMiner's last known position when mpv resets during stop.
- Derived cast device identity from the OS hostname and always report the client as SubMiner, ignoring legacy configurable identity fields so multiple installs no longer share a remote-session identity.
- Fixed the Windows setup login flow with an IPC bridge, immediate progress feedback, and a timeout with an inline error for unreachable servers.
-6
View File
@@ -1,6 +0,0 @@
type: fixed
area: jellyfin
- Show the visible subtitle overlay automatically during Jellyfin playback so `subtitleStyle` appearance applies, and inject the bundled mpv plugin when SubMiner auto-launches mpv so mpv-side keybindings work without overlay focus.
- Made the `y-t` overlay toggle reliable and sticky across stream redirects that change mpv's path, re-arming managed subtitle defaults on redirect so Japanese primary subtitles load, collapsing duplicate toggle events on Hyprland, and keeping passive Linux/Hyprland overlay shows from stealing keyboard focus from mpv.
- Improved subtitle timing by preferring default embedded streams over external sidecars, stripping Jellyfin's server-selected stream from playback URLs, suppressing mpv auto-selection while SubMiner stages managed tracks, correcting clear Japanese-vs-English cue offsets, and restoring per-stream subtitle delay shifts. Track selection tolerates transient `track-list` read failures and numeric string track IDs on Linux.
-8
View File
@@ -1,8 +0,0 @@
type: fixed
area: launcher
- Launcher-opened videos reuse an already-running background SubMiner instance, reapply preferred subtitles on warm launches, and close launcher-owned tray apps after playback ends.
- Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete, with mpv plugin subtitle auto-selection moved to pre-load so launch-time choices are not reset.
- `subminer settings` on macOS no longer emits Electron menu diagnostics and exits cleanly when the window is closed.
- `subminer app` on Linux returns control to the terminal immediately, and Linux first-run launcher installs build with a valid Bun shebang.
- `subminer app --setup` opens the setup flow when SubMiner is already running in the background.
-6
View File
@@ -1,6 +0,0 @@
type: added
area: launcher
- Added `subminer --version` / `subminer -v` to print the installed app version.
- Added `mpv.profile` config and Settings support for passing an mpv profile to SubMiner-managed mpv launches.
- Made bundled mpv plugin startup options configurable from SubMiner config.
-5
View File
@@ -1,5 +0,0 @@
type: changed
area: updater
- Linux tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows tray flow, instead of stopping at a "manual update required" dialog. AppImages managed by a system package (AUR `/opt/SubMiner/SubMiner.AppImage`) and non-AppImage launches (no `APPIMAGE` env) still fall back to the GitHub-asset flow.
- Routed `electron-updater` HTTP through `/usr/bin/curl` on Linux and disabled differential downloads, matching the macOS path, so background update checks stay off Electron's network service.
-4
View File
@@ -1,4 +0,0 @@
type: added
area: logs
- Add sanitized log ZIP exports from the tray menu and `subminer logs -e`, with home-directory usernames redacted from exported log contents.
-7
View File
@@ -1,7 +0,0 @@
type: fixed
area: logging
- Forward SubMiner `logging.level` into launcher-started and Windows shortcut-started mpv sessions, covering mpv log verbosity, plugin script logging, and plugin-launched app logging.
- Added numeric `logging.rotation` (default 7 days of retained daily app, launcher, and mpv logs) and `logging.files` toggles per component, with mpv logs disabled by default unless explicitly enabled for debugging.
- Added Windows mpv launch, IPC socket, subtitle track, and Yomitan/dictionary diagnostics, including expected/active IPC socket values when plugin auto-start skips on a socket mismatch.
- Stop repeated mpv IPC socket warning spam while the app waits in the background for mpv to recreate the socket.
-5
View File
@@ -1,5 +0,0 @@
type: changed
area: config
- `ankiConnect.nPlusOne.enabled` is no longer implicitly set to `true` when known-word highlighting is enabled; existing configs that already had N+1 highlighting keep it, but new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly.
- Updated known-word cache docs and examples to recommend expression/word fields and removed legacy-option references from user-facing config docs.
-8
View File
@@ -1,8 +0,0 @@
type: fixed
area: overlay
- Primed the first startup subtitle before autoplay resumes so the overlay renders text before video playback begins.
- Kept the visible overlay and subtitle stream alive after restarting SubMiner from the mpv `y-r` shortcut, with correct Linux bounds reapplication and user-paused playback preserved through readiness gates.
- Fixed managed mpv startup so launcher-owned videos quit SubMiner when playback ends while background/tray sessions stay alive, with pause-until-ready waiting for overlay and tokenization readiness.
- Fixed the subtitle sync modal on macOS so it no longer flashes and hides on the first attempt or leaves stale state after syncing.
- Fixed Windows managed mpv launches from a background instance so the warm app receives the start command, retargets the new mpv socket, binds to the player window, and receives startup overlay options.
-7
View File
@@ -1,7 +0,0 @@
type: fixed
area: overlay
- Refreshed overlay placement after leaving mpv fullscreen so the visible overlay stays aligned to the player on Hyprland.
- Kept the visible overlay stacked above mpv after mpv regains focus from clicks or overlay movement, and suspended it while the in-player stats window is open, restoring it mouse-passive afterward.
- Promoted SubMiner and Yomitan settings windows above the subtitle overlay on Hyprland instead of opening behind it, without hiding subtitles.
- Hid the visible overlay as soon as the character dictionary modal opens, including while AniList lookup is loading or returns no results.
-7
View File
@@ -1,7 +0,0 @@
type: fixed
area: overlay
- Hid the macOS visible overlay when mpv loses focus, is minimized, or is no longer the foreground target so other apps and Spaces are not covered, treating the frontmost mpv app as the focus signal.
- Kept the overlay stable through transient window-tracking misses, when mpv remains frontmost but window geometry temporarily disappears from macOS APIs, and when clicking from the overlay back into mpv.
- Kept the overlay correctly layered during stats mouse passthrough, opened the stats overlay inactive so it appears over fullscreen mpv without switching Spaces, and fixed passthrough so mpv controls stay clickable before hovering a subtitle bar.
- Reduced window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused.
-6
View File
@@ -1,6 +0,0 @@
type: fixed
area: overlay
- Kept playback paused for Yomitan lookup popups opened from the subtitle sidebar when popup auto-pause is enabled.
- Fixed Yomitan popups not opening when playback/overlay startup races the Yomitan extension load.
- Fixed subtitle sidebar mining so Yomitan-enriched cards use audio and images from the clicked sidebar line instead of the current primary subtitle line.
-5
View File
@@ -1,5 +0,0 @@
type: fixed
area: release
- Fixed macOS packaging so the compiled mpv window helper is built into `dist/scripts` and bundled, preventing the overlay from falling back to slow Swift source startup, and removed a stale Windows helper resource entry that produced harmless missing-file warnings.
- Fixed one-shot `make clean build install` flows so install picks up the AppImage built earlier in the same make invocation.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: websocket
- WebSocket: Kept the regular subtitle websocket plain-text only; annotation spans and token metadata now stay on the annotation websocket.
-4
View File
@@ -1,4 +0,0 @@
type: added
area: config
- Added `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitles visible while a Yomitan popup is open.
-5
View File
@@ -1,5 +0,0 @@
type: internal
area: release
- Release-note polishing treats pending fragments and reviewed prerelease notes as a cumulative final outcome, collapsing prerelease-only fixes or breakages into the final user-facing change.
- Prerelease note generation reuses existing reviewed notes and merges only new fragment material, and `make clean` preserves `release/prerelease-notes.md`.
@@ -1,4 +0,0 @@
type: changed
area: jellyfin
- Removed the Jellyfin setup server presets dropdown; setup now shows a single editable server URL field.
@@ -1,4 +0,0 @@
type: internal
area: tests
- Removed stale Yomitan vendor source-inspection assertions for changes that were not shipped.
-8
View File
@@ -1,8 +0,0 @@
type: added
area: config
- Added a dedicated Settings window via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections with click-to-learn keybinding controls (including the AniSkip button key) and AnkiConnect-backed deck, field, and note-type pickers.
- Expanded live reload so Settings saves apply immediately for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, Anki field mappings, sentence card model, and selected annotation/runtime options.
- Settings search works across all categories, narrows on multi-word terms, and hides settings owned by richer editors.
- The note-fields note type picker defaults to the configured Anki deck's note type, then exact `Kiku`, then exact `Lapis`, leaving it blank for manual selection otherwise.
- AI and translation settings remain config-file only.
-7
View File
@@ -1,7 +0,0 @@
type: added
area: setup
- Added optional first-run setup controls to install Bun and the `subminer` command-line launcher on Linux, macOS, and Windows, with a Windows `subminer.cmd` PATH shim so `subminer` works without manually adding `SubMiner.exe` to PATH.
- Added an Open SubMiner Settings button to first-run setup and moved Finish to the right-side action slot.
- First-run setup recognizes existing `subminer` installs in Homebrew or user PATH directories, while manual setup avoids writing into Homebrew-owned paths.
- The standalone setup app quits after completing first-run setup, returning the terminal instead of leaving the process open.
-6
View File
@@ -1,6 +0,0 @@
type: fixed
area: shortcuts
- Disabled native mpv menu shortcuts during managed macOS playback so configured SubMiner shortcuts work while mpv has focus.
- Wired configured session shortcuts, including `stats.markWatchedKey`, through mpv so custom changes work while mpv has focus.
- Focus the visible overlay when entering multi-line copy/mine selection so number keys choose the line count on macOS and Windows.
-5
View File
@@ -1,5 +0,0 @@
type: fixed
area: stats
- Fixed in-player stats layering so delete confirmations, overlay modals, and update-check dialogs appear above the stats window.
- Grouped Jellyfin playback stats under item metadata instead of stream URLs, so watched episodes merge with matching local library titles and keep clean display names.
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: subtitles
- Subsync now always opens the manual picker and the `subsync.defaultMode` config/settings option has been removed.
-8
View File
@@ -1,8 +0,0 @@
type: changed
area: config
- Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`; sidebar appearance uses `subtitleSidebar.css`.
- Moved known-word and N+1 annotation colors to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`; legacy Anki color keys are still accepted with deprecation warnings.
- Updated subtitle font defaults to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
- Existing configs are migrated automatically: legacy primary/secondary appearance options and hover token colors fold into `subtitleStyle.css`, and user config files are preserved during legacy compatibility handling.
- Live Settings saves apply subtitle CSS declarations immediately to open video overlays, and the generated example config uses the same CSS declaration paths.
-5
View File
@@ -1,5 +0,0 @@
type: fixed
area: subtitles
- Kept frequency highlighting for determiner-led noun compounds like `その場` while still filtering standalone determiners.
- Fixed frequency annotations for Yomitan single-token compounds with internal particles such as `目の前`, while keeping pure grammar/kana helper spans unannotated.
-7
View File
@@ -1,7 +0,0 @@
type: fixed
area: tray
- Kept the tray app running when closing tray-launched Yomitan settings, with a close-only menu so closing settings does not quit the tray, and an in-page close button on Hyprland where native window controls are unavailable.
- Kept settings loading from blocking other tray actions, serialized copied Yomitan extension refreshes at startup, and disabled the embedded popup preview to avoid renderer hangs during sidebar navigation.
- Fixed session help focus handling so the modal can close without mpv running.
- Fixed the Windows tray "Open SubMiner Setup" action so it opens the setup window after first-run setup is complete.
+6
View File
@@ -0,0 +1,6 @@
type: docs
area: troubleshooting
- Updated the Hyprland overlay troubleshooting with current Lua (`hl.window_rule`) config and the legacy `hyprland.conf` window rules, and noted SubMiner attempts automatic placement via `hyprctl`.
- Added a Character Dictionary troubleshooting section covering name matching, inline portraits, and external-profile mode (no AniList auth required).
- Added a "See Also" index linking each feature's own troubleshooting page.
-6
View File
@@ -1,6 +0,0 @@
type: fixed
area: updater
- Linux: `subminer -u` performs release updates independently of any running tray app (reporting `up to date` without downloading when not newer), and update checks use GitHub release metadata/assets instead of the native Electron updater to avoid network-service crashes during startup.
- macOS: update dialogs from `subminer -u` reliably appear in the foreground; builds that cannot apply native updates show a manual-install message instead of a restart prompt; `electron-updater` metadata and ZIP downloads route through `/usr/bin/curl` to avoid Electron network crashes while preserving the Squirrel install path; and metadata mismatches from conflicting ZIP filenames are resolved.
- Windows: automatic updates keep the native `electron-updater`/NSIS install path while routing updater HTTP through main-process fetch, avoiding the delayed app exit after launch.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: windows
- Windows startup failures now show a native error dialog and write fatal details to the SubMiner app log instead of exiting silently.
-6
View File
@@ -1,6 +0,0 @@
type: fixed
area: youtube
- Downloaded selected YouTube primary subtitles to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit, and suppressed false load-failure notifications by re-checking live mpv subtitle state.
- Launcher-managed playback commands create the tray icon even when attaching to an already-running process, and app-owned YouTube playback no longer lets the mpv plugin start a second SubMiner instance.
- Logged Linux tray registration failures with a StatusNotifier/AppIndicator hint and documented the Hyprland tray-host requirement.
+3 -3
View File
@@ -380,7 +380,7 @@
"word-spacing": "0", // Word spacing setting. "word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting. "font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting. "text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting. "backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting. "--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting. "--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
@@ -405,7 +405,7 @@
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used. "sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000). "topX": 10000, // Only color tokens with frequency rank <= topX (default: 10000).
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
@@ -430,7 +430,7 @@
"word-spacing": "0", // Word spacing setting. "word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting. "font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting. "text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)", // Text shadow setting.
"backdrop-filter": "blur(6px)" // Backdrop filter setting. "backdrop-filter": "blur(6px)" // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. } // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting. } // Secondary setting.
+1 -1
View File
@@ -36,7 +36,7 @@ In both modes, the enrichment workflow is the same:
4. Fills the translation field from the secondary subtitle or AI. 4. Fills the translation field from the secondary subtitle or AI.
5. Writes metadata to the miscInfo field. 5. Writes metadata to the miscInfo field.
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills from Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect.
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`. Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
### Proxy Mode Setup (Yomitan / Texthooker) ### Proxy Mode Setup (Yomitan / Texthooker)
+156 -5
View File
@@ -1,12 +1,163 @@
# Changelog # Changelog
## Unreleased ## v0.15.0 (2026-05-29)
- **Character Dictionary:** Loaded entries are now scoped to the current AniList media for subtitle name matching and inline portraits. Added a character dictionary manager at `Ctrl/Cmd+D`; AniList overrides now live inside that manager instead of using a separate default shortcut. **Breaking Changes**
## v0.14.0 (2026-05-12) - **Subsync:**
- The `subsync.defaultMode` config option has been removed
- Subsync now always opens the manual subtitle picker regardless of any previously set default mode
- **N+1 Highlighting:**
- N+1 highlighting now has its own dedicated `ankiConnect.nPlusOne.enabled` option, separate from known-word highlighting
- It is no longer enabled automatically when known-word highlighting is on — enable it explicitly to keep N+1 annotations
SubMiner no longer requires a globally-installed mpv plugin. The bundled plugin is injected at runtime only when SubMiner launches mpv — through the `subminer` launcher, the app's managed launch, or the packaged Windows SubMiner mpv shortcut. When you open mpv on its own, SubMiner is not involved and the plugin is never loaded. If you have a legacy global SubMiner plugin under mpv's `scripts` directory, first-run setup detects it and prompts you to remove it before playback starts. **Added**
- **Auto-Updater:**
- Tray and `subminer -u` update checks with app update prompts
- Launcher and Linux rofi theme auto-updates
- Checksum verification and configurable notifications
- Opt-in prerelease channel via `updates.channel: "prerelease"`
- **Settings Window:**
- New dedicated Settings window via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections
- Click-to-learn keybinding controls
- AnkiConnect-backed deck, field, and note-type pickers that auto-fill from the configured Anki deck
- Cross-category search
- Live save for most options including subtitle CSS, stats keys, logging level, Jimaku, Subsync, and Anki mappings
- AI and translation settings remain config-file only
- **Inline Character Portraits:**
- Optional AniList character portraits appear inline for name-matched subtitle text
- Manual AniList overrides scoped per parent media directory so separate season folders maintain separate character dictionary selections
- **Character Dictionary Manager:** New `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded entries.
- **Log Export:** Sanitized log ZIP export from the tray menu and via `subminer logs -e`, with home-directory usernames redacted from exported contents.
- **Launcher CLI:**
- `subminer --version` / `subminer -v` prints the installed app version
- `mpv.profile` config and Settings support passes a named mpv profile to managed launches
- Bundled mpv plugin startup options are now configurable from SubMiner config
- **First-Run Setup:**
- Optional installer for Bun and the `subminer` CLI on Linux, macOS, and Windows
- Windows `subminer.cmd` PATH shim so `subminer` works without manually adding `SubMiner.exe` to PATH
- Setup recognizes existing Homebrew or user PATH installs and avoids writing into Homebrew-owned paths
- Includes an Open SubMiner Settings button
- Standalone setup app quits after completing, returning terminal control
- **Primary Subtitle Visibility on Yomitan Popup:** New `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan popup is open.
**Changed**
- **Subtitle Appearance Config:**
- Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`
- Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`
- Subtitle font defaults updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`
- Existing configs migrate automatically; legacy Anki color keys still accepted with deprecation warnings
- **Subtitle Style Defaults:**
- Stronger outline-style text shadow
- Thicker JLPT underlines
- Frequency `topX` default raised to `10000`
- **Character Dictionary:**
- Entries scoped to the current AniList media for name matching and inline portraits
- Generates Japanese-only name aliases so raw romanized/English aliases no longer surface as separate results
- In-app AniList selector waits for an explicit search with the box prefilled from the current filename
- `subtitleStyle.nameMatchEnabled` is now the sole switch for dictionary sync and builds
- **Electron Runtime:** Updated from 39.8.6 to 42.2.0, returning SubMiner to a supported Electron release line.
- **Jellyfin Setup:**
- Removed the server presets dropdown
- Setup now shows a single editable server URL field
- **Jellyfin Cast Identity:**
- Device identity now derived from the OS hostname and always reported as SubMiner
- Previously configurable identity fields are ignored, preventing multiple installs from sharing a remote-session identity
- **Startup Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
- **Setup Appearance:** Removed the bundled mpv runtime plugin readiness card from the setup flow.
**Fixed**
- **AniList Progress:**
- Progress updates fire correctly when playback reaches or skips past the watched threshold, using fresh mpv timing events
- Season-specific results preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching
- Repeated missing-token checks no longer exhaust retry attempts or duplicate dead-letter entries
- **Anki Mining:**
- Sentence-audio padding is opt-in by default
- Animated AVIF freeze-frame duration aligned to word audio length without double-counting
- Multi-line sentence alignment fixed for repeated subtitle text
- Kiku duplicate-card detection, auto-merge, modal acknowledgment race, and field/tag ordering corrected
- YouTube playback cards use mpv's resolved stream URLs
- Sentence cards refresh the secondary subtitle before saving
- **Jellyfin Discovery:**
- Startup, subtitle track selection, and duplicate ready-signal handling all fixed
- Paused mpv no longer misreported as playing
- Resume corrected when a remote play command sends `StartPositionTicks: 0` despite saved progress
- **Jellyfin Remote:**
- Tray checkbox stays in sync on Linux after tray, CLI, or startup changes
- Remote controller visibility and progress sync fixed for seeks, stops, startup path changes, and Linux websocket reconnect windows
- Play and Resume now behave correctly (Play from beginning, Resume from saved position)
- Final progress reports reuse SubMiner's last known position when mpv resets on stop
- Windows setup login flow fixed with an IPC bridge, immediate feedback, and a timeout with inline error for unreachable servers
- **Overlay (macOS):**
- Overlay hides when mpv loses focus, is minimized, or is no longer the foreground app
- Stays stable through transient window geometry disappearances from macOS APIs and when clicking from the overlay back into mpv
- Stats overlay opened inactive so it appears over fullscreen mpv without switching Spaces
- Passthrough fixed so mpv controls stay clickable before hovering a subtitle bar
- **Yomitan Sidebar:**
- Playback stays paused for sidebar-opened Yomitan popups when auto-pause is enabled
- Popups now open when startup races the Yomitan extension load
- Sidebar mining cards use audio and images from the clicked sidebar line instead of the current primary subtitle
- **Launcher:**
- `subminer app` on Linux returns terminal control immediately
- `subminer app --setup` opens the setup flow when SubMiner is already running in the background
- **YouTube Playback:**
- Selected subtitles downloaded to local temp files so the primary bar and sidebar read the same source, with cleanup on reload and quit
- False load-failure notifications suppressed
- Tray icon created on launcher-managed playback that attaches to an already-running process
- **Shortcuts:**
- Native mpv menu shortcuts disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus
- Custom session shortcuts including `stats.markWatchedKey` wired through mpv
- Multi-line copy/mine overlay correctly focused so number keys choose the line count on macOS and Windows
- **Controller Bindings:**
- Controller config and debug shortcuts stay closed while controller support is disabled
- Binding learn mode starts from the edit pencil
- Remaps saved per controller profile
- Binding badges also start learn mode
- Row reset buttons restore individual bindings to defaults
- **Logging:**
- `logging.level` forwarded to launcher-started and Windows shortcut-started mpv sessions, covering mpv log verbosity, plugin logging, and plugin-launched app logging
- `logging.rotation` (default 7 days) and per-component `logging.files` toggles added, with mpv logs disabled by default
- Repeated IPC socket warning spam suppressed while waiting for mpv to recreate the socket
- Windows mpv IPC, subtitle track, and Yomitan diagnostics added
- **In-Player Stats:**
- Layering fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window
- Jellyfin playback stats grouped by item metadata so watched episodes merge with matching local library titles and keep clean display names
- **WebSocket Annotations:**
- Annotation spans and token metadata stay on the annotation WebSocket
- The regular subtitle WebSocket is plain-text only
- **Subtitle Annotation Prefetching:** Cached colored annotations and character images ready sooner for live subtitle changes without delaying raw subtitle display.
- **Windows Startup Errors:** Fatal startup failures now show a native error dialog and write details to the app log instead of exiting silently.
**Docs**
- **Documentation Site:**
- Published stable docs at the site root with current development docs under `/main/`
- Fixed versioned docs navigation, archived page link handling, and local dev version routing
- Documented all previously undocumented config options including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options
- Added Playback Startup Flow and Runtime Sockets diagrams to the architecture docs with cross-reference pointers in the MPV Plugin and Troubleshooting pages
<details>
<summary>Internal changes</summary>
**Internal**
- **Release Tooling:**
- Release-note polishing treats pending fragments and reviewed prerelease notes as a cumulative final outcome, collapsing prerelease-only fixes into the final user-facing change
- Prerelease generation reuses existing reviewed notes and merges only new fragment material
- `make clean` preserves `release/prerelease-notes.md`
- **Tests:** Removed stale Yomitan vendor source-inspection assertions for changes that were not shipped.
</details>
## Previous Versions
<details>
<summary>v0.14.x</summary>
<h2>v0.14.0 (2026-05-12)</h2>
**Added** **Added**
@@ -68,7 +219,7 @@ SubMiner no longer requires a globally-installed mpv plugin. The bundled plugin
</details> </details>
## Previous Versions </details>
<details> <details>
<summary>v0.12.x</summary> <summary>v0.12.x</summary>
+28 -8
View File
@@ -20,18 +20,15 @@ The feature has three stages: **snapshot**, **merge**, and **match**.
Character dictionary sync is disabled by default. To turn it on: Character dictionary sync is disabled by default. To turn it on:
1. Authenticate with AniList (see [AniList Integration](/anilist-integration#setup)). 1. Enable **Name Match** in Settings → Subtitle Style, or set `subtitleStyle.nameMatchEnabled: true` in your config.
2. Set `subtitleStyle.nameMatchEnabled` to `true` in your config or enable **Name Match Enabled** in Settings. 2. Start watching — SubMiner queries AniList's public GraphQL API (no authentication required) and imports the merged dictionary into Yomitan automatically.
3. Start watching — SubMiner will generate a snapshot for the current media and import the merged dictionary into Yomitan automatically. 3. Optionally enable **Name Match Images** (Settings → Subtitle Style) to show inline circular character portraits next to matched names in subtitles.
```jsonc ```jsonc
{ {
"anilist": {
"enabled": true,
"accessToken": "your-token",
},
"subtitleStyle": { "subtitleStyle": {
"nameMatchEnabled": true, "nameMatchEnabled": true,
"nameMatchImagesEnabled": true, // optional — inline portraits
}, },
} }
``` ```
@@ -40,6 +37,10 @@ Character dictionary sync is disabled by default. To turn it on:
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup. The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
::: :::
::: info
AniList character data is fetched via public GraphQL queries — no account or access token is needed. AniList authentication is only required for the separate [watch-progress sync](/anilist-integration) feature.
:::
::: warning ::: warning
If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled. If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled.
::: :::
@@ -106,6 +107,25 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names | | `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
## Inline Character Portraits
When `subtitleStyle.nameMatchImagesEnabled` is enabled, SubMiner injects a small circular portrait image directly into the subtitle line next to each matched character name.
Portraits are sourced from the local snapshot — they are embedded at snapshot-generation time and served from the cached ZIP, so no network request happens during playback. Images are downloaded from AniList CDN once per character and stored in `character-dictionaries/img/`.
If a snapshot was generated before portrait data was available (e.g. during an earlier version or offline sync), SubMiner detects the missing image data on the next media match and automatically refreshes the snapshot so portraits are included in the next merged dictionary build.
**To enable:**
- Settings → Subtitle Style → **Name Match Images**, or
- `subtitleStyle.nameMatchImagesEnabled: true` in config.
The portrait size is controlled by the surrounding subtitle font size and renders as a circle clipped from the character's AniList cover image.
::: tip
Inline portraits help you quickly associate names with faces while building vocabulary — especially useful for shows with large casts where you're still learning who's who.
:::
## Dictionary Entries ## Dictionary Entries
Each character entry in the Yomitan dictionary includes structured content: Each character entry in the Yomitan dictionary includes structured content:
@@ -281,5 +301,5 @@ If you work with visual novels or want a standalone dictionary generator indepen
## Related ## Related
- [Subtitle Annotations](/subtitle-annotations) — how name matches interact with N+1, frequency, and JLPT layers - [Subtitle Annotations](/subtitle-annotations) — how name matches interact with N+1, frequency, and JLPT layers
- [AniList Integration](/anilist-integration) — authentication, episode tracking, and AniList settings - [AniList Integration](/anilist-integration) — watch-progress sync and AniList authentication (separate from character dictionary)
- [Configuration Reference](/configuration) — full config options - [Configuration Reference](/configuration) — full config options
+15 -16
View File
@@ -52,7 +52,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf
- Tracking & App - Tracking & App
- Advanced - Advanced
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes. Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and auto-fills an empty setting when one is found. Keybinding fields use click-to-learn controls instead of raw text boxes.
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies. The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
@@ -96,8 +96,8 @@ SubMiner watches the active config file (`config.jsonc` or `config.json`) while
Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings, Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings,
logging level, selected source-language preferences, Jimaku/Subsync settings, and logging level, selected source-language preferences, Jimaku/Subsync settings, and
the Anki known-word, N+1, field, sentence-card, and Kiku options listed in the the Anki deck, known-word, N+1, field, sentence-card, and Kiku options listed
reference tables below. in the reference tables below.
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active. When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
@@ -105,7 +105,7 @@ Restart-required changes:
- Any other config sections still require restart. - Any other config sections still require restart.
- Shared top-level `ai` provider settings still require restart. - Shared top-level `ai` provider settings still require restart.
- AnkiConnect transport/proxy/media/deck/tag fields still require restart unless listed above. - AnkiConnect transport/proxy/media/tag fields still require restart unless listed above.
- SubMiner shows an on-screen/system notification listing restart-required sections when they change. - SubMiner shows an on-screen/system notification listing restart-required sections when they change.
### Configuration Options Overview ### Configuration Options Overview
@@ -339,7 +339,7 @@ See `config.example.jsonc` for detailed configuration options.
"word-spacing": "0", "word-spacing": "0",
"font-kerning": "normal", "font-kerning": "normal",
"text-rendering": "geometricPrecision", "text-rendering": "geometricPrecision",
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)",
"font-style": "normal", "font-style": "normal",
"backdrop-filter": "blur(6px)", "backdrop-filter": "blur(6px)",
"--subtitle-hover-token-color": "#f4dbd6", "--subtitle-hover-token-color": "#f4dbd6",
@@ -351,7 +351,7 @@ See `config.example.jsonc` for detailed configuration options.
"color": "#cad3f5", "color": "#cad3f5",
"background-color": "transparent", "background-color": "transparent",
"font-size": "24px", "font-size": "24px",
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)" "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)"
} }
} }
} }
@@ -375,7 +375,7 @@ See `config.example.jsonc` for detailed configuration options.
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) | | `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | | `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. | | `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) | | `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) | | `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) | | `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode | | `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
@@ -897,8 +897,8 @@ Enable automatic Anki card creation and updates with media generation:
}, },
"ai": { "ai": {
"enabled": false, "enabled": false,
"model": "openai/gpt-4o-mini", "model": "",
"systemPrompt": "Translate mined sentence text only." "systemPrompt": ""
}, },
"media": { "media": {
"generateAudio": true, "generateAudio": true,
@@ -906,11 +906,11 @@ Enable automatic Anki card creation and updates with media generation:
"imageType": "static", "imageType": "static",
"imageFormat": "jpg", "imageFormat": "jpg",
"imageQuality": 92, "imageQuality": 92,
"imageMaxWidth": 1280, "imageMaxWidth": 0,
"imageMaxHeight": 720, "imageMaxHeight": 0,
"animatedFps": 10, "animatedFps": 10,
"animatedMaxWidth": 640, "animatedMaxWidth": 640,
"animatedMaxHeight": 360, "animatedMaxHeight": 0,
"animatedCrf": 35, "animatedCrf": 35,
"audioPadding": 0, "audioPadding": 0,
"fallbackDuration": 3, "fallbackDuration": 3,
@@ -925,8 +925,8 @@ Enable automatic Anki card creation and updates with media generation:
"pattern": "[SubMiner] %f (%t)" "pattern": "[SubMiner] %f (%t)"
}, },
"isLapis": { "isLapis": {
"enabled": true, "enabled": false,
"sentenceCardModel": "Japanese sentences" "sentenceCardModel": "Lapis"
}, },
"isKiku": { "isKiku": {
"enabled": false, "enabled": false,
@@ -951,7 +951,7 @@ This example is intentionally compact. The option table below documents availabl
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | | `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | | `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. | | `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. In Settings, this dropdown auto-fills from Yomitan's current mining deck when available. |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | | `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | | `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) | | `fields.image` | string | Card field for images (default: `Picture`) |
@@ -1133,7 +1133,6 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
"enabled": true, "enabled": true,
"accessToken": "", "accessToken": "",
"characterDictionary": { "characterDictionary": {
"enabled": false,
"maxLoaded": 3, "maxLoaded": 3,
"profileScope": "all", "profileScope": "all",
"collapsibleSections": { "collapsibleSections": {
+3 -3
View File
@@ -380,7 +380,7 @@
"word-spacing": "0", // Word spacing setting. "word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting. "font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting. "text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting. "backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting. "--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting. "--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
@@ -405,7 +405,7 @@
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used. "sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000). "topX": 10000, // Only color tokens with frequency rank <= topX (default: 10000).
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
@@ -430,7 +430,7 @@
"word-spacing": "0", // Word spacing setting. "word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting. "font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting. "text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)", // Text shadow setting.
"backdrop-filter": "blur(6px)" // Backdrop filter setting. "backdrop-filter": "blur(6px)" // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. } // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting. } // Secondary setting.
+2 -1
View File
@@ -67,7 +67,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Right-click + drag` | Reposition subtitles (on subtitle area) | | `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | | `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions. On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
@@ -86,6 +86,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | | `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
| `` ` `` | Toggle stats overlay | `stats.toggleKey` | | `` ` `` | Toggle stats overlay | `stats.toggleKey` |
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`. The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
+1 -1
View File
@@ -74,7 +74,7 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
| Option | Default | Description | | Option | Default | Description |
| ------------------------------------------------ | ------------ | ---------------------------------------------------------------- | | ------------------------------------------------ | ------------ | ---------------------------------------------------------------- |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting | | `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight | | `subtitleStyle.frequencyDictionary.topX` | `10000` | Max frequency rank to highlight |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` | | `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` | | `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for single mode | | `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for single mode |
+35 -20
View File
@@ -35,30 +35,45 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
"toggleKey": "Backslash", "toggleKey": "Backslash",
"pauseVideoOnHover": true, "pauseVideoOnHover": true,
"autoScroll": true, "autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", "css": {
"fontSize": 16 "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"color": "#cad3f5",
"background-color": "rgba(73, 77, 100, 0.9)",
"font-size": "16px",
"opacity": "0.95",
"--subtitle-sidebar-max-width": "420px",
"--subtitle-sidebar-timestamp-color": "#a5adcb",
"--subtitle-sidebar-active-line-color": "#f5bde6",
"--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)",
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)"
}
} }
} }
``` ```
| Option | Type | Default | Description | Styling lives under the `css` object, using CSS property names and CSS custom properties (the same pattern as `subtitleStyle.css`).
| --------------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | `true` | Enable subtitle sidebar support | | Option | Type | Default | Description |
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup | | ------------------- | ------- | ------------- | -------------------------------------------------------------------------- |
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space | | `enabled` | boolean | `true` | Enable subtitle sidebar support |
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut | | `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
| `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list | | `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback | | `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels | | `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list |
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` | | `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
| `backgroundColor` | string | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
| `textColor` | string | `#cad3f5` | Default cue text color | | `css` property | Default | Description |
| `fontFamily` | string | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | CSS `font-family` applied to cue text | | ------------------------------------------- | --------------------------- | ---------------------------- |
| `fontSize` | number | `16` | Base cue font size in CSS pixels | | `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family |
| `timestampColor` | string | `#a5adcb` | Cue timestamp color | | `color` | `#cad3f5` | Default cue text color |
| `activeLineColor` | string | `#f5bde6` | Active cue text color | | `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
| `activeLineBackgroundColor` | string | `rgba(138, 173, 244, 0.22)` | Active cue background color | | `font-size` | `16px` | Base cue font size |
| `hoverLineBackgroundColor` | string | `rgba(54, 58, 79, 0.84)` | Hovered cue background color | | `opacity` | `0.95` | Sidebar opacity between `0` and `1` |
| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color |
| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color |
| `--subtitle-sidebar-active-background-color`| `rgba(138, 173, 244, 0.22)` | Active cue background color |
| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
## Keyboard Shortcut ## Keyboard Shortcut
+52 -7
View File
@@ -18,7 +18,7 @@ If the overlay never appears at all, see [Playback Startup Flow](./architecture#
## Logging and App Mode ## Logging and App Mode
- Default log output is `info`. - Default log output is `warn`.
- Use `--log-level` for more/less output. - Use `--log-level` for more/less output.
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity. - Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics. - You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
@@ -46,7 +46,7 @@ If the overlay never appears at all, see [Playback Startup Flow](./architecture#
2. Reduce rendering pressure: 2. Reduce rendering pressure:
- lower `subtitleStyle.fontSize` - lower `subtitleStyle.css["font-size"]`
- keep overlay complexity minimal during heavy CPU periods - keep overlay complexity minimal during heavy CPU periods
3. Reduce media overhead: 3. Reduce media overhead:
@@ -66,7 +66,9 @@ If the overlay never appears at all, see [Playback Startup Flow](./architecture#
```json ```json
{ {
"subtitleStyle": { "subtitleStyle": {
"fontSize": 30, "css": {
"font-size": "30px"
},
"enableJlpt": false, "enableJlpt": false,
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false "enabled": false
@@ -95,7 +97,7 @@ If the overlay never appears at all, see [Playback Startup Flow](./architecture#
- Confirm only one SubMiner instance is running. - Confirm only one SubMiner instance is running.
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor. - Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
- Use `info` logs by default; keep `debug` for targeted diagnosis. - Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur. - Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
**"Failed to parse MPV message"** **"Failed to parse MPV message"**
@@ -230,6 +232,15 @@ Japanese word boundaries depend on Yomitan parser output. If segmentation seems
- Verify Yomitan dictionaries are installed and active. - Verify Yomitan dictionaries are installed and active.
- Note that CJK characters without spaces are segmented using parser heuristics, which is not always perfect. - Note that CJK characters without spaces are segmented using parser heuristics, which is not always perfect.
## Character Dictionary
Character names from AniList are matched and highlighted in subtitles via the bundled Yomitan. See [Character Dictionary](/character-dictionary) for setup and the full troubleshooting list — the most common issues:
- **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`, and that the current media resolved to an AniList entry (SubMiner needs a media ID to fetch characters). No AniList account or token is required — character data uses public GraphQL queries.
- **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. Portraits also require AniList to return an image and the download to succeed during snapshot generation.
- **Wrong characters showing:** Open the in-app manager (`Ctrl/Cmd+D`) and use **Override** to pin the correct AniList match for the series.
- **Feature unavailable:** If `yomitan.externalProfilePath` is set, SubMiner runs in read-only external-profile mode and its character-dictionary features are disabled.
## Media Generation ## Media Generation
**"FFmpeg not found"** **"FFmpeg not found"**
@@ -322,11 +333,28 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
### Hyprland ### Hyprland
SubMiner's overlay is a transparent, frameless, always-on-top Electron window. Hyprland needs window rules to keep it transparent and borderless, and `pass` bindings to forward global shortcuts to SubMiner. SubMiner's overlay is a transparent, frameless, always-on-top Electron window. SubMiner tries to apply the floating, borderless, no-shadow, and no-blur properties itself each time it places the overlay. It detects Hyprland's active config provider and uses Lua `hl.dsp.window.*` dispatchers for recent Hyprland Lua configs, or the legacy dispatcher syntax for older hyprlang configs. On many configurations that is enough, but if your Hyprland version doesn't honor those runtime dispatches — or a broad rule in your config forces opacity/blur on every window — add explicit window rules so the overlay is exempt. You also need `pass` bindings to forward global shortcuts to SubMiner (see below).
**Overlay is not transparent or has a visible border** **Overlay is not transparent or has a visible border**
Add these window rules to your `hyprland.conf`: Add a window rule matching SubMiner's window class. Recent Hyprland uses the Lua config format:
```lua
hl.window_rule({
match = { class = "^SubMiner$" },
float = true,
border_size = 0,
xray = false,
no_shadow = true,
no_blur = true,
no_dim = true,
opaque = true,
dim_around = false,
opacity = "1.0 override 1.0 override",
})
```
On older Hyprland releases that still use the hyprlang config (`hyprland.conf`), use the equivalent `windowrule` lines:
```ini ```ini
windowrule = float on, match:class SubMiner windowrule = float on, match:class SubMiner
@@ -336,7 +364,7 @@ windowrule = no_shadow on, match:class SubMiner
windowrule = no_blur on, match:class SubMiner windowrule = no_blur on, match:class SubMiner
``` ```
Without `xray off override`, the compositor may render the transparent overlay incorrectly — you might see a solid background or visual artifacts instead of the mpv video underneath. If you still see a solid background or visual artifacts instead of the mpv video underneath, the culprit is almost always a global opacity/blur rule applying to the overlay — the `opaque`/`opacity` and `no_blur` fields above override it.
**Global shortcuts not working** **Global shortcuts not working**
@@ -361,3 +389,20 @@ For more details, see the Hyprland docs on [global keybinds](https://wiki.hypr.l
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility. - **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app` - **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`
## See Also
Feature-specific issues are covered in each feature's own page:
- [Anki Integration](/anki-integration) — card creation, field mapping, and AnkiConnect setup
- [AniList Integration](/anilist-integration) — watch-progress sync and authentication
- [Character Dictionary](/character-dictionary) — AniList character name matching and inline portraits
- [Jellyfin Integration](/jellyfin-integration) — remote playback and library connection
- [Jimaku Integration](/jimaku-integration) — subtitle fetching and API rate limits
- [YouTube Integration](/youtube-integration) — subtitle generation and playback
- [Immersion Tracking](/immersion-tracking) — telemetry and session logging
- [WebSocket / Texthooker API](/websocket-texthooker-api) — external texthooker clients
- [Subtitle Annotations](/subtitle-annotations) — N+1, frequency, JLPT, and name-match layers
- [Subtitle Sidebar](/subtitle-sidebar) — sidebar navigation and behavior
- [Configuration Reference](/configuration) — full config options
- [Shortcuts](/shortcuts) — keybinding reference
+1 -1
View File
@@ -116,7 +116,7 @@ subminer dictionary --candidates /path/to/file.mkv
subminer dictionary --select 21355 /path/to/file.mkv subminer dictionary --select 21355 /path/to/file.mkv
subminer texthooker # Launch texthooker-only mode subminer texthooker # Launch texthooker-only mode
subminer texthooker -o # Launch texthooker and open it in your browser subminer texthooker -o # Launch texthooker and open it in your browser
subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow) subminer app --anilist-setup # Pass args directly to SubMiner binary (example: AniList login flow)
# Direct packaged app control # Direct packaged app control
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs) SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
+4 -4
View File
@@ -24,7 +24,7 @@ This page documents those integration points and shows how to build custom consu
## Enable and Configure the Services ## Enable and Configure the Services
SubMiner's integration ports are configured in `config.jsonc`. SubMiner's integration ports are configured in `config.jsonc`. All three services are **off by default** — the block below shows the values to set to turn them on.
```jsonc ```jsonc
{ {
@@ -45,9 +45,9 @@ SubMiner's integration ports are configured in `config.jsonc`.
### How startup behaves ### How startup behaves
- `websocket.enabled: "auto"` starts the basic subtitle websocket unless SubMiner detects the external `mpv_websocket` plugin. - `websocket.enabled` defaults to `false`. Set it to `"auto"` to start the basic subtitle websocket unless SubMiner detects the external `mpv_websocket` plugin, or `true` to always start it.
- `annotationWebsocket` is independent from `websocket` and stays enabled unless you explicitly disable it. - `annotationWebsocket.enabled` defaults to `false` and is independent from `websocket`. Set it to `true` to start the annotated stream.
- `texthooker.launchAtStartup` starts the local HTTP UI automatically. - `texthooker.launchAtStartup` defaults to `false`. Set it to `true` to start the local HTTP UI automatically.
- `texthooker.openBrowser` controls whether SubMiner opens the texthooker page in your browser when it starts. - `texthooker.openBrowser` controls whether SubMiner opens the texthooker page in your browser when it starts.
If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only helper process. The launcher derives the plugin's texthooker setting from your SubMiner config (`texthooker.launchAtStartup`) and injects it at runtime — there is no plugin config file to edit. If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only helper process. The launcher derives the plugin's texthooker setting from your SubMiner config (`texthooker.launchAtStartup`) and injects it at runtime — there is no plugin config file to edit.
+3 -3
View File
@@ -33,7 +33,7 @@
`bun run build` `bun run build`
When validating auto-update metadata, also run the relevant platform package When validating auto-update metadata, also run the relevant platform package
build and confirm `release/` contains the generated updater metadata build and confirm `release/` contains the generated updater metadata
(`*.yml`) and blockmaps (`*.blockmap`). (`latest*.yml`) and blockmaps (`*.blockmap`).
8. If `docs-site/` changed, also run: 8. If `docs-site/` changed, also run:
`bun run docs:test` `bun run docs:test`
`bun run docs:build` `bun run docs:build`
@@ -55,7 +55,7 @@
`bun run test:env` `bun run test:env`
`bun run build` `bun run build`
When validating packaged updater output, confirm the platform build writes When validating packaged updater output, confirm the platform build writes
`*.yml` and `*.blockmap` files under `release/`. `latest*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep (package.json version bump + the generated 5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the `release/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. If you add more committed file — so review it before committing. If you add more
@@ -87,7 +87,7 @@ Notes:
- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`. - Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed. - AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled. - Release and prerelease workflows upload updater metadata (`latest*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS `SubMiner-<version>-mac.zip`, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer. - macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS `SubMiner-<version>-mac.zip`, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks. - macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks. - Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.15.0-beta.11", "version": "0.15.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
+79 -47
View File
@@ -3,39 +3,48 @@
## Highlights ## Highlights
### Added ### Added
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections. Includes click-to-learn keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, Anki field mappings, sentence card model, and selected annotation/runtime options. Settings search works across all categories and narrows on multi-word terms. AI and translation settings remain config-file only. - **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections.
- Includes click-to-learn keybinding controls, an AnkiConnect deck dropdown that auto-fills from Yomitan's current mining deck, and AnkiConnect-backed deck, field, and note-type pickers.
- Live-saves changes for subtitle CSS declarations, stats keys, logging level, Anki field mappings, sentence card model, and other annotation and runtime options; search narrows across all categories including on multi-word terms. AI and translation settings remain config-file only.
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification, configurable update notifications, and an opt-in prerelease channel. The `subminer` launcher and Linux rofi theme update automatically. Set `updates.channel` to `"prerelease"` to receive beta and RC builds. - **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification and configurable update notifications.
- The `subminer` launcher and Linux rofi theme update automatically alongside the app.
- Set `updates.channel` to `"prerelease"` to receive beta and RC builds.
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows, with an Open SubMiner Settings button on completion. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH. Setup recognizes existing `subminer` installs in Homebrew or user PATH directories, avoids writing into Homebrew-owned paths, and quits the standalone setup app on completion. - **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows.
- Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
- Setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths. An Open SubMiner Settings button is included on completion; the standalone setup app quits after finishing.
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed app version. The new `mpv.profile` config option passes an mpv profile to SubMiner-managed mpv launches. Bundled mpv plugin startup options are now configurable from SubMiner config. - **Character Portraits:** Character-name subtitle matches can now show optional inline AniList character portraits.
- Manual AniList title overrides are scoped per media directory so separate season folders keep independent character dictionary selections.
- **Character Portraits:** Character-name subtitle matches can now show optional inline AniList character portraits. Manual AniList title overrides are scoped per media directory so separate season folders keep independent character dictionary selections.
- **Log Export:** Sanitized log ZIP archives can be exported from the tray menu or by running `subminer logs -e`, with home-directory usernames redacted from the exported contents. - **Log Export:** Sanitized log ZIP archives can be exported from the tray menu or by running `subminer logs -e`, with home-directory usernames redacted from the exported contents.
- **Logging Configuration:** SubMiner's logging level is now forwarded into launcher-started and Windows shortcut-started mpv sessions, controlling mpv log verbosity and plugin script logging. The new `logging.rotation` config sets daily log retention (default 7 days), and `logging.files` toggles let you enable or disable per-component log files; mpv logs are off by default unless explicitly enabled for debugging. - **Logging Configuration:** SubMiner's logging level is now forwarded into launcher-started and Windows shortcut-started mpv sessions, controlling mpv log verbosity and plugin script logging.
- The new `logging.rotation` config sets daily log retention (default 7 days). `logging.files` toggles let you enable or disable per-component log files; mpv logs are off by default unless explicitly enabled.
- **Yomitan Popup Visibility:** The new `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan lookup popup is open. - **Yomitan Popup Visibility:** The new `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan lookup popup is open.
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed app version. The new `mpv.profile` config option passes an mpv profile to SubMiner-managed mpv launches, and bundled mpv plugin startup options are now configurable from SubMiner config.
### Changed ### Changed
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Sidebar appearance is configured via `subtitleSidebar.css`. The default subtitle font stack is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. Existing configs are migrated automatically. - **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`; sidebar appearance uses `subtitleSidebar.css`.
- Default font stack updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`; default text shadow is stronger, JLPT underlines are thicker, and the frequency `topX` threshold defaults to `10000`.
- Existing configs are migrated automatically: legacy appearance options and hover token colors fold into `subtitleStyle.css`, and user config files are preserved.
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys remain accepted with deprecation warnings. N+1 highlighting is preserved for configs that already had it enabled; new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly. - **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys remain accepted with deprecation warnings.
- N+1 highlighting is preserved for configs that already had it enabled; new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly.
- **Character Dictionary:** A new `Ctrl/Cmd+D` manager modal lets you remove, reorder, or override loaded dictionary entries. Entries are scoped to the current AniList media and generate Japanese name aliases only, so raw romanized or English aliases no longer appear as separate results. The in-app AniList title selector now waits for an explicit search rather than triggering automatically; the search box is prefilled from the current filename guess. The manager is blocked with a notice when character dictionary annotations are disabled, and `subtitleStyle.nameMatchEnabled` is the sole switch for enabling name matching and dictionary builds. - **Character Dictionary:** Entries are now scoped to the current AniList media and generate Japanese name aliases only, so raw romanized or English aliases no longer appear as separate results.
- A new `Ctrl/Cmd+D` manager modal lets you remove, reorder, or override loaded dictionary entries.
- The in-app AniList title selector now waits for an explicit search rather than triggering automatically; the search box is prefilled from the current filename guess.
- **Linux Updater:** Tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows update flow. System-package-managed AppImages (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow. - **Linux Updater:** Tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows update flow. System-package-managed AppImages and non-AppImage launches fall back to the GitHub-asset flow.
- **Subsync:** The subtitle sync dialog now always opens the manual picker; the `subsync.defaultMode` config option has been removed. - **Subsync:** The subtitle sync dialog now always opens the manual picker; the `subsync.defaultMode` config option has been removed.
- **Jellyfin:** The server presets dropdown in Jellyfin setup is replaced by a single editable server URL field. - **Jellyfin Setup:** The server presets dropdown is replaced by a single editable server URL field.
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
- **Setup:** The bundled mpv runtime plugin readiness card is removed from first-run setup; the legacy mpv plugin removal notice still appears when needed.
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off. - **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
@@ -43,73 +52,96 @@
### Fixed ### Fixed
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; opens over fullscreen mpv without switching Spaces; and stays stable when mpv remains frontmost but window geometry temporarily disappears from macOS APIs. Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The overlay stays stable when clicking from the overlay back into mpv. Background tracking overhead is reduced while mpv is stably focused. The compiled mpv window helper is now correctly bundled, preventing the overlay from falling back to slower startup on first launch. - **macOS Overlay:** Significantly improved overlay focus and stability across a range of scenarios.
- The overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; and opens over fullscreen mpv without switching Spaces.
- Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The compiled mpv window helper is now correctly bundled, preventing the overlay from falling back to a slower startup path on first launch.
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player. The visible overlay remains stacked above mpv after mpv regains focus from clicks, and is suspended while the in-player stats window is open. Settings windows (SubMiner and Yomitan) now open above the subtitle overlay on Hyprland instead of behind it. The overlay is hidden immediately when the character dictionary modal opens, including while AniList lookup is in progress or returns no results. - **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player.
- The overlay stays stacked above mpv after click-to-focus events and is suspended while the in-player stats window is open.
- Settings windows (SubMiner and Yomitan) now open above the subtitle overlay; the overlay hides immediately when the character dictionary modal opens, including while AniList lookup is in progress.
- **Jellyfin Playback:** Resolved a wide range of Jellyfin discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, duplicate ready signals no longer re-show the overlay, and long-lived sidebar ffmpeg extractors no longer run against stream URLs. Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track. Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress. - **Jellyfin Playback:** Resolved a wide range of discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, and duplicate ready signals no longer re-show the overlay.
- Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track.
- Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress.
- **Jellyfin Subtitles:** Improved subtitle timing by preferring default embedded streams over external sidecars, stripping Jellyfin's server-selected stream from playback URLs, suppressing mpv auto-selection while SubMiner stages managed tracks, and automatically correcting clear Japanese-vs-English cue timeline offsets. Per-stream subtitle delay shifts are restored on load. Track selection now tolerates transient `track-list` read failures and numeric string track IDs on Linux. - **Jellyfin Subtitles:** Improved subtitle timing by preferring default embedded streams over external sidecars, stripping Jellyfin's server-selected stream from playback URLs, suppressing mpv auto-selection while SubMiner stages managed tracks, and automatically correcting Japanese-vs-English cue timeline offsets.
- Per-stream subtitle delay shifts are restored on load. Track selection now tolerates transient `track-list` read failures and numeric string track IDs on Linux.
- **Jellyfin Overlay:** The visible subtitle overlay now shows automatically during Jellyfin playback so `subtitleStyle` appearance applies. The bundled mpv plugin is injected when SubMiner auto-launches mpv for Jellyfin so mpv-side keybindings work without overlay focus. The `y-t` overlay toggle is reliable and remains sticky across stream redirects. Passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv. - **Jellyfin Overlay:** The visible subtitle overlay now shows automatically during Jellyfin playback so `subtitleStyle` appearance applies, and the bundled mpv plugin is injected when SubMiner auto-launches mpv so mpv-side keybindings work without overlay focus.
- The `y-t` overlay toggle is reliable and remains sticky across stream redirects.
- Passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv.
- **Jellyfin Remote Progress:** Fixed progress sync for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows. Play and Resume are now distinct: Play starts from the beginning while Resume starts at the saved position. Final progress reports use SubMiner's last known position when mpv resets during stop. - **Jellyfin Remote Progress:** Fixed progress sync for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows.
- Play and Resume are now distinct: Play starts from the beginning while Resume starts at the saved position.
- Final progress reports use SubMiner's last known position when mpv resets during stop.
- **Jellyfin Identity:** Cast device identity is now derived from the OS hostname. Multiple SubMiner installs no longer share the same remote-session identity, and SubMiner always reports itself as the client regardless of legacy configurable identity fields. - **Jellyfin Identity:** Cast device identity is now derived from the OS hostname. Multiple SubMiner installs no longer share the same remote-session identity.
- **Jellyfin Tray:** The discovery tray checkbox stays in sync on Linux after tray, CLI, or startup remote-session changes. Stale discovery sessions restart automatically when the server no longer lists the SubMiner cast target. Library discovery works correctly when the app log level is set above info. - **Jellyfin Tray:** The discovery tray checkbox stays in sync on Linux after tray, CLI, or startup remote-session changes. Stale discovery sessions restart automatically when the server no longer lists the SubMiner cast target.
- **Jellyfin Setup:** Fixed the Jellyfin setup login flow on Windows: login now uses an IPC bridge with immediate progress feedback, and unreachable servers time out with an inline error instead of hanging. - **Jellyfin Setup:** Fixed the Windows login flow with an IPC bridge and immediate progress feedback; unreachable servers time out with an inline error instead of hanging.
- **Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing. - **AniList Progress:** Threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold.
- Season-specific results are preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching status.
- Repeated missing-token checks no longer exhaust AniList retry attempts or create duplicate dead-letter entries for the same episode.
- **Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults. - **Anki:** Sentence-audio padding is now opt-in by default; animated AVIF freeze-frame duration is correctly aligned to word audio length without double-counting padding.
- Multi-line sentence mining stays aligned for repeated subtitle text; Kiku duplicate-card detection and merge flow are fixed; clipboard card updates from YouTube use mpv's resolved stream URLs; sentence cards refresh the secondary subtitle before saving.
- Known-word cache append is fixed when no default Anki mining deck is configured but multiple known-word deck field mappings are present.
- **AniList Progress:** Progress threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status. Repeated missing-token checks no longer rapidly exhaust AniList retry attempts or create duplicate dead-letter entries for the same episode. - **YouTube:** Primary subtitles are downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit.
- False load-failure notifications are suppressed. Launcher-managed playback creates the tray icon when attaching to an already-running process, and app-owned playback no longer lets the mpv plugin start a second SubMiner instance.
- **Anki:** Sentence-audio padding is now opt-in by default. When padding is configured, animated AVIF freeze-frame duration is correctly aligned to the word audio length without double-counting sentence audio padding. Multi-line sentence mining stays aligned when repeated subtitle text appears in the selected history range. Manual clipboard card updates from YouTube playback now use mpv's resolved stream URLs for generated audio and images. Sentence cards now refresh the current secondary subtitle before saving so the translation field contains the loaded subtitle rather than repeating the primary text. Kiku duplicate-card detection correctly groups fields, modal-open acknowledgement races no longer cancel the merge flow, and merged fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics. - **Character Dictionary:** Surname honorifics are now matched for Japanese localized aliases embedded in AniList alternative names; cached snapshots are regenerated to include them.
- Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests. Manager keyboard shortcuts are correctly forwarded to the mpv plugin.
- **YouTube:** Primary subtitles are now downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit. False subtitle load failure notifications are suppressed after SubMiner confirms the selected track loaded. Launcher-managed playback commands create the tray icon even when attaching to an already-running process, and app-owned YouTube playback no longer lets the mpv plugin start a second SubMiner instance. - **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground.
- Builds that cannot apply native updates show a manual-install message instead of a restart prompt. Windows retains the native NSIS update path while routing updater HTTP through the main process.
- **Character Dictionary:** Surname honorifics are now matched for Japanese localized aliases embedded in AniList alternative names (e.g. Korean-source characters whose Japanese name appears in parentheses), and cached snapshots are regenerated to include them. Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. Character dictionary manager keyboard shortcuts are now correctly forwarded to the mpv plugin. - **Setup - macOS:** First-run setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths.
- `subminer app --setup` opens the setup flow even when SubMiner is already running. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed.
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground; builds that cannot apply native updates show a manual-install message instead of a restart prompt; Windows retains the native NSIS update path while routing updater HTTP through the main process; and macOS updater metadata mismatches from conflicting ZIP filenames are resolved. - **Tray App:** Fixed several lifecycle issues: the tray stays running when Yomitan settings are closed; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable.
- Settings loading no longer blocks other tray actions; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized; the session help modal closes correctly without mpv running.
- On Windows, "Open SubMiner Setup" now correctly opens the setup window after first-run setup is complete.
- **Setup - macOS:** First-run setup now recognizes existing `subminer` installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed. - **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused until subtitle priming and tokenization readiness complete.
- `subminer settings` on macOS no longer emits Electron menu diagnostics and exits cleanly when the window is closed. Linux first-run launcher installs build with a valid Bun shebang; `subminer app` on Linux returns control to the terminal immediately.
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can close correctly without mpv running. On Windows, the tray "Open SubMiner Setup" action now correctly opens the setup window after first-run setup is complete. - On Windows, managed mpv launches from a background instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends. `subminer settings` on macOS no longer emits Electron menu diagnostics. Linux first-run launcher installs now build with a valid Bun shebang. `subminer app` on Linux returns control to the terminal immediately. On Windows, managed mpv launches from a background SubMiner instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive. - **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
- The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct Linux bounds reapplication and user-paused playback preserved through readiness gates.
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered. Frequency annotations are also corrected for Yomitan single-token compounds with internal particles such as `目の前`, while pure grammar and kana helper spans remain unannotated. - **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered. Annotations are corrected for Yomitan single-token compounds with internal particles like `目の前`.
- **Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so configured SubMiner shortcuts also work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are correctly wired through mpv. The visible overlay receives focus when entering multi-line copy/mine selection so number keys work on macOS and Windows. - **Subtitle Annotation Prefetch:** Cached annotations and character images are ready for more live subtitle changes without delaying raw subtitle display.
- **Overlay Restart:** The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct bounds reapplication on Linux and user-paused playback preserved through readiness gates. - **Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so SubMiner shortcuts also work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are correctly wired through mpv. The visible overlay receives focus when entering multi-line copy/mine selection so number keys work on macOS and Windows.
- **Stats:** In-player stats layering is fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window. Jellyfin playback stats are grouped under item metadata instead of stream URLs, so watched episodes merge with matching local library titles and display clean names. - **Stats:** In-player stats layering is fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window. Jellyfin playback stats are grouped under item metadata so watched episodes merge with matching local library titles and display clean names.
- **Sidebar:** Yomitan lookup popups opened from the subtitle sidebar now correctly pause playback when popup auto-pause is enabled. Yomitan-enriched cards mined from the sidebar now use audio and images from the clicked subtitle line rather than the current primary line. - **Sidebar:** Yomitan lookup popups opened from the subtitle sidebar now correctly pause playback when popup auto-pause is enabled. Mined cards use audio and images from the clicked subtitle line rather than the current primary line.
- **Controller:** Config and debug shortcuts stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge; remaps are saved per controller profile, and individual bindings can be reset to their defaults.
- **Discord Rich Presence:** Presence no longer falls back to Jellyfin stream URLs; Jellyfin playback titles are primed before loading tokenized streams so presence shows the show/episode title. - **Discord Rich Presence:** Presence no longer falls back to Jellyfin stream URLs; Jellyfin playback titles are primed before loading tokenized streams so presence shows the show/episode title.
- **WebSocket:** The regular subtitle WebSocket now sends plain text only; annotation spans and token metadata are sent exclusively on the annotation WebSocket. - **WebSocket:** The regular subtitle WebSocket now sends plain text only; annotation spans and token metadata are sent exclusively on the annotation WebSocket.
- **Windows:** Startup failures now show a native error dialog and write fatal details to the app log instead of exiting silently. - **Windows Startup:** Fatal startup errors now show a native error dialog and write details to the app log instead of exiting silently.
- **Yomitan:** Fixed Yomitan popups not opening when overlay startup races the Yomitan extension load. - **Yomitan:** Fixed popups not opening when overlay startup races the Yomitan extension load.
- **Settings:** Search now works across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`. The note-fields note type picker defaults to the configured Anki deck's note type, then `Kiku`, then `Lapis`, leaving it blank for manual selection otherwise. User config files are preserved during legacy config compatibility handling. The generated example config uses the same CSS declaration paths written by the Settings window. - **Subtitle Sync Modal:** Fixed a macOS issue where the modal would flash and disappear on the first attempt, or leave stale state after syncing.
### Docs ### Docs
- **Versioned Docs:** Stable docs are now published at the site root with current development docs under `/main/`. Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests paths incorrectly, local dev version routes serve warmed archive files instead of redirecting to production, and internal README files no longer break archived builds. - **Versioned Docs:** Stable docs are now published at the site root with current development docs under `/main/`.
- Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests paths incorrectly, and local dev version routes serve warmed archive files instead of redirecting to production.
- **Configuration Reference:** All previously undocumented config options are now covered, including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options. Updated known-word cache docs and examples to recommend expression/word fields. - **Configuration Reference:** All previously undocumented config options are now covered, including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options. Updated known-word cache docs and examples to recommend expression/word fields.
- **Architecture Docs:** Added a Playback Startup Flow diagram showing how managed launches inject the plugin, establish the IPC socket, and bring up the overlay via the two convergent triggers. Added a Runtime Sockets section and diagram to the IPC + Runtime Contracts page, with cross-reference pointers in the MPV Plugin and Troubleshooting pages. - **Architecture Docs:** Added a Playback Startup Flow diagram and a Runtime Sockets section and diagram to the IPC + Runtime Contracts page, with cross-reference pointers in the MPV Plugin and Troubleshooting pages.
## Installation ## Installation
+16
View File
@@ -43,6 +43,14 @@ function fragmentTypesInPrompt(input: string): string[] {
.map((line) => line.slice('type: '.length).trim()); .map((line) => line.slice('type: '.length).trim());
} }
function assertReleaseNotesPromptRequestsNestedBullets(input: string): void {
assert.match(input, /In MODE: release-notes, use short top-level change bullets/);
assert.match(input, /Nested bullets should cover the change, user benefit, and any user action/);
assert.match(input, /Do not require the exact nested labels/);
assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/);
assert.match(input, /Avoid paragraph-style release-note bullets/);
}
function defaultPolishedBody(input: string): string { function defaultPolishedBody(input: string): string {
const mode = modeFromPrompt(input); const mode = modeFromPrompt(input);
const types = fragmentTypesInPrompt(input); const types = fragmentTypesInPrompt(input);
@@ -171,6 +179,7 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./); assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./); assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
assert.match(releaseNotes, /- Windows: `SubMiner-\*\.exe` and `SubMiner-\*-win\.zip`/);
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
} }
@@ -437,6 +446,12 @@ test('writeChangelogArtifacts prompts Claude to summarize the final stable outco
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/, /Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
); );
} }
const releaseNotesPrompt = stub.calls.find(
(call) => modeFromPrompt(call.input) === 'release-notes',
);
assert.ok(releaseNotesPrompt, 'expected a release-notes Claude invocation');
assertReleaseNotesPromptRequestsNestedBullets(releaseNotesPrompt.input);
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
} }
@@ -706,6 +721,7 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
prompt, prompt,
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/, /Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
); );
assertReleaseNotesPromptRequestsNestedBullets(prompt);
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
} }
+9 -4
View File
@@ -260,14 +260,18 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
</details> </details>
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode. Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
4. Each bullet should: 4. Each top-level change item should:
- Lead with a short feature/area name in title case followed by a colon, e.g. "Playlist browser:", "Windows overlay:", "Stats dashboard:". Pick the name from the fragment's bullet content, not the raw 'area:' slug. - Lead with a short feature/area name in title case. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers. - Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five. - Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users. - Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them. - Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
5. Do not invent features. Every bullet must be grounded in the input fragments. 5. In MODE: changelog, each item may be a conventional single-level bullet, e.g. "- Playlist Browser: Adds faster saved-show browsing."
6. Do not include the version heading (## v...) that wrapper is added by the caller. 6. In MODE: release-notes, use short top-level change bullets with two or three nested bullets when an item needs explanation.
Nested bullets should cover the change, user benefit, and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets.
7. Do not invent features. Every bullet must be grounded in the input fragments.
8. Do not include the version heading (## v...) that wrapper is added by the caller.
The input begins below. The input begins below.
@@ -485,6 +489,7 @@ function renderReleaseNotes(
'', '',
'- Linux: `SubMiner.AppImage`', '- Linux: `SubMiner.AppImage`',
'- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`', '- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`',
'- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`',
'- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher', '- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher',
'', '',
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.', 'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
+48
View File
@@ -19,6 +19,7 @@ interface IntegrationTestContext {
function createIntegrationTestContext( function createIntegrationTestContext(
options: { options: {
highlightEnabled?: boolean; highlightEnabled?: boolean;
nPlusOneEnabled?: boolean;
onFindNotes?: () => Promise<number[]>; onFindNotes?: () => Promise<number[]>;
onNotesInfo?: () => Promise<unknown[]>; onNotesInfo?: () => Promise<unknown[]>;
stateDirPrefix?: string; stateDirPrefix?: string;
@@ -59,6 +60,12 @@ function createIntegrationTestContext(
knownWords: { knownWords: {
highlightEnabled: options.highlightEnabled ?? true, highlightEnabled: options.highlightEnabled ?? true,
}, },
nPlusOne:
options.nPlusOneEnabled === undefined
? undefined
: {
enabled: options.nPlusOneEnabled,
},
}, },
{} as never, {} as never,
{} as never, {} as never,
@@ -161,6 +168,47 @@ test('AnkiIntegration.refreshKnownWordCache bypasses stale checks', async () =>
} }
}); });
test('AnkiIntegration.refreshKnownWordCache notifies annotation cache listeners', async () => {
const ctx = createIntegrationTestContext({
stateDirPrefix: 'subminer-anki-integration-refresh-notify-',
});
let notifications = 0;
try {
ctx.integration.setKnownWordCacheUpdatedCallback(() => {
notifications += 1;
});
await ctx.integration.refreshKnownWordCache();
assert.equal(notifications, 1);
} finally {
cleanupIntegrationTestContext(ctx);
}
});
test('AnkiIntegration.refreshKnownWordCache notifies when n+1 is enabled without highlights', async () => {
const ctx = createIntegrationTestContext({
highlightEnabled: false,
nPlusOneEnabled: true,
stateDirPrefix: 'subminer-anki-integration-nplusone-notify-',
});
let notifications = 0;
try {
ctx.integration.setKnownWordCacheUpdatedCallback(() => {
notifications += 1;
});
await ctx.integration.refreshKnownWordCache();
assert.equal(ctx.calls.findNotes, 1);
assert.equal(notifications, 1);
} finally {
cleanupIntegrationTestContext(ctx);
}
});
test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => { test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => {
const ctx = createIntegrationTestContext({ const ctx = createIntegrationTestContext({
highlightEnabled: false, highlightEnabled: false,
+8 -2
View File
@@ -526,7 +526,9 @@ export class AnkiIntegration {
} }
private isKnownWordCacheEnabled(): boolean { private isKnownWordCacheEnabled(): boolean {
return this.config.knownWords?.highlightEnabled === true; return (
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true
);
} }
private getConfiguredAnkiTags(): string[] { private getConfiguredAnkiTags(): string[] {
@@ -549,7 +551,11 @@ export class AnkiIntegration {
} }
async refreshKnownWordCache(): Promise<void> { async refreshKnownWordCache(): Promise<void> {
return this.knownWordCache.refresh(true); const shouldNotify = this.isKnownWordCacheEnabled();
await this.knownWordCache.refresh(true);
if (shouldNotify) {
this.notifyKnownWordCacheUpdated();
}
} }
private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void { private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void {
@@ -520,6 +520,36 @@ test('KnownWordCacheManager uses the current deck fields for immediate append',
} }
}); });
test('KnownWordCacheManager uses all configured deck fields for immediate append without a current deck', () => {
const config: AnkiConnectConfig = {
deck: '',
fields: {
word: 'Expression',
},
knownWords: {
highlightEnabled: true,
decks: {
'Kaishi 1.5k': ['Word'],
Minecraft: ['Expression', 'Word'],
},
},
};
const { manager, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 1,
fields: {
Expression: { value: '別人' },
},
});
assert.equal(manager.isKnownWord('別人'), true);
} finally {
cleanup();
}
});
test('KnownWordCacheManager reports immediate append cache clears as mutations', () => { test('KnownWordCacheManager reports immediate append cache clears as mutations', () => {
const config: AnkiConnectConfig = { const config: AnkiConnectConfig = {
fields: { fields: {
+12 -1
View File
@@ -326,7 +326,18 @@ export class KnownWordCacheManager {
: null; : null;
if (!selectedDeckEntry) { if (!selectedDeckEntry) {
return null; const configuredFields = trimmedDeckEntries.flatMap(([, fields]) =>
Array.isArray(fields) ? fields : [],
);
const normalizedFields = [
...new Set(
configuredFields
.map(String)
.map((field) => field.trim())
.filter((field) => field.length > 0),
),
];
return normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields();
} }
const deckFields = selectedDeckEntry[1]; const deckFields = selectedDeckEntry[1];
+2 -1
View File
@@ -22,7 +22,8 @@ import {
const DEFAULT_SUBTITLE_FONT_FAMILY = const DEFAULT_SUBTITLE_FONT_FAMILY =
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP'; 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY; const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)'; const DEFAULT_SUBTITLE_TEXT_SHADOW =
'-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)';
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar']; const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
function makeTempDir(): string { function makeTempDir(): string {
+5 -3
View File
@@ -23,7 +23,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
wordSpacing: 0, wordSpacing: 0,
fontKerning: 'normal', fontKerning: 'normal',
textRendering: 'geometricPrecision', textRendering: 'geometricPrecision',
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)', textShadow:
'-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)',
paintOrder: '', paintOrder: '',
WebkitTextStroke: '', WebkitTextStroke: '',
fontStyle: 'normal', fontStyle: 'normal',
@@ -41,7 +42,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
frequencyDictionary: { frequencyDictionary: {
enabled: false, enabled: false,
sourcePath: '', sourcePath: '',
topX: 1000, topX: 10000,
mode: 'single', mode: 'single',
matchMode: 'headword', matchMode: 'headword',
singleColor: '#f5a97f', singleColor: '#f5a97f',
@@ -57,7 +58,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
wordSpacing: 0, wordSpacing: 0,
fontKerning: 'normal', fontKerning: 'normal',
textRendering: 'geometricPrecision', textRendering: 'geometricPrecision',
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)', textShadow:
'-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)',
paintOrder: '', paintOrder: '',
WebkitTextStroke: '', WebkitTextStroke: '',
backgroundColor: 'transparent', backgroundColor: 'transparent',
+1 -1
View File
@@ -127,7 +127,7 @@ export function buildSubtitleConfigOptionRegistry(
path: 'subtitleStyle.frequencyDictionary.topX', path: 'subtitleStyle.frequencyDictionary.topX',
kind: 'number', kind: 'number',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.topX, defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.topX,
description: 'Only color tokens with frequency rank <= topX (default: 1000).', description: 'Only color tokens with frequency rank <= topX (default: 10000).',
}, },
{ {
path: 'subtitleStyle.frequencyDictionary.mode', path: 'subtitleStyle.frequencyDictionary.mode',
+11
View File
@@ -151,6 +151,7 @@ test('settings registry exposes mpv aniskip button as an mpv key learn control',
}); });
test('settings registry exposes specialized controls for config-assisted inputs', () => { test('settings registry exposes specialized controls for config-assisted inputs', () => {
assert.equal(field('ankiConnect.deck').control, 'anki-deck');
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks'); assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type'); assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
assert.equal(field('ankiConnect.fields.word').control, 'anki-field'); assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
@@ -228,6 +229,7 @@ test('settings registry routes playback-related integrations into integrations',
test('settings registry puts feature toggles first, then other toggles alphabetically', () => { test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect'); const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled'); assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
assert.equal(ankiConnect[1]?.configPath, 'ankiConnect.deck');
assert.ok( assert.ok(
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') < ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') <
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'), ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'),
@@ -236,6 +238,14 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
fields.findIndex((candidate) => candidate.section === 'AnkiConnect') < fields.findIndex((candidate) => candidate.section === 'AnkiConnect') <
fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'), fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'),
); );
const miningSections = [
...new Set(
fields
.filter((candidate) => candidate.category === 'mining-anki')
.map((candidate) => candidate.section),
),
];
assert.equal(miningSections[0], 'AnkiConnect');
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features'); const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
assert.deepEqual( assert.deepEqual(
@@ -288,6 +298,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'jimaku.maxEntryResults', 'jimaku.maxEntryResults',
'subsync.replace', 'subsync.replace',
'ankiConnect.behavior.autoUpdateNewCards', 'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.deck',
'ankiConnect.knownWords.highlightEnabled', 'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes', 'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately', 'ankiConnect.knownWords.addMinedWordsImmediately',
+5 -2
View File
@@ -17,7 +17,6 @@ type Leaf = {
}; };
export const LEGACY_HIDDEN_CONFIG_PATHS = [ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'ankiConnect.deck',
'ankiConnect.wordField', 'ankiConnect.wordField',
'ankiConnect.audioField', 'ankiConnect.audioField',
'ankiConnect.imageField', 'ankiConnect.imageField',
@@ -129,11 +128,11 @@ const SECTION_ORDER = new Map<string, number>(
'Subtitle Sidebar Behavior', 'Subtitle Sidebar Behavior',
'YouTube Playback Settings', 'YouTube Playback Settings',
'mpv Playback', 'mpv Playback',
'AnkiConnect',
'Note Fields', 'Note Fields',
'Media Capture', 'Media Capture',
'Kiku/Lapis Features', 'Kiku/Lapis Features',
'Anki AI', 'Anki AI',
'AnkiConnect',
'AnkiConnect Proxy', 'AnkiConnect Proxy',
'Jimaku', 'Jimaku',
'Subtitle Sync', 'Subtitle Sync',
@@ -159,6 +158,7 @@ const SECTION_ORDER = new Map<string, number>(
const PATH_ORDER = new Map<string, number>( const PATH_ORDER = new Map<string, number>(
[ [
'ankiConnect.enabled', 'ankiConnect.enabled',
'ankiConnect.deck',
'ankiConnect.proxy.enabled', 'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled', 'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled', 'ankiConnect.isKiku.enabled',
@@ -494,6 +494,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (SECRET_PATHS.has(path)) return 'secret'; if (SECRET_PATHS.has(path)) return 'secret';
if (getSubtitleCssScopeForPath(path)) return 'css-declarations'; if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
if (path === 'keybindings') return 'mpv-keybindings'; if (path === 'keybindings') return 'mpv-keybindings';
if (path === 'ankiConnect.deck') return 'anki-deck';
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks'; if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type'; if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
if (path.startsWith('ankiConnect.fields.')) return 'anki-field'; if (path.startsWith('ankiConnect.fields.')) return 'anki-field';
@@ -611,6 +612,7 @@ function isFeatureToggle(field: ConfigSettingsField): boolean {
function fieldTypeRank(field: ConfigSettingsField): number { function fieldTypeRank(field: ConfigSettingsField): number {
if (field.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup') return 2; if (field.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup') return 2;
if (field.configPath === 'ankiConnect.deck') return 1;
if (field.control !== 'boolean') return 2; if (field.control !== 'boolean') return 2;
return isFeatureToggle(field) ? 0 : 1; return isFeatureToggle(field) ? 0 : 1;
} }
@@ -661,6 +663,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'subtitleStyle') || pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') || pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' || path === 'secondarySub.defaultMode' ||
path === 'ankiConnect.deck' ||
path === 'ankiConnect.ai.enabled' || path === 'ankiConnect.ai.enabled' ||
path === 'ankiConnect.behavior.autoUpdateNewCards' || path === 'ankiConnect.behavior.autoUpdateNewCards' ||
path === 'ankiConnect.knownWords.highlightEnabled' || path === 'ankiConnect.knownWords.highlightEnabled' ||
@@ -30,6 +30,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
next.youtube.primarySubLanguages = ['ja', 'en']; next.youtube.primarySubLanguages = ['ja', 'en'];
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1; next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
next.subsync.replace = !prev.subsync.replace; next.subsync.replace = !prev.subsync.replace;
next.ankiConnect.deck = 'Mining';
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards; next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled; next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5; next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
@@ -63,6 +64,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
'youtube.primarySubLanguages', 'youtube.primarySubLanguages',
'jimaku.maxEntryResults', 'jimaku.maxEntryResults',
'subsync.replace', 'subsync.replace',
'ankiConnect.deck',
'ankiConnect.behavior.autoUpdateNewCards', 'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled', 'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes', 'ankiConnect.knownWords.refreshMinutes',
+1
View File
@@ -66,6 +66,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'youtube.primarySubLanguages', 'youtube.primarySubLanguages',
'jimaku', 'jimaku',
'subsync', 'subsync',
'ankiConnect.deck',
'ankiConnect.behavior.autoUpdateNewCards', 'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled', 'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes', 'ankiConnect.knownWords.refreshMinutes',
@@ -95,6 +95,54 @@ test('buildHyprlandPlacementDispatches force-aligns floating overlay windows to
); );
}); });
test('buildHyprlandPlacementDispatches emits Lua dispatchers for Lua-config Hyprland sessions', () => {
assert.deepEqual(
buildHyprlandPlacementDispatches(
{
address: '0xabc',
floating: false,
pinned: true,
},
{
x: 0,
y: 0,
width: 1920,
height: 1080,
},
{
configProvider: 'lua',
},
),
[
['dispatch', 'hl.dsp.window.float({ action = "on", window = "address:0xabc" })'],
['dispatch', 'hl.dsp.window.pin({ action = "off", window = "address:0xabc" })'],
['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xabc" })'],
['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xabc" })'],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xabc" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = "address:0xabc" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = "address:0xabc" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "no_blur", value = "1", window = "address:0xabc" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "decorate", value = "0", window = "address:0xabc" })',
],
['dispatch', 'hl.dsp.window.alter_zorder({ mode = "top", window = "address:0xabc" })'],
],
);
});
test('buildHyprlandPlacementDispatches does not pin already floating overlay windows', () => { test('buildHyprlandPlacementDispatches does not pin already floating overlay windows', () => {
assert.deepEqual( assert.deepEqual(
buildHyprlandPlacementDispatches({ buildHyprlandPlacementDispatches({
@@ -177,6 +225,9 @@ test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for ma
}, },
]); ]);
} }
if (args.join(' ') === '-j status') {
return JSON.stringify({ configProvider: 'hyprlang' });
}
return ''; return '';
}) as never, }) as never,
}); });
@@ -186,6 +237,7 @@ test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for ma
calls.map(([, args]) => args), calls.map(([, args]) => args),
[ [
['-j', 'clients'], ['-j', 'clients'],
['-j', 'status'],
['dispatch', 'setfloating', 'address:0xmatch'], ['dispatch', 'setfloating', 'address:0xmatch'],
['dispatch', 'alterzorder', 'top,address:0xmatch'], ['dispatch', 'alterzorder', 'top,address:0xmatch'],
], ],
@@ -221,6 +273,9 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe
}, },
]); ]);
} }
if (args.join(' ') === '-j status') {
return JSON.stringify({ configProvider: 'hyprlang' });
}
return ''; return '';
}) as never, }) as never,
}); });
@@ -230,6 +285,7 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe
calls.map(([, args]) => args), calls.map(([, args]) => args),
[ [
['-j', 'clients'], ['-j', 'clients'],
['-j', 'status'],
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'], ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'],
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xmatch'], ['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xmatch'],
['dispatch', 'setprop', 'address:0xmatch rounding 0'], ['dispatch', 'setprop', 'address:0xmatch rounding 0'],
@@ -241,3 +297,72 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe
], ],
); );
}); });
test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config Hyprland sessions', () => {
const calls: unknown[][] = [];
const placed = ensureHyprlandWindowFloatingByTitle({
title: 'SubMiner Stats',
platform: 'linux',
env: {
HYPRLAND_INSTANCE_SIGNATURE: 'abc',
},
pid: 456,
bounds: {
x: 0,
y: 0,
width: 1920,
height: 1080,
},
execFileSync: ((command: string, args: string[], options: unknown) => {
calls.push([command, args, options]);
if (args.join(' ') === '-j clients') {
return JSON.stringify([
{
address: '0xmatch',
pid: 456,
title: 'SubMiner Stats',
mapped: true,
floating: true,
pinned: false,
},
]);
}
if (args.join(' ') === '-j status') {
return JSON.stringify({ configProvider: 'lua' });
}
return '';
}) as never,
});
assert.equal(placed, true);
assert.deepEqual(
calls.map(([, args]) => args),
[
['-j', 'clients'],
['-j', 'status'],
['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'],
['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xmatch" })'],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xmatch" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = "address:0xmatch" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = "address:0xmatch" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "no_blur", value = "1", window = "address:0xmatch" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "decorate", value = "0", window = "address:0xmatch" })',
],
['dispatch', 'hl.dsp.window.alter_zorder({ mode = "top", window = "address:0xmatch" })'],
],
);
});
+97 -18
View File
@@ -19,10 +19,12 @@ export interface HyprlandPlacementBounds {
} }
export interface HyprlandPlacementDispatchOptions { export interface HyprlandPlacementDispatchOptions {
configProvider?: HyprlandConfigProvider;
promote?: boolean; promote?: boolean;
} }
type ExecFileSync = typeof execFileSync; type ExecFileSync = typeof execFileSync;
export type HyprlandConfigProvider = 'hyprlang' | 'lua';
export function shouldAttemptHyprlandWindowPlacement( export function shouldAttemptHyprlandWindowPlacement(
platform: NodeJS.Platform = process.platform, platform: NodeJS.Platform = process.platform,
@@ -75,37 +77,88 @@ export function buildHyprlandPlacementDispatches(
} }
const windowAddress = `address:${client.address}`; const windowAddress = `address:${client.address}`;
const configProvider = options.configProvider ?? 'hyprlang';
const dispatches: string[][] = []; const dispatches: string[][] = [];
if (client.floating !== true) { if (client.floating !== true) {
dispatches.push(['dispatch', 'setfloating', windowAddress]); dispatches.push(
configProvider === 'lua'
? luaWindowDispatch('float', windowAddress, ['action = "on"'])
: ['dispatch', 'setfloating', windowAddress],
);
} }
if (client.pinned === true) { if (client.pinned === true) {
dispatches.push(['dispatch', 'pin', windowAddress]); dispatches.push(
configProvider === 'lua'
? luaWindowDispatch('pin', windowAddress, ['action = "off"'])
: ['dispatch', 'pin', windowAddress],
);
} }
const roundedBounds = roundPlacementBounds(bounds); const roundedBounds = roundPlacementBounds(bounds);
if (roundedBounds) { if (roundedBounds) {
dispatches.push([ if (configProvider === 'lua') {
'dispatch', dispatches.push(
'movewindowpixel', luaWindowDispatch('move', windowAddress, [
`exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`, `x = ${roundedBounds.x}`,
]); `y = ${roundedBounds.y}`,
dispatches.push([ ]),
'dispatch', );
'resizewindowpixel', dispatches.push(
`exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`, luaWindowDispatch('resize', windowAddress, [
]); `x = ${roundedBounds.width}`,
dispatches.push(['dispatch', 'setprop', `${windowAddress} rounding 0`]); `y = ${roundedBounds.height}`,
dispatches.push(['dispatch', 'setprop', `${windowAddress} border_size 0`]); ]),
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_shadow 1`]); );
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]); dispatches.push(luaWindowSetProp(windowAddress, 'rounding', '0'));
dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]); dispatches.push(luaWindowSetProp(windowAddress, 'border_size', '0'));
dispatches.push(luaWindowSetProp(windowAddress, 'no_shadow', '1'));
dispatches.push(luaWindowSetProp(windowAddress, 'no_blur', '1'));
dispatches.push(luaWindowSetProp(windowAddress, 'decorate', '0'));
} else {
dispatches.push([
'dispatch',
'movewindowpixel',
`exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`,
]);
dispatches.push([
'dispatch',
'resizewindowpixel',
`exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`,
]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} rounding 0`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} border_size 0`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_shadow 1`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]);
}
} }
if (options.promote !== false) { if (options.promote !== false) {
dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]); dispatches.push(
configProvider === 'lua'
? luaWindowDispatch('alter_zorder', windowAddress, ['mode = "top"'])
: ['dispatch', 'alterzorder', `top,${windowAddress}`],
);
} }
return dispatches; return dispatches;
} }
function luaWindowDispatch(name: string, windowAddress: string, fields: string[]): string[] {
return [
'dispatch',
`hl.dsp.window.${name}({ ${[...fields, `window = ${luaString(windowAddress)}`].join(', ')} })`,
];
}
function luaWindowSetProp(windowAddress: string, prop: string, value: string): string[] {
return luaWindowDispatch('set_prop', windowAddress, [
`prop = ${luaString(prop)}`,
`value = ${luaString(value)}`,
]);
}
function luaString(value: string): string {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function roundPlacementBounds( function roundPlacementBounds(
bounds?: HyprlandPlacementBounds | null, bounds?: HyprlandPlacementBounds | null,
): HyprlandPlacementBounds | null { ): HyprlandPlacementBounds | null {
@@ -154,7 +207,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
return false; return false;
} }
const configProvider = detectHyprlandConfigProvider(run);
const dispatches = buildHyprlandPlacementDispatches(client, options.bounds, { const dispatches = buildHyprlandPlacementDispatches(client, options.bounds, {
configProvider,
promote: options.promote, promote: options.promote,
}); });
for (const args of dispatches) { for (const args of dispatches) {
@@ -165,3 +220,27 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
return false; return false;
} }
} }
function detectHyprlandConfigProvider(run: ExecFileSync): HyprlandConfigProvider {
try {
return parseHyprlandConfigProvider(
String(run('hyprctl', ['-j', 'status'], { encoding: 'utf-8' })),
);
} catch {
return 'hyprlang';
}
}
function parseHyprlandConfigProvider(output: string): HyprlandConfigProvider {
const payloadStart = output.indexOf('{');
if (payloadStart < 0) {
return 'hyprlang';
}
const parsed = JSON.parse(output.slice(payloadStart)) as unknown;
return isHyprlandStatusPayload(parsed) && parsed.configProvider === 'lua' ? 'lua' : 'hyprlang';
}
function isHyprlandStatusPayload(value: unknown): value is { configProvider?: unknown } {
return Boolean(value) && typeof value === 'object';
}
+1
View File
@@ -36,6 +36,7 @@ export {
} from './tokenizer/yomitan-parser-runtime'; } from './tokenizer/yomitan-parser-runtime';
export { export {
deleteYomitanDictionaryByTitle, deleteYomitanDictionaryByTitle,
getYomitanCurrentAnkiDeckName,
getYomitanDictionaryInfo, getYomitanDictionaryInfo,
getYomitanSettingsFull, getYomitanSettingsFull,
importYomitanDictionaryFromZip, importYomitanDictionaryFromZip,
@@ -242,3 +242,62 @@ test('prefetch service pause/resume halts and continues tokenization', async ()
assert.ok(tokenizeCalls > callsWhenPaused + 1, 'Should resume tokenizing after unpause'); assert.ok(tokenizeCalls > callsWhenPaused + 1, 'Should resume tokenizing after unpause');
}); });
test('prefetch service skips cues already present in tokenization cache', async () => {
const cues = makeCues(5);
const tokenizedTexts: string[] = [];
const service = createSubtitlePrefetchService({
cues,
tokenizeSubtitle: async (text) => {
tokenizedTexts.push(text);
return { text, tokens: [] };
},
preCacheTokenization: () => {},
hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1',
isCacheFull: () => false,
priorityWindowSize: 3,
});
service.start(0);
for (let i = 0; i < 10; i += 1) {
await flushMicrotasks();
}
service.stop();
assert.ok(!tokenizedTexts.includes('line-0'));
assert.ok(!tokenizedTexts.includes('line-1'));
assert.ok(tokenizedTexts.includes('line-2'));
});
test('prefetch service deduplicates repeated cue text within a run', async () => {
const cues: SubtitleCue[] = [
{ startTime: 0, endTime: 1, text: 'same' },
{ startTime: 1, endTime: 2, text: 'same' },
{ startTime: 2, endTime: 3, text: 'other' },
];
const tokenizedTexts: string[] = [];
const service = createSubtitlePrefetchService({
cues,
tokenizeSubtitle: async (text) => {
tokenizedTexts.push(text);
return { text, tokens: [] };
},
preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3,
});
service.start(0);
for (let i = 0; i < 10; i += 1) {
await flushMicrotasks();
}
service.stop();
assert.deepEqual(
tokenizedTexts.filter((text) => text === 'same'),
['same'],
);
assert.ok(tokenizedTexts.includes('other'));
});
+16 -3
View File
@@ -1,10 +1,12 @@
import type { SubtitleData } from '../../types'; import type { SubtitleData } from '../../types';
import type { SubtitleCue } from '../../types'; import type { SubtitleCue } from '../../types';
import { normalizeSubtitleCacheKey } from './subtitle-processing-controller';
export interface SubtitlePrefetchServiceDeps { export interface SubtitlePrefetchServiceDeps {
cues: SubtitleCue[]; cues: SubtitleCue[];
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean; isCacheFull: () => boolean;
priorityWindowSize?: number; priorityWindowSize?: number;
} }
@@ -58,6 +60,7 @@ export function createSubtitlePrefetchService(
async function tokenizeCueList( async function tokenizeCueList(
cuesToProcess: SubtitleCue[], cuesToProcess: SubtitleCue[],
runId: number, runId: number,
warmedKeys: Set<string>,
options: { allowWhenCacheFull?: boolean } = {}, options: { allowWhenCacheFull?: boolean } = {},
): Promise<void> { ): Promise<void> {
for (const cue of cuesToProcess) { for (const cue of cuesToProcess) {
@@ -78,6 +81,15 @@ export function createSubtitlePrefetchService(
return; return;
} }
const cacheKey = normalizeSubtitleCacheKey(cue.text);
if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) {
if (cacheKey) {
warmedKeys.add(cacheKey);
}
continue;
}
warmedKeys.add(cacheKey);
try { try {
const result = await deps.tokenizeSubtitle(cue.text); const result = await deps.tokenizeSubtitle(cue.text);
if (result && !stopped && runId === currentRunId) { if (result && !stopped && runId === currentRunId) {
@@ -94,10 +106,11 @@ export function createSubtitlePrefetchService(
async function startPrefetching(currentTimeSeconds: number, runId: number): Promise<void> { async function startPrefetching(currentTimeSeconds: number, runId: number): Promise<void> {
const cues = deps.cues; const cues = deps.cues;
const warmedKeys = new Set<string>();
// Phase 1: Priority window // Phase 1: Priority window
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize); const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
await tokenizeCueList(priorityCues, runId, { allowWhenCacheFull: true }); await tokenizeCueList(priorityCues, runId, warmedKeys, { allowWhenCacheFull: true });
if (stopped || runId !== currentRunId) { if (stopped || runId !== currentRunId) {
return; return;
@@ -108,7 +121,7 @@ export function createSubtitlePrefetchService(
const remainingCues = cues.filter( const remainingCues = cues.filter(
(cue) => cue.startTime > currentTimeSeconds && !priorityTexts.has(cue.text), (cue) => cue.startTime > currentTimeSeconds && !priorityTexts.has(cue.text),
); );
await tokenizeCueList(remainingCues, runId); await tokenizeCueList(remainingCues, runId, warmedKeys);
if (stopped || runId !== currentRunId) { if (stopped || runId !== currentRunId) {
return; return;
@@ -118,7 +131,7 @@ export function createSubtitlePrefetchService(
const earlierCues = cues.filter( const earlierCues = cues.filter(
(cue) => cue.startTime <= currentTimeSeconds && !priorityTexts.has(cue.text), (cue) => cue.startTime <= currentTimeSeconds && !priorityTexts.has(cue.text),
); );
await tokenizeCueList(earlierCues, runId); await tokenizeCueList(earlierCues, runId, warmedKeys);
} }
return { return {
@@ -236,6 +236,31 @@ test('consumeCachedSubtitle returns prefetched payload and prevents reprocessing
assert.deepEqual(emitted, []); assert.deepEqual(emitted, []);
}); });
test('hasCachedSubtitle checks prefetched entries without consuming them', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.preCacheTokenization('猫\\Nです', { text: '猫\nです', tokens: [] });
assert.equal(controller.hasCachedSubtitle('猫\nです'), true);
controller.onSubtitleChange('猫\nです');
await flushMicrotasks();
assert.equal(tokenizeCalls, 0);
assert.deepEqual(emitted, [{ text: '猫\nです', tokens: [] }]);
controller.invalidateTokenizationCache();
assert.equal(controller.hasCachedSubtitle('猫\nです'), false);
});
test('isCacheFull returns false when cache is below limit', () => { test('isCacheFull returns false when cache is below limit', () => {
const controller = createSubtitleProcessingController({ const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: null }), tokenizeSubtitle: async (text) => ({ text, tokens: null }),
@@ -13,10 +13,11 @@ export interface SubtitleProcessingController {
invalidateTokenizationCache: () => void; invalidateTokenizationCache: () => void;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
consumeCachedSubtitle: (text: string) => SubtitleData | null; consumeCachedSubtitle: (text: string) => SubtitleData | null;
hasCachedSubtitle: (text: string) => boolean;
isCacheFull: () => boolean; isCacheFull: () => boolean;
} }
function normalizeSubtitleCacheKey(text: string): string { export function normalizeSubtitleCacheKey(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim(); return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim();
} }
@@ -152,6 +153,9 @@ export function createSubtitleProcessingController(
refreshRequested = false; refreshRequested = false;
return cached; return cached;
}, },
hasCachedSubtitle: (text: string) => {
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
},
isCacheFull: () => { isCacheFull: () => {
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT; return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
}, },
@@ -6,6 +6,7 @@ import test from 'node:test';
import * as vm from 'node:vm'; import * as vm from 'node:vm';
import { import {
addYomitanNoteViaSearch, addYomitanNoteViaSearch,
extractYomitanCurrentAnkiDeckName,
getYomitanDictionaryInfo, getYomitanDictionaryInfo,
importYomitanDictionaryFromZip, importYomitanDictionaryFromZip,
deleteYomitanDictionaryByTitle, deleteYomitanDictionaryByTitle,
@@ -181,6 +182,72 @@ test('syncYomitanDefaultAnkiServer no-ops for empty target url', async () => {
assert.equal(executeCount, 0); assert.equal(executeCount, 0);
}); });
test('extractYomitanCurrentAnkiDeckName prefers the active profile first term card format deck', () => {
assert.equal(
extractYomitanCurrentAnkiDeckName({
profileCurrent: 1,
profiles: [
{
options: {
anki: {
cardFormats: [{ type: 'term', deck: 'Inactive' }],
},
},
},
{
options: {
anki: {
cardFormats: [
{ type: 'kanji', deck: 'Kanji' },
{ type: 'term', deck: 'Mining' },
],
},
},
},
],
}),
'Mining',
);
});
test('extractYomitanCurrentAnkiDeckName ignores disabled card format decks', () => {
assert.equal(
extractYomitanCurrentAnkiDeckName({
profiles: [
{
options: {
anki: {
cardFormats: [
{ type: 'term', deck: 'Disabled Term', enabled: false },
{ type: 'kanji', deck: 'Disabled Kanji', enabled: false },
{ type: 'term', deck: 'Mining', enabled: true },
],
},
},
},
],
}),
'Mining',
);
});
test('extractYomitanCurrentAnkiDeckName falls back to legacy term deck', () => {
assert.equal(
extractYomitanCurrentAnkiDeckName({
profiles: [
{
options: {
anki: {
terms: { deck: 'Legacy Mining' },
},
},
},
],
}),
'Legacy Mining',
);
});
test('requestYomitanTermFrequencies returns normalized frequency entries', async () => { test('requestYomitanTermFrequencies returns normalized frequency entries', async () => {
let scriptValue = ''; let scriptValue = '';
const deps = createDeps(async (script) => { const deps = createDeps(async (script) => {
@@ -1897,6 +1897,73 @@ export async function syncYomitanDefaultAnkiServer(
} }
} }
function readDeckName(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function getYomitanDeckFromProfileOptions(profileOptions: Record<string, unknown>): string {
const anki = profileOptions.anki;
if (!isObject(anki)) {
return '';
}
const cardFormats = Array.isArray(anki.cardFormats) ? anki.cardFormats : [];
const enabledCardFormats = cardFormats
.filter((cardFormat): cardFormat is Record<string, unknown> => isObject(cardFormat))
.filter((cardFormat) => cardFormat.enabled !== false);
const termDeck = enabledCardFormats.find(
(cardFormat) => cardFormat.type === 'term' && readDeckName(cardFormat.deck).length > 0,
);
if (termDeck) {
return readDeckName(termDeck.deck);
}
const firstDeck = enabledCardFormats
.map((cardFormat) => readDeckName(cardFormat.deck))
.find((deckName) => deckName.length > 0);
if (firstDeck) {
return firstDeck;
}
const terms = anki.terms;
if (isObject(terms)) {
const legacyTermDeck = readDeckName(terms.deck);
if (legacyTermDeck) {
return legacyTermDeck;
}
}
const kanji = anki.kanji;
return isObject(kanji) ? readDeckName(kanji.deck) : '';
}
export function extractYomitanCurrentAnkiDeckName(optionsFull: Record<string, unknown>): string {
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
if (profiles.length === 0) {
return '';
}
const profileCurrent =
typeof optionsFull.profileCurrent === 'number' && Number.isFinite(optionsFull.profileCurrent)
? Math.max(0, Math.floor(optionsFull.profileCurrent))
: 0;
const targetProfile = profiles[profileCurrent];
if (!isObject(targetProfile) || !isObject(targetProfile.options)) {
return '';
}
return getYomitanDeckFromProfileOptions(targetProfile.options as Record<string, unknown>);
}
export async function getYomitanCurrentAnkiDeckName(
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<string> {
const optionsFull = await getYomitanSettingsFull(deps, logger);
return optionsFull ? extractYomitanCurrentAnkiDeckName(optionsFull) : '';
}
function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string): string { function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string): string {
return ` return `
(async () => { (async () => {
+28 -1
View File
@@ -350,6 +350,7 @@ import {
saveSubtitlePosition as saveSubtitlePositionCore, saveSubtitlePosition as saveSubtitlePositionCore,
addYomitanNoteViaSearch, addYomitanNoteViaSearch,
clearYomitanParserCachesForWindow, clearYomitanParserCachesForWindow,
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
sendMpvCommandRuntime, sendMpvCommandRuntime,
setMpvSubVisibilityRuntime, setMpvSubVisibilityRuntime,
@@ -1753,10 +1754,17 @@ function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
} }
appState.currentSubText = text; appState.currentSubText = text;
subtitlePrefetchService?.pause();
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
if (cachedPayload) {
subtitleProcessingController.onSubtitleChange(text);
emitSubtitlePayload(cachedPayload);
return true;
}
const rawPayload = withCurrentSubtitleTiming({ text, tokens: null }); const rawPayload = withCurrentSubtitleTiming({ text, tokens: null });
appState.currentSubtitleData = rawPayload; appState.currentSubtitleData = rawPayload;
broadcastToOverlayWindows('subtitle:set', rawPayload); broadcastToOverlayWindows('subtitle:set', rawPayload);
subtitlePrefetchService?.pause();
subtitleProcessingController.onSubtitleChange(text); subtitleProcessingController.onSubtitleChange(text);
return true; return true;
} }
@@ -1833,6 +1841,7 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
preCacheTokenization: (text, data) => { preCacheTokenization: (text, data) => {
subtitleProcessingController.preCacheTokenization(text, data); subtitleProcessingController.preCacheTokenization(text, data);
}, },
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
isCacheFull: () => subtitleProcessingController.isCacheFull(), isCacheFull: () => subtitleProcessingController.isCacheFull(),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
@@ -2067,6 +2076,17 @@ const configSettingsRuntime = createConfigSettingsRuntime({
onHotReloadApplied: applyConfigHotReloadDiff, onHotReloadApplied: applyConfigHotReloadDiff,
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url, defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: (url) => new AnkiConnectClient(url), createAnkiClient: (url) => new AnkiConnectClient(url),
getYomitanAnkiDeckName: async () => {
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), {
error: (message, ...args) => {
logger.error(message, ...args);
},
info: (message, ...args) => {
logger.info(message, ...args);
},
});
},
getSettingsWindow: () => appState.configSettingsWindow, getSettingsWindow: () => appState.configSettingsWindow,
setSettingsWindow: (window) => { setSettingsWindow: (window) => {
appState.configSettingsWindow = window as BrowserWindow | null; appState.configSettingsWindow = window as BrowserWindow | null;
@@ -4207,6 +4227,12 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
appState.immersionTracker?.recordCardsMined(count, noteIds); appState.immersionTracker?.recordCardsMined(count, noteIds);
}; };
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => { const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
if (hasCurrentSubtitle) {
subtitlePrefetchService?.pause();
}
subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}; };
let hasAttemptedImmersionTrackerStartup = false; let hasAttemptedImmersionTrackerStartup = false;
@@ -4591,6 +4617,7 @@ const {
}, },
onSubtitleChange: (text) => { onSubtitleChange: (text) => {
subtitlePrefetchService?.pause(); subtitlePrefetchService?.pause();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
subtitleProcessingController.onSubtitleChange(text); subtitleProcessingController.onSubtitleChange(text);
}, },
refreshDiscordPresence: () => { refreshDiscordPresence: () => {
+64
View File
@@ -89,6 +89,70 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
); );
}); });
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
const source = readMainSource();
const actionBlock = source.match(
/onSubtitleChange:\s*\(text\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n refreshDiscordPresence:/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /subtitlePrefetchService\?\.pause\(\);/);
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
assert.ok(
actionBlock.indexOf('subtitlePrefetchService?.pause();') <
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);'),
);
assert.ok(
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);') <
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
);
});
test('autoplay subtitle prime prefers cached annotated payload before raw fallback', () => {
const source = readMainSource();
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,
/const rawPayload = withCurrentSubtitleTiming\(\{ text, tokens: null \}\);/,
);
assert.ok(
actionBlock.indexOf('consumeCachedSubtitle(text)') <
actionBlock.indexOf('withCurrentSubtitleTiming({ text, tokens: null })'),
);
});
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
const source = readMainSource();
const actionBlock = source.match(
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /subtitleProcessingController\.invalidateTokenizationCache\(\);/);
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
assert.match(
actionBlock,
/subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\);/,
);
assert.ok(
actionBlock.indexOf('subtitleProcessingController.invalidateTokenizationCache();') <
actionBlock.indexOf(
'subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);',
),
);
});
test('manual visible overlay changes notify mpv plugin visibility state', () => { test('manual visible overlay changes notify mpv plugin visibility state', () => {
const source = readMainSource(); const source = readMainSource();
const setBlock = source.match( const setBlock = source.match(
@@ -60,6 +60,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and logging changes', () => { test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and logging changes', () => {
const config = deepCloneConfig(DEFAULT_CONFIG); const config = deepCloneConfig(DEFAULT_CONFIG);
config.ankiConnect.behavior.autoUpdateNewCards = false; config.ankiConnect.behavior.autoUpdateNewCards = false;
config.ankiConnect.deck = 'Mining';
config.ankiConnect.knownWords.highlightEnabled = true; config.ankiConnect.knownWords.highlightEnabled = true;
config.ankiConnect.knownWords.refreshMinutes = 90; config.ankiConnect.knownWords.refreshMinutes = 90;
config.ankiConnect.knownWords.decks = { Anime: ['Mining'] }; config.ankiConnect.knownWords.decks = { Anime: ['Mining'] };
@@ -100,6 +101,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
{ {
hotReloadFields: [ hotReloadFields: [
'ankiConnect.behavior.autoUpdateNewCards', 'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.deck',
'ankiConnect.knownWords.highlightEnabled', 'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes', 'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.decks', 'ankiConnect.knownWords.decks',
@@ -123,6 +125,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
assert.deepEqual(ankiPatches, [ assert.deepEqual(ankiPatches, [
{ {
deck: 'Mining',
behavior: { autoUpdateNewCards: false }, behavior: { autoUpdateNewCards: false },
knownWords: config.ankiConnect.knownWords, knownWords: config.ankiConnect.knownWords,
nPlusOne: config.ankiConnect.nPlusOne, nPlusOne: config.ankiConnect.nPlusOne,
@@ -86,6 +86,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) { if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) {
patch.behavior = { autoUpdateNewCards: config.ankiConnect.behavior.autoUpdateNewCards }; patch.behavior = { autoUpdateNewCards: config.ankiConnect.behavior.autoUpdateNewCards };
} }
if (diff.hotReloadFields.includes('ankiConnect.deck')) {
patch.deck = config.ankiConnect.deck;
}
if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) { if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) {
patch.knownWords = config.ankiConnect.knownWords; patch.knownWords = config.ankiConnect.knownWords;
} }
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { createConfigSettingsRuntime } from './config-settings-runtime';
test('config settings runtime exposes inferred Yomitan Anki deck lookup', async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const runtime = createConfigSettingsRuntime({
fields: [],
getConfigPath: () => '/tmp/config.jsonc',
getRawConfig: () => ({}),
getConfig: () => deepCloneConfig(DEFAULT_CONFIG),
getWarnings: () => [],
reloadConfigStrict: () =>
({
ok: true,
config: deepCloneConfig(DEFAULT_CONFIG),
warnings: [],
path: '/tmp/config.jsonc',
}) as never,
getSettingsWindow: () => null,
setSettingsWindow: () => undefined,
createSettingsWindow: () => ({}) as never,
settingsHtmlPath: '/tmp/settings.html',
openPath: async () => '',
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: () =>
({
deckNames: async () => [],
fieldNamesForDeck: async () => [],
modelNamesForDeck: async () => [],
modelNames: async () => [],
modelFieldNames: async () => [],
}) as never,
getYomitanAnkiDeckName: async () => 'Mining',
ipcMain: {
handle: (channel, listener) => {
handlers.set(channel, listener);
},
},
ipcChannels: IPC_CHANNELS.request,
});
runtime.registerHandlers();
const handler = handlers.get(IPC_CHANNELS.request.getConfigSettingsYomitanAnkiDeckName);
assert.ok(handler);
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' });
});
@@ -3,6 +3,7 @@ import path from 'node:path';
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit'; import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config'; import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
import type { import type {
ConfigSettingsAnkiDeckResult,
ConfigSettingsAnkiListResult, ConfigSettingsAnkiListResult,
ConfigSettingsField, ConfigSettingsField,
ConfigSettingsSaveResult, ConfigSettingsSaveResult,
@@ -34,6 +35,7 @@ export interface ConfigSettingsIpcChannels {
getConfigSettingsAnkiDeckModelNames: string; getConfigSettingsAnkiDeckModelNames: string;
getConfigSettingsAnkiModelNames: string; getConfigSettingsAnkiModelNames: string;
getConfigSettingsAnkiModelFieldNames: string; getConfigSettingsAnkiModelFieldNames: string;
getConfigSettingsYomitanAnkiDeckName: string;
} }
export interface ConfigSettingsAnkiClient { export interface ConfigSettingsAnkiClient {
@@ -60,6 +62,7 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
openPath(path: string): Promise<string>; openPath(path: string): Promise<string>;
defaultAnkiConnectUrl: string; defaultAnkiConnectUrl: string;
createAnkiClient(url: string): ConfigSettingsAnkiClient; createAnkiClient(url: string): ConfigSettingsAnkiClient;
getYomitanAnkiDeckName?: () => Promise<string | null | undefined>;
ipcMain: ConfigSettingsIpcMainLike; ipcMain: ConfigSettingsIpcMainLike;
ipcChannels: ConfigSettingsIpcChannels; ipcChannels: ConfigSettingsIpcChannels;
log?: (message: string) => void; log?: (message: string) => void;
@@ -190,6 +193,22 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
}; };
} }
async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> {
if (!deps.getYomitanAnkiDeckName) {
return { ok: true, value: '' };
}
try {
const value = await deps.getYomitanAnkiDeckName();
return { ok: true, value: typeof value === 'string' ? value.trim() : '' };
} catch (error) {
return {
ok: false,
value: '',
error: error instanceof Error ? error.message : 'Failed to query Yomitan.',
};
}
}
function registerHandlers(): void { function registerHandlers(): void {
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot()); deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => { deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
@@ -236,6 +255,9 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
: invalidAnkiListResult('Note type is required.'); : invalidAnkiListResult('Note type is required.');
}, },
); );
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsYomitanAnkiDeckName, () =>
getYomitanAnkiDeckName(),
);
} }
return { return {
@@ -13,6 +13,7 @@ export interface SubtitlePrefetchInitControllerDeps {
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService; createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean; isCacheFull: () => boolean;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logWarn: (message: string) => void; logWarn: (message: string) => void;
@@ -67,6 +68,7 @@ export function createSubtitlePrefetchInitController(
cues, cues,
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text), tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data), preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
isCacheFull: () => deps.isCacheFull(), isCacheFull: () => deps.isCacheFull(),
}); });
+1
View File
@@ -18,6 +18,7 @@ test('settings preload exposes Anki lookup helpers', () => {
'getAnkiDeckModelNames', 'getAnkiDeckModelNames',
'getAnkiModelNames', 'getAnkiModelNames',
'getAnkiModelFieldNames', 'getAnkiModelFieldNames',
'getYomitanAnkiDeckName',
]) { ]) {
assert.match(source, new RegExp(`${method}:`)); assert.match(source, new RegExp(`${method}:`));
} }
+4
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import type { import type {
ConfigSettingsAnkiDeckResult,
ConfigSettingsAnkiListResult, ConfigSettingsAnkiListResult,
ConfigSettingsAPI, ConfigSettingsAPI,
ConfigSettingsPatch, ConfigSettingsPatch,
@@ -17,6 +18,7 @@ const SETTINGS_IPC_CHANNELS = {
getAnkiDeckModelNames: 'config-settings:anki-deck-model-names', getAnkiDeckModelNames: 'config-settings:anki-deck-model-names',
getAnkiModelNames: 'config-settings:anki-model-names', getAnkiModelNames: 'config-settings:anki-model-names',
getAnkiModelFieldNames: 'config-settings:anki-model-field-names', getAnkiModelFieldNames: 'config-settings:anki-model-field-names',
getYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name',
} as const; } as const;
const configSettingsAPI: ConfigSettingsAPI = { const configSettingsAPI: ConfigSettingsAPI = {
@@ -45,6 +47,8 @@ const configSettingsAPI: ConfigSettingsAPI = {
draftUrl?: string, draftUrl?: string,
): Promise<ConfigSettingsAnkiListResult> => ): Promise<ConfigSettingsAnkiListResult> =>
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelFieldNames, modelName, draftUrl), ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelFieldNames, modelName, draftUrl),
getYomitanAnkiDeckName: (): Promise<ConfigSettingsAnkiDeckResult> =>
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getYomitanAnkiDeckName),
}; };
contextBridge.exposeInMainWorld('configSettingsAPI', configSettingsAPI); contextBridge.exposeInMainWorld('configSettingsAPI', configSettingsAPI);
+7 -2
View File
@@ -69,14 +69,19 @@ test('prerelease workflow builds and uploads all release platforms', () => {
test('prerelease workflow publishes the same release assets as the stable workflow', () => { test('prerelease workflow publishes the same release assets as the stable workflow', () => {
assert.match( assert.match(
prereleaseWorkflow, prereleaseWorkflow,
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/\*\.yml release\/\*\.blockmap dist\/launcher\/subminer\)/, /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/latest\*\.yml release\/\*\.blockmap dist\/launcher\/subminer\)/,
); );
assert.match( assert.match(
prereleaseWorkflow, prereleaseWorkflow,
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/, /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/latest\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
); );
}); });
test('prerelease workflow uploads updater metadata without builder debug YAML files', () => {
assert.match(prereleaseWorkflow, /release\/latest\*\.yml/);
assert.doesNotMatch(prereleaseWorkflow, /release\/\*\.yml/);
});
test('prerelease workflow writes checksum entries using release asset basenames', () => { test('prerelease workflow writes checksum entries using release asset basenames', () => {
assert.match(prereleaseWorkflow, /: > release\/SHA256SUMS\.txt/); assert.match(prereleaseWorkflow, /: > release\/SHA256SUMS\.txt/);
assert.match(prereleaseWorkflow, /for file in "\$\{files\[@\]\}"; do/); assert.match(prereleaseWorkflow, /for file in "\$\{files\[@\]\}"; do/);
+7 -2
View File
@@ -105,14 +105,19 @@ test('release workflow generates release notes from committed changelog output',
test('release workflow includes the Windows installer in checksums and uploaded assets', () => { test('release workflow includes the Windows installer in checksums and uploaded assets', () => {
assert.match( assert.match(
releaseWorkflow, releaseWorkflow,
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/\*\.yml release\/\*\.blockmap dist\/launcher\/subminer\)/, /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/latest\*\.yml release\/\*\.blockmap dist\/launcher\/subminer\)/,
); );
assert.match( assert.match(
releaseWorkflow, releaseWorkflow,
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/, /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/latest\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
); );
}); });
test('release workflow uploads updater metadata without builder debug YAML files', () => {
assert.match(releaseWorkflow, /release\/latest\*\.yml/);
assert.doesNotMatch(releaseWorkflow, /release\/\*\.yml/);
});
test('release package metadata enables GitHub updater metadata without builder uploads', () => { test('release package metadata enables GitHub updater metadata without builder uploads', () => {
assert.equal(packageJson.build?.publish?.[0]?.provider, 'github'); assert.equal(packageJson.build?.publish?.[0]?.provider, 'github');
assert.equal(packageJson.build?.publish?.[0]?.owner, 'ksyasuda'); assert.equal(packageJson.build?.publish?.[0]?.owner, 'ksyasuda');
+22 -10
View File
@@ -667,9 +667,13 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
--subtitle-frequency-band-3-color: #f9e2af; --subtitle-frequency-band-3-color: #f9e2af;
--subtitle-frequency-band-4-color: #8bd5ca; --subtitle-frequency-band-4-color: #8bd5ca;
--subtitle-frequency-band-5-color: #8aadf4; --subtitle-frequency-band-5-color: #8aadf4;
text-shadow: text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.8), -1px -1px 2px rgba(0, 0, 0, 0.95),
-1px -1px 2px rgba(0, 0, 0, 0.5); 1px -1px 2px rgba(0, 0, 0, 0.95),
-1px 1px 2px rgba(0, 0, 0, 0.95),
1px 1px 2px rgba(0, 0, 0, 0.95),
0 0 8px rgba(0, 0, 0, 0.5);
/* Enable text selection for Yomitan */ /* Enable text selection for Yomitan */
user-select: text; user-select: text;
cursor: text; cursor: text;
@@ -817,6 +821,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
display: inline-block; display: inline-block;
position: relative; position: relative;
padding-left: 1.08em; padding-left: 1.08em;
margin-left: 0.18em;
vertical-align: baseline; vertical-align: baseline;
} }
@@ -837,7 +842,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n1 { #subtitleRoot .word.word-jlpt-n1 {
text-decoration-line: none; text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n1-color, #ed8796); border-bottom: 3px solid var(--subtitle-jlpt-n1-color, #ed8796);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
} }
#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after {
@@ -846,7 +852,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n2 { #subtitleRoot .word.word-jlpt-n2 {
text-decoration-line: none; text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n2-color, #f5a97f); border-bottom: 3px solid var(--subtitle-jlpt-n2-color, #f5a97f);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
} }
#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after {
@@ -855,7 +862,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n3 { #subtitleRoot .word.word-jlpt-n3 {
text-decoration-line: none; text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n3-color, #f9e2af); border-bottom: 3px solid var(--subtitle-jlpt-n3-color, #f9e2af);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
} }
#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after {
@@ -864,7 +872,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n4 { #subtitleRoot .word.word-jlpt-n4 {
text-decoration-line: none; text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n4-color, #a6e3a1); border-bottom: 3px solid var(--subtitle-jlpt-n4-color, #a6e3a1);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
} }
#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after {
@@ -873,7 +882,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n5 { #subtitleRoot .word.word-jlpt-n5 {
text-decoration-line: none; text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n5-color, #8aadf4); border-bottom: 3px solid var(--subtitle-jlpt-n5-color, #8aadf4);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
} }
#subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after {
@@ -1186,9 +1196,11 @@ body.layer-modal #overlay {
-webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7); -webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7);
paint-order: stroke fill; paint-order: stroke fill;
text-shadow: text-shadow:
0 2px 4px rgba(0, 0, 0, 0.95), -1px -1px 2px rgba(0, 0, 0, 0.95),
0 0 8px rgba(0, 0, 0, 0.8), 1px -1px 2px rgba(0, 0, 0, 0.95),
0 0 16px rgba(0, 0, 0, 0.55); -1px 1px 2px rgba(0, 0, 0, 0.95),
1px 1px 2px rgba(0, 0, 0, 0.95),
0 0 8px rgba(0, 0, 0, 0.5);
user-select: text; user-select: text;
cursor: text; cursor: text;
} }
+4 -3
View File
@@ -909,8 +909,8 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
assert.doesNotMatch(plainJlptBlock, /text-decoration\s*:[^;]*\bunderline\b/i); assert.doesNotMatch(plainJlptBlock, /text-decoration\s*:[^;]*\bunderline\b/i);
assert.match( assert.match(
plainJlptBlock, plainJlptBlock,
new RegExp(`border-bottom:\\s*2px\\s+solid\\s+var\\(--subtitle-jlpt-n${level}-color,`), new RegExp(`border-bottom:\\s*3px\\s+solid\\s+var\\(--subtitle-jlpt-n${level}-color,`),
`JLPT level must paint a permanent 2px border-bottom in the level color`, `JLPT level must paint a permanent 3px border-bottom in the level color`,
); );
// JLPT tagging must communicate level *only* via the underline; it must // JLPT tagging must communicate level *only* via the underline; it must
@@ -973,6 +973,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
assert.match(characterImageTokenBlock, /display:\s*inline-block;/); assert.match(characterImageTokenBlock, /display:\s*inline-block;/);
assert.match(characterImageTokenBlock, /position:\s*relative;/); assert.match(characterImageTokenBlock, /position:\s*relative;/);
assert.match(characterImageTokenBlock, /padding-left:\s*1\.08em;/); assert.match(characterImageTokenBlock, /padding-left:\s*1\.08em;/);
assert.match(characterImageTokenBlock, /margin-left:\s*0\.18em;/);
const characterImageBlock = extractClassBlock(cssText, '#subtitleRoot .word-character-image'); const characterImageBlock = extractClassBlock(cssText, '#subtitleRoot .word-character-image');
assert.match(characterImageBlock, /position:\s*absolute;/); assert.match(characterImageBlock, /position:\s*absolute;/);
@@ -1186,7 +1187,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
assert.match(secondaryRootBlock, /-webkit-text-stroke:\s*0\.45px rgba\(0,\s*0,\s*0,\s*0\.7\);/); assert.match(secondaryRootBlock, /-webkit-text-stroke:\s*0\.45px rgba\(0,\s*0,\s*0,\s*0\.7\);/);
assert.match( assert.match(
secondaryRootBlock, secondaryRootBlock,
/text-shadow:\s*0 2px 4px rgba\(0,\s*0,\s*0,\s*0\.95\),\s*0 0 8px rgba\(0,\s*0,\s*0,\s*0\.8\),\s*0 0 16px rgba\(0,\s*0,\s*0,\s*0\.55\);/, /text-shadow:\s*-1px -1px 2px rgba\(0,\s*0,\s*0,\s*0\.95\),\s*1px -1px 2px rgba\(0,\s*0,\s*0,\s*0\.95\),\s*-1px 1px 2px rgba\(0,\s*0,\s*0,\s*0\.95\),\s*1px 1px 2px rgba\(0,\s*0,\s*0,\s*0\.95\),\s*0 0 8px rgba\(0,\s*0,\s*0,\s*0\.5\);/,
); );
const secondaryHoverBaseBlock = extractClassBlock( const secondaryHoverBaseBlock = extractClassBlock(

Some files were not shown because too many files have changed in this diff Show More