Compare commits

...

13 Commits

Author SHA1 Message Date
sudacode 54e90754ef chore: release 0.15.1 2026-05-31 22:40:40 -07:00
sudacode 487143802a feat(keybindings): add mouse button support for mpv keybindings (#103) 2026-05-31 22:22:38 -07:00
sudacode e6a004ab8b Fix Windows mpv shortcut attachment to background app (#105) 2026-05-31 21:46:00 -07:00
sudacode b510c54875 fix(overlay): restore mpv focus and pointer state on macOS (#104) 2026-05-31 21:25:04 -07:00
sudacode e1ea464bc9 fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101) 2026-05-31 20:59:18 -07:00
sudacode b46b8dfa41 chore: add issue forms and expand PR template (#100) 2026-05-30 23:50:00 -07:00
sudacode ca067a6ccf Add FUNDING.yml 2026-05-30 20:15:58 -07:00
sudacode d719b346e0 fix(overlay): use Lua dispatch syntax for Hyprland 0.55+ Lua configs (#99) 2026-05-29 00:13:31 -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
185 changed files with 8173 additions and 718 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [ksyasuda]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: sudacode
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+105
View File
@@ -0,0 +1,105 @@
name: Bug Report
description: Report something that is broken or behaving incorrectly
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug report! Please search [existing issues](https://github.com/ksyasuda/SubMiner/issues?q=is%3Aissue) first to avoid duplicates.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear description of the bug, including what you expected to happen instead.
placeholder: When I open the Yomitan popup, the overlay freezes...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Minimal, ordered steps that reliably trigger the bug.
placeholder: |
1. Launch `subminer`
2. Play a video in MPV
3. Hover a word and press ...
4. See error
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
description: Which part of SubMiner is affected?
options:
- Overlay / Yomitan popup
- Anki mining
- Subtitle annotations
- Subtitle sidebar
- Immersion tracking / stats
- Launcher / CLI
- MPV plugin
- Jellyfin integration
- Jimaku integration
- AniList integration
- YouTube integration
- Character dictionary
- WebSocket / texthooker API
- Configuration
- Documentation
- Other / not sure
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- Linux
- macOS
- Windows
validations:
required: true
- type: input
id: version
attributes:
label: SubMiner version
description: Output of `subminer --version`, or the release tag / commit you are running.
placeholder: v0.15.0
validations:
required: true
- type: input
id: compositor
attributes:
label: Compositor (Linux only)
description: SubMiner's overlay supports Hyprland and sway. Name yours (and version if known). Leave blank on macOS / Windows.
placeholder: Hyprland 0.55
validations:
required: false
- type: input
id: mpv-version
attributes:
label: MPV version
description: Output of `mpv --version` (first line).
placeholder: mpv 0.38.0
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs / console output
description: |
Relevant logs. For verbose output, run `electron . --dev --log-level debug`.
This will be rendered as code automatically — no backticks needed.
render: shell
validations:
required: false
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://docs.subminer.moe
about: Setup, configuration, and feature docs — check here before filing an issue.
- name: Troubleshooting guide
url: https://docs.subminer.moe/troubleshooting
about: Common problems and fixes (Hyprland rules, MPV detection, Anki connection, etc.).
@@ -0,0 +1,59 @@
name: Feature Request
description: Suggest a new feature or an improvement to an existing one
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for the idea! Please search [existing issues](https://github.com/ksyasuda/SubMiner/issues?q=is%3Aissue) first to avoid duplicates.
- type: textarea
id: problem
attributes:
label: Problem / motivation
description: What problem are you trying to solve? What is missing or frustrating today?
placeholder: When mining a card I have to manually switch to Anki because...
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe the feature or change you'd like to see.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Related area
description: Which part of SubMiner does this relate to?
options:
- Overlay / Yomitan popup
- Anki mining
- Subtitle annotations
- Subtitle sidebar
- Immersion tracking / stats
- Launcher / CLI
- MPV plugin
- Jellyfin integration
- Jimaku integration
- AniList integration
- YouTube integration
- Character dictionary
- WebSocket / texthooker API
- Configuration
- Documentation
- Other / new area
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any workarounds you currently use or other approaches you've thought about.
validations:
required: false
+34 -1
View File
@@ -1,3 +1,36 @@
<!--
Thanks for contributing to SubMiner! Fill out the sections below.
Keep it short — a couple of sentences per section is fine.
-->
## Summary
<!-- What does this PR do and why? -->
## Type of change
<!-- Check all that apply. -->
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / internal
- [ ] Documentation
- [ ] Other
## Related issues
<!-- e.g. "Closes #123". Delete if none. -->
## How was this tested?
<!--
Describe verification. The default handoff gate is:
bun run typecheck && bun run test:fast && bun run test:env && bun run build && bun run test:smoke:dist
If docs-site/ changed, also: bun run docs:test && bun run docs:build
-->
## Checklist
- [ ] Added a changelog fragment in `changes/`, or this PR is labeled `skip-changelog`
- [ ] Added a changelog fragment, or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
- [ ] Docs updated in the same PR if behavior, defaults, flags, shortcuts, ports, or APIs changed
- [ ] Relevant checks pass locally (typecheck, tests, build)
+5 -5
View File
@@ -148,7 +148,7 @@ jobs:
name: appimage
path: |
release/*.AppImage
release/*.yml
release/latest*.yml
release/*.blockmap
if-no-files-found: error
@@ -226,7 +226,7 @@ jobs:
path: |
release/*.dmg
release/*.zip
release/*.yml
release/latest*.yml
release/*.blockmap
if-no-files-found: error
@@ -279,7 +279,7 @@ jobs:
path: |
release/*.exe
release/*.zip
release/*.yml
release/latest*.yml
release/*.blockmap
if-no-files-found: error
@@ -353,7 +353,7 @@ jobs:
- name: Generate checksums
run: |
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
echo "No release artifacts found for checksum generation."
exit 1
@@ -389,7 +389,7 @@ jobs:
release/*.exe
release/*.zip
release/*.tar.gz
release/*.yml
release/latest*.yml
release/*.blockmap
release/SHA256SUMS.txt
dist/launcher/subminer
+5 -5
View File
@@ -139,7 +139,7 @@ jobs:
name: appimage
path: |
release/*.AppImage
release/*.yml
release/latest*.yml
release/*.blockmap
build-macos:
@@ -216,7 +216,7 @@ jobs:
path: |
release/*.dmg
release/*.zip
release/*.yml
release/latest*.yml
release/*.blockmap
build-windows:
@@ -268,7 +268,7 @@ jobs:
path: |
release/*.exe
release/*.zip
release/*.yml
release/latest*.yml
release/*.blockmap
if-no-files-found: error
@@ -342,7 +342,7 @@ jobs:
- name: Generate checksums
run: |
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
echo "No release artifacts found for checksum generation."
exit 1
@@ -396,7 +396,7 @@ jobs:
release/*.exe
release/*.zip
release/*.tar.gz
release/*.yml
release/latest*.yml
release/*.blockmap
release/SHA256SUMS.txt
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.
`CLAUDE.md` is a symlink to this file — there is one project instruction file, not two.
## Quick Start
- 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`
- 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
- Launcher source of truth: `launcher/*.ts`
@@ -52,7 +68,8 @@ Start here, then leave this file.
## 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`
- PR review helpers:
- `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
- CI red: `gh run list/view`, rerun, fix, repeat until green
- 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`
+168
View File
@@ -1,5 +1,173 @@
# Changelog
## v0.15.1 (2026-05-31)
### Fixed
- **Linux Overlay Stacking**: Fixed the overlay intermittently dropping behind mpv on KDE Plasma and other non-Hyprland/Sway Wayland sessions; restored subtitle hover, pause-on-hover, and Yomitan lookups on X11/XWayland; the overlay now correctly layers above/below mpv based on fullscreen state, yields to foreground windows (Settings, Yomitan, AniList, etc.), and avoids startup flashes and fullscreen transition glitches.
- **Linux Overlay (Hyprland Lua)**: Fixed overlay placement on Hyprland 0.55+ when using a Lua-based config.
- **Manual Overlay Startup**: Fixed manual visible-overlay startup from mpv — now correctly attaches to playback, keeps the window bounds synced with mpv, and primes current subtitles before showing.
- **Playlist Transitions**: Reused the warm overlay when mpv advances to the next playlist item, avoiding a redundant tokenization pause and preserving visible subtitles across tracks.
- **macOS Overlay**: Fixed the visible subtitle overlay staying click-through after pause-until-ready releases playback; restored mpv focus after closing modal windows so subtitles and keybinds resume without clicking the player.
- **Mouse Keybindings**: Fixed keybinding capture and runtime handling for mouse buttons, including side buttons like `MBTN_BACK` and `MBTN_FORWARD`.
- **Windows mpv Shortcut**: Fixed the Windows `SubMiner mpv` shortcut so videos attach to an already-running background app instead of spawning a second process.
### Docs
- **Troubleshooting**: Updated Hyprland overlay docs with current Lua (`hl.window_rule`) and legacy config syntax; added troubleshooting for KDE/Wayland and other non-Hyprland/Sway Wayland sessions; added a Character Dictionary troubleshooting section; added a "See Also" index linking each feature's troubleshooting page.
## 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)
### Added
-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.
-10
View File
@@ -1,10 +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.
- Fixed immediate known-word cache append when no default Anki mining deck is configured but multiple known-word deck field mappings are present.
- Added an AnkiConnect deck dropdown at the top of Mining & Anki settings that auto-fills from Yomitan's current mining deck when available.
-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.
-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.
-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.
-6
View File
@@ -1,6 +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`.
- Release-note polishing now asks Claude to write short, nested highlight bullets so longer changes are easier to scan.
@@ -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.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: subtitles
- Improved subtitle annotation prefetching so cached colored annotations and character images are ready for more live subtitle changes without delaying raw subtitle display.
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: subtitles
- Updated subtitle defaults with a stronger outline-style text shadow, thicker JLPT underlines, and a `topX` frequency highlighting default of `10000`.
-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
@@ -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.
+172 -5
View File
@@ -1,12 +1,179 @@
# Changelog
## Unreleased
## v0.15.1 (2026-05-31)
- **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.
**Fixed**
## v0.14.0 (2026-05-12)
- **Linux Overlay Stacking**: Fixed the overlay intermittently dropping behind mpv on KDE Plasma and other non-Hyprland/Sway Wayland sessions; restored subtitle hover, pause-on-hover, and Yomitan lookups on X11/XWayland; the overlay now correctly layers above/below mpv based on fullscreen state, yields to foreground windows (Settings, Yomitan, AniList, etc.), and avoids startup flashes and fullscreen transition glitches.
- **Linux Overlay (Hyprland Lua)**: Fixed overlay placement on Hyprland 0.55+ when using a Lua-based config.
- **Manual Overlay Startup**: Fixed manual visible-overlay startup from mpv — now correctly attaches to playback, keeps the window bounds synced with mpv, and primes current subtitles before showing.
- **Playlist Transitions**: Reused the warm overlay when mpv advances to the next playlist item, avoiding a redundant tokenization pause and preserving visible subtitles across tracks.
- **macOS Overlay**: Fixed the visible subtitle overlay staying click-through after pause-until-ready releases playback; restored mpv focus after closing modal windows so subtitles and keybinds resume without clicking the player.
- **Mouse Keybindings**: Fixed keybinding capture and runtime handling for mouse buttons, including side buttons like `MBTN_BACK` and `MBTN_FORWARD`.
- **Windows mpv Shortcut**: Fixed the Windows `SubMiner mpv` shortcut so videos attach to an already-running background app instead of spawning a second process.
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.
**Docs**
- **Troubleshooting**: Updated Hyprland overlay docs with current Lua (`hl.window_rule`) and legacy config syntax; added troubleshooting for KDE/Wayland and other non-Hyprland/Sway Wayland sessions; added a Character Dictionary troubleshooting section; added a "See Also" index linking each feature's troubleshooting page.
## 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>
## Previous Versions
<details>
<summary>v0.14.x</summary>
<h2>v0.14.0 (2026-05-12)</h2>
**Added**
@@ -68,7 +235,7 @@ SubMiner no longer requires a globally-installed mpv plugin. The bundled plugin
</details>
## Previous Versions
</details>
<details>
<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:
1. Authenticate with AniList (see [AniList Integration](/anilist-integration#setup)).
2. Set `subtitleStyle.nameMatchEnabled` to `true` in your config or enable **Name Match Enabled** in Settings.
3. Start watching — SubMiner will generate a snapshot for the current media and import the merged dictionary into Yomitan automatically.
1. Enable **Name Match** in Settings → Subtitle Style, or set `subtitleStyle.nameMatchEnabled: true` in your config.
2. Start watching — SubMiner queries AniList's public GraphQL API (no authentication required) and imports 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
{
"anilist": {
"enabled": true,
"accessToken": "your-token",
},
"subtitleStyle": {
"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.
:::
::: 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
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.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
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
- [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
+13 -12
View File
@@ -339,7 +339,7 @@ See `config.example.jsonc` for detailed configuration options.
"word-spacing": "0",
"font-kerning": "normal",
"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",
"backdrop-filter": "blur(6px)",
"--subtitle-hover-token-color": "#f4dbd6",
@@ -351,7 +351,7 @@ See `config.example.jsonc` for detailed configuration options.
"color": "#cad3f5",
"background-color": "transparent",
"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`) |
| `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.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.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
@@ -571,13 +571,15 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "ArrowRight", "command": ["seek", 5] },
{ "key": "ArrowLeft", "command": ["seek", -5] },
{ "key": "Shift+ArrowRight", "command": ["seek", 30] },
{ "key": "MBTN_BACK", "command": ["sub-seek", -1] },
{ "key": "MBTN_FORWARD", "command": ["sub-seek", 1] },
{ "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] },
{ "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] }
]
}
```
**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`).
**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`). Mouse buttons use mpv button names: `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`.
**Disable a default binding:** Set command to `null`:
@@ -897,8 +899,8 @@ Enable automatic Anki card creation and updates with media generation:
},
"ai": {
"enabled": false,
"model": "openai/gpt-4o-mini",
"systemPrompt": "Translate mined sentence text only."
"model": "",
"systemPrompt": ""
},
"media": {
"generateAudio": true,
@@ -906,11 +908,11 @@ Enable automatic Anki card creation and updates with media generation:
"imageType": "static",
"imageFormat": "jpg",
"imageQuality": 92,
"imageMaxWidth": 1280,
"imageMaxHeight": 720,
"imageMaxWidth": 0,
"imageMaxHeight": 0,
"animatedFps": 10,
"animatedMaxWidth": 640,
"animatedMaxHeight": 360,
"animatedMaxHeight": 0,
"animatedCrf": 35,
"audioPadding": 0,
"fallbackDuration": 3,
@@ -925,8 +927,8 @@ Enable automatic Anki card creation and updates with media generation:
"pattern": "[SubMiner] %f (%t)"
},
"isLapis": {
"enabled": true,
"sentenceCardModel": "Japanese sentences"
"enabled": false,
"sentenceCardModel": "Lapis"
},
"isKiku": {
"enabled": false,
@@ -1133,7 +1135,6 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
"enabled": true,
"accessToken": "",
"characterDictionary": {
"enabled": false,
"maxLoaded": 3,
"profileScope": "all",
"collapsibleSections": {
+6 -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) |
| `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.
@@ -86,6 +86,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.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`.
@@ -151,9 +152,13 @@ The `keybindings` array overrides or extends the overlay's built-in key handling
"keybindings": [
{ "key": "f", "command": ["cycle", "fullscreen"] },
{ "key": "m", "command": ["cycle", "mute"] },
{ "key": "MBTN_BACK", "command": ["sub-seek", -1] },
{ "key": "MBTN_FORWARD", "command": ["sub-seek", 1] },
{ "key": "Space", "command": null }, // disable default Space → pause
],
}
```
Mouse keybinding names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`.
Both `shortcuts`, `keybindings`, and `subtitleSidebar` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner.
+1 -1
View File
@@ -74,7 +74,7 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
| Option | Default | Description |
| ------------------------------------------------ | ------------ | ---------------------------------------------------------------- |
| `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.matchMode` | `"headword"` | `"headword"` or `"surface"` |
| `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",
"pauseVideoOnHover": true,
"autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"fontSize": 16
"css": {
"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 |
| --------------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | `true` | Enable subtitle sidebar support |
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
| `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list |
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
| `backgroundColor` | string | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
| `textColor` | string | `#cad3f5` | Default cue text color |
| `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 |
| `timestampColor` | string | `#a5adcb` | Cue timestamp color |
| `activeLineColor` | string | `#f5bde6` | Active cue text color |
| `activeLineBackgroundColor` | string | `rgba(138, 173, 244, 0.22)` | Active cue background color |
| `hoverLineBackgroundColor` | string | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
Styling lives under the `css` object, using CSS property names and CSS custom properties (the same pattern as `subtitleStyle.css`).
| Option | Type | Default | Description |
| ------------------- | ------- | ------------- | -------------------------------------------------------------------------- |
| `enabled` | boolean | `true` | Enable subtitle sidebar support |
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
| `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list |
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
| `css` property | Default | Description |
| ------------------------------------------- | --------------------------- | ---------------------------- |
| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family |
| `color` | `#cad3f5` | Default cue text color |
| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
| `font-size` | `16px` | Base cue font size |
| `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
+84 -10
View File
@@ -18,7 +18,7 @@ If the overlay never appears at all, see [Playback Startup Flow](./architecture#
## Logging and App Mode
- Default log output is `info`.
- Default log output is `warn`.
- 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.
- 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:
- lower `subtitleStyle.fontSize`
- lower `subtitleStyle.css["font-size"]`
- keep overlay complexity minimal during heavy CPU periods
3. Reduce media overhead:
@@ -66,7 +66,9 @@ If the overlay never appears at all, see [Playback Startup Flow](./architecture#
```json
{
"subtitleStyle": {
"fontSize": 30,
"css": {
"font-size": "30px"
},
"enableJlpt": false,
"frequencyDictionary": {
"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.
- 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.
**"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.
- 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
**"FFmpeg not found"**
@@ -315,18 +326,35 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
### Linux
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors are not supported — both mpv and SubMiner must run under X11 or Xwayland instead.
- **X11 / Xwayland**: Requires `xdotool` and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway — both mpv and SubMiner must be running under X11/Xwayland for window tracking to work.
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively — both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma--other-wayland-compositors)).
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway — both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
- **Mouse passthrough**: On Linux, Electron's mouse passthrough is unreliable. SubMiner keeps pointer events enabled, meaning you may need to toggle the overlay off to interact with mpv controls underneath.
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
### 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 Electron window that must be kept above mpv. 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**
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
windowrule = float on, match:class SubMiner
@@ -336,7 +364,7 @@ windowrule = no_shadow 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**
@@ -357,7 +385,53 @@ SubMiner watches mpv's `fullscreen` property and refreshes the overlay geometry
For more details, see the Hyprland docs on [global keybinds](https://wiki.hypr.land/Configuring/Binds/#global-keybinds) and [window rules](https://wiki.hypr.land/Configuring/Window-Rules/).
### KDE Plasma & other Wayland compositors
On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and others), the overlay can only stay above mpv when both processes run under **XWayland** — the Wayland protocol forbids clients from controlling window stacking, so the overlay's "always on top" becomes a no-op on a native Wayland surface.
SubMiner handles this automatically:
- It launches its own window under XWayland (it sets `--ozone-platform-hint=x11`).
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too — Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11egl,x11`) is applied.
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
- During startup and fullscreen transitions, SubMiner waits for tracked mpv geometry before showing the visible overlay and skips the fullscreen restack hide/show path after mpv leaves fullscreen. That avoids a temporary full-screen overlay or black window while the subtitle tokenizer and Yomitan warmups finish.
- If the subtitle sidebar is open during a windowed/fullscreen transition, SubMiner restores it on the replacement overlay window. Subtitle hit regions are also refreshed as soon as the first measured subtitle line is reported, so hover and Yomitan lookup should work on the first visible line.
Requirements: `xdotool`, `xprop`, and `xwininfo` must be installed. SubMiner uses root `_NET_ACTIVE_WINDOW` from `xprop` for focus detection and falls back to `xdotool getactivewindow` when that signal is unavailable.
**Overlay sits behind mpv / pause-on-hover and Yomitan stop working**
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11egl …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11egl` in your `mpv.conf`.
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
**Overlay stays above an unrelated foreground app**
SubMiner can only detect focus for X11/Xwayland windows in this mode. If a native Wayland app covers mpv but the overlay stays visible, run that app under Xwayland too or use Hyprland/Sway native support. Generic X11 cannot observe native Wayland foreground windows.
### macOS
- **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`
## 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 texthooker # Launch texthooker-only mode
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
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
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
{
@@ -45,9 +45,9 @@ SubMiner's integration ports are configured in `config.jsonc`.
### How startup behaves
- `websocket.enabled: "auto"` starts the basic subtitle websocket unless SubMiner detects the external `mpv_websocket` plugin.
- `annotationWebsocket` is independent from `websocket` and stays enabled unless you explicitly disable it.
- `texthooker.launchAtStartup` starts the local HTTP UI automatically.
- `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.enabled` defaults to `false` and is independent from `websocket`. Set it to `true` to start the annotated stream.
- `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.
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`
When validating auto-update metadata, also run the relevant platform package
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:
`bun run docs:test`
`bun run docs:build`
@@ -55,7 +55,7 @@
`bun run test:env`
`bun run build`
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
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
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`.
- 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.
- 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 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.
+1
View File
@@ -19,6 +19,7 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
- [Domains](./domains.md) - who owns what
- [Layering](./layering.md) - how modules should depend on each other
- [Subtitle Overlay Priming](./subtitle-overlay-priming.md) - visible-overlay subtitle startup flow
- Public contributor summary: [`docs-site/architecture.md`](../../docs-site/architecture.md)
## Current Shape
@@ -0,0 +1,83 @@
<!-- read_when: changing visible overlay startup, Linux/X11 overlay window shape, mpv subtitle callbacks, or subtitle tokenization emission -->
# Subtitle Overlay Priming
Status: active
Last verified: 2026-06-01
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
Visible-overlay subtitle priming fills the overlay from mpv's current subtitle properties before
waiting for the next live mpv subtitle event. This avoids a stale or blank overlay when the user
manually shows the visible overlay while playback is already sitting on a subtitle.
On Linux/X11, visible-overlay show and later mpv bounds refreshes restore the Electron window shape
to the full current overlay bounds. Electron's `BrowserWindow.setShape()` applies a bounding shape,
not an input-only region; stale shapes can leave a mapped 1920x1080 overlay with smaller X11 shape
extents such as `800x600+0+0`, so renderer and websocket subtitle state are correct while bottom
subtitles do not draw.
## Entry Points
- `src/main.ts` calls `primeCurrentSubtitleForVisibleOverlay()` when manual visible-overlay show
paths run.
- `src/main.ts` calls `restoreVisibleOverlayWindowShapeForShow()` before visible-overlay show
actions on Linux, and `resetVisibleOverlayInputState()` restores a full shape instead of applying
an empty shape.
- `src/main.ts` also restores the Linux/X11 shape after applying mpv overlay bounds, so a newly
created 800x600 hidden Electron window cannot keep clipping after it is resized to mpv geometry.
- `primeCurrentSubtitleForVisibleOverlay()` delegates to
`primeVisibleOverlaySubtitleFromMpv()` in `src/main/runtime/current-subtitle-snapshot.ts`.
- `restoreVisibleOverlayWindowShapeForShow()` delegates to `restoreLinuxOverlayWindowShape()` in
`src/main/runtime/linux-overlay-window-shape.ts`.
- Inputs are callback deps, not globals: `getMpvClient`, `setCurrentSubText`,
`getCurrentSubtitleData`, `consumeCachedSubtitle`, `onSubtitleChange`,
`refreshCurrentSubtitle`, `emitSubtitle`, optional secondary-subtitle callbacks, and `logDebug`.
## Primary Subtitle Flow
1. Read the connected mpv client through `getMpvClient()`. Exit if no connected client.
2. Request mpv `sub-text`. On failure, log a
`[visible-overlay-subtitle-prime] failed to read sub-text` debug line and exit.
3. Normalize non-string `sub-text` to `''`, then call `setCurrentSubText(text)` so app state
matches mpv before any overlay emission.
4. Empty text: call `onSubtitleChange(text)`, emit `{ text, tokens: null }`, then prime secondary
subtitles.
5. Current cached payload: if `getCurrentSubtitleData()?.text === text`, call
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
payload.
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
`subtitleProcessingController` method. This gives the visible overlay priority over background
prefetch work and re-centers prefetch around the live playback time.
## Emitted State
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
annotated subtitle payload to overlay windows and subtitle websocket listeners.
- Secondary priming reads mpv `secondary-sub-text`, stores it in
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
written.
## Linux/X11 Window Shape
- `restoreLinuxOverlayWindowShape()` reads `BrowserWindow.getBounds()` and calls `setShape()` with
one full-window rectangle: `{ x: 0, y: 0, width, height }`.
- Restore the shape after `setBounds()`/mpv geometry updates, not only before showing the overlay.
Manual startup can create the hidden overlay at Electron's default 800x600 size before the window
tracker applies the real mpv bounds.
- Do not use `setShape([])` as a passive reset for the visible overlay. On the tested X11/XWayland
path, empty or stale bounding shapes produced invisible or clipped subtitles even though the
overlay window remained mapped above mpv.
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
the Linux cursor-poll fallback, not bounding-shape clipping.
## Config And Migration
No config or schema migration. This workflow reuses existing mpv properties, overlay IPC events,
subtitle tokenization cache, and prefetch controls.
+1
View File
@@ -13,6 +13,7 @@ Read when: finding internal docs or checking verification status
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
+39
View File
@@ -10,6 +10,7 @@ import { getAppControlSocketPath } from '../src/shared/app-control';
import { withProcessExitIntercept } from './test-support/exit-intercept.js';
import {
buildConfiguredMpvDefaultArgs,
buildRuntimeExtraScriptOptParts,
buildMpvBackendArgs,
buildMpvEnv,
cleanupPlaybackSession,
@@ -22,6 +23,7 @@ import {
runAppCommandCaptureOutput,
resolveLauncherRuntimePluginPath,
resolveLauncherRuntimePluginPlan,
shouldResolveAniSkipMetadataForLaunch,
shouldResolveAniSkipMetadata,
stopOverlay,
startOverlay,
@@ -374,6 +376,43 @@ test('resolveLauncherRuntimePluginPlan reports missing bundled plugin when no in
assert.match(plan.errorMessage ?? '', /Packaged mpv plugin assets were not found/);
});
test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate', () => {
assert.deepEqual(
buildRuntimeExtraScriptOptParts('/tmp/video.mkv', 'file', {
startPaused: true,
runtimePluginConfig: {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
}),
['subminer-auto_start_pause_until_ready_owns_initial_pause=yes'],
);
});
test('shouldResolveAniSkipMetadataForLaunch respects disabled runtime plugin AniSkip', () => {
assert.equal(
shouldResolveAniSkipMetadataForLaunch('/tmp/video.mkv', 'file', undefined, {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'TAB',
}),
false,
);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
+61 -51
View File
@@ -5,6 +5,12 @@ import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
getLinuxDesktopEnv,
shouldForceX11MpvBackend as shouldForceX11MpvBackendForBackend,
} from '../src/shared/mpv-x11-backend.js';
import {
isAppControlServerAvailable as checkAppControlServerAvailable,
sendAppControlCommand,
@@ -458,39 +464,8 @@ export function detectBackend(
fail('Could not detect display backend');
}
type LinuxDesktopEnv = {
xdgCurrentDesktop: string;
xdgSessionDesktop: string;
hasWayland: boolean;
};
function getLinuxDesktopEnv(env: NodeJS.ProcessEnv): LinuxDesktopEnv {
const xdgCurrentDesktop = (env.XDG_CURRENT_DESKTOP || '').toLowerCase();
const xdgSessionDesktop = (env.XDG_SESSION_DESKTOP || '').toLowerCase();
const xdgSessionType = (env.XDG_SESSION_TYPE || '').toLowerCase();
return {
xdgCurrentDesktop,
xdgSessionDesktop,
hasWayland: Boolean(env.WAYLAND_DISPLAY) || xdgSessionType === 'wayland',
};
}
function shouldForceX11MpvBackend(args: Pick<Args, 'backend'>, env: NodeJS.ProcessEnv): boolean {
if (process.platform !== 'linux' || !env.DISPLAY?.trim()) {
return false;
}
const linuxDesktopEnv = getLinuxDesktopEnv(env);
const supportedWaylandBackend =
Boolean(env.HYPRLAND_INSTANCE_SIGNATURE || env.SWAYSOCK) ||
linuxDesktopEnv.xdgCurrentDesktop.includes('hyprland') ||
linuxDesktopEnv.xdgCurrentDesktop.includes('sway') ||
linuxDesktopEnv.xdgSessionDesktop.includes('hyprland') ||
linuxDesktopEnv.xdgSessionDesktop.includes('sway');
return (
args.backend === 'x11' ||
(args.backend === 'auto' && linuxDesktopEnv.hasWayland && !supportedWaylandBackend)
);
return shouldForceX11MpvBackendForBackend(args.backend, env);
}
function resolveAppBinaryCandidate(candidate: string, pathModule: PathModule = path): string {
@@ -862,6 +837,50 @@ export function shouldResolveAniSkipMetadata(
return !isYoutubeTarget(target);
}
type StartMpvOptions = {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
runtimePluginPath?: string | null;
runtimePluginConfig?: PluginRuntimeConfig;
};
export function shouldResolveAniSkipMetadataForLaunch(
target: string,
targetKind: 'file' | 'url',
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
runtimePluginConfig?: PluginRuntimeConfig,
): boolean {
if (runtimePluginConfig?.aniskipEnabled === false) {
return false;
}
return shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles);
}
export function buildRuntimeExtraScriptOptParts(
target: string,
targetKind: 'file' | 'url',
options?: Pick<
StartMpvOptions,
'startPaused' | 'disableYoutubeSubtitleAutoLoad' | 'runtimePluginConfig'
>,
): string[] {
const launcherOwnsAutoplayReadyInitialPause =
options?.startPaused === true &&
options.runtimePluginConfig?.autoStart === true &&
options.runtimePluginConfig.autoStartVisibleOverlay === true &&
options.runtimePluginConfig.autoStartPauseUntilReady === true;
return [
...(launcherOwnsAutoplayReadyInitialPause
? ['subminer-auto_start_pause_until_ready_owns_initial_pause=yes']
: []),
...(targetKind === 'url' &&
isYoutubeTarget(target) &&
options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no']
: []),
];
}
export async function startMpv(
target: string,
targetKind: 'file' | 'url',
@@ -869,12 +888,7 @@ export async function startMpv(
socketPath: string,
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
runtimePluginPath?: string | null;
runtimePluginConfig?: PluginRuntimeConfig;
},
options?: StartMpvOptions,
): Promise<void> {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
@@ -932,15 +946,15 @@ export async function startMpv(
if (options?.startPaused) {
mpvArgs.push('--pause=yes');
}
const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles)
const aniSkipMetadata = shouldResolveAniSkipMetadataForLaunch(
target,
targetKind,
preloadedSubtitles,
options?.runtimePluginConfig,
)
? await resolveAniSkipMetadataForFile(target)
: null;
const extraScriptOpts =
targetKind === 'url' &&
isYoutubeTarget(target) &&
options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no']
: [];
const extraScriptOpts = buildRuntimeExtraScriptOptParts(target, targetKind, options);
const runtimeScriptOpts = options?.runtimePluginConfig
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
@@ -1344,11 +1358,7 @@ export function buildMpvEnv(
return env;
}
delete env.WAYLAND_DISPLAY;
delete env.HYPRLAND_INSTANCE_SIGNATURE;
delete env.SWAYSOCK;
env.XDG_SESSION_TYPE = 'x11';
return env;
return applyX11EnvOverrides(env);
}
export function buildMpvBackendArgs(
@@ -1358,7 +1368,7 @@ export function buildMpvBackendArgs(
if (!shouldForceX11MpvBackend(args, baseEnv)) {
return [];
}
return ['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'];
return [...MPV_X11_BACKEND_ARGS];
}
export function buildConfiguredMpvDefaultArgs(
+5
View File
@@ -559,6 +559,7 @@ test(
socketPath: smokeCase.socketPath,
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
aniskipEnabled: false,
},
}),
);
@@ -582,6 +583,10 @@ test(
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(Array.isArray(mpvFirstArgs), true);
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
assert.match(
(mpvFirstArgs as string[]).find((arg) => arg.startsWith('--script-opts=')) ?? '',
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
);
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
});
},
+3 -3
View File
File diff suppressed because one or more lines are too long
+53 -2
View File
@@ -2,6 +2,7 @@ local M = {}
local AUTO_START_SOCKET_RETRY_DELAY_SECONDS = 0.2
local AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS = 25
local WARM_END_FILE_HIDE_DELAY_SECONDS = 0.25
function M.create(ctx)
local mp = ctx.mp
@@ -58,6 +59,40 @@ function M.create(ctx)
end)
end
local function clear_pending_visible_overlay_hide()
local timer = state.pending_visible_overlay_hide_timer
if timer and timer.kill then
timer:kill()
end
state.pending_visible_overlay_hide_timer = nil
state.pending_visible_overlay_hide_generation = (state.pending_visible_overlay_hide_generation or 0) + 1
end
local resolve_auto_start_visible_overlay_enabled
local function hide_visible_overlay_after_end_file()
if state.visible_overlay_requested == true and not resolve_auto_start_visible_overlay_enabled() then
return
end
if not state.auto_play_ready_signal_seen then
process.hide_visible_overlay()
return
end
clear_pending_visible_overlay_hide()
local generation = (state.pending_visible_overlay_hide_generation or 0) + 1
state.pending_visible_overlay_hide_generation = generation
state.pending_visible_overlay_hide_timer = mp.add_timeout(WARM_END_FILE_HIDE_DELAY_SECONDS, function()
if state.pending_visible_overlay_hide_generation ~= generation then
return
end
state.pending_visible_overlay_hide_timer = nil
if state.overlay_running then
process.hide_visible_overlay()
end
end)
end
local function resolve_auto_start_enabled()
local raw_auto_start = opts.auto_start
if raw_auto_start == nil then
@@ -69,6 +104,14 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_auto_start, false)
end
resolve_auto_start_visible_overlay_enabled = function()
local raw_visible_overlay = opts.auto_start_visible_overlay
if raw_visible_overlay == nil then
raw_visible_overlay = opts["auto-start-visible-overlay"]
end
return options_helper.coerce_bool(raw_visible_overlay, false)
end
local function next_auto_start_retry_generation()
state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1
return state.auto_start_retry_generation
@@ -103,6 +146,11 @@ function M.create(ctx)
return true
end
local function should_rearm_pause_until_ready(same_media_loaded)
return not same_media_loaded
and not (state.overlay_running and state.auto_play_ready_signal_seen == true)
end
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
if generation ~= state.auto_start_retry_generation then
return
@@ -137,7 +185,7 @@ function M.create(ctx)
process.start_overlay({
auto_start_trigger = true,
socket_path = opts.socket_path,
rearm_pause_until_ready = not same_media_loaded,
rearm_pause_until_ready = should_rearm_pause_until_ready(same_media_loaded),
})
-- Give the overlay process a moment to initialize before querying AniSkip.
schedule_aniskip_fetch("overlay-start", 0.8)
@@ -155,6 +203,7 @@ function M.create(ctx)
end
local function on_file_loaded()
clear_pending_visible_overlay_hide()
local media_identity = resolve_media_identity()
local media_title = resolve_media_title()
local retry_generation = next_auto_start_retry_generation()
@@ -242,6 +291,8 @@ function M.create(ctx)
aniskip.clear_aniskip_state()
hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate()
clear_pending_visible_overlay_hide()
state.auto_play_ready_signal_seen = false
state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil
@@ -277,7 +328,7 @@ function M.create(ctx)
state.app_managed_playback_pending = false
state.app_managed_playback_active = false
if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay()
hide_visible_overlay_after_end_file()
end
end)
mp.register_event("shutdown", function()
+1
View File
@@ -33,6 +33,7 @@ function M.load(options_lib, default_socket_path)
auto_start = false,
auto_start_visible_overlay = false,
auto_start_pause_until_ready = true,
auto_start_pause_until_ready_owns_initial_pause = false,
auto_start_pause_until_ready_timeout_seconds = 15,
osd_messages = true,
log_level = "info",
+37 -1
View File
@@ -39,6 +39,9 @@ function M.create(ctx)
end
return "show-visible-overlay"
end
if state.visible_overlay_requested == true then
return nil
end
return "hide-visible-overlay"
end
@@ -50,6 +53,25 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_pause_until_ready, false)
end
local function resolve_pause_until_ready_owns_initial_pause()
local raw_owns_initial_pause = opts.auto_start_pause_until_ready_owns_initial_pause
if raw_owns_initial_pause == nil then
raw_owns_initial_pause = opts["auto-start-pause-until-ready-owns-initial-pause"]
end
return options_helper.coerce_bool(raw_owns_initial_pause, false)
end
local function consume_pause_until_ready_initial_pause_ownership()
if state.auto_play_ready_initial_pause_ownership_consumed then
return false
end
if not resolve_pause_until_ready_owns_initial_pause() then
return false
end
state.auto_play_ready_initial_pause_ownership_consumed = true
return true
end
local function resolve_texthooker_enabled(override_value)
if override_value ~= nil then
return options_helper.coerce_bool(override_value, false)
@@ -260,7 +282,8 @@ function M.create(ctx)
clear_auto_play_ready_osd_timer()
end
if not was_armed then
state.auto_play_ready_should_resume_playback = mp.get_property_native("pause") ~= true
state.auto_play_ready_should_resume_playback = consume_pause_until_ready_initial_pause_ownership()
or mp.get_property_native("pause") ~= true
end
state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true)
@@ -290,6 +313,7 @@ function M.create(ctx)
end
local function notify_auto_play_ready()
state.auto_play_ready_signal_seen = true
local released_ready_gate = release_auto_play_ready_gate("tokenization-ready")
local force_ready_overlay_restore = state.force_ready_overlay_restore == true
state.force_ready_overlay_restore = false
@@ -601,6 +625,7 @@ function M.create(ctx)
end
state.overlay_running = false
state.auto_play_ready_signal_seen = false
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
show_osd("Overlay start failed")
release_auto_play_ready_gate("overlay-start-failed")
@@ -653,6 +678,7 @@ function M.create(ctx)
state.overlay_running = false
state.texthooker_running = false
state.auto_play_ready_signal_seen = false
disarm_auto_play_ready_gate()
show_osd("Stopped")
end
@@ -709,6 +735,14 @@ function M.create(ctx)
end)
return
end
if not state.overlay_running then
state.suppress_ready_overlay_restore = false
disarm_auto_play_ready_gate({ resume_playback = false })
start_overlay({
show_visible_overlay = true,
})
return
end
state.suppress_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
@@ -773,6 +807,7 @@ function M.create(ctx)
state.overlay_running = false
state.texthooker_running = false
state.auto_play_ready_signal_seen = false
state.suppress_ready_overlay_restore = false
state.force_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
@@ -795,6 +830,7 @@ function M.create(ctx)
}, function(success, result, error)
if not success or (result and result.status ~= 0) then
state.overlay_running = false
state.auto_play_ready_signal_seen = false
subminer_log(
"error",
"process",
+5
View File
@@ -24,6 +24,11 @@ local KEY_NAME_MAP = {
BracketLeft = "[",
BracketRight = "]",
Backquote = "`",
MBTN_LEFT = "MBTN_LEFT",
MBTN_MID = "MBTN_MID",
MBTN_RIGHT = "MBTN_RIGHT",
MBTN_BACK = "MBTN_BACK",
MBTN_FORWARD = "MBTN_FORWARD",
}
local MODIFIER_MAP = {
+4
View File
@@ -33,6 +33,10 @@ function M.new()
auto_play_ready_should_resume_playback = false,
auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil,
auto_play_ready_signal_seen = false,
auto_play_ready_initial_pause_ownership_consumed = false,
pending_visible_overlay_hide_timer = nil,
pending_visible_overlay_hide_generation = 0,
suppress_ready_overlay_restore = false,
force_ready_overlay_restore = false,
visible_overlay_requested = nil,
+47
View File
@@ -0,0 +1,47 @@
## Highlights
### Fixed
- **Linux Overlay Stacking (XWayland / Wayland)**
- The overlay no longer drops behind mpv on KDE Plasma and other non-Hyprland/Sway Wayland sessions; subtitle hover, pause-on-hover, and Yomitan lookups now work correctly on those desktops.
- Stacking is now focus-sensitive: overlay stays managed while mpv is windowed, switches to non-interactive mode in fullscreen, and automatically yields to foreground windows (Settings, Yomitan, other apps) rather than covering them.
- Startup glitches are resolved — no more display-sized overlay flash or black screen before playback begins.
- **Hyprland Overlay Placement (0.55+ / Lua configs)**
- Overlay placement now works on Hyprland 0.55+ installations that use the new Lua config format; SubMiner detects Lua mode and uses the correct `hl.window_rule` dispatcher automatically.
- **macOS Overlay**
- Fixed the subtitle overlay remaining click-through after pause-until-ready releases playback; hovering and Yomitan lookups resume normally.
- Restored automatic mpv focus after closing Settings, AniList setup, and other modal windows so subtitles and playback keybinds work without clicking the player.
- **Manual Overlay Startup**
- Starting the visible overlay manually from mpv now correctly attaches to playback, syncs the overlay window to mpv bounds on Linux/X11, and loads the current primary and secondary subtitles before revealing.
- **Playlist Transitions**
- Advancing to the next mpv playlist item no longer triggers a second startup and tokenization delay; the overlay stays warm and visible subtitles are preserved across the transition.
- **Windows Launcher**
- The `SubMiner mpv` shortcut on Windows now attaches the video to an already-running background app instead of spawning a duplicate warmup process.
- **Mouse Keybindings**
- Side mouse buttons (`MBTN_BACK`, `MBTN_FORWARD`) and other mouse buttons can now be captured in the keybinding settings and work correctly at runtime.
### Docs
- **Troubleshooting Guides**
- Hyprland overlay guide updated with both Lua (`hl.window_rule`) and legacy `hyprland.conf` window rule syntax, plus a note on automatic placement via `hyprctl`.
- New KDE Plasma / Wayland section covering XWayland workarounds when launching mpv manually.
- New Character Dictionary section covering name matching, inline portraits, and external-profile mode (no AniList login required).
- Added a "See Also" index linking each feature to its own troubleshooting page.
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- 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`.
+1
View File
@@ -179,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, /### Fixed\n- Polished: fixed entry\./);
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
assert.match(releaseNotes, /- Windows: `SubMiner-\*\.exe` and `SubMiner-\*-win\.zip`/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
+1
View File
@@ -489,6 +489,7 @@ function renderReleaseNotes(
'',
'- Linux: `SubMiner.AppImage`',
'- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`',
'- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`',
'- 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`.',
+9
View File
@@ -229,6 +229,14 @@ local ctx = {
actionType = "mpv-command",
command = { "quit" },
},
{
key = {
code = "MBTN_BACK",
modifiers = {},
},
actionType = "mpv-command",
command = { "sub-seek", -1 },
},
{
key = {
code = "KeyW",
@@ -317,6 +325,7 @@ local expected_mpv_bindings = {
{ keys = "L", command = { "sub-seek", 1 } },
{ keys = "q", command = { "quit" } },
{ keys = "Ctrl+w", command = { "quit" } },
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } },
}
for _, expected in ipairs(expected_mpv_bindings) do
+173 -8
View File
@@ -13,6 +13,7 @@ local function run_plugin_scenario(config)
property_sets = {},
periodic_timers = {},
timeouts = {},
timeout_handles = {},
}
local function make_mp_stub()
@@ -139,15 +140,17 @@ local function run_plugin_scenario(config)
recorded.timeouts[#recorded.timeouts + 1] = seconds
local timeout = {
killed = false,
callback = callback,
}
function timeout:kill()
self.killed = true
end
local delay = tonumber(seconds) or 0
if callback and delay < 5 then
if callback and delay < 5 and not config.defer_timeouts then
callback()
end
recorded.timeout_handles[#recorded.timeout_handles + 1] = timeout
return timeout
end
@@ -612,6 +615,15 @@ local function fire_event(recorded, name, ...)
end
end
local function fire_pending_timeouts(recorded)
for _, timeout in ipairs(recorded.timeout_handles or {}) do
if not timeout.killed and timeout.callback then
timeout.killed = true
timeout.callback()
end
end
end
local function fire_observer(recorded, name, value)
local listeners = recorded.observers[name] or {}
for _, listener in ipairs(listeners) do
@@ -647,13 +659,88 @@ do
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
recorded.script_messages["subminer-start"]("texthooker=no")
assert_true(find_start_call(recorded.async_calls) ~= nil, "expected cold-start to invoke --start command when process is absent")
assert_true(
find_start_call(recorded.async_calls) ~= nil,
"expected cold-start to invoke --start command when process is absent"
)
assert_true(
not has_sync_command(recorded.sync_calls, "ps"),
"expected cold-start start command to avoid synchronous process list scan"
)
end
do
local scenario = {
process_list = "",
defer_timeouts = true,
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/episode-01.mkv",
media_title = "Episode 1",
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for warm playlist visibility scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "end-file", { reason = "eof" })
scenario.path = "/media/episode-02.mkv"
scenario.media_title = "Episode 2"
fire_event(recorded, "file-loaded")
fire_pending_timeouts(recorded)
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0,
"warm playlist advance should cancel the end-file hide before it hides the next video's overlay"
)
assert_true(
count_start_calls(recorded.async_calls) == 1,
"warm playlist visibility reuse should not issue another --start command"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "no",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/manual-episode-01.mkv",
media_title = "Manual Episode 1",
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for manual warm playlist visibility scenario: " .. tostring(err))
recorded.script_messages["subminer-toggle"]()
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "end-file", { reason = "eof" })
scenario.path = "/media/manual-episode-02.mkv"
scenario.media_title = "Manual Episode 2"
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0,
"manual visible overlay should remain visible across warm playlist auto-start reattach"
)
assert_true(
count_start_calls(recorded.async_calls) == 1,
"manual warm playlist visibility reuse should not issue another --start command"
)
end
do
local scenario = {
process_list = "",
@@ -714,13 +801,13 @@ do
"new media after prior playback should reuse the running overlay"
)
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 2,
"new media after prior playback should re-arm pause-until-ready"
count_property_set(recorded.property_sets, "pause", true) == 1,
"new media after prior ready playback should not re-arm pause-until-ready"
)
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 2,
"new media after prior playback should resume only after readiness"
count_property_set(recorded.property_sets, "pause", false) == 1,
"new media after prior ready playback should not wait for another readiness signal"
)
end
@@ -1800,6 +1887,61 @@ do
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
auto_start_pause_until_ready_owns_initial_pause = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for launcher-owned pause-until-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
has_property_set(recorded.property_sets, "pause", false),
"launcher-owned initial pause should resume when autoplay-ready arrives"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
auto_start_pause_until_ready_owns_initial_pause = "yes",
auto_start_pause_until_ready_timeout_seconds = 0.1,
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for launcher-owned pause timeout scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(
has_property_set(recorded.property_sets, "pause", false),
"launcher-owned initial pause should resume when autoplay-ready timeout fires"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
@@ -1992,7 +2134,9 @@ do
option_overrides = {
binary_path = binary_path,
auto_start = "no",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
files = {
[binary_path] = true,
},
@@ -2000,9 +2144,30 @@ do
assert_true(recorded ~= nil, "plugin failed to load for manual toggle command scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
local start_call = find_start_call(recorded.async_calls)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1,
"script-message toggle should issue explicit visible-overlay toggle command"
start_call ~= nil,
"first manual toggle from a stopped overlay should start SubMiner with mpv attachment"
)
assert_true(
call_has_arg(start_call, "--managed-playback"),
"first manual toggle should attach managed playback so subtitles reach the overlay"
)
assert_true(
call_has_arg(start_call, "--socket") and call_has_arg(start_call, "/tmp/subminer-socket"),
"first manual toggle should pass the active mpv socket to SubMiner"
)
assert_true(
call_has_arg(start_call, "--show-visible-overlay"),
"first manual toggle should start directly into visible overlay state"
)
assert_true(
not call_has_arg(start_call, "--hide-visible-overlay"),
"first manual toggle should not start hidden"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"first manual toggle should not issue a bare visible-overlay toggle before mpv is attached"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle") == 0,
@@ -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', () => {
assert.deepEqual(
buildHyprlandPlacementDispatches({
@@ -177,6 +225,9 @@ test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for ma
},
]);
}
if (args.join(' ') === '-j status') {
return JSON.stringify({ configProvider: 'hyprlang' });
}
return '';
}) as never,
});
@@ -186,6 +237,7 @@ test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for ma
calls.map(([, args]) => args),
[
['-j', 'clients'],
['-j', 'status'],
['dispatch', 'setfloating', '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 '';
}) as never,
});
@@ -230,6 +285,7 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe
calls.map(([, args]) => args),
[
['-j', 'clients'],
['-j', 'status'],
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'],
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xmatch'],
['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 {
configProvider?: HyprlandConfigProvider;
promote?: boolean;
}
type ExecFileSync = typeof execFileSync;
export type HyprlandConfigProvider = 'hyprlang' | 'lua';
export function shouldAttemptHyprlandWindowPlacement(
platform: NodeJS.Platform = process.platform,
@@ -75,37 +77,88 @@ export function buildHyprlandPlacementDispatches(
}
const windowAddress = `address:${client.address}`;
const configProvider = options.configProvider ?? 'hyprlang';
const dispatches: string[][] = [];
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) {
dispatches.push(['dispatch', 'pin', windowAddress]);
dispatches.push(
configProvider === 'lua'
? luaWindowDispatch('pin', windowAddress, ['action = "off"'])
: ['dispatch', 'pin', windowAddress],
);
}
const roundedBounds = roundPlacementBounds(bounds);
if (roundedBounds) {
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 (configProvider === 'lua') {
dispatches.push(
luaWindowDispatch('move', windowAddress, [
`x = ${roundedBounds.x}`,
`y = ${roundedBounds.y}`,
]),
);
dispatches.push(
luaWindowDispatch('resize', windowAddress, [
`x = ${roundedBounds.width}`,
`y = ${roundedBounds.height}`,
]),
);
dispatches.push(luaWindowSetProp(windowAddress, 'rounding', '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) {
dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]);
dispatches.push(
configProvider === 'lua'
? luaWindowDispatch('alter_zorder', windowAddress, ['mode = "top"'])
: ['dispatch', 'alterzorder', `top,${windowAddress}`],
);
}
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(
bounds?: HyprlandPlacementBounds | null,
): HyprlandPlacementBounds | null {
@@ -154,7 +207,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
return false;
}
const configProvider = detectHyprlandConfigProvider(run);
const dispatches = buildHyprlandPlacementDispatches(client, options.bounds, {
configProvider,
promote: options.promote,
});
for (const args of dispatches) {
@@ -165,3 +220,27 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
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';
}
+66
View File
@@ -143,6 +143,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
activatePlaybackWindowForOverlayInteraction: () => false,
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
@@ -247,6 +248,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getSecondarySubMode: () => 'hover',
getMpvClient: () => null,
focusMainWindow: () => {},
activatePlaybackWindowForOverlayInteraction: () => false,
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => ({}),
@@ -312,6 +314,28 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.equal(deps.getPlaybackPaused(), true);
});
test('createIpcDepsRuntime ignores overlay content reports from stale visible renderers', () => {
const mainWindow = { id: 'main', isDestroyed: () => false } as never;
const staleWindow = { id: 'stale', isDestroyed: () => false } as never;
const reports: unknown[] = [];
const deps = createIpcDepsRuntime({
getMainWindow: () => mainWindow,
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
} as unknown as Parameters<typeof createIpcDepsRuntime>[0]);
const report = deps.reportOverlayContentBounds as (
payload: unknown,
senderWindow: unknown,
) => void;
report({ source: 'stale' }, staleWindow);
report({ source: 'main' }, mainWindow);
report({ source: 'missing' }, null);
assert.deepEqual(reports, [{ source: 'main' }]);
});
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -334,6 +358,27 @@ test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction activ
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
});
test('registerIpcHandlers passes sender window to overlay content bounds reports', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const senderWindows: unknown[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
reportOverlayContentBounds: ((_payload: unknown, senderWindow: unknown) => {
senderWindows.push(senderWindow);
}) as IpcServiceDeps['reportOverlayContentBounds'],
}),
registrar,
);
const handler = handlers.on.get(IPC_CHANNELS.command.reportOverlayContentBounds);
assert.equal(typeof handler, 'function');
handler?.({}, { layer: 'visible' });
assert.deepEqual(senderWindows, [null]);
});
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -608,6 +653,27 @@ test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () =
assert.deepEqual(await handler!({}), snapshot);
});
test('registerIpcHandlers exposes playback window activation request', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
activatePlaybackWindowForOverlayInteraction: async () => {
calls.push('activate');
return true;
},
}),
registrar,
);
const handler = handlers.handle.get(
IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction,
);
assert.ok(handler);
assert.equal(await handler!({}), true);
assert.deepEqual(calls, ['activate']);
});
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
+48 -6
View File
@@ -49,6 +49,10 @@ export interface IpcServiceDeps {
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayInteractiveHint?: (
interactive: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
@@ -58,7 +62,8 @@ export interface IpcServiceDeps {
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => boolean | null;
getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -81,6 +86,7 @@ export interface IpcServiceDeps {
getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string;
focusMainWindow: () => void;
activatePlaybackWindowForOverlayInteraction?: () => boolean | Promise<boolean>;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
@@ -89,7 +95,10 @@ export interface IpcServiceDeps {
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
reportOverlayContentBounds: (
payload: unknown,
senderWindow: ElectronBrowserWindow | null,
) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
@@ -229,6 +238,10 @@ export interface IpcDepsRuntimeOptions {
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayInteractiveHint?: (
interactive: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
@@ -236,7 +249,8 @@ export interface IpcDepsRuntimeOptions {
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => boolean | null;
getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -254,6 +268,7 @@ export interface IpcDepsRuntimeOptions {
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void;
activatePlaybackWindowForOverlayInteraction?: () => boolean | Promise<boolean>;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
@@ -296,6 +311,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
onOverlayModalClosed: options.onOverlayModalClosed,
onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
onOverlayInteractiveHint: options.onOverlayInteractiveHint,
openYomitanSettings: options.openYomitanSettings,
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
quitApp: options.quitApp,
@@ -310,6 +326,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false),
getPlaybackPaused: options.getPlaybackPaused,
getSubtitlePosition: options.getSubtitlePosition,
getSubtitleStyle: options.getSubtitleStyle,
@@ -342,13 +359,21 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.focus();
},
activatePlaybackWindowForOverlayInteraction:
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
runSubsyncManual: options.runSubsyncManual,
onYoutubePickerResolve: options.onYoutubePickerResolve,
getAnkiConnectStatus: options.getAnkiConnectStatus,
getRuntimeOptions: options.getRuntimeOptions,
setRuntimeOption: options.setRuntimeOption,
cycleRuntimeOption: options.cycleRuntimeOption,
reportOverlayContentBounds: options.reportOverlayContentBounds,
reportOverlayContentBounds: (payload, senderWindow) => {
const mainWindow = options.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (!senderWindow || senderWindow !== (mainWindow as unknown as ElectronBrowserWindow))
return;
options.reportOverlayContentBounds(payload);
},
getAnilistStatus: options.getAnilistStatus,
clearAnilistToken: options.clearAnilistToken,
openAnilistSetup: options.openAnilistSetup,
@@ -526,6 +551,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.getSubtitleSidebarSnapshot();
});
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarOpen, () => {
return deps.getSubtitleSidebarOpen?.() ?? false;
});
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
return deps.getPlaybackPaused();
});
@@ -628,6 +657,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.focusMainWindow();
});
ipc.handle(IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction, async () => {
return (await deps.activatePlaybackWindowForOverlayInteraction?.()) ?? false;
});
ipc.handle(IPC_CHANNELS.request.runSubsyncManual, async (_event, request: unknown) => {
const parsedRequest = parseSubsyncManualRunRequest(request);
if (!parsedRequest) {
@@ -668,8 +701,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.cycleRuntimeOption(parsedId, parsedDirection);
});
ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (_event: unknown, payload: unknown) => {
deps.reportOverlayContentBounds(payload);
ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (event: unknown, payload: unknown) => {
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.reportOverlayContentBounds(payload, senderWindow);
});
ipc.on(IPC_CHANNELS.command.reportOverlayInteractive, (event: unknown, interactive: unknown) => {
if (typeof interactive !== 'boolean') return;
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayInteractiveHint?.(interactive, senderWindow);
});
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
@@ -58,6 +58,50 @@ test('overlay measurement store keeps latest payload for visible layer', () => {
assert.equal(store.getLatestByLayer('visible')?.contentRect?.width, 400);
});
test('overlay measurement store clears stale visible measurements', () => {
const store = createOverlayContentMeasurementStore({
now: () => 1000,
warn: () => {
// noop
},
});
store.report({
layer: 'visible',
measuredAtMs: 900,
viewport: { width: 1280, height: 720 },
contentRect: { x: 50, y: 60, width: 400, height: 80 },
interactiveRects: [{ x: 50, y: 60, width: 400, height: 80 }],
});
assert.notEqual(store.getLatestByLayer('visible'), null);
store.clear('visible');
assert.equal(store.getLatestByLayer('visible'), null);
});
test('sanitizeOverlayContentMeasurement preserves separate interactive rects', () => {
const measurement = sanitizeOverlayContentMeasurement(
{
layer: 'visible',
measuredAtMs: 100,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 50, y: 60, width: 400, height: 80 },
interactiveRects: [
{ x: 50, y: 60, width: 400, height: 80 },
{ x: 100, y: 900, width: 500, height: 90 },
],
},
500,
);
assert.deepEqual(measurement?.interactiveRects, [
{ x: 50, y: 60, width: 400, height: 80 },
{ x: 100, y: 900, width: 500, height: 90 },
]);
});
test('overlay measurement store rate-limits invalid payload warnings', () => {
let now = 1_000;
const warnings: string[] = [];
@@ -5,6 +5,7 @@ const logger = createLogger('main:overlay-content-measurement');
const MAX_VIEWPORT = 10000;
const MAX_RECT_DIMENSION = 10000;
const MAX_RECT_OFFSET = 50000;
const MAX_INTERACTIVE_RECTS = 8;
const MAX_FUTURE_TIMESTAMP_MS = 60_000;
const INVALID_LOG_THROTTLE_MS = 10_000;
@@ -26,6 +27,7 @@ export function sanitizeOverlayContentMeasurement(
width?: unknown;
height?: unknown;
} | null;
interactiveRects?: unknown;
};
if (candidate.layer !== 'visible') {
@@ -53,11 +55,21 @@ export function sanitizeOverlayContentMeasurement(
return null;
}
let interactiveRects: OverlayContentRect[] | undefined;
if (candidate.interactiveRects !== undefined) {
const sanitizedRects = sanitizeOverlayInteractiveRects(candidate.interactiveRects);
if (!sanitizedRects) {
return null;
}
interactiveRects = sanitizedRects;
}
return {
layer: candidate.layer,
measuredAtMs,
viewport: { width: viewportWidth, height: viewportHeight },
contentRect,
...(interactiveRects !== undefined ? { interactiveRects } : {}),
};
}
@@ -94,6 +106,22 @@ function sanitizeOverlayContentRect(rect: unknown): OverlayContentRect | null {
return { x, y, width, height };
}
function sanitizeOverlayInteractiveRects(rects: unknown): OverlayContentRect[] | null {
if (!Array.isArray(rects) || rects.length > MAX_INTERACTIVE_RECTS) {
return null;
}
const sanitized: OverlayContentRect[] = [];
for (const rect of rects) {
const sanitizedRect = sanitizeOverlayContentRect(rect);
if (!sanitizedRect) {
return null;
}
sanitized.push(sanitizedRect);
}
return sanitized;
}
function readFiniteInRange(value: unknown, min: number, max: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return Number.NaN;
@@ -140,7 +168,12 @@ export function createOverlayContentMeasurementStore(options?: {
return latestByLayer[layer];
}
function clear(layer: OverlayLayer): void {
latestByLayer[layer] = null;
}
return {
clear,
getLatestByLayer,
report,
};
+273 -2
View File
@@ -62,6 +62,9 @@ function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {
setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`);
},
setFullScreen: (fullscreen: boolean) => {
calls.push(`fullscreen:${fullscreen}`);
},
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
@@ -259,6 +262,50 @@ test('non-native passive overlay stays click-through after subsequent visibility
assert.ok(calls.includes('mouse-ignore:true:forward'));
});
test('non-native shaped input region stays mouse-enabled without focusing the overlay', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: false,
nonNativeInputRegionActive: true,
showOverlayLoadingOsd: () => {},
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
});
test('suspended visible overlay hides without refreshing bounds or z-order', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
@@ -306,7 +353,7 @@ test('suspended visible overlay hides without refreshing bounds or z-order', ()
assert.ok(!calls.includes('focus'));
});
test('untracked non-macOS overlay shows passively when no tracker exists', () => {
test('untracked Linux overlay stays hidden when no tracker exists', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
@@ -341,7 +388,8 @@ test('untracked non-macOS overlay shows passively when no tracker exists', () =>
} as never);
assert.equal(trackerWarning, false);
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('osd'));
@@ -384,6 +432,184 @@ test('passive Linux visible overlay does not take keyboard focus', () => {
assert.ok(!calls.includes('focus'));
});
test('passive Linux tracked overlay releases global topmost when mpv loses focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('fullscreen:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('passive Linux fullscreen override overlay hides when mpv loses focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
hideNonNativeOverlayWhenTargetUnfocused: true,
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('Linux active overlay interaction does not focus the overlay over fullscreen mpv', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: true,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('focus'));
});
test('Linux active hover keeps global topmost when mpv loses focus and overlay is not focused', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: true,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
});
test('tracked non-macOS overlay reapplies bounds after first show', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
@@ -604,6 +830,51 @@ test('Windows visible overlay waits for content-ready before first reveal', () =
assert.ok(calls.includes('show-inactive'));
});
test('Linux visible overlay waits for content-ready before first reveal', () => {
const { window, calls, setContentReady } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
setContentReady(false);
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
run();
assert.ok(!calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
setContentReady(true);
run();
assert.ok(calls.includes('show-inactive'));
});
test('tracked Windows overlay refresh rebinds while already visible', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
+37 -21
View File
@@ -18,6 +18,10 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
function releaseOverlayWindowLevel(window: BrowserWindow): void {
window.setAlwaysOnTop(false);
const fullscreenWindow = window as BrowserWindow & {
setFullScreen?: (fullscreen: boolean) => void;
};
fullscreenWindow.setFullScreen?.(false);
const allWorkspacesWindow = window as BrowserWindow & {
setVisibleOnAllWorkspaces?: (
visible: boolean,
@@ -64,6 +68,7 @@ export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
nonNativeInputRegionActive?: boolean;
suspendVisibleOverlay?: boolean;
overlayInteractionActive?: boolean;
mainWindow: BrowserWindow | null;
@@ -87,6 +92,7 @@ export function updateVisibleOverlayVisibility(args: {
markOverlayLoadingOsdShown?: () => void;
resetOverlayLoadingOsdSuppression?: () => void;
resolveFallbackBounds?: () => WindowGeometry;
hideNonNativeOverlayWhenTargetUnfocused?: boolean;
}): void {
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
return;
@@ -120,9 +126,9 @@ export function updateVisibleOverlayVisibility(args: {
const showPassiveVisibleOverlay = (): boolean => {
const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible();
const isVisibleOverlayFocused =
overlayInteractionActive ||
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
const isVisibleOverlayWindowFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
const isVisibleOverlayFocused = overlayInteractionActive || isVisibleOverlayWindowFocused;
const windowTracker = args.windowTracker;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
@@ -181,12 +187,23 @@ export function updateVisibleOverlayVisibility(args: {
!isTrackedWindowsTargetMinimized &&
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
const isNonNativePassiveOverlay =
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
const isNonNativeOverlay = !args.isWindowsPlatform && !args.isMacOSPlatform;
const isNonNativePassiveOverlay = isNonNativeOverlay && !overlayInteractionActive;
const hasNonNativeInputRegion =
isNonNativePassiveOverlay && args.nonNativeInputRegionActive === true;
const isTrackedNonNativeTargetFocused =
!args.isWindowsPlatform && !args.isMacOSPlatform && !!args.windowTracker
? (args.windowTracker.isTargetWindowFocused?.() ?? true)
: true;
const shouldReleaseNonNativeOverlayLevel =
isNonNativeOverlay &&
!!args.windowTracker &&
!isVisibleOverlayFocused &&
!isTrackedNonNativeTargetFocused;
const shouldIgnoreMouseEvents =
shouldUseMacOSMousePassthrough ||
forceMousePassthrough ||
isNonNativePassiveOverlay ||
(isNonNativePassiveOverlay && !hasNonNativeInputRegion) ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost =
@@ -214,6 +231,11 @@ export function updateVisibleOverlayVisibility(args: {
// On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management.
} else if (shouldReleaseNonNativeOverlayLevel) {
releaseOverlayWindowLevel(mainWindow);
if (args.hideNonNativeOverlayWhenTargetUnfocused && wasVisible) {
mainWindow.hide();
}
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
args.ensureOverlayWindowLevel(mainWindow);
} else {
@@ -223,7 +245,6 @@ export function updateVisibleOverlayVisibility(args: {
const hasWebContents =
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
if (
args.isWindowsPlatform &&
hasWebContents &&
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
) {
@@ -238,7 +259,11 @@ export function updateVisibleOverlayVisibility(args: {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
if (hasNonNativeInputRegion) {
mainWindow.setIgnoreMouseEvents(false);
} else {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
}
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(
mainWindow,
@@ -277,16 +302,7 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.focus();
}
if (
!args.isWindowsPlatform &&
!args.isMacOSPlatform &&
!forceMousePassthrough &&
overlayInteractionActive
) {
mainWindow.focus();
}
return !shouldReleaseMacOSOverlayLevel;
return !shouldReleaseNonNativeOverlayLevel;
};
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
@@ -385,9 +401,9 @@ export function updateVisibleOverlayVisibility(args: {
return;
}
args.setTrackerNotReadyWarningShown(false);
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
args.enforceOverlayLayerOrder();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
releaseOverlayWindowLevel(mainWindow);
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
@@ -10,6 +10,7 @@ test('overlay window config explicitly disables renderer sandbox for preload com
assert.equal(options.title, 'SubMiner Overlay');
assert.equal(options.backgroundColor, '#00000000');
assert.equal(options.paintWhenInitiallyHidden, true);
assert.equal(options.webPreferences?.sandbox, false);
assert.equal(options.webPreferences?.backgroundThrottling, false);
});
@@ -41,6 +42,59 @@ test('Linux visible overlay window allows compositor resize for mpv-sized placem
}
});
test('Linux visible overlay window stays managed so native apps can cover it', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
const modalOptions = buildOverlayWindowOptions('modal', {
isDev: false,
yomitanSession: null,
});
assert.equal(visibleOptions.alwaysOnTop, false);
assert.equal(visibleOptions.focusable, true);
assert.equal(modalOptions.focusable, true);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('Linux fullscreen visible overlay window uses X11 override-redirect-friendly options', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
linuxX11FullscreenOverlay: true,
yomitanSession: null,
});
assert.equal(visibleOptions.alwaysOnTop, true);
assert.equal(visibleOptions.focusable, false);
assert.equal(visibleOptions.resizable, false);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
+1 -13
View File
@@ -69,23 +69,11 @@ export function handleOverlayWindowBlurred(options: {
onVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean {
const platform = options.platform ?? process.platform;
if (platform === 'win32' && options.kind === 'visible') {
if (options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false;
}
if (platform === 'darwin' && options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false;
}
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
return false;
}
options.ensureOverlayWindowLevel();
if (options.kind === 'visible' && options.windowVisible) {
options.moveWindowTop();
}
return true;
}
+10 -3
View File
@@ -11,12 +11,18 @@ export function buildOverlayWindowOptions(
kind: OverlayWindowKind,
options: {
isDev: boolean;
linuxX11FullscreenOverlay?: boolean;
yomitanSession?: Session | null;
},
): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
const shouldAllowCompositorResize = process.platform === 'linux' && kind === 'visible';
const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible';
const isLinuxFullscreenOverlay =
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
const shouldStartAlwaysOnTop =
!(process.platform === 'win32' && kind === 'visible') &&
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
return {
show: false,
@@ -26,13 +32,14 @@ export function buildOverlayWindowOptions(
x: 0,
y: 0,
transparent: true,
paintWhenInitiallyHidden: true,
backgroundColor: '#00000000',
frame: false,
alwaysOnTop: shouldStartAlwaysOnTop,
skipTaskbar: true,
resizable: shouldAllowCompositorResize,
hasShadow: false,
focusable: true,
focusable: !isLinuxFullscreenOverlay,
acceptFirstMouse: true,
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
webPreferences: {
+81 -18
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ensureOverlayWindowLevel } from './overlay-window';
import {
handleOverlayWindowBeforeInputEvent,
handleOverlayWindowBlurred,
@@ -166,6 +167,49 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred skips Linux visible overlay restacking after focus loss', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
platform: 'linux',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred notifies Linux visible overlay blur callback without restacking', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'linux',
});
assert.equal(handled, false);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
const calls: string[] = [];
@@ -189,25 +233,9 @@ test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback wi
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
test('handleOverlayWindowBlurred preserves modal window stacking', () => {
const calls: string[] = [];
assert.equal(
handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-visible');
},
moveWindowTop: () => {
calls.push('move-visible');
},
platform: 'linux',
}),
true,
);
assert.equal(
handleOverlayWindowBlurred({
kind: 'modal',
@@ -223,5 +251,40 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking'
true,
);
assert.deepEqual(calls, ['ensure-visible', 'move-visible', 'ensure-modal']);
assert.deepEqual(calls, ['ensure-modal']);
});
test('ensureOverlayWindowLevel promotes Linux overlay above fullscreen mpv without changing workspaces', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
ensureOverlayWindowLevel({
getTitle: () => 'SubMiner Overlay',
moveTop: () => calls.push('move-top'),
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
},
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
);
},
} as never);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
assert.deepEqual(calls, [
'always-on-top:true:screen-saver:1',
'all-workspaces:true:fullscreen',
'move-top',
]);
});
+13 -3
View File
@@ -78,7 +78,9 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
window.moveTop();
return;
}
window.setAlwaysOnTop(true);
// Linux/X11 overlays start managed and only assert topmost while mpv owns the overlay layer.
// Focus loss releases this again so native Wayland apps can cover the overlay on KDE.
window.setAlwaysOnTop(true, 'screen-saver', 1);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() });
window.moveTop();
@@ -106,13 +108,16 @@ export function createOverlayWindow(
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (kind: OverlayWindowKind) => void;
onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void;
yomitanSession?: Session | null;
},
): BrowserWindow {
const window = new ElectronBrowserWindow(buildOverlayWindowOptions(kind, options));
window.setSkipTaskbar(true);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = false;
@@ -172,7 +177,7 @@ export function createOverlayWindow(
window.hide();
window.on('closed', () => {
options.onWindowClosed(kind);
options.onWindowClosed(kind, window);
});
window.on('blur', () => {
@@ -192,6 +197,11 @@ export function createOverlayWindow(
});
});
window.on('focus', () => {
if (window.isDestroyed() || kind !== 'visible') return;
options.onVisibleWindowFocused?.();
});
if (options.isDev && kind === 'visible') {
window.webContents.openDevTools({ mode: 'detach' });
}
@@ -162,6 +162,46 @@ test('compileSessionBindings resolves CommandOrControl in DOM key strings per pl
);
});
test('compileSessionBindings supports mpv mouse button keybindings', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [
createKeybinding('MBTN_BACK', ['sub-seek', -1]),
createKeybinding('Shift+MBTN_FORWARD', ['sub-seek', 1]),
],
platform: 'win32',
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(
result.bindings.map((binding) => ({
code: binding.key.code,
modifiers: binding.key.modifiers,
command: binding.actionType === 'mpv-command' ? binding.command : null,
})),
[
{ code: 'MBTN_BACK', modifiers: [], command: ['sub-seek', -1] },
{ code: 'MBTN_FORWARD', modifiers: ['shift'], command: ['sub-seek', 1] },
],
);
});
test('compileSessionBindings keeps mouse buttons scoped to keybindings', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openJimaku: 'MBTN_BACK',
}),
keybindings: [createKeybinding('MBTN_BACK', ['sub-seek', -1])],
platform: 'win32',
});
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
assert.deepEqual(
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
['unsupported:shortcuts.openJimaku'],
);
});
test('compileSessionBindings drops conflicting bindings that canonicalize to the same key', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
@@ -516,3 +556,28 @@ test('buildPluginSessionBindingsArtifact emits CLI args for plugin-bound session
},
});
});
test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-count multi-line actions', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
copySubtitleMultiple: 'Ctrl+Shift+C',
mineSentenceMultiple: 'Ctrl+Shift+S',
}),
keybindings: [],
platform: 'linux',
});
const artifact = buildPluginSessionBindingsArtifact({
bindings: result.bindings,
warnings: result.warnings,
numericSelectionTimeoutMs: 2500,
});
const byActionId = new Map(
artifact.bindings.flatMap((binding) =>
binding.actionType === 'session-action' ? [[binding.actionId, binding]] : [],
),
);
assert.equal(byActionId.get('copySubtitleMultiple')?.cliArgs, undefined);
assert.equal(byActionId.get('mineSentenceMultiple')?.cliArgs, undefined);
});
+25 -2
View File
@@ -30,6 +30,13 @@ type DraftBinding = {
};
const MODIFIER_ORDER: SessionKeyModifier[] = ['ctrl', 'alt', 'shift', 'meta'];
const MPV_MOUSE_BUTTON_CODES = new Set([
'MBTN_LEFT',
'MBTN_MID',
'MBTN_RIGHT',
'MBTN_BACK',
'MBTN_FORWARD',
]);
const SESSION_SHORTCUT_ACTIONS: Array<{
key: keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>;
@@ -64,9 +71,18 @@ function isValidCommandEntry(value: unknown): value is string | number {
return typeof value === 'string' || typeof value === 'number';
}
function normalizeCodeToken(token: string): string | null {
function normalizeCodeToken(
token: string,
options: { allowMouseButtons?: boolean } = {},
): string | null {
const normalized = token.trim();
if (!normalized) return null;
if (options.allowMouseButtons === true) {
const normalizedMouse = normalized.toUpperCase();
if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) {
return normalizedMouse;
}
}
if (/^[a-z]$/i.test(normalized)) {
return `Key${normalized.toUpperCase()}`;
}
@@ -238,7 +254,7 @@ function parseDomKeyString(
};
}
const code = normalizeCodeToken(keyToken);
const code = normalizeCodeToken(keyToken, { allowMouseButtons: true });
if (!code) {
return {
key: null,
@@ -358,6 +374,13 @@ function toPluginSessionBinding(binding: CompiledSessionBinding): PluginSessionB
return binding;
}
if (
(binding.actionId === 'copySubtitleMultiple' || binding.actionId === 'mineSentenceMultiple') &&
binding.payload?.count === undefined
) {
return binding;
}
return { ...binding, cliArgs: buildSessionActionCliArgs(binding) };
}
+4 -1
View File
@@ -295,6 +295,9 @@ test('prefetch service deduplicates repeated cue text within a run', async () =>
}
service.stop();
assert.deepEqual(tokenizedTexts.filter((text) => text === 'same'), ['same']);
assert.deepEqual(
tokenizedTexts.filter((text) => text === 'same'),
['same'],
);
assert.ok(tokenizedTexts.includes('other'));
});
@@ -103,7 +103,7 @@ test('subtitle processing falls back to plain subtitle when tokenization returns
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
});
test('subtitle processing can refresh current subtitle without text change', async () => {
test('subtitle processing ignores duplicate current subtitle refresh without cache invalidation', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
@@ -119,10 +119,57 @@ test('subtitle processing can refresh current subtitle without text change', asy
controller.refreshCurrentSubtitle();
await flushMicrotasks();
assert.equal(tokenizeCalls, 1);
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
});
test('subtitle processing coalesces refresh requests while current subtitle is processing', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return await new Promise<SubtitleData | null>((resolve) => {
resolveTokenization = () => resolve({ text, tokens: [] });
});
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('same');
controller.refreshCurrentSubtitle();
controller.refreshCurrentSubtitle('same');
assert.ok(resolveTokenization);
resolveTokenization({ text: 'same', tokens: [] });
await flushMicrotasks();
await flushMicrotasks();
assert.equal(tokenizeCalls, 1);
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
});
test('subtitle processing refresh re-tokenizes after cache invalidation', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return { text, tokens: [{ value: tokenizeCalls } as never] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('same');
await flushMicrotasks();
controller.invalidateTokenizationCache();
controller.refreshCurrentSubtitle();
await flushMicrotasks();
assert.equal(tokenizeCalls, 2);
assert.deepEqual(emitted, [
{ text: 'same', tokens: [] },
{ text: 'same', tokens: [] },
{ text: 'same', tokens: [{ value: 1 } as never] },
{ text: 'same', tokens: [{ value: 2 } as never] },
]);
});
@@ -27,9 +27,10 @@ export function createSubtitleProcessingController(
const SUBTITLE_TOKENIZATION_CACHE_LIMIT = 256;
let latestText = '';
let lastEmittedText = '';
let cacheGeneration = 0;
let lastEmittedGeneration = 0;
let processing = false;
let staleDropCount = 0;
let refreshRequested = false;
const tokenizationCache = new Map<string, SubtitleData>();
const now = deps.now ?? (() => Date.now());
@@ -65,19 +66,19 @@ export function createSubtitleProcessingController(
void (async () => {
while (true) {
const text = latestText;
const forceRefresh = refreshRequested;
refreshRequested = false;
const generation = cacheGeneration;
const startedAtMs = now();
if (!text.trim()) {
deps.emitSubtitle({ text, tokens: null });
lastEmittedText = text;
lastEmittedGeneration = generation;
break;
}
let output: SubtitleData = { text, tokens: null };
try {
const cachedTokenized = forceRefresh ? null : getCachedTokenization(text);
const cachedTokenized = getCachedTokenization(text);
if (cachedTokenized) {
output = cachedTokenized;
} else {
@@ -99,8 +100,16 @@ export function createSubtitleProcessingController(
continue;
}
if (generation !== cacheGeneration) {
deps.logDebug?.(
`Dropped stale subtitle tokenization result after cache invalidation; elapsed=${now() - startedAtMs}ms`,
);
continue;
}
deps.emitSubtitle(output);
lastEmittedText = text;
lastEmittedGeneration = generation;
deps.logDebug?.(
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
);
@@ -112,7 +121,10 @@ export function createSubtitleProcessingController(
})
.finally(() => {
processing = false;
if (refreshRequested || latestText !== lastEmittedText) {
if (
latestText !== lastEmittedText ||
(latestText.trim() && cacheGeneration !== lastEmittedGeneration)
) {
processLatest();
}
});
@@ -133,11 +145,17 @@ export function createSubtitleProcessingController(
if (!latestText.trim()) {
return;
}
refreshRequested = true;
if (
processing ||
(latestText === lastEmittedText && cacheGeneration === lastEmittedGeneration)
) {
return;
}
processLatest();
},
invalidateTokenizationCache: () => {
tokenizationCache.clear();
cacheGeneration += 1;
},
preCacheTokenization: (text: string, data: SubtitleData) => {
setCachedTokenization(text, data);
@@ -150,7 +168,7 @@ export function createSubtitleProcessingController(
latestText = text;
lastEmittedText = text;
refreshRequested = false;
lastEmittedGeneration = cacheGeneration;
return cached;
},
hasCachedSubtitle: (text: string) => {
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldForceX11ElectronBackend } from './electron-backend';
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
const original = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { configurable: true, value: platform });
try {
run();
} finally {
if (original) Object.defineProperty(process, 'platform', original);
}
}
test('shouldForceX11ElectronBackend forces X11 on Linux except Hyprland/Sway', () => {
withPlatform('linux', () => {
assert.equal(shouldForceX11ElectronBackend({ XDG_CURRENT_DESKTOP: 'KDE' }), true);
assert.equal(shouldForceX11ElectronBackend({ WAYLAND_DISPLAY: 'wayland-0' }), true);
// Even an explicit Wayland hint is overridden to x11 on unsupported compositors.
assert.equal(shouldForceX11ElectronBackend({ ELECTRON_OZONE_PLATFORM_HINT: 'wayland' }), true);
// Hyprland/Sway keep native Wayland (guard reports explicit wayland hints elsewhere).
assert.equal(shouldForceX11ElectronBackend({ HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }), false);
assert.equal(shouldForceX11ElectronBackend({ SWAYSOCK: '/tmp/sway.sock' }), false);
});
});
test('shouldForceX11ElectronBackend is false off Linux', () => {
withPlatform('darwin', () => {
assert.equal(shouldForceX11ElectronBackend({ XDG_CURRENT_DESKTOP: 'KDE' }), false);
});
withPlatform('win32', () => {
assert.equal(shouldForceX11ElectronBackend({}), false);
});
});
+21 -10
View File
@@ -1,27 +1,38 @@
import { CliArgs, shouldStartApp } from '../../cli/args';
import { createLogger } from '../../logger';
import { isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
const logger = createLogger('core:electron-backend');
function getElectronOzonePlatformHint(): string | null {
const hint = process.env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
function getElectronOzonePlatformHint(env: NodeJS.ProcessEnv = process.env): string | null {
const hint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
if (hint) return hint;
const ozone = process.env.OZONE_PLATFORM?.trim().toLowerCase();
const ozone = env.OZONE_PLATFORM?.trim().toLowerCase();
if (ozone) return ozone;
return null;
}
function shouldPreferWaylandBackend(): boolean {
return Boolean(process.env.HYPRLAND_INSTANCE_SIGNATURE || process.env.SWAYSOCK);
/**
* Should the Electron app be pinned to the X11/XWayland ozone backend? True on Linux
* unless we're on a natively-supported Wayland compositor (Hyprland/Sway) or the user
* explicitly opted into the (unsupported) Wayland backend which is reported by
* {@link enforceUnsupportedWaylandMode} instead.
*
* The overlay relies on `setAlwaysOnTop`/`moveTop` to stay above mpv; those are no-ops
* under a native Wayland surface, so XWayland is required for parity with Win/macOS. An
* explicit `ELECTRON_OZONE_PLATFORM_HINT=wayland` is still overridden to x11 here (the
* Electron Wayland backend is unsupported); the Hyprland/Sway case is left untouched so
* {@link enforceUnsupportedWaylandMode} can report it.
*/
export function shouldForceX11ElectronBackend(env: NodeJS.ProcessEnv = process.env): boolean {
if (process.platform !== 'linux') return false;
return !isSupportedWaylandCompositor(env);
}
export function forceX11Backend(args: CliArgs): void {
if (process.platform !== 'linux') return;
if (!shouldStartApp(args)) return;
if (shouldPreferWaylandBackend()) return;
const hint = getElectronOzonePlatformHint();
if (hint === 'x11') return;
if (!shouldForceX11ElectronBackend()) return;
if (getElectronOzonePlatformHint() === 'x11') return;
process.env.ELECTRON_OZONE_PLATFORM_HINT = 'x11';
process.env.OZONE_PLATFORM = 'x11';

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