mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 03:13:32 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
54e90754ef
|
|||
| 487143802a | |||
| e6a004ab8b | |||
| b510c54875 | |||
| e1ea464bc9 | |||
| b46b8dfa41 | |||
| ca067a6ccf | |||
| d719b346e0 | |||
|
a1da3dcdc8
|
|||
|
9927ef1581
|
|||
|
791c993870
|
@@ -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']
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+143
-44
@@ -1,71 +1,170 @@
|
||||
# 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.
|
||||
- **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: Adds tray and `subminer -u` update checks with app update prompts, launcher and Linux rofi theme auto-updates, checksum verification, configurable notifications, and an 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 including the AniSkip button key; AnkiConnect-backed deck, field, and note-type pickers that auto-fill from the configured Anki deck; cross-category search; and 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.
|
||||
- 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, including a 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.
|
||||
- **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, and 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; new `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded entries; 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.
|
||||
- N+1 Highlighting Default: `ankiConnect.nPlusOne.enabled` is no longer implicitly enabled when known-word highlighting is on; existing configs that already had N+1 enabled are unchanged, but new configs must set it explicitly.
|
||||
- Linux Auto-Update Flow: Linux tray "Check for Updates" now installs the new AppImage automatically, matching macOS and Windows; AppImages managed by a system package (e.g. AUR) and non-AppImage launches still use the GitHub-asset flow.
|
||||
- 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.
|
||||
- **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; known-word cache appends correctly with multiple deck field mappings.
|
||||
- Jellyfin Discovery: Startup, subtitle track selection, and duplicate ready-signal handling all fixed; paused mpv no longer misreported as playing; startup unpause no longer repeats after a manual pause or `y-t` toggle; delayed Japanese subtitle selection, later-loading foreign track hijacking, and long-lived sidebar ffmpeg extractor leaks fixed; resume corrected when a remote play command sends `StartPositionTicks: 0` despite saved progress; picker library discovery kept working regardless of app log level.
|
||||
- Jellyfin Remote: Tray checkbox stays in sync on Linux after tray, CLI, or startup changes; stale discovery sessions restarted when the server no longer lists the SubMiner cast target; 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.
|
||||
- Jellyfin Subtitles and Overlay: Subtitle overlay shown automatically during Jellyfin playback; `y-t` toggle made reliable and sticky across stream redirects; managed subtitle defaults re-armed on redirect; passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv; subtitle timing improved with preferred embedded streams over external sidecars, correct Japanese-vs-English cue offset handling, per-stream delay shift restoration, and transient track-list read failure tolerance.
|
||||
- Overlay (macOS): Overlay hides when mpv loses focus, is minimized, or is no longer the foreground app; 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; window-tracker polling reduced while mpv is stably focused.
|
||||
- Overlay (Linux / Hyprland): Placement refreshes after leaving fullscreen; overlay stays above mpv after focus changes from clicks or movement; Settings and Yomitan windows promoted above the subtitle overlay instead of opening behind it; overlay hides when the character dictionary modal opens, including during AniList lookup.
|
||||
- Overlay Lifecycle: First startup subtitle primed before autoplay resumes so the overlay renders text before playback begins; overlay and subtitle stream kept alive after `y-r` restart with correct Linux bounds reapplication; launcher-owned playback quits SubMiner on end while background/tray sessions stay alive; subtitle sync modal fixed on macOS so it no longer flashes on first attempt or leaves stale state; Windows managed mpv launches from a background instance now correctly receive the start command, retarget the new socket, bind to the player window, and receive startup overlay options.
|
||||
- Yomitan Sidebar: Playback stays paused for sidebar-opened Yomitan popups when auto-pause is enabled; fixed popups not opening 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: Warm launches reuse a running background instance, reapply preferred subtitles, and close launcher-owned tray apps after playback ends; videos stay paused until subtitle priming and tokenization readiness complete; `subminer settings` on macOS exits cleanly when the window is closed; `subminer app` on Linux returns terminal control immediately; Linux first-run installs build with a valid Bun shebang; `subminer app --setup` opens the setup flow when SubMiner is already running in 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; mpv plugin no longer starts a second SubMiner instance for app-owned YouTube playback.
|
||||
- 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.
|
||||
- Updater: Linux `subminer -u` performs release updates independently of any running tray app using GitHub release metadata; macOS update dialogs from `subminer -u` reliably appear in the foreground with a manual-install message for builds that cannot apply native updates; macOS and Linux `electron-updater` routes through `/usr/bin/curl` to avoid Electron network crashes; Windows automatic updates keep the native NSIS install path while routing updater HTTP through main-process fetch to avoid delayed exit after launch.
|
||||
- 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.
|
||||
- Tray: Tray stays running when Yomitan settings are closed; settings loading no longer blocks other tray actions; Yomitan extension refreshes serialized at startup; embedded popup preview disabled to prevent renderer hangs during sidebar navigation; Windows "Open SubMiner Setup" action opens the setup window correctly after first-run is complete; session help modal close fixed without mpv running.
|
||||
- Discord Rich Presence: No longer falls back to Jellyfin stream URLs; Jellyfin playback titles primed before stream loading so presence shows the show/episode title instead of a URL.
|
||||
- WebSocket Annotations: Annotation spans and token metadata stay on the annotation WebSocket; the regular subtitle WebSocket is plain-text only.
|
||||
- Subtitle Frequency Highlighting: Frequency annotations kept for determiner-led noun compounds like `その場` while still filtering standalone determiners; fixed for Yomitan single-token compounds with internal particles such as `目の前` while keeping pure grammar/kana helper spans unannotated.
|
||||
- Subtitle Annotation Prefetching: Cached colored annotations and character images ready sooner for live subtitle changes without delaying raw subtitle display.
|
||||
- Packaging: macOS compiled mpv window helper correctly built into `dist/scripts` and bundled, preventing fallback to slow Swift source startup; stale Windows helper resource entry removed; one-shot `make clean build install` AppImage flows fixed so install picks up the AppImage built earlier in the same invocation.
|
||||
- Windows Startup Errors: Fatal startup failures now show a native error dialog and write details to the app log instead of exiting silently.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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>
|
||||
|
||||
|
||||
+143
-44
@@ -1,71 +1,170 @@
|
||||
# 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.
|
||||
- **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: Adds tray and `subminer -u` update checks with app update prompts, launcher and Linux rofi theme auto-updates, checksum verification, configurable notifications, and an 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 including the AniSkip button key; AnkiConnect-backed deck, field, and note-type pickers that auto-fill from the configured Anki deck; cross-category search; and 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.
|
||||
- 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, including a 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.
|
||||
- **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, and 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; new `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded entries; 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.
|
||||
- N+1 Highlighting Default: `ankiConnect.nPlusOne.enabled` is no longer implicitly enabled when known-word highlighting is on; existing configs that already had N+1 enabled are unchanged, but new configs must set it explicitly.
|
||||
- Linux Auto-Update Flow: Linux tray "Check for Updates" now installs the new AppImage automatically, matching macOS and Windows; AppImages managed by a system package (e.g. AUR) and non-AppImage launches still use the GitHub-asset flow.
|
||||
- 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.
|
||||
- **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; known-word cache appends correctly with multiple deck field mappings.
|
||||
- Jellyfin Discovery: Startup, subtitle track selection, and duplicate ready-signal handling all fixed; paused mpv no longer misreported as playing; startup unpause no longer repeats after a manual pause or `y-t` toggle; delayed Japanese subtitle selection, later-loading foreign track hijacking, and long-lived sidebar ffmpeg extractor leaks fixed; resume corrected when a remote play command sends `StartPositionTicks: 0` despite saved progress; picker library discovery kept working regardless of app log level.
|
||||
- Jellyfin Remote: Tray checkbox stays in sync on Linux after tray, CLI, or startup changes; stale discovery sessions restarted when the server no longer lists the SubMiner cast target; 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.
|
||||
- Jellyfin Subtitles and Overlay: Subtitle overlay shown automatically during Jellyfin playback; `y-t` toggle made reliable and sticky across stream redirects; managed subtitle defaults re-armed on redirect; passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv; subtitle timing improved with preferred embedded streams over external sidecars, correct Japanese-vs-English cue offset handling, per-stream delay shift restoration, and transient track-list read failure tolerance.
|
||||
- Overlay (macOS): Overlay hides when mpv loses focus, is minimized, or is no longer the foreground app; 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; window-tracker polling reduced while mpv is stably focused.
|
||||
- Overlay (Linux / Hyprland): Placement refreshes after leaving fullscreen; overlay stays above mpv after focus changes from clicks or movement; Settings and Yomitan windows promoted above the subtitle overlay instead of opening behind it; overlay hides when the character dictionary modal opens, including during AniList lookup.
|
||||
- Overlay Lifecycle: First startup subtitle primed before autoplay resumes so the overlay renders text before playback begins; overlay and subtitle stream kept alive after `y-r` restart with correct Linux bounds reapplication; launcher-owned playback quits SubMiner on end while background/tray sessions stay alive; subtitle sync modal fixed on macOS so it no longer flashes on first attempt or leaves stale state; Windows managed mpv launches from a background instance now correctly receive the start command, retarget the new socket, bind to the player window, and receive startup overlay options.
|
||||
- Yomitan Sidebar: Playback stays paused for sidebar-opened Yomitan popups when auto-pause is enabled; fixed popups not opening 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: Warm launches reuse a running background instance, reapply preferred subtitles, and close launcher-owned tray apps after playback ends; videos stay paused until subtitle priming and tokenization readiness complete; `subminer settings` on macOS exits cleanly when the window is closed; `subminer app` on Linux returns terminal control immediately; Linux first-run installs build with a valid Bun shebang; `subminer app --setup` opens the setup flow when SubMiner is already running in 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; mpv plugin no longer starts a second SubMiner instance for app-owned YouTube playback.
|
||||
- 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.
|
||||
- Updater: Linux `subminer -u` performs release updates independently of any running tray app using GitHub release metadata; macOS update dialogs from `subminer -u` reliably appear in the foreground with a manual-install message for builds that cannot apply native updates; macOS and Linux `electron-updater` routes through `/usr/bin/curl` to avoid Electron network crashes; Windows automatic updates keep the native NSIS install path while routing updater HTTP through main-process fetch to avoid delayed exit after launch.
|
||||
- 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.
|
||||
- Tray: Tray stays running when Yomitan settings are closed; settings loading no longer blocks other tray actions; Yomitan extension refreshes serialized at startup; embedded popup preview disabled to prevent renderer hangs during sidebar navigation; Windows "Open SubMiner Setup" action opens the setup window correctly after first-run is complete; session help modal close fixed without mpv running.
|
||||
- Discord Rich Presence: No longer falls back to Jellyfin stream URLs; Jellyfin playback titles primed before stream loading so presence shows the show/episode title instead of a URL.
|
||||
- WebSocket Annotations: Annotation spans and token metadata stay on the annotation WebSocket; the regular subtitle WebSocket is plain-text only.
|
||||
- Subtitle Frequency Highlighting: Frequency annotations kept for determiner-led noun compounds like `その場` while still filtering standalone determiners; fixed for Yomitan single-token compounds with internal particles such as `目の前` while keeping pure grammar/kana helper spans unannotated.
|
||||
- Subtitle Annotation Prefetching: Cached colored annotations and character images ready sooner for live subtitle changes without delaying raw subtitle display.
|
||||
- Packaging: macOS compiled mpv window helper correctly built into `dist/scripts` and bundled, preventing fallback to slow Swift source startup; stale Windows helper resource entry removed; one-shot `make clean build install` AppImage flows fixed so install picks up the AppImage built earlier in the same invocation.
|
||||
- Windows Startup Errors: Fatal startup failures now show a native error dialog and write details to the app log instead of exiting silently.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -152,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.
|
||||
|
||||
@@ -232,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"**
|
||||
@@ -317,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
|
||||
@@ -338,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**
|
||||
|
||||
@@ -359,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
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+28
-51
@@ -1,60 +1,37 @@
|
||||
## Highlights
|
||||
### 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.
|
||||
|
||||
### Added
|
||||
|
||||
- Auto-Updater: Adds tray and `subminer -u` update checks with app update prompts, launcher and Linux rofi theme auto-updates, checksum verification, configurable notifications, and an 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 including the AniSkip button key; AnkiConnect-backed deck, field, and note-type pickers that auto-fill from the configured Anki deck; cross-category search; and 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.
|
||||
- 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, including a 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, and 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; new `Ctrl/Cmd+D` manager modal to remove, reorder, or override loaded entries; 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.
|
||||
- N+1 Highlighting Default: `ankiConnect.nPlusOne.enabled` is no longer implicitly enabled when known-word highlighting is on; existing configs that already had N+1 enabled are unchanged, but new configs must set it explicitly.
|
||||
- Linux Auto-Update Flow: Linux tray "Check for Updates" now installs the new AppImage automatically, matching macOS and Windows; AppImages managed by a system package (e.g. AUR) and non-AppImage launches still use the GitHub-asset flow.
|
||||
- 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; known-word cache appends correctly with multiple deck field mappings.
|
||||
- Jellyfin Discovery: Startup, subtitle track selection, and duplicate ready-signal handling all fixed; paused mpv no longer misreported as playing; startup unpause no longer repeats after a manual pause or `y-t` toggle; delayed Japanese subtitle selection, later-loading foreign track hijacking, and long-lived sidebar ffmpeg extractor leaks fixed; resume corrected when a remote play command sends `StartPositionTicks: 0` despite saved progress; picker library discovery kept working regardless of app log level.
|
||||
- Jellyfin Remote: Tray checkbox stays in sync on Linux after tray, CLI, or startup changes; stale discovery sessions restarted when the server no longer lists the SubMiner cast target; 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.
|
||||
- Jellyfin Subtitles and Overlay: Subtitle overlay shown automatically during Jellyfin playback; `y-t` toggle made reliable and sticky across stream redirects; managed subtitle defaults re-armed on redirect; passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv; subtitle timing improved with preferred embedded streams over external sidecars, correct Japanese-vs-English cue offset handling, per-stream delay shift restoration, and transient track-list read failure tolerance.
|
||||
- Overlay (macOS): Overlay hides when mpv loses focus, is minimized, or is no longer the foreground app; 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; window-tracker polling reduced while mpv is stably focused.
|
||||
- Overlay (Linux / Hyprland): Placement refreshes after leaving fullscreen; overlay stays above mpv after focus changes from clicks or movement; Settings and Yomitan windows promoted above the subtitle overlay instead of opening behind it; overlay hides when the character dictionary modal opens, including during AniList lookup.
|
||||
- Overlay Lifecycle: First startup subtitle primed before autoplay resumes so the overlay renders text before playback begins; overlay and subtitle stream kept alive after `y-r` restart with correct Linux bounds reapplication; launcher-owned playback quits SubMiner on end while background/tray sessions stay alive; subtitle sync modal fixed on macOS so it no longer flashes on first attempt or leaves stale state; Windows managed mpv launches from a background instance now correctly receive the start command, retarget the new socket, bind to the player window, and receive startup overlay options.
|
||||
- Yomitan Sidebar: Playback stays paused for sidebar-opened Yomitan popups when auto-pause is enabled; fixed popups not opening 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: Warm launches reuse a running background instance, reapply preferred subtitles, and close launcher-owned tray apps after playback ends; videos stay paused until subtitle priming and tokenization readiness complete; `subminer settings` on macOS exits cleanly when the window is closed; `subminer app` on Linux returns terminal control immediately; Linux first-run installs build with a valid Bun shebang; `subminer app --setup` opens the setup flow when SubMiner is already running in 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; mpv plugin no longer starts a second SubMiner instance for app-owned YouTube playback.
|
||||
- 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.
|
||||
- Updater: Linux `subminer -u` performs release updates independently of any running tray app using GitHub release metadata; macOS update dialogs from `subminer -u` reliably appear in the foreground with a manual-install message for builds that cannot apply native updates; macOS and Linux `electron-updater` routes through `/usr/bin/curl` to avoid Electron network crashes; Windows automatic updates keep the native NSIS install path while routing updater HTTP through main-process fetch to avoid delayed exit after launch.
|
||||
- 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.
|
||||
- Tray: Tray stays running when Yomitan settings are closed; settings loading no longer blocks other tray actions; Yomitan extension refreshes serialized at startup; embedded popup preview disabled to prevent renderer hangs during sidebar navigation; Windows "Open SubMiner Setup" action opens the setup window correctly after first-run is complete; session help modal close fixed without mpv running.
|
||||
- Discord Rich Presence: No longer falls back to Jellyfin stream URLs; Jellyfin playback titles primed before stream loading so presence shows the show/episode title instead of a URL.
|
||||
- WebSocket Annotations: Annotation spans and token metadata stay on the annotation WebSocket; the regular subtitle WebSocket is plain-text only.
|
||||
- Subtitle Frequency Highlighting: Frequency annotations kept for determiner-led noun compounds like `その場` while still filtering standalone determiners; fixed for Yomitan single-token compounds with internal particles such as `目の前` while keeping pure grammar/kana helper spans unannotated.
|
||||
- Subtitle Annotation Prefetching: Cached colored annotations and character images ready sooner for live subtitle changes without delaying raw subtitle display.
|
||||
- Packaging: macOS compiled mpv window helper correctly built into `dist/scripts` and bundled, preventing fallback to slow Swift source startup; stale Windows helper resource entry removed; one-shot `make clean build install` AppImage flows fixed so install picks up the AppImage built earlier in the same invocation.
|
||||
- Windows Startup Errors: Fatal startup failures now show a native error dialog and write details to the app log instead of exiting silently.
|
||||
- **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
|
||||
|
||||
- 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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" })'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export { generateDefaultConfigFile } from './config-gen';
|
||||
export { enforceUnsupportedWaylandMode, forceX11Backend } from './electron-backend';
|
||||
export {
|
||||
enforceUnsupportedWaylandMode,
|
||||
forceX11Backend,
|
||||
shouldForceX11ElectronBackend,
|
||||
} from './electron-backend';
|
||||
export { resolveKeybindings } from './keybindings';
|
||||
export { resolveConfiguredShortcuts } from './shortcut-config';
|
||||
export { showDesktopNotification } from './notification';
|
||||
|
||||
+11
-1
@@ -21,7 +21,7 @@ import {
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||
import { sendAppControlCommand } from './shared/app-control-client';
|
||||
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
||||
import {
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
detectInstalledMpvPlugin,
|
||||
@@ -249,6 +249,16 @@ async function runEntryProcess(): Promise<void> {
|
||||
normalizeLaunchMpvTargets(process.argv),
|
||||
createWindowsMpvLaunchDeps({
|
||||
getEnv: (name) => process.env[name],
|
||||
isAppControlServerAvailable: () =>
|
||||
isAppControlServerAvailable({
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 350,
|
||||
}),
|
||||
sendAppControlCommand: (argv) =>
|
||||
sendAppControlCommand(argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
showError: (title, content) => {
|
||||
dialog.showErrorBox(title, content);
|
||||
},
|
||||
|
||||
+656
-30
File diff suppressed because it is too large
Load Diff
@@ -219,6 +219,7 @@ export function createMainBootServices<
|
||||
params.getSyncOverlayVisibilityForModal()();
|
||||
},
|
||||
restoreMainWindowFocus: () => {
|
||||
if (params.platform === 'darwin') return;
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return;
|
||||
try {
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
|
||||
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
|
||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
@@ -66,8 +67,10 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
||||
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
||||
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
|
||||
getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen'];
|
||||
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
|
||||
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
||||
activatePlaybackWindowForOverlayInteraction?: IpcDepsRuntimeOptions['activatePlaybackWindowForOverlayInteraction'];
|
||||
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
|
||||
getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle'];
|
||||
saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition'];
|
||||
@@ -236,6 +239,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
onOverlayModalClosed: params.onOverlayModalClosed,
|
||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
|
||||
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
|
||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
@@ -244,6 +248,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
||||
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
||||
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
|
||||
getSubtitleSidebarOpen: params.getSubtitleSidebarOpen,
|
||||
getPlaybackPaused: params.getPlaybackPaused,
|
||||
getSubtitlePosition: params.getSubtitlePosition,
|
||||
getSubtitleStyle: params.getSubtitleStyle,
|
||||
@@ -260,6 +265,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
saveControllerConfig: params.saveControllerConfig,
|
||||
saveControllerPreference: params.saveControllerPreference,
|
||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||
activatePlaybackWindowForOverlayInteraction: params.activatePlaybackWindowForOverlayInteraction,
|
||||
getSecondarySubMode: params.getSecondarySubMode,
|
||||
getMpvClient: params.getMpvClient,
|
||||
runSubsyncManual: params.runSubsyncManual,
|
||||
|
||||
@@ -72,6 +72,35 @@ test('manual visible overlay toggles only release current-media autoplay when hi
|
||||
);
|
||||
});
|
||||
|
||||
test('all visible overlay hide paths clear stale overlay input state', () => {
|
||||
const source = readMainSource();
|
||||
const setVisibleBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const setOverlayBlock = source.match(
|
||||
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setVisibleBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.ok(setOverlayBlock);
|
||||
assert.match(
|
||||
setVisibleBlock,
|
||||
/if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
setOverlayBlock,
|
||||
/if \(!visible\) \{\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -109,7 +138,7 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay subtitle prime prefers cached annotated payload before raw fallback', () => {
|
||||
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
||||
@@ -122,16 +151,124 @@ test('autoplay subtitle prime prefers cached annotated payload before raw fallba
|
||||
);
|
||||
assert.match(actionBlock, /if \(cachedPayload\) \{/);
|
||||
assert.match(actionBlock, /emitSubtitlePayload\(cachedPayload\);/);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/const rawPayload = withCurrentSubtitleTiming\(\{ text, tokens: null \}\);/,
|
||||
);
|
||||
assert.doesNotMatch(actionBlock, /withCurrentSubtitleTiming\(\{ text, tokens: null \}\)/);
|
||||
assert.doesNotMatch(actionBlock, /broadcastToOverlayWindows\('subtitle:set', rawPayload\)/);
|
||||
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('consumeCachedSubtitle(text)') <
|
||||
actionBlock.indexOf('withCurrentSubtitleTiming({ text, tokens: null })'),
|
||||
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
|
||||
);
|
||||
});
|
||||
|
||||
test('startup autoplay release is tied to tokenization and visible overlay measurement readiness', () => {
|
||||
const source = readMainSource();
|
||||
const gateBlock = source.match(
|
||||
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(gateBlock);
|
||||
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
|
||||
assert.match(gateBlock, /isTokenizationWarmupReady\(\)/);
|
||||
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
|
||||
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
|
||||
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
|
||||
});
|
||||
|
||||
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
|
||||
const source = readMainSource();
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar open state is restored for replacement visible overlay windows', () => {
|
||||
const source = readMainSource();
|
||||
const openedBlock = source.match(
|
||||
/onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const closedBlock = source.match(
|
||||
/onOverlayModalClosed:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups
|
||||
?.body;
|
||||
|
||||
assert.ok(openedBlock);
|
||||
assert.ok(closedBlock);
|
||||
assert.ok(depsBlock);
|
||||
assert.match(openedBlock, /if \(modal === 'subtitle-sidebar'/);
|
||||
assert.match(openedBlock, /subtitleSidebarRequestedOpen = true;/);
|
||||
assert.match(closedBlock, /if \(modal === 'subtitle-sidebar'/);
|
||||
assert.match(closedBlock, /subtitleSidebarRequestedOpen = false;/);
|
||||
assert.match(depsBlock, /subtitleSidebarRequestedOpen/);
|
||||
});
|
||||
|
||||
test('warm tokenization release reuses current subtitle payload instead of synthetic readiness', () => {
|
||||
const source = readMainSource();
|
||||
const warmReleaseBlock = source.match(
|
||||
/signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
const currentPayloadBlock = source.match(
|
||||
/function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(warmReleaseBlock);
|
||||
assert.match(
|
||||
warmReleaseBlock,
|
||||
/signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/,
|
||||
);
|
||||
assert.doesNotMatch(warmReleaseBlock, /__warm__/);
|
||||
|
||||
assert.ok(currentPayloadBlock);
|
||||
assert.match(currentPayloadBlock, /appState\.currentSubtitleData/);
|
||||
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
|
||||
});
|
||||
|
||||
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const resetBlock = source.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.ok(resetBlock);
|
||||
assert.match(actionBlock, /resetVisibleOverlayInputState\(\);/);
|
||||
assert.match(resetBlock, /overlayContentMeasurementStore\.clear\('visible'\);/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('resetVisibleOverlayInputState();') <
|
||||
actionBlock.indexOf('createMainWindow();'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /const trackedGeometry = getCurrentTrackedOverlayGeometry\(\);/);
|
||||
assert.match(actionBlock, /if \(trackedGeometry\) \{/);
|
||||
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
|
||||
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
||||
});
|
||||
|
||||
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -169,6 +306,60 @@ test('manual visible overlay changes notify mpv plugin visibility state', () =>
|
||||
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
|
||||
});
|
||||
|
||||
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/else \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
|
||||
const source = readMainSource();
|
||||
const resetBlock = source.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(resetBlock);
|
||||
assert.ok(setBlock);
|
||||
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
|
||||
const source = readMainSource();
|
||||
const afterBoundsBlock = source.match(
|
||||
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(afterBoundsBlock);
|
||||
assert.match(afterBoundsBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||
assert.ok(
|
||||
afterBoundsBlock.indexOf('restoreLinuxOverlayWindowShape(mainWindow);') <
|
||||
afterBoundsBlock.indexOf('ensureOverlayWindowLevel(mainWindow);'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||
const source = readMainSource();
|
||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||
|
||||
@@ -417,17 +417,25 @@ test('modal window path makes visible main overlay click-through until modal clo
|
||||
assert.equal(mainWindow.ignoreMouseEvents, true);
|
||||
});
|
||||
|
||||
test('modal window path hides visible main overlay until modal closes', () => {
|
||||
test('modal window path restores visible main overlay before modal input deactivates', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
const modalWindow = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
const events: string[] = [];
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
onModalStateChange: (active: boolean): void => {
|
||||
events.push(`state:${active}:visible:${mainWindow.isVisible()}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'youtube:picker-open',
|
||||
@@ -444,8 +452,88 @@ test('modal window path hides visible main overlay until modal closes', () => {
|
||||
|
||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||
|
||||
assert.equal(mainWindow.getShowCount(), 0);
|
||||
assert.equal(mainWindow.isVisible(), false);
|
||||
assert.equal(mainWindow.getShowCount(), 1);
|
||||
assert.equal(mainWindow.isVisible(), true);
|
||||
assert.deepEqual(events, ['state:true:visible:true', 'state:false:visible:true']);
|
||||
});
|
||||
|
||||
test('modal window path runs final close handoff before modal input deactivates', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
const modalWindow = createMockWindow();
|
||||
const events: string[] = [];
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
onFinalModalClosed: (): void => {
|
||||
events.push(`handoff:visible:${mainWindow.isVisible()}`);
|
||||
},
|
||||
onModalStateChange: (active: boolean): void => {
|
||||
events.push(`state:${active}:visible:${mainWindow.isVisible()}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'youtube:picker-open',
|
||||
{ sessionId: 'yt-1' },
|
||||
{
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
);
|
||||
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||
|
||||
assert.deepEqual(events, [
|
||||
'state:true:visible:true',
|
||||
'handoff:visible:true',
|
||||
'state:false:visible:true',
|
||||
]);
|
||||
});
|
||||
|
||||
test('modal runtime deactivates modal state when final close handoff throws', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
const modalWindow = createMockWindow();
|
||||
const events: string[] = [];
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
onFinalModalClosed: (): void => {
|
||||
events.push('handoff');
|
||||
throw new Error('handoff failed');
|
||||
},
|
||||
onModalStateChange: (active: boolean): void => {
|
||||
events.push(`state:${active}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'youtube:picker-open',
|
||||
{ sessionId: 'yt-1' },
|
||||
{
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
);
|
||||
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||
|
||||
assert.doesNotThrow(() => runtime.handleOverlayModalClosed('youtube-track-picker'));
|
||||
assert.deepEqual(events, ['state:true', 'handoff', 'state:false']);
|
||||
});
|
||||
|
||||
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
||||
|
||||
@@ -54,6 +54,7 @@ type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeou
|
||||
|
||||
export interface OverlayModalRuntimeOptions {
|
||||
onModalStateChange?: (isActive: boolean) => void;
|
||||
onFinalModalClosed?: () => void;
|
||||
scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
|
||||
clearRevealFallback?: (timeout: RevealFallbackHandle) => void;
|
||||
}
|
||||
@@ -387,8 +388,14 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
modalWindowPrimedForImmediateShow = false;
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
mainWindowHiddenByModal = false;
|
||||
notifyModalStateChange(false);
|
||||
setMainWindowVisibilityForModal(false);
|
||||
try {
|
||||
options.onFinalModalClosed?.();
|
||||
} catch {
|
||||
// Modal state still needs to deactivate if focus handoff fails.
|
||||
} finally {
|
||||
notifyModalStateChange(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
getModalActive: () => boolean;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getNonNativeInputRegionActive?: () => boolean;
|
||||
getSuspendVisibleOverlay?: () => boolean;
|
||||
getOverlayInteractionActive?: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
@@ -30,6 +31,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
isWindowsPlatform: () => boolean;
|
||||
showOverlayLoadingOsd: (message: string) => void;
|
||||
resolveFallbackBounds: () => WindowGeometry;
|
||||
hideNonNativeOverlayWhenTargetUnfocused?: () => boolean;
|
||||
}
|
||||
|
||||
export interface OverlayVisibilityRuntimeService {
|
||||
@@ -53,6 +55,7 @@ export function createOverlayVisibilityRuntimeService(
|
||||
visibleOverlayVisible,
|
||||
modalActive: deps.getModalActive(),
|
||||
forceMousePassthrough,
|
||||
nonNativeInputRegionActive: deps.getNonNativeInputRegionActive?.() ?? false,
|
||||
suspendVisibleOverlay,
|
||||
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
|
||||
mainWindow,
|
||||
@@ -86,6 +89,8 @@ export function createOverlayVisibilityRuntimeService(
|
||||
resetOverlayLoadingOsdSuppression: () => {
|
||||
lastOverlayLoadingOsdAtMs = null;
|
||||
},
|
||||
hideNonNativeOverlayWhenTargetUnfocused:
|
||||
deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false,
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -95,6 +95,48 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate requests overlay pointer recovery when media readiness is signaled', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let pointerRecoveryRequests = 0;
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
requestOverlayPointerRecovery: () => {
|
||||
pointerRecoveryRequests += 1;
|
||||
},
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady(
|
||||
{ text: '字幕その2', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(pointerRecoveryRequests, 1);
|
||||
});
|
||||
|
||||
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let playbackPaused = true;
|
||||
@@ -311,3 +353,58 @@ test('autoplay ready gate drops deferred readiness after media changes before fl
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('autoplay ready gate passes the pending subtitle signal to the readiness predicate', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let targetReadyText: string | null = null;
|
||||
let observedText: string | null = null;
|
||||
let observedRequestedAtMs: number | null = null;
|
||||
let now = 1_000;
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
isSignalTargetReady: ((signal: { payload: { text: string }; requestedAtMs: number }) => {
|
||||
observedText = signal.payload.text;
|
||||
observedRequestedAtMs = signal.requestedAtMs;
|
||||
return targetReadyText === signal.payload.text;
|
||||
}) as never,
|
||||
now: () => now,
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(observedText, '字幕');
|
||||
assert.equal(observedRequestedAtMs, 1_000);
|
||||
assert.deepEqual(commands, []);
|
||||
|
||||
now = 2_000;
|
||||
targetReadyText = '字幕';
|
||||
gate.flushPendingAutoplayReadySignal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(observedRequestedAtMs, 1_000);
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'script-message'),
|
||||
[['script-message', 'subminer-autoplay-ready']],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,15 @@ type MpvClientLike = {
|
||||
send: (payload: { command: Array<string | boolean> }) => void;
|
||||
};
|
||||
|
||||
type AutoplayReadyOptions = { forceWhilePaused?: boolean };
|
||||
|
||||
export type AutoplayReadySignal = {
|
||||
mediaPath: string;
|
||||
payload: SubtitleData;
|
||||
requestedAtMs: number;
|
||||
options?: AutoplayReadyOptions;
|
||||
};
|
||||
|
||||
export type AutoplayReadyGateDeps = {
|
||||
isAppOwnedFlowInFlight: () => boolean;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
@@ -14,7 +23,9 @@ export type AutoplayReadyGateDeps = {
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
signalPluginAutoplayReady: () => void;
|
||||
isSignalTargetReady?: () => boolean;
|
||||
requestOverlayPointerRecovery?: () => void;
|
||||
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
||||
now?: () => number;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logDebug: (message: string) => void;
|
||||
};
|
||||
@@ -22,11 +33,8 @@ export type AutoplayReadyGateDeps = {
|
||||
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
let autoPlayReadySignalMediaPath: string | null = null;
|
||||
let autoPlayReadySignalGeneration = 0;
|
||||
let pendingAutoplayReadySignal: {
|
||||
mediaPath: string;
|
||||
payload: SubtitleData;
|
||||
options?: { forceWhilePaused?: boolean };
|
||||
} | null = null;
|
||||
let pendingAutoplayReadySignal: AutoplayReadySignal | null = null;
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
|
||||
const invalidatePendingAutoplayReadyFallbacks = (): void => {
|
||||
autoPlayReadySignalMediaPath = null;
|
||||
@@ -34,7 +42,8 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
};
|
||||
|
||||
const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true;
|
||||
const isSignalTargetReady = (signal: AutoplayReadySignal): boolean =>
|
||||
deps.isSignalTargetReady?.(signal) ?? true;
|
||||
|
||||
const getSignalMediaPath = (): string =>
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||
@@ -45,23 +54,23 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
};
|
||||
|
||||
const maybeSignalPluginAutoplayReady = (
|
||||
payload: SubtitleData,
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
): void => {
|
||||
if (deps.isAppOwnedFlowInFlight()) {
|
||||
deps.logDebug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
|
||||
return;
|
||||
}
|
||||
if (!payload.text.trim()) {
|
||||
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
||||
if (
|
||||
pendingAutoplayReadySignal &&
|
||||
pendingAutoplayReadySignal.mediaPath === signal.mediaPath &&
|
||||
pendingAutoplayReadySignal.payload.text === signal.payload.text &&
|
||||
pendingAutoplayReadySignal.requestedAtMs <= signal.requestedAtMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pendingAutoplayReadySignal = signal;
|
||||
};
|
||||
|
||||
const mediaPath = getSignalMediaPath();
|
||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||
const releaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
||||
const mediaPath = signal.mediaPath;
|
||||
const releaseRetryDelayMs = 200;
|
||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||
forceWhilePaused: options?.forceWhilePaused === true,
|
||||
forceWhilePaused: signal.options?.forceWhilePaused === true,
|
||||
retryDelayMs: releaseRetryDelayMs,
|
||||
});
|
||||
let releaseUnpauseSent = false;
|
||||
@@ -129,39 +138,64 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
})();
|
||||
};
|
||||
|
||||
if (duplicateMediaSignal) {
|
||||
pendingAutoplayReadySignal = null;
|
||||
return;
|
||||
}
|
||||
if (!isSignalTargetReady()) {
|
||||
pendingAutoplayReadySignal = { mediaPath, payload, options };
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAutoplayReadySignal = null;
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
deps.requestOverlayPointerRecovery?.();
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
};
|
||||
|
||||
const maybeReleaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
||||
if (autoPlayReadySignalMediaPath === signal.mediaPath) {
|
||||
pendingAutoplayReadySignal = null;
|
||||
return;
|
||||
}
|
||||
if (!isSignalTargetReady(signal)) {
|
||||
setPendingAutoplayReadySignal(signal);
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
releaseAutoplayReadySignal(signal);
|
||||
};
|
||||
|
||||
const maybeSignalPluginAutoplayReady = (
|
||||
payload: SubtitleData,
|
||||
options?: AutoplayReadyOptions,
|
||||
): void => {
|
||||
if (deps.isAppOwnedFlowInFlight()) {
|
||||
deps.logDebug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
|
||||
return;
|
||||
}
|
||||
if (!payload.text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
maybeReleaseAutoplayReadySignal({
|
||||
mediaPath: getSignalMediaPath(),
|
||||
payload,
|
||||
requestedAtMs: now(),
|
||||
options,
|
||||
});
|
||||
};
|
||||
|
||||
const flushPendingAutoplayReadySignal = (): void => {
|
||||
if (!pendingAutoplayReadySignal || !isSignalTargetReady()) {
|
||||
if (!pendingAutoplayReadySignal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingSignal = pendingAutoplayReadySignal;
|
||||
pendingAutoplayReadySignal = null;
|
||||
if (getSignalMediaPath() !== pendingSignal.mediaPath) {
|
||||
pendingAutoplayReadySignal = null;
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] dropped deferred signal for stale media ${pendingSignal.mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options);
|
||||
maybeReleaseAutoplayReadySignal(pendingSignal);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import { resolveCurrentSubtitleForRenderer } from './current-subtitle-snapshot';
|
||||
import {
|
||||
primeVisibleOverlaySubtitleFromMpv,
|
||||
resolveCurrentSubtitleForRenderer,
|
||||
} from './current-subtitle-snapshot';
|
||||
|
||||
function withTiming(payload: SubtitleData): SubtitleData {
|
||||
return {
|
||||
@@ -58,3 +61,95 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.deepEqual(payload.tokens, [{ text: '新' }]);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime refreshes current text from mpv before showing overlay', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
calls.push(`request:${name}`);
|
||||
return '国内外から';
|
||||
},
|
||||
}),
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getCurrentSubtitleData: () => null,
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から', 'refresh:国内外から']);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => {
|
||||
const calls: string[] = [];
|
||||
const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] };
|
||||
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => '字幕',
|
||||
}),
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getCurrentSubtitleData: () => cachedPayload,
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||
emitSubtitle: (payload) => calls.push(`emit:${payload.text}:${payload.tokens?.length ?? 0}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['set:字幕', 'emit:字幕:1', 'refresh:字幕']);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime clears stale subtitle when mpv has no current text', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => '',
|
||||
}),
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getCurrentSubtitleData: () => ({ text: 'old', tokens: null }),
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||
emitSubtitle: (payload) => calls.push(`emit:${payload.text}:${payload.tokens}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['set:', 'change:', 'emit::null']);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime refreshes secondary subtitle when available', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
calls.push(`request:${name}`);
|
||||
return name === 'secondary-sub-text' ? 'from abroad' : '国内外から';
|
||||
},
|
||||
}),
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getCurrentSubtitleData: () => null,
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
|
||||
setCurrentSecondarySubText: (text) => calls.push(`set-secondary:${text}`),
|
||||
emitSecondarySubtitle: (text) => calls.push(`emit-secondary:${text}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'request:sub-text',
|
||||
'set:国内外から',
|
||||
'refresh:国内外から',
|
||||
'request:secondary-sub-text',
|
||||
'set-secondary:from abroad',
|
||||
'emit-secondary:from abroad',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
type CurrentSubtitleMpvClient = {
|
||||
connected?: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
currentSubText: string;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
@@ -27,3 +32,81 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
||||
getMpvClient: () => CurrentSubtitleMpvClient | null;
|
||||
setCurrentSubText: (text: string) => void;
|
||||
getCurrentSubtitleData: () => SubtitleData | null;
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
emitSubtitle: (payload: SubtitleData) => void;
|
||||
setCurrentSecondarySubText?: (text: string) => void;
|
||||
emitSecondarySubtitle?: (text: string) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let subTextRaw: unknown;
|
||||
try {
|
||||
subTextRaw = await client.requestProperty('sub-text');
|
||||
} catch (error) {
|
||||
deps.logDebug?.(
|
||||
`[visible-overlay-subtitle-prime] failed to read sub-text: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
deps.setCurrentSubText(text);
|
||||
|
||||
const primeSecondarySubtitle = async (): Promise<void> => {
|
||||
if (!deps.setCurrentSecondarySubText && !deps.emitSecondarySubtitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const secondarySubTextRaw = await client.requestProperty('secondary-sub-text');
|
||||
const secondaryText = typeof secondarySubTextRaw === 'string' ? secondarySubTextRaw : '';
|
||||
deps.setCurrentSecondarySubText?.(secondaryText);
|
||||
deps.emitSecondarySubtitle?.(secondaryText);
|
||||
} catch (error) {
|
||||
deps.logDebug?.(
|
||||
`[visible-overlay-subtitle-prime] failed to read secondary-sub-text: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!text.trim()) {
|
||||
deps.onSubtitleChange(text);
|
||||
deps.emitSubtitle({ text, tokens: null });
|
||||
await primeSecondarySubtitle();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPayload = deps.getCurrentSubtitleData();
|
||||
if (currentPayload?.text === text) {
|
||||
deps.emitSubtitle(currentPayload);
|
||||
deps.refreshCurrentSubtitle(text);
|
||||
await primeSecondarySubtitle();
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedPayload = deps.consumeCachedSubtitle(text);
|
||||
if (cachedPayload) {
|
||||
deps.onSubtitleChange(text);
|
||||
deps.emitSubtitle(cachedPayload);
|
||||
await primeSecondarySubtitle();
|
||||
return;
|
||||
}
|
||||
|
||||
deps.refreshCurrentSubtitle(text);
|
||||
await primeSecondarySubtitle();
|
||||
}
|
||||
|
||||
@@ -16,13 +16,19 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst({
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
|
||||
overlayManager: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isFullScreen: () => false,
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
|
||||
calls.push(
|
||||
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
|
||||
),
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
@@ -30,6 +36,8 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
|
||||
calls.push(`sync-overlay-mode:${fullscreen}`),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
});
|
||||
|
||||
@@ -39,8 +47,11 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
|
||||
}
|
||||
|
||||
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
|
||||
assert.ok(calls.includes('sync-overlay-mode:true'));
|
||||
assert.ok(!calls.includes('fullscreen:true'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(calls.includes('showInactive'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('ensureOverlayWindowLevel'));
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
@@ -50,7 +61,46 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
|
||||
}
|
||||
});
|
||||
|
||||
test('linux mpv fullscreen overlay refresh update schedules a fresh burst when fullscreen exits', async () => {
|
||||
test('linux mpv fullscreen overlay refresh remembers mode even when overlay is hidden', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
|
||||
overlayManager: {
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
|
||||
calls.push(`sync-overlay-mode:${fullscreen}`),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 200;
|
||||
while (!calls.includes('sync-overlay-mode:true') && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
assert.ok(calls.includes('sync-overlay-mode:true'));
|
||||
assert.ok(!calls.includes('updateVisibleOverlayVisibility'));
|
||||
assert.ok(!calls.includes('ensureOverlayWindowLevel'));
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('linux mpv fullscreen overlay refresh updates mode without hide/show when fullscreen exits', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
@@ -65,8 +115,14 @@ test('linux mpv fullscreen overlay refresh update schedules a fresh burst when f
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isFullScreen: () => true,
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
|
||||
calls.push(
|
||||
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
|
||||
),
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
@@ -74,6 +130,8 @@ test('linux mpv fullscreen overlay refresh update schedules a fresh burst when f
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
|
||||
calls.push(`sync-overlay-mode:${fullscreen}`),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
};
|
||||
|
||||
@@ -84,9 +142,125 @@ test('linux mpv fullscreen overlay refresh update schedules a fresh burst when f
|
||||
|
||||
assert.equal(typeof nextCancel, 'function');
|
||||
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(calls.includes('showInactive'));
|
||||
assert.ok(calls.includes('ensureOverlayWindowLevel'));
|
||||
assert.ok(calls.includes('sync-overlay-mode:false'));
|
||||
assert.ok(!calls.includes('fullscreen:false'));
|
||||
assert.equal(calls.includes('hide'), false);
|
||||
assert.equal(calls.includes('showInactive'), false);
|
||||
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
|
||||
assert.equal(calls.includes('ensureOverlayWindowLevel'), false);
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('linux mpv fullscreen overlay refresh restores click-through after restacking', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
|
||||
overlayManager: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isFullScreen: () => false,
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
|
||||
calls.push(
|
||||
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
|
||||
),
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
|
||||
calls.push(`sync-overlay-mode:${fullscreen}`),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 200;
|
||||
while (!calls.includes('mouse-ignore:true:forward') && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
const showIndex = calls.indexOf('showInactive');
|
||||
const passthroughIndex = calls.indexOf('mouse-ignore:true:forward');
|
||||
const levelIndex = calls.indexOf('ensureOverlayWindowLevel');
|
||||
const syncIndex = calls.indexOf('sync-overlay-mode:true');
|
||||
|
||||
assert.ok(syncIndex >= 0);
|
||||
assert.ok(showIndex >= 0);
|
||||
assert.ok(syncIndex < showIndex);
|
||||
assert.ok(passthroughIndex > showIndex);
|
||||
assert.ok(levelIndex > passthroughIndex);
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('linux mpv fullscreen overlay refresh preserves active subtitle interaction after restacking', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
|
||||
overlayManager: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isFullScreen: () => false,
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
|
||||
calls.push(
|
||||
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
|
||||
),
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
|
||||
calls.push(`sync-overlay-mode:${fullscreen}`),
|
||||
getOverlayInteractionActive: () => true,
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 200;
|
||||
while (!calls.includes('mouse-ignore:false:plain') && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
const showIndex = calls.indexOf('showInactive');
|
||||
const interactiveIndex = calls.indexOf('mouse-ignore:false:plain');
|
||||
|
||||
assert.ok(showIndex >= 0);
|
||||
assert.ok(interactiveIndex > showIndex);
|
||||
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
|
||||
@@ -2,6 +2,7 @@ type LinuxMpvFullscreenOverlayWindow = {
|
||||
hide: () => void;
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
showInactive: () => void;
|
||||
};
|
||||
|
||||
@@ -13,6 +14,8 @@ export type LinuxMpvFullscreenOverlayRefreshDeps = {
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
};
|
||||
syncVisibleOverlayMpvFullscreenMode?: (fullscreen: boolean) => void;
|
||||
getOverlayInteractionActive?: () => boolean;
|
||||
ensureOverlayWindowLevel: (window: LinuxMpvFullscreenOverlayWindow) => void;
|
||||
};
|
||||
export type CancelLinuxMpvFullscreenOverlayRefreshBurst = () => void;
|
||||
@@ -28,13 +31,21 @@ function clearLinuxMpvFullscreenOverlayRefreshTimeouts(): void {
|
||||
}
|
||||
|
||||
function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
|
||||
fullscreen: boolean,
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
): void {
|
||||
if (process.platform !== 'linux' || !deps.overlayManager.getVisibleOverlayVisible()) {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.syncVisibleOverlayMpvFullscreenMode?.(fullscreen);
|
||||
if (!deps.overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
if (!fullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainWindow = deps.overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
@@ -43,10 +54,16 @@ function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
|
||||
|
||||
mainWindow.hide();
|
||||
mainWindow.showInactive();
|
||||
if (deps.getOverlayInteractionActive?.() === true) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
deps.ensureOverlayWindowLevel(mainWindow);
|
||||
}
|
||||
|
||||
export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
|
||||
isFullscreen: boolean,
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
): CancelLinuxMpvFullscreenOverlayRefreshBurst {
|
||||
if (process.platform !== 'linux') {
|
||||
@@ -59,7 +76,7 @@ export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts = linuxMpvFullscreenOverlayRefreshTimeouts.filter(
|
||||
(timeout) => timeout !== refreshTimeout,
|
||||
);
|
||||
refreshLinuxVisibleOverlayAfterMpvFullscreenChange(deps);
|
||||
refreshLinuxVisibleOverlayAfterMpvFullscreenChange(isFullscreen, deps);
|
||||
}, delayMs);
|
||||
refreshTimeout.unref?.();
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts.push(refreshTimeout);
|
||||
@@ -68,13 +85,13 @@ export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
|
||||
}
|
||||
|
||||
export function updateLinuxMpvFullscreenOverlayRefreshBurst(
|
||||
_isFullscreen: boolean,
|
||||
isFullscreen: boolean,
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
cancelCurrentBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null,
|
||||
): CancelLinuxMpvFullscreenOverlayRefreshBurst | null {
|
||||
cancelCurrentBurst?.();
|
||||
|
||||
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(deps);
|
||||
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(isFullscreen, deps);
|
||||
}
|
||||
|
||||
export { clearLinuxMpvFullscreenOverlayRefreshTimeouts };
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyLinuxOverlayInputShape,
|
||||
applyLinuxOverlayPointerInteractionMousePassthrough,
|
||||
type LinuxOverlayPointerInteractionDeps,
|
||||
isCursorOverSubtitle,
|
||||
type ForegroundSuppressionGraceState,
|
||||
mapOverlayMeasurementForPointerInteraction,
|
||||
resolveDesiredOverlayInteractive,
|
||||
resolveForegroundSuppressionWithGrace,
|
||||
shouldSuppressPointerInteractionForForegroundWindow,
|
||||
tickLinuxOverlayPointerInteraction,
|
||||
} from './linux-overlay-pointer-interaction';
|
||||
|
||||
const BOUNDS = { x: 100, y: 100, width: 1920, height: 1080 };
|
||||
const MEASUREMENT = {
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 800, y: 900, width: 320, height: 80 },
|
||||
};
|
||||
|
||||
test('isCursorOverSubtitle hit-tests the subtitle rect in screen coords (1:1 scale)', () => {
|
||||
// Subtitle rect maps to screen [900..1220] x [1000..1080] (+100 window origin).
|
||||
assert.equal(isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, MEASUREMENT), true);
|
||||
assert.equal(isCursorOverSubtitle({ x: 500, y: 1040 }, BOUNDS, MEASUREMENT), false);
|
||||
assert.equal(isCursorOverSubtitle({ x: 1000, y: 500 }, BOUNDS, MEASUREMENT), false);
|
||||
});
|
||||
|
||||
test('isCursorOverSubtitle scales viewport px to window px', () => {
|
||||
// Window is 2x the reported viewport → rect doubles.
|
||||
const scaled = { ...BOUNDS, width: 3840, height: 2160 };
|
||||
// contentRect.x*2=1600 +100 origin → left ~1700; a point at 1700,1900 is inside.
|
||||
assert.equal(isCursorOverSubtitle({ x: 1700, y: 1900 }, scaled, MEASUREMENT), true);
|
||||
});
|
||||
|
||||
test('isCursorOverSubtitle returns false without a content rect', () => {
|
||||
assert.equal(
|
||||
isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, {
|
||||
viewport: MEASUREMENT.viewport,
|
||||
contentRect: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, null), false);
|
||||
});
|
||||
|
||||
test('isCursorOverSubtitle falls back to content rect when interactive rects are empty', () => {
|
||||
assert.equal(
|
||||
isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, {
|
||||
...MEASUREMENT,
|
||||
interactiveRects: [],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
function makeDeps(overrides: Partial<LinuxOverlayPointerInteractionDeps>): {
|
||||
deps: LinuxOverlayPointerInteractionDeps;
|
||||
state: { active: boolean };
|
||||
} {
|
||||
const state = { active: false };
|
||||
const deps: LinuxOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
}),
|
||||
getCursorScreenPoint: () => ({ x: 1000, y: 1040 }),
|
||||
getSubtitleMeasurement: () => MEASUREMENT,
|
||||
getRendererInteractiveHint: () => false,
|
||||
shouldSuspend: () => false,
|
||||
getInteractionActive: () => state.active,
|
||||
setInteractionActive: (active) => {
|
||||
state.active = active;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { deps, state };
|
||||
}
|
||||
|
||||
test('resolveDesiredOverlayInteractive: interactive over subtitle, passthrough off it', () => {
|
||||
assert.equal(resolveDesiredOverlayInteractive(makeDeps({}).deps), true);
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(
|
||||
makeDeps({ getCursorScreenPoint: () => ({ x: 200, y: 200 }) }).deps,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDesiredOverlayInteractive: renderer hint keeps it interactive off the rect', () => {
|
||||
const { deps } = makeDeps({
|
||||
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
|
||||
getRendererInteractiveHint: () => true,
|
||||
});
|
||||
assert.equal(resolveDesiredOverlayInteractive(deps), true);
|
||||
});
|
||||
|
||||
test('resolveDesiredOverlayInteractive: hit-tests separate subtitle bars without blocking between them', () => {
|
||||
const measurement = {
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
||||
interactiveRects: [
|
||||
{ x: 700, y: 40, width: 520, height: 80 },
|
||||
{ x: 760, y: 900, width: 400, height: 80 },
|
||||
],
|
||||
} as unknown as ReturnType<LinuxOverlayPointerInteractionDeps['getSubtitleMeasurement']>;
|
||||
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(
|
||||
makeDeps({
|
||||
getCursorScreenPoint: () => ({ x: 900, y: 300 }),
|
||||
getSubtitleMeasurement: () => measurement,
|
||||
}).deps,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(
|
||||
makeDeps({
|
||||
getCursorScreenPoint: () => ({ x: 900, y: 1060 }),
|
||||
getSubtitleMeasurement: () => measurement,
|
||||
}).deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(
|
||||
makeDeps({
|
||||
getCursorScreenPoint: () => ({ x: 900, y: 180 }),
|
||||
getSubtitleMeasurement: () => measurement,
|
||||
}).deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('mapOverlayMeasurementForPointerInteraction preserves renderer interactive rects', () => {
|
||||
const mapped = mapOverlayMeasurementForPointerInteraction({
|
||||
layer: 'visible',
|
||||
measuredAtMs: 1,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
||||
interactiveRects: [
|
||||
{ x: 700, y: 40, width: 520, height: 80 },
|
||||
{ x: 760, y: 900, width: 400, height: 80 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(mapped, {
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
||||
interactiveRects: [
|
||||
{ x: 700, y: 40, width: 520, height: 80 },
|
||||
{ x: 760, y: 900, width: 400, height: 80 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('shouldSuppressPointerInteractionForForegroundWindow suppresses hover when another app is foreground', () => {
|
||||
assert.equal(
|
||||
shouldSuppressPointerInteractionForForegroundWindow({
|
||||
hasForegroundSeparateWindow: false,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: false,
|
||||
isOverlayWindowFocused: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSuppressPointerInteractionForForegroundWindow({
|
||||
hasForegroundSeparateWindow: false,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: true,
|
||||
isOverlayWindowFocused: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSuppressPointerInteractionForForegroundWindow({
|
||||
hasForegroundSeparateWindow: false,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: false,
|
||||
isOverlayWindowFocused: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveForegroundSuppressionWithGrace ignores a transient startup focus blip', () => {
|
||||
// Regression: right after playback starts the overlay can briefly become the X11 active
|
||||
// window, so the tracker reports mpv unfocused. Suppressing immediately leaves subtitles
|
||||
// inert for ~1s. The grace must hold interaction available until the loss is *stable*.
|
||||
const state: ForegroundSuppressionGraceState = { lossSinceMs: null };
|
||||
const base = {
|
||||
hasForegroundSeparateWindow: false,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: false,
|
||||
isOverlayWindowFocused: false,
|
||||
graceMs: 500,
|
||||
state,
|
||||
};
|
||||
|
||||
// Blip starts: not yet suppressed.
|
||||
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_000 }), false);
|
||||
// Still within grace.
|
||||
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_400 }), false);
|
||||
// mpv regains focus before the grace elapses → reset, never suppressed.
|
||||
assert.equal(
|
||||
resolveForegroundSuppressionWithGrace({ ...base, isMpvWindowFocused: true, nowMs: 1_450 }),
|
||||
false,
|
||||
);
|
||||
assert.equal(state.lossSinceMs, null);
|
||||
});
|
||||
|
||||
test('resolveForegroundSuppressionWithGrace suppresses once foreground loss is stable', () => {
|
||||
const state: ForegroundSuppressionGraceState = { lossSinceMs: null };
|
||||
const base = {
|
||||
hasForegroundSeparateWindow: false,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: false,
|
||||
isOverlayWindowFocused: false,
|
||||
graceMs: 500,
|
||||
state,
|
||||
};
|
||||
|
||||
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_000 }), false);
|
||||
// A real app stays foreground past the grace → suppress.
|
||||
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_500 }), true);
|
||||
});
|
||||
|
||||
test('resolveForegroundSuppressionWithGrace defers to a separate window immediately', () => {
|
||||
const state: ForegroundSuppressionGraceState = { lossSinceMs: 1_000 };
|
||||
assert.equal(
|
||||
resolveForegroundSuppressionWithGrace({
|
||||
hasForegroundSeparateWindow: true,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: true,
|
||||
isOverlayWindowFocused: false,
|
||||
nowMs: 2_000,
|
||||
graceMs: 500,
|
||||
state,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(state.lossSinceMs, null);
|
||||
});
|
||||
|
||||
test('shouldSuppressPointerInteractionForForegroundWindow suppresses hover for separate app windows', () => {
|
||||
assert.equal(
|
||||
shouldSuppressPointerInteractionForForegroundWindow({
|
||||
hasForegroundSeparateWindow: true,
|
||||
isTrackingMpvWindow: true,
|
||||
isMpvWindowFocused: true,
|
||||
isOverlayWindowFocused: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDesiredOverlayInteractive: false when overlay hidden, null when suspended/no window', () => {
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(makeDeps({ getVisibleOverlayVisible: () => false }).deps),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(makeDeps({ shouldSuspend: () => true }).deps),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesiredOverlayInteractive(makeDeps({ getMainWindow: () => null }).deps),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('tick only writes interaction state on change', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps, state } = makeDeps({
|
||||
setInteractionActive: (active) => {
|
||||
calls.push(active);
|
||||
state.active = active;
|
||||
},
|
||||
});
|
||||
tickLinuxOverlayPointerInteraction(deps); // off→on
|
||||
tickLinuxOverlayPointerInteraction(deps); // no change
|
||||
assert.deepEqual(calls, [true]);
|
||||
});
|
||||
|
||||
test('tick does not flip state when suspended (returns null)', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps } = makeDeps({
|
||||
getInteractionActive: () => true,
|
||||
shouldSuspend: () => true,
|
||||
setInteractionActive: (active) => calls.push(active),
|
||||
});
|
||||
tickLinuxOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('tick clears active hover while a separate SubMiner window suppresses overlay interaction', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps, state } = makeDeps({
|
||||
getInteractionActive: () => true,
|
||||
shouldSuppressInteraction: () => true,
|
||||
setInteractionActive: (active) => {
|
||||
calls.push(active);
|
||||
state.active = active;
|
||||
},
|
||||
});
|
||||
|
||||
state.active = true;
|
||||
tickLinuxOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, [false]);
|
||||
});
|
||||
|
||||
test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns hit rects', () => {
|
||||
const calls: boolean[] = [];
|
||||
const { deps } = makeDeps({
|
||||
getInteractionActive: () => false,
|
||||
shouldUseInputShape: () => true,
|
||||
setInteractionActive: (active) => calls.push(active),
|
||||
});
|
||||
|
||||
tickLinuxOverlayPointerInteraction(deps);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('applyLinuxOverlayInputShape shapes measured subtitle rects and enables mouse input', () => {
|
||||
const calls: string[] = [];
|
||||
const window = {
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => ({ ...BOUNDS, width: 3840, height: 2160 }),
|
||||
setShape: (rects: Array<{ x: number; y: number; width: number; height: number }>) => {
|
||||
calls.push(`shape:${JSON.stringify(rects)}`);
|
||||
},
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
applyLinuxOverlayInputShape({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => window,
|
||||
getSubtitleMeasurement: () => MEASUREMENT,
|
||||
getRendererInteractiveHint: () => false,
|
||||
shouldSuspend: () => false,
|
||||
shouldSuppressInteraction: () => false,
|
||||
}),
|
||||
{ handled: true, active: true },
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
'shape:[{"x":1594,"y":1794,"width":652,"height":172}]',
|
||||
'ignore:false:plain',
|
||||
]);
|
||||
});
|
||||
|
||||
test('applyLinuxOverlayInputShape uses the full window while renderer reports off-rect interaction', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
assert.deepEqual(
|
||||
applyLinuxOverlayInputShape({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
setShape: (rects: Array<{ x: number; y: number; width: number; height: number }>) => {
|
||||
calls.push(`shape:${JSON.stringify(rects)}`);
|
||||
},
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||
},
|
||||
}),
|
||||
getSubtitleMeasurement: () => null,
|
||||
getRendererInteractiveHint: () => true,
|
||||
shouldSuspend: () => false,
|
||||
}),
|
||||
{ handled: true, active: true },
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
'shape:[{"x":0,"y":0,"width":1920,"height":1080}]',
|
||||
'ignore:false:plain',
|
||||
]);
|
||||
});
|
||||
|
||||
test('applyLinuxOverlayInputShape falls back when setShape is unavailable', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
assert.deepEqual(
|
||||
applyLinuxOverlayInputShape({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
setIgnoreMouseEvents: () => {
|
||||
calls.push('ignore');
|
||||
},
|
||||
}),
|
||||
getSubtitleMeasurement: () => MEASUREMENT,
|
||||
getRendererInteractiveHint: () => false,
|
||||
shouldSuspend: () => false,
|
||||
}),
|
||||
{ handled: false, active: false },
|
||||
);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('applyLinuxOverlayPointerInteractionMousePassthrough toggles mouse input without full visibility refresh', () => {
|
||||
const calls: string[] = [];
|
||||
const window = {
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
applyLinuxOverlayPointerInteractionMousePassthrough({
|
||||
active: true,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => window,
|
||||
shouldSuspend: () => false,
|
||||
shouldSuppressInteraction: () => false,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('full-refresh');
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, ['ignore:false:plain']);
|
||||
});
|
||||
|
||||
test('applyLinuxOverlayPointerInteractionMousePassthrough falls back when pointer interaction is suppressed', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
assert.equal(
|
||||
applyLinuxOverlayPointerInteractionMousePassthrough({
|
||||
active: false,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setIgnoreMouseEvents: () => {
|
||||
calls.push('mouse-ignore');
|
||||
},
|
||||
}),
|
||||
shouldSuspend: () => false,
|
||||
shouldSuppressInteraction: () => true,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('full-refresh');
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(calls, ['full-refresh']);
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
/*
|
||||
Linux overlay pointer-interaction loop.
|
||||
|
||||
Electron cannot forward mouse-move events through a click-through window on Linux/X11
|
||||
(the `forward` option of setIgnoreMouseEvents is unsupported there — electron/electron#16777).
|
||||
The overlay's hover/lookup interaction relied on those forwarded events, so under XWayland
|
||||
the click-through overlay never sees the cursor and stays inert.
|
||||
|
||||
This restores the Windows/macOS behavior with either a Linux input shape (preferred) or a
|
||||
main-process cursor poll fallback. Input shapes keep only reported subtitle/sidebar rects
|
||||
mouse-active so entering a subtitle does not have to flip BrowserWindow mouse-ignore state.
|
||||
The cursor poll remains for runtimes where BrowserWindow.setShape is unavailable.
|
||||
*/
|
||||
|
||||
import type { OverlayContentMeasurement } from '../../types';
|
||||
|
||||
export type PointerPoint = { x: number; y: number };
|
||||
export type PointerRect = { x: number; y: number; width: number; height: number };
|
||||
export type PointerViewport = { width: number; height: number };
|
||||
|
||||
export type OverlayContentMeasurementLike = {
|
||||
viewport: PointerViewport;
|
||||
contentRect: PointerRect | null;
|
||||
interactiveRects?: PointerRect[] | null;
|
||||
} | null;
|
||||
|
||||
type PointerInteractionWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
getBounds: () => PointerRect;
|
||||
};
|
||||
|
||||
type PointerInteractionMousePassthroughWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
};
|
||||
|
||||
type PointerInteractionShapeWindow = PointerInteractionMousePassthroughWindow & {
|
||||
getBounds: () => PointerRect;
|
||||
setShape?: (rects: PointerRect[]) => void;
|
||||
};
|
||||
|
||||
export type LinuxOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => PointerInteractionWindow | null;
|
||||
getCursorScreenPoint: () => PointerPoint;
|
||||
getSubtitleMeasurement: () => OverlayContentMeasurementLike;
|
||||
getRendererInteractiveHint: () => boolean;
|
||||
/** True when a modal/stats overlay owns input — leave interaction state to that logic. */
|
||||
shouldSuspend: () => boolean;
|
||||
/** True when a separate app window should stay above the overlay. */
|
||||
shouldSuppressInteraction?: () => boolean;
|
||||
shouldUseInputShape?: () => boolean;
|
||||
getInteractionActive: () => boolean;
|
||||
setInteractionActive: (active: boolean) => void;
|
||||
};
|
||||
|
||||
export const LINUX_OVERLAY_POINTER_POLL_INTERVAL_MS = 60;
|
||||
// Padding (in window px) so the cursor doesn't have to land pixel-perfectly on the text.
|
||||
const SUBTITLE_HIT_PADDING_PX = 6;
|
||||
|
||||
let pointerInteractionInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function mapOverlayMeasurementForPointerInteraction(
|
||||
measurement: OverlayContentMeasurement | null,
|
||||
): OverlayContentMeasurementLike {
|
||||
if (!measurement) return null;
|
||||
return {
|
||||
viewport: measurement.viewport,
|
||||
contentRect: measurement.contentRect,
|
||||
...(measurement.interactiveRects ? { interactiveRects: measurement.interactiveRects } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldSuppressPointerInteractionForForegroundWindow(options: {
|
||||
hasForegroundSeparateWindow: boolean;
|
||||
isTrackingMpvWindow: boolean;
|
||||
isMpvWindowFocused: boolean;
|
||||
isOverlayWindowFocused: boolean;
|
||||
}): boolean {
|
||||
if (options.hasForegroundSeparateWindow) return true;
|
||||
if (!options.isTrackingMpvWindow) return false;
|
||||
return !options.isMpvWindowFocused && !options.isOverlayWindowFocused;
|
||||
}
|
||||
|
||||
/** Mutable timer state for {@link resolveForegroundSuppressionWithGrace}. */
|
||||
export type ForegroundSuppressionGraceState = { lossSinceMs: number | null };
|
||||
|
||||
/**
|
||||
* Suppress subtitle pointer interaction for a foreground window, but only once the foreground
|
||||
* loss has been *stable* for `graceMs`. A separate SubMiner window defers immediately; a plain
|
||||
* focus blip (e.g. the overlay briefly becoming the X11 active window at playback start) is
|
||||
* ignored so subtitles don't go inert for a poll cycle while focus settles back onto mpv.
|
||||
*/
|
||||
export function resolveForegroundSuppressionWithGrace(options: {
|
||||
hasForegroundSeparateWindow: boolean;
|
||||
isTrackingMpvWindow: boolean;
|
||||
isMpvWindowFocused: boolean;
|
||||
isOverlayWindowFocused: boolean;
|
||||
nowMs: number;
|
||||
graceMs: number;
|
||||
state: ForegroundSuppressionGraceState;
|
||||
}): boolean {
|
||||
if (options.hasForegroundSeparateWindow) {
|
||||
options.state.lossSinceMs = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const rawSuppress = shouldSuppressPointerInteractionForForegroundWindow(options);
|
||||
if (!rawSuppress) {
|
||||
options.state.lossSinceMs = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.state.lossSinceMs === null) {
|
||||
options.state.lossSinceMs = options.nowMs;
|
||||
}
|
||||
return options.nowMs - options.state.lossSinceMs >= options.graceMs;
|
||||
}
|
||||
|
||||
function isCursorOverRect(
|
||||
cursor: PointerPoint,
|
||||
bounds: PointerRect,
|
||||
viewport: PointerViewport,
|
||||
rect: PointerRect,
|
||||
): boolean {
|
||||
if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
|
||||
|
||||
const scaleX = bounds.width / viewport.width;
|
||||
const scaleY = bounds.height / viewport.height;
|
||||
const left = bounds.x + rect.x * scaleX - SUBTITLE_HIT_PADDING_PX;
|
||||
const top = bounds.y + rect.y * scaleY - SUBTITLE_HIT_PADDING_PX;
|
||||
const right = left + rect.width * scaleX + SUBTITLE_HIT_PADDING_PX * 2;
|
||||
const bottom = top + rect.height * scaleY + SUBTITLE_HIT_PADDING_PX * 2;
|
||||
|
||||
return cursor.x >= left && cursor.x <= right && cursor.y >= top && cursor.y <= bottom;
|
||||
}
|
||||
|
||||
function measuredRectsForInput(measurement: OverlayContentMeasurementLike): PointerRect[] {
|
||||
if (!measurement) return [];
|
||||
return Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.length > 0
|
||||
? measurement.interactiveRects
|
||||
: measurement.contentRect
|
||||
? [measurement.contentRect]
|
||||
: [];
|
||||
}
|
||||
|
||||
function clampRectToWindow(rect: PointerRect, bounds: PointerRect): PointerRect | null {
|
||||
const left = Math.max(0, Math.floor(rect.x));
|
||||
const top = Math.max(0, Math.floor(rect.y));
|
||||
const right = Math.min(Math.ceil(bounds.width), Math.ceil(rect.x + rect.width));
|
||||
const bottom = Math.min(Math.ceil(bounds.height), Math.ceil(rect.y + rect.height));
|
||||
if (right <= left || bottom <= top) return null;
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMeasuredRectToWindowShape(
|
||||
bounds: PointerRect,
|
||||
viewport: PointerViewport,
|
||||
rect: PointerRect,
|
||||
): PointerRect | null {
|
||||
if (!(bounds.width > 0) || !(bounds.height > 0)) return null;
|
||||
if (!(viewport.width > 0) || !(viewport.height > 0)) return null;
|
||||
|
||||
const scaleX = bounds.width / viewport.width;
|
||||
const scaleY = bounds.height / viewport.height;
|
||||
return clampRectToWindow(
|
||||
{
|
||||
x: rect.x * scaleX - SUBTITLE_HIT_PADDING_PX,
|
||||
y: rect.y * scaleY - SUBTITLE_HIT_PADDING_PX,
|
||||
width: rect.width * scaleX + SUBTITLE_HIT_PADDING_PX * 2,
|
||||
height: rect.height * scaleY + SUBTITLE_HIT_PADDING_PX * 2,
|
||||
},
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInputShapeRects(options: {
|
||||
bounds: PointerRect;
|
||||
measurement: OverlayContentMeasurementLike;
|
||||
rendererInteractiveHint: boolean;
|
||||
}): PointerRect[] {
|
||||
const { bounds } = options;
|
||||
if (!(bounds.width > 0) || !(bounds.height > 0)) return [];
|
||||
|
||||
if (options.rendererInteractiveHint) {
|
||||
return [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.ceil(bounds.width),
|
||||
height: Math.ceil(bounds.height),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const measurement = options.measurement;
|
||||
if (!measurement) return [];
|
||||
return measuredRectsForInput(measurement)
|
||||
.map((rect) => mapMeasuredRectToWindowShape(bounds, measurement.viewport, rect))
|
||||
.filter((rect): rect is PointerRect => rect !== null);
|
||||
}
|
||||
|
||||
/** Hit-test the global cursor against subtitle bar rects, mapping viewport px → screen px. */
|
||||
export function isCursorOverSubtitle(
|
||||
cursor: PointerPoint,
|
||||
bounds: PointerRect,
|
||||
measurement: OverlayContentMeasurementLike,
|
||||
): boolean {
|
||||
if (!measurement) return false;
|
||||
const { viewport } = measurement;
|
||||
if (!(viewport.width > 0) || !(viewport.height > 0)) return false;
|
||||
|
||||
const rects = measuredRectsForInput(measurement);
|
||||
|
||||
return rects.some((rect) => isCursorOverRect(cursor, bounds, viewport, rect));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the desired interactive state, or null when the loop should not touch it
|
||||
* (overlay hidden/destroyed or another surface owns input).
|
||||
*/
|
||||
export function resolveDesiredOverlayInteractive(
|
||||
deps: LinuxOverlayPointerInteractionDeps,
|
||||
): boolean | null {
|
||||
if (!deps.getVisibleOverlayVisible()) return false;
|
||||
if (deps.shouldSuspend()) return null;
|
||||
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (deps.shouldSuppressInteraction?.()) return false;
|
||||
if (deps.getRendererInteractiveHint()) return true;
|
||||
return isCursorOverSubtitle(
|
||||
deps.getCursorScreenPoint(),
|
||||
mainWindow.getBounds(),
|
||||
deps.getSubtitleMeasurement(),
|
||||
);
|
||||
}
|
||||
|
||||
export function tickLinuxOverlayPointerInteraction(deps: LinuxOverlayPointerInteractionDeps): void {
|
||||
if (deps.shouldUseInputShape?.()) return;
|
||||
const desired = resolveDesiredOverlayInteractive(deps);
|
||||
if (desired === null) return;
|
||||
if (deps.getInteractionActive() === desired) return;
|
||||
deps.setInteractionActive(desired);
|
||||
}
|
||||
|
||||
export function applyLinuxOverlayInputShape(deps: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => PointerInteractionShapeWindow | null;
|
||||
getSubtitleMeasurement: () => OverlayContentMeasurementLike;
|
||||
getRendererInteractiveHint: () => boolean;
|
||||
shouldSuspend: () => boolean;
|
||||
shouldSuppressInteraction?: () => boolean;
|
||||
}): { handled: boolean; active: boolean } {
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || typeof mainWindow.setShape !== 'function') {
|
||||
return { handled: false, active: false };
|
||||
}
|
||||
|
||||
if (
|
||||
!deps.getVisibleOverlayVisible() ||
|
||||
deps.shouldSuspend() ||
|
||||
mainWindow.isDestroyed() ||
|
||||
deps.shouldSuppressInteraction?.()
|
||||
) {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
mainWindow.setShape([]);
|
||||
return { handled: true, active: false };
|
||||
}
|
||||
|
||||
const rects = resolveInputShapeRects({
|
||||
bounds: mainWindow.getBounds(),
|
||||
measurement: deps.getSubtitleMeasurement(),
|
||||
rendererInteractiveHint: deps.getRendererInteractiveHint(),
|
||||
});
|
||||
|
||||
if (rects.length === 0) {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
mainWindow.setShape([]);
|
||||
return { handled: true, active: false };
|
||||
}
|
||||
|
||||
mainWindow.setShape(rects);
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
return { handled: true, active: true };
|
||||
}
|
||||
|
||||
export function applyLinuxOverlayPointerInteractionMousePassthrough(deps: {
|
||||
active: boolean;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => PointerInteractionMousePassthroughWindow | null;
|
||||
shouldSuspend: () => boolean;
|
||||
shouldSuppressInteraction?: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
}): boolean {
|
||||
if (!deps.getVisibleOverlayVisible() || deps.shouldSuspend()) {
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
return false;
|
||||
}
|
||||
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deps.shouldSuppressInteraction?.()) {
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deps.active) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureLinuxOverlayPointerInteractionLoop(
|
||||
deps: LinuxOverlayPointerInteractionDeps,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): void {
|
||||
if (pointerInteractionInterval !== null) return;
|
||||
if (platform !== 'linux') return;
|
||||
|
||||
pointerInteractionInterval = setInterval(() => {
|
||||
tickLinuxOverlayPointerInteraction(deps);
|
||||
}, LINUX_OVERLAY_POINTER_POLL_INTERVAL_MS);
|
||||
pointerInteractionInterval.unref?.();
|
||||
}
|
||||
|
||||
export function stopLinuxOverlayPointerInteractionLoop(): void {
|
||||
if (pointerInteractionInterval === null) return;
|
||||
clearInterval(pointerInteractionInterval);
|
||||
pointerInteractionInterval = null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
buildFullWindowShapeRect,
|
||||
restoreLinuxOverlayWindowShape,
|
||||
} from './linux-overlay-window-shape';
|
||||
|
||||
test('buildFullWindowShapeRect maps current bounds to a full-window shape', () => {
|
||||
assert.deepEqual(buildFullWindowShapeRect({ x: 100, y: 50, width: 1919.6, height: 1080.4 }), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
test('buildFullWindowShapeRect rejects invalid dimensions', () => {
|
||||
assert.equal(buildFullWindowShapeRect({ x: 0, y: 0, width: 0, height: 1080 }), null);
|
||||
assert.equal(buildFullWindowShapeRect({ x: 0, y: 0, width: 1920, height: Number.NaN }), null);
|
||||
});
|
||||
|
||||
test('restoreLinuxOverlayWindowShape restores a full drawable shape', () => {
|
||||
const calls: unknown[] = [];
|
||||
|
||||
assert.equal(
|
||||
restoreLinuxOverlayWindowShape({
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ x: 760, y: 152, width: 1920, height: 1080 }),
|
||||
setShape: (rects) => calls.push(rects),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, [[{ x: 0, y: 0, width: 1920, height: 1080 }]]);
|
||||
});
|
||||
|
||||
test('restoreLinuxOverlayWindowShape skips destroyed or unsupported windows', () => {
|
||||
assert.equal(
|
||||
restoreLinuxOverlayWindowShape({
|
||||
isDestroyed: () => true,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
|
||||
setShape: () => {
|
||||
throw new Error('should not shape destroyed windows');
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
restoreLinuxOverlayWindowShape({
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
export type LinuxOverlayShapeRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type LinuxOverlayShapeWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
getBounds?: () => LinuxOverlayShapeRect;
|
||||
setShape?: (rects: LinuxOverlayShapeRect[]) => void;
|
||||
};
|
||||
|
||||
function toPositivePixel(value: number): number | null {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(1, Math.round(value));
|
||||
}
|
||||
|
||||
export function buildFullWindowShapeRect(
|
||||
bounds: LinuxOverlayShapeRect,
|
||||
): LinuxOverlayShapeRect | null {
|
||||
const width = toPositivePixel(bounds.width);
|
||||
const height = toPositivePixel(bounds.height);
|
||||
if (width === null || height === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreLinuxOverlayWindowShape(window: LinuxOverlayShapeWindow | null): boolean {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
if (typeof window.setShape !== 'function' || typeof window.getBounds !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = buildFullWindowShapeRect(window.getBounds());
|
||||
if (!rect) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.setShape([rect]);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
ensureLinuxOverlayZOrderKeepAliveLoop,
|
||||
type LinuxOverlayZOrderKeepAliveDeps,
|
||||
shouldRunLinuxOverlayZOrderKeepAlive,
|
||||
stopLinuxOverlayZOrderKeepAliveLoop,
|
||||
tickLinuxOverlayZOrderKeepAlive,
|
||||
} from './linux-overlay-zorder-keepalive';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
overrides: Partial<LinuxOverlayZOrderKeepAliveDeps>,
|
||||
calls: string[],
|
||||
): LinuxOverlayZOrderKeepAliveDeps {
|
||||
return {
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({ isDestroyed: () => false, isVisible: () => true }),
|
||||
isTrackingMpvWindow: () => true,
|
||||
isMpvWindowFocused: () => true,
|
||||
isOverlayWindowFocused: () => false,
|
||||
shouldSuppressReassert: () => false,
|
||||
raiseMpvWindow: async () => {
|
||||
calls.push('raise-mpv');
|
||||
return true;
|
||||
},
|
||||
releaseOverlayLayerOrder: () => calls.push('release'),
|
||||
enforceOverlayLayerOrder: () => calls.push('enforce'),
|
||||
focusOverlayWindow: () => calls.push('focus-overlay'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('shouldRunLinuxOverlayZOrderKeepAlive runs on Linux except Hyprland/Sway', () => {
|
||||
withPlatform('linux', () => {
|
||||
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({ XDG_CURRENT_DESKTOP: 'KDE' }), true);
|
||||
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({ HYPRLAND_INSTANCE_SIGNATURE: 'h' }), false);
|
||||
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({ SWAYSOCK: '/tmp/s' }), false);
|
||||
});
|
||||
withPlatform('win32', () => {
|
||||
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({}), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('tick re-asserts overlay level when the overlay is shown and unobstructed', async () => {
|
||||
const calls: string[] = [];
|
||||
await tickLinuxOverlayZOrderKeepAlive(makeDeps({}, calls));
|
||||
assert.deepEqual(calls, ['enforce']);
|
||||
});
|
||||
|
||||
test('tick raises mpv behind a focused overlay when mpv is behind another app', async () => {
|
||||
const calls: string[] = [];
|
||||
await tickLinuxOverlayZOrderKeepAlive(
|
||||
makeDeps(
|
||||
{
|
||||
isMpvWindowFocused: () => false,
|
||||
isOverlayWindowFocused: () => true,
|
||||
},
|
||||
calls,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(calls, ['raise-mpv', 'enforce', 'focus-overlay']);
|
||||
});
|
||||
|
||||
test('tick releases stale overlay topmost when another app is focused', async () => {
|
||||
const calls: string[] = [];
|
||||
await tickLinuxOverlayZOrderKeepAlive(
|
||||
makeDeps(
|
||||
{
|
||||
isMpvWindowFocused: () => false,
|
||||
isOverlayWindowFocused: () => false,
|
||||
},
|
||||
calls,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(calls, ['release']);
|
||||
});
|
||||
|
||||
test('tick skips when overlay hidden, mpv untracked, suppressed, or window gone', async () => {
|
||||
for (const override of [
|
||||
{ getVisibleOverlayVisible: () => false },
|
||||
{ isTrackingMpvWindow: () => false },
|
||||
{ shouldSuppressReassert: () => true },
|
||||
{ getMainWindow: () => null },
|
||||
{ getMainWindow: () => ({ isDestroyed: () => true, isVisible: () => true }) },
|
||||
{ getMainWindow: () => ({ isDestroyed: () => false, isVisible: () => false }) },
|
||||
] satisfies Array<Partial<LinuxOverlayZOrderKeepAliveDeps>>) {
|
||||
const calls: string[] = [];
|
||||
await tickLinuxOverlayZOrderKeepAlive(makeDeps(override, calls));
|
||||
assert.deepEqual(calls, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('keep-alive loop skips overlapping ticks and resets after async completion', async () => {
|
||||
const originalSetInterval = globalThis.setInterval;
|
||||
const originalClearInterval = globalThis.clearInterval;
|
||||
let intervalCallback: (() => void) | null = null;
|
||||
let resolveRaise: (() => void) | null = null;
|
||||
let raiseCalls = 0;
|
||||
|
||||
globalThis.setInterval = ((callback: () => void) => {
|
||||
intervalCallback = callback;
|
||||
return { unref: () => {} } as ReturnType<typeof setInterval>;
|
||||
}) as typeof setInterval;
|
||||
globalThis.clearInterval = (() => {}) as typeof clearInterval;
|
||||
|
||||
try {
|
||||
withPlatform('linux', () => {
|
||||
ensureLinuxOverlayZOrderKeepAliveLoop(
|
||||
makeDeps(
|
||||
{
|
||||
isMpvWindowFocused: () => false,
|
||||
isOverlayWindowFocused: () => true,
|
||||
raiseMpvWindow: async () => {
|
||||
raiseCalls += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveRaise = resolve;
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
[],
|
||||
),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
assert.ok(intervalCallback);
|
||||
const tick = intervalCallback as () => void;
|
||||
tick();
|
||||
tick();
|
||||
assert.equal(raiseCalls, 1);
|
||||
|
||||
assert.ok(resolveRaise);
|
||||
const finishRaise = resolveRaise as () => void;
|
||||
finishRaise();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
tick();
|
||||
assert.equal(raiseCalls, 2);
|
||||
} finally {
|
||||
stopLinuxOverlayZOrderKeepAliveLoop();
|
||||
globalThis.setInterval = originalSetInterval;
|
||||
globalThis.clearInterval = originalClearInterval;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
|
||||
|
||||
/*
|
||||
Linux overlay z-order keep-alive loop.
|
||||
|
||||
The visible overlay re-asserts its always-on-top level only when mpv's geometry changes
|
||||
(the bounds-update path) or on a fullscreen toggle (the fullscreen refresh burst). When mpv
|
||||
is raised above the overlay WITHOUT a geometry change — click-to-raise, focus change, or a
|
||||
compositor restack on KDE/GNOME/other X11/XWayland window managers — nothing re-raises the
|
||||
overlay and it stays buried. Windows guards against this with a foreground poll loop; this is
|
||||
the Linux equivalent: a lightweight periodic re-assert while the overlay is shown and mpv
|
||||
remains the foreground window. If another app is active, the overlay releases its global
|
||||
keep-above level so that app can cover it.
|
||||
|
||||
Gated to X11/XWayland sessions (not Hyprland/Sway, which place the overlay natively and would
|
||||
otherwise be spammed with hyprctl dispatches).
|
||||
*/
|
||||
|
||||
type KeepAliveOverlayWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
focus?: () => void;
|
||||
};
|
||||
|
||||
export type LinuxOverlayZOrderKeepAliveDeps = {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => KeepAliveOverlayWindow | null;
|
||||
isTrackingMpvWindow: () => boolean;
|
||||
isMpvWindowFocused: () => boolean;
|
||||
isOverlayWindowFocused: () => boolean;
|
||||
/** True when a modal/stats overlay or active interaction owns the top — skip re-asserting. */
|
||||
shouldSuppressReassert: () => boolean;
|
||||
raiseMpvWindow: () => Promise<boolean>;
|
||||
releaseOverlayLayerOrder: () => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
focusOverlayWindow?: () => void;
|
||||
};
|
||||
|
||||
export const LINUX_OVERLAY_ZORDER_KEEPALIVE_INTERVAL_MS = 700;
|
||||
|
||||
let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let keepAliveTickInFlight = false;
|
||||
|
||||
export function shouldRunLinuxOverlayZOrderKeepAlive(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return process.platform === 'linux' && !isSupportedWaylandCompositor(env);
|
||||
}
|
||||
|
||||
export async function tickLinuxOverlayZOrderKeepAlive(
|
||||
deps: LinuxOverlayZOrderKeepAliveDeps,
|
||||
): Promise<void> {
|
||||
if (!deps.getVisibleOverlayVisible()) return;
|
||||
if (!deps.isTrackingMpvWindow()) return;
|
||||
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlayFocused = deps.isOverlayWindowFocused();
|
||||
const mpvFocused = deps.isMpvWindowFocused();
|
||||
if (!mpvFocused && !overlayFocused) {
|
||||
deps.releaseOverlayLayerOrder();
|
||||
return;
|
||||
}
|
||||
if (deps.shouldSuppressReassert()) return;
|
||||
|
||||
if (overlayFocused && !mpvFocused) {
|
||||
await deps.raiseMpvWindow();
|
||||
}
|
||||
deps.enforceOverlayLayerOrder();
|
||||
if (overlayFocused && !mpvFocused) {
|
||||
deps.focusOverlayWindow?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureLinuxOverlayZOrderKeepAliveLoop(
|
||||
deps: LinuxOverlayZOrderKeepAliveDeps,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
if (keepAliveInterval !== null) return;
|
||||
if (!shouldRunLinuxOverlayZOrderKeepAlive(env)) return;
|
||||
|
||||
keepAliveInterval = setInterval(() => {
|
||||
if (keepAliveTickInFlight) return;
|
||||
keepAliveTickInFlight = true;
|
||||
void tickLinuxOverlayZOrderKeepAlive(deps)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
keepAliveTickInFlight = false;
|
||||
});
|
||||
}, LINUX_OVERLAY_ZORDER_KEEPALIVE_INTERVAL_MS);
|
||||
keepAliveInterval.unref?.();
|
||||
}
|
||||
|
||||
export function stopLinuxOverlayZOrderKeepAliveLoop(): void {
|
||||
if (keepAliveInterval === null) return;
|
||||
clearInterval(keepAliveInterval);
|
||||
keepAliveInterval = null;
|
||||
keepAliveTickInFlight = false;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
resolveLinuxVisibleOverlayWindowModeAction,
|
||||
shouldExitFullscreenOverrideForTrackedGeometry,
|
||||
} from './linux-visible-overlay-window-mode';
|
||||
|
||||
test('linux overlay mode sync records fullscreen without creating a hidden overlay', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: 'managed',
|
||||
fullscreen: true,
|
||||
hasLiveWindow: false,
|
||||
visibleOverlayVisible: false,
|
||||
}),
|
||||
{
|
||||
nextMode: 'fullscreen-override',
|
||||
shouldCreateWindow: false,
|
||||
shouldDestroyCurrentWindow: false,
|
||||
shouldRefreshVisibleOverlay: false,
|
||||
createWindowTiming: 'none',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('linux overlay mode sync destroys stale hidden window without replacing it', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: 'managed',
|
||||
fullscreen: true,
|
||||
hasLiveWindow: true,
|
||||
visibleOverlayVisible: false,
|
||||
}),
|
||||
{
|
||||
nextMode: 'fullscreen-override',
|
||||
shouldCreateWindow: false,
|
||||
shouldDestroyCurrentWindow: true,
|
||||
shouldRefreshVisibleOverlay: false,
|
||||
createWindowTiming: 'none',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('linux overlay mode sync replaces visible window when fullscreen mode changes', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: 'managed',
|
||||
fullscreen: true,
|
||||
hasLiveWindow: true,
|
||||
visibleOverlayVisible: true,
|
||||
}),
|
||||
{
|
||||
nextMode: 'fullscreen-override',
|
||||
shouldCreateWindow: true,
|
||||
shouldDestroyCurrentWindow: true,
|
||||
shouldRefreshVisibleOverlay: true,
|
||||
createWindowTiming: 'after-current-destroyed',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('linux overlay mode sync creates correct visible window when none exists', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: 'fullscreen-override',
|
||||
fullscreen: true,
|
||||
hasLiveWindow: false,
|
||||
visibleOverlayVisible: true,
|
||||
}),
|
||||
{
|
||||
nextMode: 'fullscreen-override',
|
||||
shouldCreateWindow: true,
|
||||
shouldDestroyCurrentWindow: false,
|
||||
shouldRefreshVisibleOverlay: true,
|
||||
createWindowTiming: 'now',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('linux overlay mode sync no-ops when live window already matches mode', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxVisibleOverlayWindowModeAction({
|
||||
currentMode: 'fullscreen-override',
|
||||
fullscreen: true,
|
||||
hasLiveWindow: true,
|
||||
visibleOverlayVisible: true,
|
||||
}),
|
||||
{
|
||||
nextMode: 'fullscreen-override',
|
||||
shouldCreateWindow: false,
|
||||
shouldDestroyCurrentWindow: false,
|
||||
shouldRefreshVisibleOverlay: false,
|
||||
createWindowTiming: 'none',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('linux overlay mode exits fullscreen override when tracked geometry is windowed', () => {
|
||||
assert.equal(
|
||||
shouldExitFullscreenOverrideForTrackedGeometry({
|
||||
currentMode: 'fullscreen-override',
|
||||
trackedFullscreen: true,
|
||||
geometry: { x: 420, y: 90, width: 1280, height: 720 },
|
||||
displayBounds: { x: 0, y: 0, width: 2560, height: 1440 },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
export type LinuxVisibleOverlayWindowMode = 'managed' | 'fullscreen-override';
|
||||
|
||||
type LinuxVisibleOverlayGeometry = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type LinuxVisibleOverlayWindowModeAction = {
|
||||
nextMode: LinuxVisibleOverlayWindowMode;
|
||||
shouldCreateWindow: boolean;
|
||||
shouldDestroyCurrentWindow: boolean;
|
||||
shouldRefreshVisibleOverlay: boolean;
|
||||
createWindowTiming: 'none' | 'now' | 'after-current-destroyed';
|
||||
};
|
||||
|
||||
export function resolveLinuxVisibleOverlayWindowModeAction(options: {
|
||||
currentMode: LinuxVisibleOverlayWindowMode;
|
||||
fullscreen: boolean;
|
||||
hasLiveWindow: boolean;
|
||||
visibleOverlayVisible: boolean;
|
||||
}): LinuxVisibleOverlayWindowModeAction {
|
||||
const nextMode: LinuxVisibleOverlayWindowMode = options.fullscreen
|
||||
? 'fullscreen-override'
|
||||
: 'managed';
|
||||
const modeChanged = options.currentMode !== nextMode;
|
||||
|
||||
if (!options.visibleOverlayVisible) {
|
||||
return {
|
||||
nextMode,
|
||||
shouldCreateWindow: false,
|
||||
shouldDestroyCurrentWindow: options.hasLiveWindow && modeChanged,
|
||||
shouldRefreshVisibleOverlay: false,
|
||||
createWindowTiming: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
if (options.hasLiveWindow && !modeChanged) {
|
||||
return {
|
||||
nextMode,
|
||||
shouldCreateWindow: false,
|
||||
shouldDestroyCurrentWindow: false,
|
||||
shouldRefreshVisibleOverlay: false,
|
||||
createWindowTiming: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nextMode,
|
||||
shouldCreateWindow: true,
|
||||
shouldDestroyCurrentWindow: options.hasLiveWindow,
|
||||
shouldRefreshVisibleOverlay: true,
|
||||
createWindowTiming: options.hasLiveWindow ? 'after-current-destroyed' : 'now',
|
||||
};
|
||||
}
|
||||
|
||||
function geometryCoversDisplayBounds(
|
||||
geometry: LinuxVisibleOverlayGeometry,
|
||||
displayBounds: LinuxVisibleOverlayGeometry,
|
||||
tolerancePx: number,
|
||||
): boolean {
|
||||
const geometryRight = geometry.x + geometry.width;
|
||||
const geometryBottom = geometry.y + geometry.height;
|
||||
const displayRight = displayBounds.x + displayBounds.width;
|
||||
const displayBottom = displayBounds.y + displayBounds.height;
|
||||
|
||||
return (
|
||||
geometry.x <= displayBounds.x + tolerancePx &&
|
||||
geometry.y <= displayBounds.y + tolerancePx &&
|
||||
geometryRight >= displayRight - tolerancePx &&
|
||||
geometryBottom >= displayBottom - tolerancePx
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldExitFullscreenOverrideForTrackedGeometry(options: {
|
||||
currentMode: LinuxVisibleOverlayWindowMode;
|
||||
trackedFullscreen: boolean;
|
||||
geometry: LinuxVisibleOverlayGeometry;
|
||||
displayBounds: LinuxVisibleOverlayGeometry;
|
||||
tolerancePx?: number;
|
||||
}): boolean {
|
||||
if (options.currentMode !== 'fullscreen-override') return false;
|
||||
if (!options.trackedFullscreen) return false;
|
||||
return !geometryCoversDisplayBounds(
|
||||
options.geometry,
|
||||
options.displayBounds,
|
||||
options.tolerancePx ?? 2,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createLinuxX11CursorPointReader,
|
||||
parseXdotoolMouseLocation,
|
||||
} from './linux-x11-cursor-point';
|
||||
|
||||
test('parseXdotoolMouseLocation parses root cursor coordinates', () => {
|
||||
assert.deepEqual(
|
||||
parseXdotoolMouseLocation(`X=1700
|
||||
Y=1050
|
||||
SCREEN=0
|
||||
WINDOW=44040194
|
||||
`),
|
||||
{ x: 1700, y: 1050 },
|
||||
);
|
||||
});
|
||||
|
||||
test('createLinuxX11CursorPointReader returns cached X11 cursor point over stale fallback', async () => {
|
||||
let now = 1000;
|
||||
const pendingCommand: { resolve?: (value: string) => void } = {};
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
const reader = createLinuxX11CursorPointReader({
|
||||
env: { DISPLAY: ':1' },
|
||||
platform: 'linux',
|
||||
now: () => now,
|
||||
runCommand: (command, args) => {
|
||||
calls.push({ command, args });
|
||||
return new Promise((resolve) => {
|
||||
pendingCommand.resolve = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 877, y: 718 }), { x: 877, y: 718 });
|
||||
assert.deepEqual(calls, [{ command: 'xdotool', args: ['getmouselocation', '--shell'] }]);
|
||||
|
||||
assert.ok(pendingCommand.resolve);
|
||||
pendingCommand.resolve(`X=1700
|
||||
Y=1050
|
||||
SCREEN=0
|
||||
WINDOW=44040194
|
||||
`);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
now += 60;
|
||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 877, y: 718 }), { x: 1700, y: 1050 });
|
||||
});
|
||||
|
||||
test('createLinuxX11CursorPointReader does not spawn off X11 Linux', () => {
|
||||
const calls: string[] = [];
|
||||
const reader = createLinuxX11CursorPointReader({
|
||||
env: {},
|
||||
platform: 'linux',
|
||||
runCommand: async (command) => {
|
||||
calls.push(command);
|
||||
return '';
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 5, y: 6 }), { x: 5, y: 6 });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('createLinuxX11CursorPointReader does not spawn for supported native Wayland compositors', () => {
|
||||
const calls: string[] = [];
|
||||
const reader = createLinuxX11CursorPointReader({
|
||||
env: {
|
||||
DISPLAY: ':1',
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
|
||||
},
|
||||
platform: 'linux',
|
||||
runCommand: async (command) => {
|
||||
calls.push(command);
|
||||
return '';
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(reader.getCursorScreenPoint({ x: 7, y: 8 }), { x: 7, y: 8 });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { getLinuxDesktopEnv, isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
|
||||
import type { PointerPoint } from './linux-overlay-pointer-interaction';
|
||||
|
||||
type CommandRunner = (command: string, args: string[]) => Promise<string>;
|
||||
|
||||
const XDOTOOL_CURSOR_ARGS = ['getmouselocation', '--shell'] as const;
|
||||
const CURSOR_POINT_MAX_AGE_MS = 1000;
|
||||
const COMMAND_FAILURE_RETRY_DELAY_MS = 1000;
|
||||
|
||||
function execFileUtf8(command: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, { encoding: 'utf-8' }, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseXdotoolMouseLocation(raw: string): PointerPoint | null {
|
||||
const xMatch = raw.match(/^X=(-?\d+)$/m);
|
||||
const yMatch = raw.match(/^Y=(-?\d+)$/m);
|
||||
if (!xMatch || !yMatch) return null;
|
||||
|
||||
const x = Number.parseInt(xMatch[1]!, 10);
|
||||
const y = Number.parseInt(yMatch[1]!, 10);
|
||||
if (!Number.isInteger(x) || !Number.isInteger(y)) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
export function createLinuxX11CursorPointReader(options?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: () => number;
|
||||
platform?: NodeJS.Platform;
|
||||
runCommand?: CommandRunner;
|
||||
}) {
|
||||
const env = options?.env ?? process.env;
|
||||
const now = options?.now ?? (() => Date.now());
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const runCommand = options?.runCommand ?? execFileUtf8;
|
||||
let latest: { point: PointerPoint; updatedAtMs: number } | null = null;
|
||||
let inFlight = false;
|
||||
let retryAfterMs = 0;
|
||||
|
||||
function isSupported(): boolean {
|
||||
if (platform !== 'linux' || !env.DISPLAY?.trim()) return false;
|
||||
if (getLinuxDesktopEnv(env).hasWayland && isSupportedWaylandCompositor(env)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
const nowMs = now();
|
||||
if (!isSupported() || inFlight || nowMs < retryAfterMs) return;
|
||||
|
||||
inFlight = true;
|
||||
void runCommand('xdotool', [...XDOTOOL_CURSOR_ARGS])
|
||||
.then((raw) => {
|
||||
const point = parseXdotoolMouseLocation(raw);
|
||||
if (!point) {
|
||||
retryAfterMs = now() + COMMAND_FAILURE_RETRY_DELAY_MS;
|
||||
return;
|
||||
}
|
||||
latest = { point, updatedAtMs: now() };
|
||||
retryAfterMs = 0;
|
||||
})
|
||||
.catch(() => {
|
||||
retryAfterMs = now() + COMMAND_FAILURE_RETRY_DELAY_MS;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getCursorScreenPoint(fallback: PointerPoint): PointerPoint {
|
||||
refresh();
|
||||
if (latest && now() - latest.updatedAtMs <= CURSOR_POINT_MAX_AGE_MS) {
|
||||
return latest.point;
|
||||
}
|
||||
return fallback;
|
||||
},
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { restoreMacOSMpvFocusAfterModalClose } from './macos-modal-focus-handoff';
|
||||
|
||||
test('restoreMacOSMpvFocusAfterModalClose focuses mpv, refreshes tracker, then updates visibility on macOS', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await restoreMacOSMpvFocusAfterModalClose({
|
||||
platform: 'darwin',
|
||||
focusMpv: async () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
getWindowTracker: () => ({
|
||||
refreshNow: async () => {
|
||||
calls.push('refresh');
|
||||
},
|
||||
}),
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['focus', 'refresh', 'visibility']);
|
||||
});
|
||||
|
||||
test('restoreMacOSMpvFocusAfterModalClose skips non-macOS platforms', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await restoreMacOSMpvFocusAfterModalClose({
|
||||
platform: 'linux',
|
||||
focusMpv: async () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
getWindowTracker: () => null,
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('restoreMacOSMpvFocusAfterModalClose still updates visibility when tracker refresh fails', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await restoreMacOSMpvFocusAfterModalClose({
|
||||
platform: 'darwin',
|
||||
focusMpv: async () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
getWindowTracker: () => ({
|
||||
refreshNow: async () => {
|
||||
calls.push('refresh');
|
||||
throw new Error('refresh failed');
|
||||
},
|
||||
}),
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
warn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'focus',
|
||||
'refresh',
|
||||
'warn:Failed to refresh macOS mpv focus after modal close',
|
||||
'visibility',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
type RefreshableWindowTracker = {
|
||||
refreshNow: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type MacOSModalFocusHandoffDeps = {
|
||||
platform: NodeJS.Platform;
|
||||
focusMpv: () => Promise<void>;
|
||||
getWindowTracker: () => RefreshableWindowTracker | null;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
};
|
||||
|
||||
export async function restoreMacOSMpvFocusAfterModalClose(
|
||||
deps: MacOSModalFocusHandoffDeps,
|
||||
): Promise<void> {
|
||||
if (deps.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.focusMpv();
|
||||
} catch (error) {
|
||||
deps.warn('Failed to focus mpv after macOS modal close', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.getWindowTracker()?.refreshNow();
|
||||
} catch (error) {
|
||||
deps.warn('Failed to refresh macOS mpv focus after modal close', error);
|
||||
}
|
||||
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { focusMacOSMpvProcess } from './macos-mpv-focus';
|
||||
|
||||
test('focusMacOSMpvProcess fronts the running mpv process with osascript', async () => {
|
||||
const calls: Array<{ command: string; args: string[]; timeout?: number }> = [];
|
||||
|
||||
await focusMacOSMpvProcess({
|
||||
execFile: (command, args, options, callback) => {
|
||||
calls.push({ command, args, timeout: options.timeout });
|
||||
callback(null);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]?.command, '/usr/bin/osascript');
|
||||
assert.equal(calls[0]?.timeout, 2000);
|
||||
assert.deepEqual(calls[0]?.args, [
|
||||
'-e',
|
||||
'tell application "System Events" to set frontmost of the first process whose name is "mpv" to true',
|
||||
]);
|
||||
});
|
||||
|
||||
test('focusMacOSMpvProcess rejects when osascript fails', async () => {
|
||||
await assert.rejects(
|
||||
focusMacOSMpvProcess({
|
||||
execFile: (_command, _args, _options, callback) => {
|
||||
callback(new Error('not allowed'));
|
||||
},
|
||||
}),
|
||||
/not allowed/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { execFile as nodeExecFile } from 'node:child_process';
|
||||
|
||||
const FOCUS_MPV_PROCESS_SCRIPT =
|
||||
'tell application "System Events" to set frontmost of the first process whose name is "mpv" to true';
|
||||
|
||||
type ExecFileForMacOSFocus = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { timeout: number },
|
||||
callback: (error: Error | null) => void,
|
||||
) => void;
|
||||
|
||||
export type MacOSMpvFocusDeps = {
|
||||
execFile?: ExecFileForMacOSFocus;
|
||||
};
|
||||
|
||||
export async function focusMacOSMpvProcess(deps: MacOSMpvFocusDeps = {}): Promise<void> {
|
||||
const execFile: ExecFileForMacOSFocus =
|
||||
deps.execFile ??
|
||||
((command, args, options, callback) => {
|
||||
nodeExecFile(command, args, options, (error) => {
|
||||
callback(error);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile('/usr/bin/osascript', ['-e', FOCUS_MPV_PROCESS_SCRIPT], { timeout: 2000 }, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
createHandleMpvTimePosChangeHandler,
|
||||
} from './mpv-main-event-actions';
|
||||
|
||||
test('subtitle change handler updates state, broadcasts, and forwards', () => {
|
||||
test('subtitle change handler updates state and forwards uncached text without raw broadcast', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
@@ -23,7 +23,22 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => {
|
||||
});
|
||||
|
||||
handler({ text: 'line' });
|
||||
assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line', 'presence']);
|
||||
assert.deepEqual(calls, ['set:line', 'process:line', 'presence']);
|
||||
});
|
||||
|
||||
test('subtitle change handler clears immediately for empty subtitle text', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getImmediateSubtitlePayload: () => null,
|
||||
broadcastSubtitle: (payload) =>
|
||||
calls.push(`broadcast:${payload.text}:${payload.tokens === null ? 'plain' : 'annotated'}`),
|
||||
onSubtitleChange: (text) => calls.push(`process:${text}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ text: '' });
|
||||
assert.deepEqual(calls, ['set:', 'broadcast::plain', 'process:', 'presence']);
|
||||
});
|
||||
|
||||
test('subtitle change handler broadcasts cached annotated payload immediately when available', () => {
|
||||
|
||||
@@ -28,10 +28,12 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
deps.onSubtitleChange(text);
|
||||
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
|
||||
} else {
|
||||
deps.broadcastSubtitle({
|
||||
text,
|
||||
tokens: null,
|
||||
});
|
||||
if (!text.trim()) {
|
||||
deps.broadcastSubtitle({
|
||||
text,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
deps.onSubtitleChange(text);
|
||||
}
|
||||
deps.refreshDiscordPresence();
|
||||
|
||||
@@ -28,6 +28,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
},
|
||||
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
||||
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
||||
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
|
||||
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
|
||||
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
@@ -82,7 +83,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
|
||||
assert.ok(calls.includes('set-sub:line'));
|
||||
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||
assert.ok(calls.includes('broadcast-sub:line'));
|
||||
assert.equal(calls.includes('broadcast-sub:line'), true);
|
||||
assert.ok(calls.includes('subtitle-change:line'));
|
||||
assert.ok(calls.includes('subtitle-track-change'));
|
||||
assert.ok(calls.includes('subtitle-track-list-change'));
|
||||
|
||||
@@ -118,7 +118,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => deps.setCurrentSubText(text),
|
||||
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
|
||||
emitImmediateSubtitle: (payload) => deps.emitImmediateSubtitle?.(payload),
|
||||
emitImmediateSubtitle: deps.emitImmediateSubtitle
|
||||
? (payload) => deps.emitImmediateSubtitle?.(payload)
|
||||
: undefined,
|
||||
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
|
||||
onSubtitleChange: (text) => deps.onSubtitleChange(text),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
|
||||
@@ -75,18 +75,66 @@ test('overlay modal input state restores main window focus on deactivation', ()
|
||||
const calls: string[] = [];
|
||||
const state = createOverlayModalInputState({
|
||||
getModalWindow: () => modalWindow as never,
|
||||
syncOverlayShortcutsForModal: () => {},
|
||||
syncOverlayVisibilityForModal: () => {},
|
||||
syncOverlayShortcutsForModal: (isActive) => {
|
||||
calls.push(`shortcuts:${isActive}`);
|
||||
},
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
restoreMainWindowFocus: () => {
|
||||
calls.push('restore-focus');
|
||||
},
|
||||
});
|
||||
|
||||
state.handleModalInputStateChange(true);
|
||||
assert.deepEqual(calls, []);
|
||||
calls.length = 0;
|
||||
|
||||
state.handleModalInputStateChange(false);
|
||||
assert.deepEqual(calls, ['restore-focus']);
|
||||
assert.deepEqual(calls, ['shortcuts:false', 'visibility', 'restore-focus', 'visibility']);
|
||||
});
|
||||
|
||||
test('overlay modal input state schedules visibility settle burst after focus restore', () => {
|
||||
const modalWindow = createModalWindow();
|
||||
const calls: string[] = [];
|
||||
const scheduled: Array<{ delayMs: number; callback: () => void }> = [];
|
||||
const state = createOverlayModalInputState({
|
||||
getModalWindow: () => modalWindow as never,
|
||||
syncOverlayShortcutsForModal: () => {},
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
restoreMainWindowFocus: () => {
|
||||
calls.push('restore-focus');
|
||||
},
|
||||
schedulePostRestoreVisibilitySync: (callback, delayMs) => {
|
||||
scheduled.push({ callback, delayMs });
|
||||
return scheduled.length as never;
|
||||
},
|
||||
clearPostRestoreVisibilitySync: () => {},
|
||||
});
|
||||
|
||||
state.handleModalInputStateChange(true);
|
||||
calls.length = 0;
|
||||
|
||||
state.handleModalInputStateChange(false);
|
||||
|
||||
assert.deepEqual(
|
||||
scheduled.map((entry) => entry.delayMs),
|
||||
[50, 150, 300, 600, 1000],
|
||||
);
|
||||
for (const entry of scheduled) {
|
||||
entry.callback();
|
||||
}
|
||||
assert.deepEqual(calls, [
|
||||
'visibility',
|
||||
'restore-focus',
|
||||
'visibility',
|
||||
'visibility',
|
||||
'visibility',
|
||||
'visibility',
|
||||
'visibility',
|
||||
'visibility',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay modal input state is idempotent for unchanged state', () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
type VisibilitySyncTimeout = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
|
||||
const POST_RESTORE_VISIBILITY_SYNC_DELAYS_MS = [50, 150, 300, 600, 1000] as const;
|
||||
|
||||
function requestOverlayApplicationFocus(): void {
|
||||
try {
|
||||
const electron = require('electron') as {
|
||||
@@ -25,16 +28,48 @@ export type OverlayModalInputStateDeps = {
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
restoreMainWindowFocus?: () => void;
|
||||
schedulePostRestoreVisibilitySync?: (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
) => VisibilitySyncTimeout;
|
||||
clearPostRestoreVisibilitySync?: (timeout: VisibilitySyncTimeout) => void;
|
||||
};
|
||||
|
||||
export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
let modalInputExclusive = false;
|
||||
let postRestoreVisibilitySyncTimeouts: VisibilitySyncTimeout[] = [];
|
||||
const schedulePostRestoreVisibilitySync =
|
||||
deps.schedulePostRestoreVisibilitySync ?? globalThis.setTimeout;
|
||||
const clearPostRestoreVisibilitySync =
|
||||
deps.clearPostRestoreVisibilitySync ?? globalThis.clearTimeout;
|
||||
|
||||
const clearPostRestoreVisibilitySyncBurst = (): void => {
|
||||
for (const timeout of postRestoreVisibilitySyncTimeouts) {
|
||||
clearPostRestoreVisibilitySync(timeout);
|
||||
}
|
||||
postRestoreVisibilitySyncTimeouts = [];
|
||||
};
|
||||
|
||||
const schedulePostRestoreVisibilitySyncBurst = (): void => {
|
||||
clearPostRestoreVisibilitySyncBurst();
|
||||
for (const delayMs of POST_RESTORE_VISIBILITY_SYNC_DELAYS_MS) {
|
||||
const timeout = schedulePostRestoreVisibilitySync(() => {
|
||||
postRestoreVisibilitySyncTimeouts = postRestoreVisibilitySyncTimeouts.filter(
|
||||
(candidate) => candidate !== timeout,
|
||||
);
|
||||
deps.syncOverlayVisibilityForModal();
|
||||
}, delayMs);
|
||||
(timeout as { unref?: () => void }).unref?.();
|
||||
postRestoreVisibilitySyncTimeouts.push(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalInputStateChange = (isActive: boolean): void => {
|
||||
if (modalInputExclusive === isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPostRestoreVisibilitySyncBurst();
|
||||
modalInputExclusive = isActive;
|
||||
if (isActive) {
|
||||
const modalWindow = deps.getModalWindow();
|
||||
@@ -54,6 +89,10 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
deps.syncOverlayVisibilityForModal();
|
||||
if (!isActive) {
|
||||
deps.restoreMainWindowFocus?.();
|
||||
if (deps.restoreMainWindowFocus) {
|
||||
deps.syncOverlayVisibilityForModal();
|
||||
schedulePostRestoreVisibilitySyncBurst();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
getModalActive: () => deps.getModalActive(),
|
||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||
getNonNativeInputRegionActive: () => deps.getNonNativeInputRegionActive?.() ?? false,
|
||||
getSuspendVisibleOverlay: () => deps.getSuspendVisibleOverlay?.() ?? false,
|
||||
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
|
||||
getWindowTracker: () => deps.getWindowTracker(),
|
||||
@@ -31,6 +32,8 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
isMacOSPlatform: () => deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: () => deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
hideNonNativeOverlayWhenTargetUnfocused: () =>
|
||||
deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false,
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
||||
isOverlayVisible: (kind) => kind === 'visible',
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||
onVisibleWindowFocused: () => calls.push('visible-focus'),
|
||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||
getYomitanSession: () => yomitanSession,
|
||||
});
|
||||
@@ -27,12 +28,17 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
||||
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
|
||||
assert.equal(overlayDeps.getYomitanSession(), yomitanSession);
|
||||
overlayDeps.forwardTabToMpv();
|
||||
overlayDeps.onVisibleWindowFocused?.();
|
||||
|
||||
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
|
||||
getMainWindow: () => null,
|
||||
isWindowDestroyed: () => false,
|
||||
createOverlayWindow: () => ({ id: 'visible' }),
|
||||
setMainWindow: () => calls.push('set-main'),
|
||||
});
|
||||
const mainDeps = buildMainDeps();
|
||||
assert.equal(mainDeps.getMainWindow(), null);
|
||||
assert.equal(mainDeps.isWindowDestroyed({ id: 'visible' }), false);
|
||||
mainDeps.setMainWindow(null);
|
||||
|
||||
const buildModalDeps = createBuildCreateModalWindowMainDepsHandler({
|
||||
@@ -42,5 +48,5 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
||||
const modalDeps = buildModalDeps();
|
||||
modalDeps.setModalWindow(null);
|
||||
|
||||
assert.deepEqual(calls, ['forward-tab', 'set-main', 'set-modal']);
|
||||
assert.deepEqual(calls, ['forward-tab', 'visible-focus', 'set-main', 'set-modal']);
|
||||
});
|
||||
|
||||
@@ -11,9 +11,11 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
) => TWindow;
|
||||
@@ -24,9 +26,11 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
getLinuxX11FullscreenOverlay?: () => boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -38,7 +42,9 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onVisibleWindowFocused: deps.onVisibleWindowFocused,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||
@@ -46,10 +52,14 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
}
|
||||
|
||||
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
|
||||
getMainWindow: () => TWindow | null;
|
||||
isWindowDestroyed: (window: TWindow) => boolean;
|
||||
createOverlayWindow: (kind: 'visible' | 'modal') => TWindow;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getMainWindow: () => deps.getMainWindow(),
|
||||
isWindowDestroyed: (window: TWindow) => deps.isWindowDestroyed(window),
|
||||
createOverlayWindow: deps.createOverlayWindow,
|
||||
setMainWindow: deps.setMainWindow,
|
||||
});
|
||||
|
||||
@@ -18,9 +18,10 @@ test('create overlay window handler forwards options and kind', () => {
|
||||
assert.equal(options.isOverlayVisible('modal'), false);
|
||||
assert.equal(options.yomitanSession, yomitanSession);
|
||||
options.forwardTabToMpv();
|
||||
options.onVisibleWindowFocused?.();
|
||||
options.onRuntimeOptionsChanged();
|
||||
options.setOverlayDebugVisualizationEnabled(true);
|
||||
options.onWindowClosed(kind);
|
||||
options.onWindowClosed(kind, window);
|
||||
return window;
|
||||
},
|
||||
isDev: true,
|
||||
@@ -30,7 +31,9 @@ test('create overlay window handler forwards options and kind', () => {
|
||||
isOverlayVisible: (kind) => kind === 'visible',
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||
onVisibleWindowFocused: () => calls.push('visible-focus'),
|
||||
onWindowClosed: (kind, closedWindow) =>
|
||||
calls.push(`closed:${kind}:${(closedWindow as { id: number }).id}`),
|
||||
getYomitanSession: () => yomitanSession,
|
||||
});
|
||||
|
||||
@@ -38,27 +41,51 @@ test('create overlay window handler forwards options and kind', () => {
|
||||
assert.deepEqual(calls, [
|
||||
'kind:visible',
|
||||
'forward-tab',
|
||||
'visible-focus',
|
||||
'runtime-options',
|
||||
'debug:true',
|
||||
'closed:visible',
|
||||
'closed:visible:1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('create main window handler stores visible window', () => {
|
||||
const calls: string[] = [];
|
||||
const visibleWindow = { id: 'visible' };
|
||||
let mainWindow: typeof visibleWindow | null = null;
|
||||
const createMainWindow = createCreateMainWindowHandler({
|
||||
getMainWindow: () => mainWindow,
|
||||
isWindowDestroyed: () => false,
|
||||
createOverlayWindow: (kind) => {
|
||||
calls.push(`create:${kind}`);
|
||||
return visibleWindow;
|
||||
},
|
||||
setMainWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
|
||||
setMainWindow: (window) => {
|
||||
mainWindow = window;
|
||||
calls.push(`set:${(window as { id: string }).id}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(createMainWindow(), visibleWindow);
|
||||
assert.deepEqual(calls, ['create:visible', 'set:visible']);
|
||||
});
|
||||
|
||||
test('create main window handler reuses an existing live visible window', () => {
|
||||
const calls: string[] = [];
|
||||
const existingWindow = { id: 'existing' };
|
||||
const createMainWindow = createCreateMainWindowHandler({
|
||||
getMainWindow: () => existingWindow,
|
||||
isWindowDestroyed: () => false,
|
||||
createOverlayWindow: (kind) => {
|
||||
calls.push(`create:${kind}`);
|
||||
return { id: 'created' };
|
||||
},
|
||||
setMainWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
|
||||
});
|
||||
|
||||
assert.equal(createMainWindow(), existingWindow);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('create modal window handler stores modal window', () => {
|
||||
const calls: string[] = [];
|
||||
const modalWindow = { id: 'modal' };
|
||||
|
||||
@@ -13,9 +13,11 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
) => TWindow;
|
||||
@@ -26,9 +28,11 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
getLinuxX11FullscreenOverlay?: () => boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
}) {
|
||||
return (kind: OverlayWindowKind): TWindow => {
|
||||
@@ -40,7 +44,10 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
linuxX11FullscreenOverlay:
|
||||
kind === 'visible' ? deps.getLinuxX11FullscreenOverlay?.() : undefined,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onVisibleWindowFocused: deps.onVisibleWindowFocused,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
yomitanSession: deps.getYomitanSession?.() ?? null,
|
||||
@@ -49,10 +56,16 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
}
|
||||
|
||||
export function createCreateMainWindowHandler<TWindow>(deps: {
|
||||
getMainWindow: () => TWindow | null;
|
||||
isWindowDestroyed: (window: TWindow) => boolean;
|
||||
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
}) {
|
||||
return (): TWindow => {
|
||||
const existingWindow = deps.getMainWindow();
|
||||
if (existingWindow && !deps.isWindowDestroyed(existingWindow)) {
|
||||
return existingWindow;
|
||||
}
|
||||
const window = deps.createOverlayWindow('visible');
|
||||
deps.setMainWindow(window);
|
||||
return window;
|
||||
|
||||
@@ -10,8 +10,13 @@ test('overlay window layout main deps builders map callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const visible = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||
getCurrentOverlayWindowBounds: () => {
|
||||
calls.push('visible-current');
|
||||
return null;
|
||||
},
|
||||
setOverlayWindowBounds: () => calls.push('visible'),
|
||||
})();
|
||||
assert.equal(visible.getCurrentOverlayWindowBounds?.(), null);
|
||||
visible.setOverlayWindowBounds({ x: 0, y: 0, width: 1, height: 1 });
|
||||
|
||||
const level = createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||
@@ -42,6 +47,7 @@ test('overlay window layout main deps builders map callbacks', () => {
|
||||
order.ensureOverlayWindowLevel({});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'visible-current',
|
||||
'visible',
|
||||
'ensure-suppressed-check',
|
||||
'ensure',
|
||||
|
||||
@@ -14,6 +14,9 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
|
||||
deps: UpdateVisibleOverlayBoundsMainDeps,
|
||||
) {
|
||||
return (): UpdateVisibleOverlayBoundsMainDeps => ({
|
||||
getCurrentOverlayWindowBounds: () => deps.getCurrentOverlayWindowBounds?.() ?? null,
|
||||
shouldRefreshUnchangedGeometry: (geometry) =>
|
||||
deps.shouldRefreshUnchangedGeometry?.(geometry) ?? false,
|
||||
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
|
||||
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createEnforceOverlayLayerOrderHandler,
|
||||
createEnsureOverlayWindowLevelHandler,
|
||||
createUpdateVisibleOverlayBoundsHandler,
|
||||
hasLiveOverlayWindowBoundsMismatch,
|
||||
} from './overlay-window-layout';
|
||||
|
||||
test('visible bounds handler writes visible layer geometry', () => {
|
||||
@@ -32,6 +33,72 @@ test('visible bounds handler runs follow-up callback after applying geometry', (
|
||||
assert.deepEqual(calls, ['set-bounds', 'after-bounds']);
|
||||
});
|
||||
|
||||
test('visible bounds handler skips unchanged geometry', () => {
|
||||
const calls: string[] = [];
|
||||
const geometry = { x: 0, y: 0, width: 100, height: 50 };
|
||||
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
|
||||
getCurrentOverlayWindowBounds: () => ({ ...geometry }),
|
||||
setOverlayWindowBounds: () => calls.push('set-bounds'),
|
||||
afterSetOverlayWindowBounds: () => calls.push('after-bounds'),
|
||||
});
|
||||
|
||||
handleVisible(geometry);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('visible bounds handler can refresh unchanged geometry for mode reconciliation', () => {
|
||||
const calls: string[] = [];
|
||||
const geometry = { x: 0, y: 0, width: 100, height: 50 };
|
||||
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
|
||||
getCurrentOverlayWindowBounds: () => ({ ...geometry }),
|
||||
shouldRefreshUnchangedGeometry: (nextGeometry) => {
|
||||
assert.deepEqual(nextGeometry, geometry);
|
||||
calls.push('refresh-check');
|
||||
return true;
|
||||
},
|
||||
setOverlayWindowBounds: () => calls.push('set-bounds'),
|
||||
afterSetOverlayWindowBounds: () => calls.push('after-bounds'),
|
||||
});
|
||||
|
||||
handleVisible(geometry);
|
||||
|
||||
assert.deepEqual(calls, ['refresh-check', 'set-bounds', 'after-bounds']);
|
||||
});
|
||||
|
||||
test('live overlay bounds mismatch forces refresh after window manager restore drift', () => {
|
||||
const geometry = { x: 100, y: 80, width: 1280, height: 720 };
|
||||
|
||||
assert.equal(
|
||||
hasLiveOverlayWindowBoundsMismatch(
|
||||
[
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ x: 96, y: 76, width: 1300, height: 740 }),
|
||||
},
|
||||
],
|
||||
geometry,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasLiveOverlayWindowBoundsMismatch(
|
||||
[
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ ...geometry }),
|
||||
},
|
||||
{
|
||||
isDestroyed: () => true,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1, height: 1 }),
|
||||
},
|
||||
],
|
||||
geometry,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('ensure overlay window level handler delegates to core', () => {
|
||||
const calls: string[] = [];
|
||||
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import type { WindowGeometry } from '../../types';
|
||||
|
||||
type OverlayBoundsWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
getBounds: () => WindowGeometry;
|
||||
};
|
||||
|
||||
function sameGeometry(a: WindowGeometry | null | undefined, b: WindowGeometry): boolean {
|
||||
return a?.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
}
|
||||
|
||||
export function hasLiveOverlayWindowBoundsMismatch(
|
||||
windows: Array<OverlayBoundsWindow | null | undefined>,
|
||||
geometry: WindowGeometry,
|
||||
): boolean {
|
||||
return windows.some((window) => {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
return !sameGeometry(window.getBounds(), geometry);
|
||||
});
|
||||
}
|
||||
|
||||
export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
||||
getCurrentOverlayWindowBounds?: () => WindowGeometry | null;
|
||||
shouldRefreshUnchangedGeometry?: (geometry: WindowGeometry) => boolean;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void;
|
||||
}) {
|
||||
return (geometry: WindowGeometry): void => {
|
||||
if (
|
||||
sameGeometry(deps.getCurrentOverlayWindowBounds?.(), geometry) &&
|
||||
deps.shouldRefreshUnchangedGeometry?.(geometry) !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deps.setOverlayWindowBounds(geometry);
|
||||
deps.afterSetOverlayWindowBounds?.(geometry);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,8 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
|
||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||
getYomitanSession: () => yomitanSession,
|
||||
},
|
||||
getMainWindow: () => mainWindow,
|
||||
isWindowDestroyed: () => false,
|
||||
setMainWindow: (window) => {
|
||||
mainWindow = window;
|
||||
},
|
||||
|
||||
@@ -15,6 +15,8 @@ type CreateOverlayWindowMainDeps<TWindow> = Parameters<
|
||||
|
||||
export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
|
||||
createOverlayWindowDeps: CreateOverlayWindowMainDeps<TWindow>;
|
||||
getMainWindow: () => TWindow | null;
|
||||
isWindowDestroyed: (window: TWindow) => boolean;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
setModalWindow: (window: TWindow | null) => void;
|
||||
}) {
|
||||
@@ -23,6 +25,8 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
|
||||
);
|
||||
const createMainWindow = createCreateMainWindowHandler<TWindow>(
|
||||
createBuildCreateMainWindowMainDepsHandler<TWindow>({
|
||||
getMainWindow: () => deps.getMainWindow(),
|
||||
isWindowDestroyed: (window) => deps.isWindowDestroyed(window),
|
||||
createOverlayWindow: (kind) => createOverlayWindow(kind),
|
||||
setMainWindow: (window) => deps.setMainWindow(window),
|
||||
})(),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveFreshPlaybackPaused } from './playback-paused-state';
|
||||
|
||||
test('resolveFreshPlaybackPaused prefers the live mpv pause property over cached state', async () => {
|
||||
const paused = await resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => false,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? true : null),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(paused, true);
|
||||
});
|
||||
|
||||
test('resolveFreshPlaybackPaused trusts cached paused state without probing mpv', async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
const paused = await resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => true,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => {
|
||||
requestCount += 1;
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(paused, true);
|
||||
assert.equal(requestCount, 0);
|
||||
});
|
||||
|
||||
test('resolveFreshPlaybackPaused normalizes mpv pause property strings and numbers', async () => {
|
||||
const values: Array<[unknown, boolean]> = [
|
||||
['yes', true],
|
||||
['no', false],
|
||||
['0', false],
|
||||
[1, true],
|
||||
[0, false],
|
||||
];
|
||||
|
||||
for (const [value, expected] of values) {
|
||||
const paused = await resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => null,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => value,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(paused, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveFreshPlaybackPaused falls back to cached state when mpv is unavailable', async () => {
|
||||
assert.equal(
|
||||
await resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => true,
|
||||
getMpvClient: () => null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveFreshPlaybackPaused treats cached playing state as unknown when live state is unavailable', async () => {
|
||||
assert.equal(
|
||||
await resolveFreshPlaybackPaused({
|
||||
getCachedPlaybackPaused: () => false,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => {
|
||||
throw new Error('socket closed');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
type PlaybackPausedMpvClient = {
|
||||
connected?: boolean;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function coercePlaybackPaused(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'no' || normalized === 'false' || normalized === '0') return false;
|
||||
if (normalized === 'yes' || normalized === 'true' || normalized === '1') return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveFreshPlaybackPaused(deps: {
|
||||
getCachedPlaybackPaused: () => boolean | null;
|
||||
getMpvClient: () => PlaybackPausedMpvClient | null;
|
||||
}): Promise<boolean | null> {
|
||||
const cachedPaused = deps.getCachedPlaybackPaused();
|
||||
if (cachedPaused === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const client = deps.getMpvClient();
|
||||
if (client?.connected === true && typeof client.requestProperty === 'function') {
|
||||
try {
|
||||
const livePaused = coercePlaybackPaused(await client.requestProperty('pause'));
|
||||
if (livePaused !== null) {
|
||||
return livePaused;
|
||||
}
|
||||
} catch {
|
||||
// Avoid trusting a stale cached "playing" state for hover auto-pause.
|
||||
}
|
||||
}
|
||||
|
||||
return cachedPaused === false ? null : cachedPaused;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order';
|
||||
import {
|
||||
hasLiveSeparateWindow,
|
||||
shouldSuppressVisibleOverlayRaiseForSeparateWindow,
|
||||
} from './settings-window-z-order';
|
||||
|
||||
test('separate settings windows suppress visible overlay restacking', () => {
|
||||
const mainWindow = { id: 'overlay', isDestroyed: () => false };
|
||||
@@ -38,3 +41,20 @@ test('separate settings windows do not suppress unrelated or closed overlay work
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('live separate window detection ignores hidden and destroyed windows', () => {
|
||||
assert.equal(
|
||||
hasLiveSeparateWindow([
|
||||
{ isDestroyed: () => false, isVisible: () => false },
|
||||
{ isDestroyed: () => true, isVisible: () => true },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hasLiveSeparateWindow([
|
||||
{ isDestroyed: () => false, isVisible: () => false },
|
||||
{ isDestroyed: () => false, isVisible: () => true },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
type SeparateWindowLike = {
|
||||
isDestroyed(): boolean;
|
||||
isVisible?: () => boolean;
|
||||
};
|
||||
|
||||
function hasLiveSeparateWindow(windows: Array<SeparateWindowLike | null | undefined>): boolean {
|
||||
return windows.some((window) => Boolean(window && !window.isDestroyed()));
|
||||
export function hasLiveSeparateWindow(
|
||||
windows: Array<SeparateWindowLike | null | undefined>,
|
||||
): boolean {
|
||||
return windows.some(
|
||||
(window) =>
|
||||
Boolean(window && !window.isDestroyed()) &&
|
||||
(typeof window?.isVisible !== 'function' || window.isVisible()),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldSuppressVisibleOverlayRaiseForSeparateWindow(options: {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isVisibleOverlayAutoplayTargetReady } from './visible-overlay-autoplay-readiness';
|
||||
import type { OverlayContentMeasurement } from '../../types';
|
||||
|
||||
const visibleMeasurement = (
|
||||
measuredAtMs: number,
|
||||
rect = { x: 100, y: 800, width: 500, height: 90 },
|
||||
): OverlayContentMeasurement => ({
|
||||
layer: 'visible',
|
||||
measuredAtMs,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: rect,
|
||||
interactiveRects: [rect],
|
||||
});
|
||||
|
||||
test('visible overlay autoplay target waits for a fresh interactive subtitle measurement', () => {
|
||||
let measurement: OverlayContentMeasurement | null = null;
|
||||
const deps = {
|
||||
getVisibleOverlayVisible: () => true,
|
||||
isOverlayWindowReady: () => true,
|
||||
getLatestVisibleMeasurement: () => measurement,
|
||||
};
|
||||
const signal = {
|
||||
mediaPath: '/media/video.mkv',
|
||||
payload: { text: '字幕', tokens: null },
|
||||
requestedAtMs: 1_000,
|
||||
};
|
||||
|
||||
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), false);
|
||||
|
||||
measurement = visibleMeasurement(999);
|
||||
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), false);
|
||||
|
||||
measurement = visibleMeasurement(1_000, { x: 100, y: 800, width: 0, height: 90 });
|
||||
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), false);
|
||||
|
||||
measurement = visibleMeasurement(1_001);
|
||||
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), true);
|
||||
});
|
||||
|
||||
test('visible overlay autoplay target falls back when interactive rects have no area', () => {
|
||||
const ready = isVisibleOverlayAutoplayTargetReady(
|
||||
{
|
||||
getVisibleOverlayVisible: () => true,
|
||||
isOverlayWindowReady: () => true,
|
||||
getLatestVisibleMeasurement: () => ({
|
||||
layer: 'visible',
|
||||
measuredAtMs: 2_000,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: { x: 100, y: 800, width: 500, height: 90 },
|
||||
interactiveRects: [{ x: 100, y: 800, width: 0, height: 90 }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
mediaPath: '/media/video.mkv',
|
||||
payload: { text: '字幕', tokens: null },
|
||||
requestedAtMs: 1_000,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('visible overlay autoplay target rejects synthetic warmup readiness', () => {
|
||||
const ready = isVisibleOverlayAutoplayTargetReady(
|
||||
{
|
||||
getVisibleOverlayVisible: () => true,
|
||||
isOverlayWindowReady: () => true,
|
||||
getLatestVisibleMeasurement: () => visibleMeasurement(2_000),
|
||||
},
|
||||
{
|
||||
mediaPath: '/media/video.mkv',
|
||||
payload: { text: '__warm__', tokens: null },
|
||||
requestedAtMs: 1_000,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(ready, false);
|
||||
});
|
||||
|
||||
test('visible overlay autoplay target bypasses measurement when visible overlay is hidden', () => {
|
||||
const ready = isVisibleOverlayAutoplayTargetReady(
|
||||
{
|
||||
getVisibleOverlayVisible: () => false,
|
||||
isOverlayWindowReady: () => false,
|
||||
getLatestVisibleMeasurement: () => null,
|
||||
},
|
||||
{
|
||||
mediaPath: '/media/video.mkv',
|
||||
payload: { text: '__warm__', tokens: null },
|
||||
requestedAtMs: 1_000,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OverlayContentMeasurement, OverlayContentRect } from '../../types';
|
||||
import type { AutoplayReadySignal } from './autoplay-ready-gate';
|
||||
|
||||
export type VisibleOverlayAutoplayReadinessDeps = {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
isOverlayWindowReady: () => boolean;
|
||||
getLatestVisibleMeasurement: () => OverlayContentMeasurement | null;
|
||||
};
|
||||
|
||||
function hasArea(rect: OverlayContentRect): boolean {
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function hasMeasuredInteractiveContent(measurement: OverlayContentMeasurement): boolean {
|
||||
const rects =
|
||||
Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.some(hasArea)
|
||||
? measurement.interactiveRects
|
||||
: measurement.contentRect
|
||||
? [measurement.contentRect]
|
||||
: [];
|
||||
|
||||
return rects.some(hasArea);
|
||||
}
|
||||
|
||||
export function isVisibleOverlayAutoplayTargetReady(
|
||||
deps: VisibleOverlayAutoplayReadinessDeps,
|
||||
signal: AutoplayReadySignal,
|
||||
): boolean {
|
||||
if (!deps.getVisibleOverlayVisible()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const subtitleText = signal.payload.text.trim();
|
||||
if (!subtitleText || subtitleText === '__warm__') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!deps.isOverlayWindowReady()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const measurement = deps.getLatestVisibleMeasurement();
|
||||
if (!measurement || measurement.measuredAtMs < signal.requestedAtMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasMeasuredInteractiveContent(measurement);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user