mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-12 03:13:39 -07:00
Compare commits
15 Commits
v0.16.0
...
split-main
| Author | SHA1 | Date | |
|---|---|---|---|
|
71d106b417
|
|||
|
05ac3a0382
|
|||
|
2c5a803839
|
|||
|
572bdd1cf7
|
|||
|
b9fe555b94
|
|||
|
8f362063dd
|
|||
|
eb1af727bb
|
|||
|
1fc83a842d
|
|||
|
a4edf53d21
|
|||
|
1a3944aa4f
|
|||
|
2d1b6cb78e
|
|||
|
0ef95cde09
|
|||
| 94a65416ae | |||
| 0a384a22c9 | |||
|
b3b45521b6
|
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- Updated default overlay subtitle delay/step bindings to match mpv: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show subtitle delay on the OSD. Removed the old SubMiner-only adjacent-cue delay action.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: release
|
||||
|
||||
- Kept the GitHub release `What's Changed` and `New Contributors` attribution sections when CI regenerates release notes from the committed changelog.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: runtime
|
||||
|
||||
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Fixed manual AniList linking from the stats anime page so automatic searches drop the generated `Season N` suffix and search only the anime title.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: updates
|
||||
|
||||
- New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||
+31
-5
@@ -172,7 +172,7 @@
|
||||
"updates": {
|
||||
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
||||
"notificationType": "both", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
|
||||
}, // Automatic update check behavior.
|
||||
|
||||
@@ -290,15 +290,41 @@
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketRight", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-next-line"
|
||||
"sub-step",
|
||||
-1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketLeft", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowRight", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-prev-line"
|
||||
"sub-step",
|
||||
1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
-0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyX", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
|
||||
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
|
||||
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
|
||||
- **Update Notification Default**: New installs default `notificationType` to `both` so update alerts appear in both overlay and system notifications.
|
||||
- **Update Notification Default**: New installs default `notificationType` to `overlay`, while `both` remains available for overlay + system notifications.
|
||||
|
||||
**Fixed**
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ Configure automatic update checks and update notifications:
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"checkIntervalHours": 24,
|
||||
"notificationType": "both",
|
||||
"notificationType": "overlay",
|
||||
"channel": "stable"
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@ Configure automatic update checks and update notifications:
|
||||
| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
|
||||
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
|
||||
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"both"`, which means overlay + system. |
|
||||
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"overlay"`. `"both"` means overlay + system. |
|
||||
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
|
||||
|
||||
When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow.
|
||||
@@ -572,7 +572,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
**Default keybindings:**
|
||||
|
||||
| Key | Command | Description |
|
||||
| -------------------- | ----------------------------- | --------------------------------------- |
|
||||
| ----------------------- | ----------------------------- | --------------------------------------- |
|
||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
|
||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||
@@ -585,8 +585,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
||||
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
|
||||
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
|
||||
| `Ctrl+Shift+ArrowLeft` | `["sub-step", -1]` | Shift subtitle delay to previous cue |
|
||||
| `Ctrl+Shift+ArrowRight` | `["sub-step", 1]` | Shift subtitle delay to next cue |
|
||||
| `KeyZ` | `["add", "sub-delay", -0.1]` | Shift subtitles 100 ms earlier |
|
||||
| `Shift+KeyZ` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
|
||||
| `KeyX` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
|
||||
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||
@@ -616,11 +619,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
{ "key": "Space", "command": null }
|
||||
```
|
||||
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||
|
||||
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
||||
|
||||
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) and subtitle delay commands (`sub-delay`), SubMiner also shows an mpv OSD notification after the command runs.
|
||||
Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notification after the command runs. Subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) show playback feedback through the configured notification surface.
|
||||
|
||||
**See `config.example.jsonc`** for more keybinding examples and configuration options.
|
||||
|
||||
@@ -656,7 +659,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
||||
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||
@@ -975,7 +978,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
"updates": {
|
||||
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
||||
"notificationType": "both", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
|
||||
}, // Automatic update check behavior.
|
||||
|
||||
@@ -290,15 +290,41 @@
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketRight", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-next-line"
|
||||
"sub-step",
|
||||
-1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketLeft", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowRight", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-prev-line"
|
||||
"sub-step",
|
||||
1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
-0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyX", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
|
||||
@@ -44,7 +44,7 @@ The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcu
|
||||
These control playback and subtitle display. They require overlay window focus.
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| `Space` | Toggle mpv pause |
|
||||
| `F` | Toggle fullscreen |
|
||||
| `V` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
@@ -57,8 +57,11 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `ArrowDown` | Seek backward 60 seconds |
|
||||
| `Shift+H` | Jump to previous subtitle |
|
||||
| `Shift+L` | Jump to next subtitle |
|
||||
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
|
||||
| `Shift+]` | Shift subtitle delay to next subtitle cue |
|
||||
| `Ctrl+Shift+Left` | Shift subtitle delay to previous subtitle cue |
|
||||
| `Ctrl+Shift+Right` | Shift subtitle delay to next subtitle cue |
|
||||
| `z` | Shift subtitles 100 ms earlier |
|
||||
| `Shift+Z` | Delay subtitles by 100 ms |
|
||||
| `x` | Delay subtitles by 100 ms |
|
||||
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
|
||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
||||
| `Q` | Quit mpv |
|
||||
@@ -67,7 +70,7 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
||||
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
|
||||
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
|
||||
|
||||
@@ -76,7 +79,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
## Subtitle & Feature Shortcuts
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | -------------------------------------------------------- | ----------------------------------------------- |
|
||||
| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
|
||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||
@@ -109,7 +112,7 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
|
||||
When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key within 1 second.
|
||||
|
||||
| Chord | Action |
|
||||
| ----- | -------------------------------------- |
|
||||
| ----- | ---------------------------------------------------------- |
|
||||
| `y-y` | Open SubMiner menu (OSD) |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ Notes:
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## What’s Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
||||
|
||||
@@ -61,7 +61,7 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
|
||||
|
||||
#### Subtitle File Parsing
|
||||
|
||||
A new cue parser that extracts both timing and text content from subtitle files. The existing `parseSrtOrVttStartTimes` in `subtitle-delay-shift.ts` only extracts timing; this needs a companion that also extracts the dialogue text.
|
||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||
|
||||
**Parsed cue structure:**
|
||||
```typescript
|
||||
|
||||
@@ -270,10 +270,6 @@ function M.create(ctx)
|
||||
return { "--replay-current-subtitle" }
|
||||
elseif action_id == "playNextSubtitle" then
|
||||
return { "--play-next-subtitle" }
|
||||
elseif action_id == "shiftSubDelayPrevLine" then
|
||||
return { "--shift-sub-delay-prev-line" }
|
||||
elseif action_id == "shiftSubDelayNextLine" then
|
||||
return { "--shift-sub-delay-next-line" }
|
||||
elseif action_id == "cycleRuntimeOption" then
|
||||
local runtime_option_id = payload and payload.runtimeOptionId or nil
|
||||
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
|
||||
@@ -350,6 +346,16 @@ function M.create(ctx)
|
||||
invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs)
|
||||
end
|
||||
|
||||
local function is_supported_binding(binding)
|
||||
if binding.actionType == "mpv-command" then
|
||||
return type(binding.command) == "table" and binding.command[1] ~= nil
|
||||
end
|
||||
if binding.actionType == "session-action" then
|
||||
return build_cli_args(binding.actionId, binding.payload, binding.cliArgs) ~= nil
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function load_artifact()
|
||||
local artifact_path = environment.resolve_session_bindings_artifact_path()
|
||||
local raw = read_file(artifact_path)
|
||||
@@ -385,6 +391,13 @@ function M.create(ctx)
|
||||
local generation = state.session_binding_generation
|
||||
|
||||
for index, binding in ipairs(artifact.bindings) do
|
||||
if not is_supported_binding(binding) then
|
||||
subminer_log(
|
||||
"warn",
|
||||
"session-bindings",
|
||||
"Skipped unsupported session binding from artifact"
|
||||
)
|
||||
else
|
||||
local key_names = key_spec_to_mpv_bindings(binding.key)
|
||||
if key_names then
|
||||
for key_index, key_name in ipairs(key_names) do
|
||||
@@ -407,6 +420,7 @@ function M.create(ctx)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
remove_binding_names(previous_binding_names)
|
||||
state.session_binding_names = next_binding_names
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
## Highlights
|
||||
### Breaking Changes
|
||||
- **Notification Type `both`**: This setting now routes to overlay + system notifications instead of mpv OSD + system.
|
||||
- Set `notificationType` to `osd-system` in `config.jsonc` to keep the previous OSD + system behavior.
|
||||
- `osd` and `osd-system` remain valid config-file values but no longer appear as options in the Settings UI.
|
||||
|
||||
### Added
|
||||
- **Overlay Notifications**: A new in-app notification stack replaces bare OSD text for most alerts, using Catppuccin Macchiato styling with 3-second auto-dismiss.
|
||||
- Position via `notifications.overlayPosition` (top-left, top-center, or top-right; default top-right). Startup, mining, sync, and error alerts queue for the overlay instead of falling back to raw OSD.
|
||||
- Mined-card notifications include card thumbnails and an **Open in Anki** button; update-available notifications include a one-click **Update** button.
|
||||
|
||||
- **Notification History Panel**: A slide-in panel logging every notification from the current session, toggled with `Ctrl/Cmd+N` (configurable via `shortcuts.toggleNotificationHistory`).
|
||||
- Works whether the overlay or mpv has focus; slides in from the same edge as the notification stack.
|
||||
- Entries retain thumbnails and action buttons (Open in Anki, etc.) and can be removed individually or cleared all at once.
|
||||
|
||||
- **Stats Search**: A new Search tab for real-time subtitle sentence search across your library.
|
||||
- Matches by headword with media context; mine directly to sentence cards, word cards, or audio cards.
|
||||
- Sentence cards are queued before slow media generation finishes, so the card lands in Anki quickly with audio filled in later.
|
||||
|
||||
### Changed
|
||||
- **AniSkip**: Intro detection now runs in the SubMiner app rather than the mpv plugin.
|
||||
- Covers all files in the mpv session including playlist advances; the plugin no longer makes any network calls.
|
||||
- `mpv.aniskipEnabled` and `mpv.aniskipButtonKey` hot-reload without restarting playback. Requires SubMiner to be connected to mpv — plugin-only sessions no longer fetch skip windows.
|
||||
|
||||
- **Library**: Local and Jellyfin entries are now split by season using folder structure first, filename parsing as fallback.
|
||||
- Existing combined-series stats rows are automatically migrated to season-specific entries on startup.
|
||||
- Anime detail and cover art refresh immediately after manually changing an AniList entry.
|
||||
|
||||
- **Stats — Vocabulary Review**: Hide Known/Hide Kana filters are remembered across sessions; Related Seen Words now matches on shared readings or kanji; duplicate-collapsed exclusions cover all token variants.
|
||||
|
||||
- **Stats — Trends**: Reorganized into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections; disambiguated per-period vs. cumulative charts; added Words/Min and Cards/Hour efficiency charts.
|
||||
|
||||
- **Stats — Library Browsing**: Remembers card size between sessions; retries stored cover art preserving PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; session deletes show progress and refresh faster.
|
||||
|
||||
- **Stats Mining**: Several reliability improvements when mining from Search and vocabulary examples.
|
||||
- Empty `ankiConnect.deck` falls back to Yomitan's configured mining deck; secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
|
||||
- Invalid stored timings and out-of-order subtitle pairs are skipped before FFmpeg runs; partial media failures are shown inline rather than silently dropped.
|
||||
|
||||
### Fixed
|
||||
- **AniList**: Entries are now marked completed when a post-watch sync reaches the final known episode of the season.
|
||||
- **AniSkip**: Fixed intro markers disappearing after same-media mpv reloads; fixed detection for intros starting at 0 seconds and common release-group filenames.
|
||||
- **Jellyfin**: Session restarts after setup login so the websocket reconnects with fresh credentials; session stops on logout.
|
||||
- **Anki — Sentence Cards**: Generated audio is written only to the configured sentence audio field and no longer also fills the expression audio field.
|
||||
- **Stats Mining**: Word audio uses configured Yomitan sources; English subtitle text is no longer written to word cards; sentence clips correctly update the SentenceAudio field.
|
||||
- **Overlay Startup**: Subtitle bars are hoverable and clickable as soon as the first subtitle line appears; Linux overlay input is primed from the first measured surface so first-line subtitles and startup notifications are immediately clickable; an OSD spinner now shows from mpv connect through to content-ready.
|
||||
- **Startup Autoplay**: SubMiner now releases playback after tokenization and overlay content are ready even when playback begins before the first subtitle line appears.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Release notes now credit contributors with a What's Changed list and a New Contributors section, resolved from changelog fragments via git and the GitHub API.
|
||||
- Updated `make deps` so a fresh source checkout initializes submodules before installing root, stats, and texthooker-ui dependencies.
|
||||
- Changed PR changelog guidance to preserve multiple fragments for genuinely separate outcomes and direct contributors to consolidate same-PR churn before merging.
|
||||
|
||||
</details>
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
|
||||
## What’s Changed
|
||||
|
||||
- feat(notifications): add overlay notifications with position config by @ksyasuda in #110
|
||||
- feat(stats): speed up session maintenance and improve stats UI by @ksyasuda in #111
|
||||
- [codex] Restart Jellyfin remote session after setup login by @bee-san in #112
|
||||
- docs(changelog): require reconciled fragments, not just new ones by @ksyasuda in #113
|
||||
- feat(release): add contributor attribution to release notes by @ksyasuda in #114
|
||||
- fix(anilist): mark entry completed when final episode is reached by @ksyasuda in #115
|
||||
- feat(aniskip): move intro detection from mpv plugin to app runtime by @ksyasuda in #117
|
||||
- fix(anki): write sentence card audio only to sentence audio field by @ksyasuda in #118
|
||||
@@ -1122,13 +1122,22 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(releaseNotes, /## What’s Changed\n\n/);
|
||||
assert.match(releaseNotes, /## What's Changed\n\n/);
|
||||
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
|
||||
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
|
||||
"What's Changed should follow Highlights",
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
|
||||
'contributor attribution should appear before Installation',
|
||||
);
|
||||
assert.doesNotMatch(releaseNotes, /## What’s Changed/);
|
||||
assert.doesNotMatch(
|
||||
releaseNotes,
|
||||
/ksyasuda made their first contribution/,
|
||||
@@ -1137,13 +1146,96 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
||||
|
||||
// Attribution is a release-notes concern only; the CHANGELOG stays clean.
|
||||
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
|
||||
assert.doesNotMatch(changelog, /What’s Changed/);
|
||||
assert.doesNotMatch(changelog, /What's Changed|What’s Changed/);
|
||||
assert.doesNotMatch(changelog, /New Contributors/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeReleaseNotesForVersion preserves committed contributor attribution before installation', async () => {
|
||||
const { writeReleaseNotesForVersion } = await loadModule();
|
||||
const workspace = createWorkspace('release-notes-preserve-attribution');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
const existingChangelog = [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## v0.8.0 (2026-04-17)',
|
||||
'### Added',
|
||||
'- Polished: released feature.',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Internal changes</summary>',
|
||||
'',
|
||||
'### Internal',
|
||||
'- Polished: internal release note.',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
].join('\n');
|
||||
const committedReleaseNotes = [
|
||||
'## Highlights',
|
||||
'### Added',
|
||||
'- Old generated body.',
|
||||
'',
|
||||
'## Installation',
|
||||
'',
|
||||
'See the README and docs/installation guide for full setup steps.',
|
||||
'',
|
||||
'## Assets',
|
||||
'',
|
||||
'- Linux: `SubMiner.AppImage`',
|
||||
'',
|
||||
'## What’s Changed',
|
||||
'',
|
||||
'- feat(release): add contributor attribution by @ksyasuda in #114',
|
||||
'',
|
||||
'## New Contributors',
|
||||
'',
|
||||
'- @bee-san made their first contribution in #112',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
committedReleaseNotes,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const outputPath = writeReleaseNotesForVersion({
|
||||
cwd: projectRoot,
|
||||
version: '0.8.0',
|
||||
});
|
||||
const releaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||
|
||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: released feature\./);
|
||||
assert.doesNotMatch(releaseNotes, /<details>/);
|
||||
assert.doesNotMatch(releaseNotes, /### Internal/);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## What's Changed\n\n- feat\(release\): add contributor attribution by @ksyasuda in #114/,
|
||||
);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
|
||||
"What's Changed should follow Highlights",
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
|
||||
'New Contributors should appear before Installation',
|
||||
);
|
||||
assert.doesNotMatch(releaseNotes, /## What’s Changed/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('reuse-existing-section');
|
||||
|
||||
@@ -433,12 +433,45 @@ function resolveContributionsForFragments(
|
||||
);
|
||||
}
|
||||
|
||||
function isWhatsChangedHeading(line: string): boolean {
|
||||
return line === "## What's Changed" || line === '## What’s Changed';
|
||||
}
|
||||
|
||||
function extractContributorSections(releaseNotes: string): string[] {
|
||||
const lines = releaseNotes.split(/\r?\n/);
|
||||
const start = lines.findIndex(isWhatsChangedHeading);
|
||||
if (start === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let end = lines.length;
|
||||
for (let index = start + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index]!;
|
||||
if (line.startsWith('## ') && !isWhatsChangedHeading(line) && line !== '## New Contributors') {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const block = lines.slice(start, end);
|
||||
while (block.length > 0 && block[block.length - 1] === '') {
|
||||
block.pop();
|
||||
}
|
||||
if (block.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
block[0] = "## What's Changed";
|
||||
block.push('');
|
||||
return block;
|
||||
}
|
||||
|
||||
function renderContributorsSections(contributions: Contribution[]): string[] {
|
||||
if (contributions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines: string[] = ['## What’s Changed', ''];
|
||||
const lines: string[] = ["## What's Changed", ''];
|
||||
for (const contribution of contributions) {
|
||||
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
|
||||
}
|
||||
@@ -635,14 +668,18 @@ function renderReleaseNotes(
|
||||
options?: {
|
||||
disclaimer?: string;
|
||||
contributions?: Contribution[];
|
||||
contributorSections?: string[];
|
||||
},
|
||||
): string {
|
||||
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
||||
const contributorSections =
|
||||
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
|
||||
return [
|
||||
...prefix,
|
||||
'## Highlights',
|
||||
changes,
|
||||
'',
|
||||
...contributorSections,
|
||||
'## Installation',
|
||||
'',
|
||||
'See the README and docs/installation guide for full setup steps.',
|
||||
@@ -656,7 +693,6 @@ function renderReleaseNotes(
|
||||
'',
|
||||
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
|
||||
'',
|
||||
...renderContributorsSections(options?.contributions ?? []),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -668,6 +704,7 @@ function writeReleaseNotesFile(
|
||||
disclaimer?: string;
|
||||
outputPath?: string;
|
||||
contributions?: Contribution[];
|
||||
contributorSections?: string[];
|
||||
},
|
||||
): string {
|
||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||
@@ -960,6 +997,7 @@ export function generateDocsChangelog(options?: Pick<ChangelogOptions, 'cwd' | '
|
||||
|
||||
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
|
||||
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
|
||||
const version = resolveVersion(options ?? {});
|
||||
const changelogPath = path.join(cwd, 'CHANGELOG.md');
|
||||
@@ -970,7 +1008,14 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
|
||||
throw new Error(`Missing CHANGELOG section for v${version}.`);
|
||||
}
|
||||
|
||||
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps);
|
||||
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
|
||||
const contributorSections = existsSync(releaseNotesPath)
|
||||
? extractContributorSections(readFileSync(releaseNotesPath, 'utf8'))
|
||||
: [];
|
||||
|
||||
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps, {
|
||||
contributorSections,
|
||||
});
|
||||
}
|
||||
|
||||
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||
|
||||
@@ -165,6 +165,46 @@ local ctx = {
|
||||
actionType = "mpv-command",
|
||||
command = { "sub-seek", 1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "ArrowLeft",
|
||||
modifiers = { "ctrl", "shift" },
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "sub-step", -1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "ArrowRight",
|
||||
modifiers = { "ctrl", "shift" },
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "sub-step", 1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyZ",
|
||||
modifiers = {},
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "add", "sub-delay", -0.1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyZ",
|
||||
modifiers = { "shift" },
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "add", "sub-delay", 0.1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyX",
|
||||
modifiers = {},
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "add", "sub-delay", 0.1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "BracketRight",
|
||||
@@ -323,6 +363,11 @@ local expected_mpv_bindings = {
|
||||
{ keys = "DOWN", command = { "seek", -60 } },
|
||||
{ keys = "H", command = { "sub-seek", -1 } },
|
||||
{ keys = "L", command = { "sub-seek", 1 } },
|
||||
{ keys = "Ctrl+Shift+LEFT", command = { "sub-step", -1 } },
|
||||
{ keys = "Ctrl+Shift+RIGHT", command = { "sub-step", 1 } },
|
||||
{ keys = "z", command = { "add", "sub-delay", -0.1 } },
|
||||
{ keys = "Z", command = { "add", "sub-delay", 0.1 } },
|
||||
{ keys = "x", command = { "add", "sub-delay", 0.1 } },
|
||||
{ keys = "q", command = { "quit" } },
|
||||
{ keys = "Ctrl+w", command = { "quit" } },
|
||||
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } },
|
||||
@@ -340,10 +385,6 @@ for _, expected in ipairs(expected_mpv_bindings) do
|
||||
end
|
||||
|
||||
local expected_cli_bindings = {
|
||||
{ keys = "Shift+]", flag = "--shift-sub-delay-next-line" },
|
||||
{ keys = "}", flag = "--shift-sub-delay-next-line" },
|
||||
{ keys = "Shift+[", flag = "--shift-sub-delay-prev-line" },
|
||||
{ keys = "{", flag = "--shift-sub-delay-prev-line" },
|
||||
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
|
||||
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
|
||||
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
|
||||
@@ -365,6 +406,9 @@ for _, expected in ipairs(expected_cli_bindings) do
|
||||
assert_true(cli_call[2] == expected.flag, "default session action should pass " .. expected.flag)
|
||||
end
|
||||
|
||||
assert_true(find_binding("Shift+]") == nil, "retired subtitle delay action should not register Shift+]")
|
||||
assert_true(find_binding("Shift+[") == nil, "retired subtitle delay action should not register Shift+[")
|
||||
|
||||
local play_next = find_binding("Ctrl+L")
|
||||
assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form")
|
||||
|
||||
|
||||
@@ -101,8 +101,6 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
'--toggle-primary-subtitle-bar',
|
||||
'--replay-current-subtitle',
|
||||
'--play-next-subtitle',
|
||||
'--shift-sub-delay-prev-line',
|
||||
'--shift-sub-delay-next-line',
|
||||
'--cycle-runtime-option',
|
||||
'anki.autoUpdateNewCards:prev',
|
||||
'--session-action',
|
||||
@@ -120,8 +118,6 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(args.togglePrimarySubtitleBar, true);
|
||||
assert.equal(args.replayCurrentSubtitle, true);
|
||||
assert.equal(args.playNextSubtitle, true);
|
||||
assert.equal(args.shiftSubDelayPrevLine, true);
|
||||
assert.equal(args.shiftSubDelayNextLine, true);
|
||||
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
|
||||
assert.equal(args.cycleRuntimeOptionDirection, -1);
|
||||
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
|
||||
@@ -131,6 +127,13 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs ignores retired subtitle delay shift flags', () => {
|
||||
const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']);
|
||||
|
||||
assert.equal(hasExplicitCommand(args), false);
|
||||
assert.equal(shouldStartApp(args), false);
|
||||
});
|
||||
|
||||
test('parseArgs captures internal playback feedback command', () => {
|
||||
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@ export interface CliArgs {
|
||||
openPlaylistBrowser: boolean;
|
||||
replayCurrentSubtitle: boolean;
|
||||
playNextSubtitle: boolean;
|
||||
shiftSubDelayPrevLine: boolean;
|
||||
shiftSubDelayNextLine: boolean;
|
||||
playbackFeedback?: string;
|
||||
cycleRuntimeOptionId?: string;
|
||||
cycleRuntimeOptionDirection?: 1 | -1;
|
||||
@@ -149,8 +147,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
playbackFeedback: undefined,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
@@ -296,8 +292,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
|
||||
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
|
||||
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
|
||||
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
|
||||
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
|
||||
else if (arg.startsWith('--playback-feedback=')) {
|
||||
const value = arg.slice('--playback-feedback='.length).trim();
|
||||
if (value) args.playbackFeedback = value;
|
||||
@@ -562,8 +556,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
@@ -638,8 +630,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
@@ -705,8 +695,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
@@ -766,8 +754,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
@@ -832,8 +818,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
|
||||
@@ -153,7 +153,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.stats.autoOpenBrowser, false);
|
||||
assert.equal(config.updates.enabled, true);
|
||||
assert.equal(config.updates.checkIntervalHours, 24);
|
||||
assert.equal(config.updates.notificationType, 'both');
|
||||
assert.equal(config.updates.notificationType, 'overlay');
|
||||
assert.equal(config.updates.channel, 'stable');
|
||||
assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||
assert.equal(config.mpv.backend, 'auto');
|
||||
@@ -2814,7 +2814,7 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"notificationType": "both",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/,
|
||||
/"notificationType": "overlay",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
|
||||
@@ -128,7 +128,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
updates: {
|
||||
enabled: true,
|
||||
checkIntervalHours: 24,
|
||||
notificationType: 'both',
|
||||
notificationType: 'overlay',
|
||||
channel: 'stable',
|
||||
},
|
||||
notifications: {
|
||||
|
||||
@@ -234,3 +234,16 @@ test('default keybindings include replay and next subtitle controls', () => {
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
|
||||
});
|
||||
|
||||
test('default keybindings mirror mpv subtitle delay and sub-step keys', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]);
|
||||
assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]);
|
||||
assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]);
|
||||
assert.equal(keybindingMap.has('Shift+BracketLeft'), false);
|
||||
assert.equal(keybindingMap.has('Shift+BracketRight'), false);
|
||||
});
|
||||
|
||||
@@ -55,8 +55,6 @@ export const SPECIAL_COMMANDS = {
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||
} as const;
|
||||
@@ -72,11 +70,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||
{ key: 'ArrowDown', command: ['seek', -60] },
|
||||
{ key: 'Shift+KeyH', command: ['sub-seek', -1] },
|
||||
{ key: 'Shift+KeyL', command: ['sub-seek', 1] },
|
||||
{ key: 'Shift+BracketRight', command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START] },
|
||||
{
|
||||
key: 'Shift+BracketLeft',
|
||||
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
|
||||
},
|
||||
{ key: 'Ctrl+Shift+ArrowLeft', command: ['sub-step', -1] },
|
||||
{ key: 'Ctrl+Shift+ArrowRight', command: ['sub-step', 1] },
|
||||
{ key: 'KeyZ', command: ['add', 'sub-delay', -0.1] },
|
||||
{ key: 'Shift+KeyZ', command: ['add', 'sub-delay', 0.1] },
|
||||
{ key: 'KeyX', command: ['add', 'sub-delay', 0.1] },
|
||||
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
|
||||
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
|
||||
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||
|
||||
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
|
||||
@@ -49,8 +49,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
playbackFeedback: undefined,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
|
||||
@@ -537,18 +537,6 @@ export function handleCliCommand(
|
||||
'playNextSubtitle',
|
||||
'Play next subtitle failed',
|
||||
);
|
||||
} else if (args.shiftSubDelayPrevLine) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'shiftSubDelayPrevLine' },
|
||||
'shiftSubDelayPrevLine',
|
||||
'Shift subtitle delay failed',
|
||||
);
|
||||
} else if (args.shiftSubDelayNextLine) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'shiftSubDelayNextLine' },
|
||||
'shiftSubDelayNextLine',
|
||||
'Shift subtitle delay failed',
|
||||
);
|
||||
} else if (args.playbackFeedback) {
|
||||
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
|
||||
showFeedback(args.playbackFeedback);
|
||||
|
||||
@@ -10,7 +10,6 @@ export {
|
||||
unregisterOverlayShortcutsRuntime,
|
||||
} from './overlay-shortcut';
|
||||
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
|
||||
export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
|
||||
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
|
||||
export {
|
||||
copyCurrentSubtitle,
|
||||
|
||||
@@ -15,8 +15,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||
},
|
||||
@@ -48,9 +46,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
mpvPlayNextSubtitle: () => {
|
||||
calls.push('next');
|
||||
},
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
calls.push(`shift:${direction}`);
|
||||
},
|
||||
mpvSendCommand: (command) => {
|
||||
sentCommands.push(command);
|
||||
},
|
||||
@@ -111,20 +106,29 @@ test('handleMpvCommandFromIpc emits resolved feedback for secondary subtitle tra
|
||||
]);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits feedback for subtitle delay keybinding proxies', async () => {
|
||||
test('handleMpvCommandFromIpc emits mpv OSD for subtitle delay keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Subtitle delay: ${sub-delay}']);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
|
||||
assert.deepEqual(playbackFeedback, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => {
|
||||
test('handleMpvCommandFromIpc emits mpv OSD for subtitle step keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||
handleMpvCommandFromIpc(['sub-step', 1], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['sub-step', 1]]);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
|
||||
assert.deepEqual(playbackFeedback, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc does not dispatch retired subtitle-delay shift tokens', () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
|
||||
assert.deepEqual(calls, ['shift:next']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(sentCommands, [['__sub-delay-next-line']]);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
||||
REPLAY_SUBTITLE: string;
|
||||
PLAY_NEXT_SUBTITLE: string;
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
|
||||
YOUTUBE_PICKER_OPEN: string;
|
||||
PLAYLIST_BROWSER_OPEN: string;
|
||||
};
|
||||
@@ -25,10 +23,10 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showRawMpvOsd?: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
mpvReplaySubtitle: () => void;
|
||||
mpvPlayNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
mpvSendCommand: (command: (string | number)[]) => void;
|
||||
resolveProxyCommandOsd?: (command: (string | number)[]) => Promise<string | null>;
|
||||
isMpvConnected: () => boolean;
|
||||
@@ -44,21 +42,30 @@ const MPV_PROPERTY_COMMANDS = new Set([
|
||||
'multiply',
|
||||
]);
|
||||
|
||||
function resolveProxyCommandOsdTemplate(command: (string | number)[]): string | null {
|
||||
interface ProxyCommandFeedback {
|
||||
template: string;
|
||||
rawMpvOsd: boolean;
|
||||
}
|
||||
|
||||
function resolveProxyCommandOsdTemplate(command: (string | number)[]): ProxyCommandFeedback | null {
|
||||
const operation = typeof command[0] === 'string' ? command[0] : '';
|
||||
if (operation === 'sub-step') {
|
||||
return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
|
||||
}
|
||||
|
||||
const property = typeof command[1] === 'string' ? command[1] : '';
|
||||
if (!MPV_PROPERTY_COMMANDS.has(operation)) return null;
|
||||
if (property === 'sub-pos') {
|
||||
return 'Subtitle position: ${sub-pos}';
|
||||
return { template: 'Subtitle position: ${sub-pos}', rawMpvOsd: false };
|
||||
}
|
||||
if (property === 'sid') {
|
||||
return 'Subtitle track: ${sid}';
|
||||
return { template: 'Subtitle track: ${sid}', rawMpvOsd: false };
|
||||
}
|
||||
if (property === 'secondary-sid') {
|
||||
return 'Secondary subtitle track: ${secondary-sid}';
|
||||
return { template: 'Secondary subtitle track: ${secondary-sid}', rawMpvOsd: false };
|
||||
}
|
||||
if (property === 'sub-delay') {
|
||||
return 'Subtitle delay: ${sub-delay}';
|
||||
return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -67,16 +74,18 @@ function showResolvedProxyCommandOsd(
|
||||
command: (string | number)[],
|
||||
options: HandleMpvCommandFromIpcOptions,
|
||||
): void {
|
||||
const template = resolveProxyCommandOsdTemplate(command);
|
||||
if (!template) return;
|
||||
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd;
|
||||
const feedback = resolveProxyCommandOsdTemplate(command);
|
||||
if (!feedback) return;
|
||||
const showFeedback = feedback.rawMpvOsd
|
||||
? (options.showRawMpvOsd ?? options.showMpvOsd)
|
||||
: (options.showPlaybackFeedback ?? options.showMpvOsd);
|
||||
|
||||
const emit = async () => {
|
||||
try {
|
||||
const resolved = await options.resolveProxyCommandOsd?.(command);
|
||||
showFeedback(resolved || template);
|
||||
showFeedback(resolved || feedback.template);
|
||||
} catch {
|
||||
showFeedback(template);
|
||||
showFeedback(feedback.template);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,20 +127,6 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
|
||||
) {
|
||||
const direction =
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START
|
||||
? 'next'
|
||||
: 'previous';
|
||||
options.shiftSubDelayToAdjacentSubtitle(direction).catch((error) => {
|
||||
options.showMpvOsd(`Subtitle delay shift failed: ${(error as Error).message}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
if (!options.hasRuntimeOptionsManager()) return;
|
||||
const [, idToken, directionToken] = first.split(':');
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface MpvRuntimeClientLike {
|
||||
playNextSubtitle?: () => void;
|
||||
setSubVisibility?: (visible: boolean) => void;
|
||||
setSecondarySubVisibility?: (visible: boolean) => void;
|
||||
setCurrentSecondarySubText?: (text: string) => void;
|
||||
}
|
||||
|
||||
export function showMpvOsdRuntime(
|
||||
|
||||
@@ -47,9 +47,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
},
|
||||
replayCurrentSubtitle: () => calls.push('replay'),
|
||||
playNextSubtitle: () => calls.push('play-next'),
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
calls.push(`shift:${direction}`);
|
||||
},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
playNextPlaylistItem: () => calls.push('playlist-next'),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface SessionActionExecutorDeps {
|
||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||
replayCurrentSubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
playNextPlaylistItem: () => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
@@ -124,12 +123,6 @@ export async function dispatchSessionAction(
|
||||
case 'playNextSubtitle':
|
||||
deps.playNextSubtitle();
|
||||
return;
|
||||
case 'shiftSubDelayPrevLine':
|
||||
await deps.shiftSubDelayToAdjacentSubtitle('previous');
|
||||
return;
|
||||
case 'shiftSubDelayNextLine':
|
||||
await deps.shiftSubDelayToAdjacentSubtitle('next');
|
||||
return;
|
||||
case 'cycleRuntimeOption': {
|
||||
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
|
||||
if (!runtimeOptionId) {
|
||||
|
||||
@@ -287,8 +287,6 @@ test('compileSessionBindings keeps only the character dictionary manager bound b
|
||||
|
||||
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
|
||||
const expectedSpecialActions: Record<string, string> = {
|
||||
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
|
||||
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
|
||||
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
||||
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
|
||||
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
|
||||
@@ -320,6 +318,29 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
|
||||
}
|
||||
});
|
||||
|
||||
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
keybindings: [
|
||||
createKeybinding('Shift+BracketLeft', ['__sub-delay-prev-line']),
|
||||
createKeybinding('Shift+BracketRight', ['__sub-delay-next-line']),
|
||||
],
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(
|
||||
result.bindings.map((binding) => ({
|
||||
actionType: binding.actionType,
|
||||
command: binding.actionType === 'mpv-command' ? binding.command : undefined,
|
||||
})),
|
||||
[
|
||||
{ actionType: 'mpv-command', command: ['__sub-delay-prev-line'] },
|
||||
{ actionType: 'mpv-command', command: ['__sub-delay-next-line'] },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('compileSessionBindings omits disabled bindings', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({
|
||||
|
||||
@@ -319,14 +319,6 @@ function resolveCommandBinding(
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
|
||||
}
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
if (command.length !== 1) {
|
||||
return null;
|
||||
|
||||
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
|
||||
|
||||
function createMpvClient(props: Record<string, unknown>) {
|
||||
return {
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => props[name],
|
||||
};
|
||||
}
|
||||
|
||||
test('shift subtitle delay to next cue using active external srt track', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const osd: string[] = [];
|
||||
let loadCount = 0;
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.srt',
|
||||
},
|
||||
],
|
||||
sid: 2,
|
||||
'sub-start': 3.0,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => {
|
||||
loadCount += 1;
|
||||
return `1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
line-1
|
||||
|
||||
2
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
line-2
|
||||
|
||||
3
|
||||
00:00:05,000 --> 00:00:06,000
|
||||
line-3`;
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
|
||||
await handler('next');
|
||||
await handler('next');
|
||||
|
||||
assert.equal(loadCount, 1);
|
||||
assert.equal(commands.length, 2);
|
||||
const delta = commands[0]?.[2];
|
||||
assert.equal(commands[0]?.[0], 'add');
|
||||
assert.equal(commands[0]?.[1], 'sub-delay');
|
||||
assert.equal(typeof delta, 'number');
|
||||
assert.equal(Math.abs((delta as number) - 2) < 0.0001, true);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}', 'Subtitle delay: ${sub-delay}']);
|
||||
});
|
||||
|
||||
test('shift subtitle delay to previous cue using active external ass track', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 4,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.ass',
|
||||
},
|
||||
],
|
||||
sid: 4,
|
||||
'sub-start': 2.0,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `[Events]
|
||||
Dialogue: 0,0:00:00.50,0:00:01.50,Default,,0,0,0,,line-1
|
||||
Dialogue: 0,0:00:02.00,0:00:03.00,Default,,0,0,0,,line-2
|
||||
Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`,
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler('previous');
|
||||
|
||||
const delta = commands[0]?.[2];
|
||||
assert.equal(typeof delta, 'number');
|
||||
assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true);
|
||||
});
|
||||
|
||||
test('shift subtitle delay reports cumulative delay after adjacent cue shift', async () => {
|
||||
const shiftedDelays: number[] = [];
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.srt',
|
||||
},
|
||||
],
|
||||
sid: 2,
|
||||
'sub-start': 3.0,
|
||||
'sub-delay': 0.5,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `1
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
line-1
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:06,000
|
||||
line-2`,
|
||||
sendMpvCommand: () => {},
|
||||
showMpvOsd: () => {},
|
||||
onSubtitleDelayShifted: (delay) => shiftedDelays.push(delay),
|
||||
});
|
||||
|
||||
await handler('next');
|
||||
|
||||
assert.deepEqual(shiftedDelays, [2.5]);
|
||||
});
|
||||
|
||||
test('shift subtitle delay throws when no next cue exists', async () => {
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.vtt',
|
||||
},
|
||||
],
|
||||
sid: 1,
|
||||
'sub-start': 5.0,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `WEBVTT
|
||||
|
||||
00:00:01.000 --> 00:00:02.000
|
||||
line-1
|
||||
|
||||
00:00:03.000 --> 00:00:04.000
|
||||
line-2
|
||||
|
||||
00:00:05.000 --> 00:00:06.000
|
||||
line-3`,
|
||||
sendMpvCommand: () => {},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(() => handler('next'), /No next subtitle cue found/);
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
type SubtitleDelayShiftDirection = 'next' | 'previous';
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type MpvSubtitleTrackLike = {
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
external?: unknown;
|
||||
'external-filename'?: unknown;
|
||||
};
|
||||
|
||||
type SubtitleCueCacheEntry = {
|
||||
starts: number[];
|
||||
};
|
||||
|
||||
type SubtitleDelayShiftDeps = {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
onSubtitleDelayShifted?: (delaySeconds: number) => void;
|
||||
};
|
||||
|
||||
function asTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
if (Number.isInteger(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSrtOrVttStartTimes(content: string): number[] {
|
||||
const starts: number[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = line.match(
|
||||
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/,
|
||||
);
|
||||
if (!match) continue;
|
||||
const hours = Number(match[1] || 0);
|
||||
const minutes = Number(match[2] || 0);
|
||||
const seconds = Number(match[3] || 0);
|
||||
const millis = Number(String(match[4]).padEnd(3, '0'));
|
||||
starts.push(hours * 3600 + minutes * 60 + seconds + millis / 1000);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
function parseAssStartTimes(content: string): number[] {
|
||||
const starts: number[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = line.match(
|
||||
/^Dialogue:[^,]*,(\d+:\d{2}:\d{2}\.\d{1,2}),\d+:\d{2}:\d{2}\.\d{1,2},/,
|
||||
);
|
||||
if (!match) continue;
|
||||
const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':');
|
||||
if (secondsRaw === undefined) continue;
|
||||
const [wholeSecondsRaw, fractionRaw = '0'] = secondsRaw.split('.');
|
||||
const hours = Number(hoursRaw);
|
||||
const minutes = Number(minutesRaw);
|
||||
const wholeSeconds = Number(wholeSecondsRaw);
|
||||
const fraction = Number(`0.${fractionRaw}`);
|
||||
starts.push(hours * 3600 + minutes * 60 + wholeSeconds + fraction);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
function normalizeCueStarts(starts: number[]): number[] {
|
||||
const sorted = starts
|
||||
.filter((value) => Number.isFinite(value) && value >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return [];
|
||||
|
||||
const deduped: number[] = [sorted[0]!];
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
const current = sorted[i]!;
|
||||
const previous = deduped[deduped.length - 1]!;
|
||||
if (Math.abs(current - previous) > 0.0005) {
|
||||
deduped.push(current);
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function parseCueStarts(content: string, source: string): number[] {
|
||||
const normalizedSource = source.toLowerCase().split('?')[0] || '';
|
||||
const parseSrtLike = () => parseSrtOrVttStartTimes(content);
|
||||
const parseAssLike = () => parseAssStartTimes(content);
|
||||
|
||||
let starts: number[] = [];
|
||||
if (normalizedSource.endsWith('.ass') || normalizedSource.endsWith('.ssa')) {
|
||||
starts = parseAssLike();
|
||||
if (starts.length === 0) {
|
||||
starts = parseSrtLike();
|
||||
}
|
||||
} else {
|
||||
starts = parseSrtLike();
|
||||
if (starts.length === 0) {
|
||||
starts = parseAssLike();
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeCueStarts(starts);
|
||||
if (normalized.length === 0) {
|
||||
throw new Error('Could not parse subtitle cue timings from active subtitle source.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getActiveSubtitleSource(trackListRaw: unknown, sidRaw: unknown): string {
|
||||
const sid = asTrackId(sidRaw);
|
||||
if (sid === null) {
|
||||
throw new Error('No active subtitle track selected.');
|
||||
}
|
||||
if (!Array.isArray(trackListRaw)) {
|
||||
throw new Error('Could not inspect subtitle track list.');
|
||||
}
|
||||
|
||||
const activeTrack = trackListRaw.find((entry): entry is MpvSubtitleTrackLike => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const track = entry as MpvSubtitleTrackLike;
|
||||
return track.type === 'sub' && asTrackId(track.id) === sid;
|
||||
});
|
||||
|
||||
if (!activeTrack) {
|
||||
throw new Error('No active subtitle track found in mpv track list.');
|
||||
}
|
||||
if (activeTrack.external !== true) {
|
||||
throw new Error('Active subtitle track is internal and has no direct subtitle file source.');
|
||||
}
|
||||
|
||||
const source =
|
||||
typeof activeTrack['external-filename'] === 'string'
|
||||
? activeTrack['external-filename'].trim()
|
||||
: '';
|
||||
if (!source) {
|
||||
throw new Error('Active subtitle track has no external subtitle source path.');
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function findAdjacentCueStart(
|
||||
starts: number[],
|
||||
currentStart: number,
|
||||
direction: SubtitleDelayShiftDirection,
|
||||
): number {
|
||||
const epsilon = 0.0005;
|
||||
if (direction === 'next') {
|
||||
const target = starts.find((value) => value > currentStart + epsilon);
|
||||
if (target === undefined) {
|
||||
throw new Error('No next subtitle cue found for active subtitle source.');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
for (let index = starts.length - 1; index >= 0; index -= 1) {
|
||||
const value = starts[index]!;
|
||||
if (value < currentStart - epsilon) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new Error('No previous subtitle cue found for active subtitle source.');
|
||||
}
|
||||
|
||||
export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelayShiftDeps) {
|
||||
const cueCache = new Map<string, SubtitleCueCacheEntry>();
|
||||
|
||||
return async (direction: SubtitleDelayShiftDirection): Promise<void> => {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client || !client.connected) {
|
||||
throw new Error('MPV not connected.');
|
||||
}
|
||||
|
||||
const [trackListRaw, sidRaw, subStartRaw, subDelayRaw] = await Promise.all([
|
||||
client.requestProperty('track-list'),
|
||||
client.requestProperty('sid'),
|
||||
client.requestProperty('sub-start'),
|
||||
client.requestProperty('sub-delay'),
|
||||
]);
|
||||
|
||||
const currentStart =
|
||||
typeof subStartRaw === 'number' && Number.isFinite(subStartRaw) ? subStartRaw : null;
|
||||
if (currentStart === null) {
|
||||
throw new Error('Current subtitle start time is unavailable.');
|
||||
}
|
||||
|
||||
const source = getActiveSubtitleSource(trackListRaw, sidRaw);
|
||||
let cueStarts = cueCache.get(source)?.starts;
|
||||
if (!cueStarts) {
|
||||
const content = await deps.loadSubtitleSourceText(source);
|
||||
cueStarts = parseCueStarts(content, source);
|
||||
cueCache.set(source, { starts: cueStarts });
|
||||
}
|
||||
|
||||
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
|
||||
const delta = targetStart - currentStart;
|
||||
deps.sendMpvCommand(['add', 'sub-delay', delta]);
|
||||
const currentDelay =
|
||||
typeof subDelayRaw === 'number' && Number.isFinite(subDelayRaw) ? subDelayRaw : 0;
|
||||
try {
|
||||
deps.onSubtitleDelayShifted?.(currentDelay + delta);
|
||||
} catch {}
|
||||
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
|
||||
};
|
||||
}
|
||||
+316
-1977
File diff suppressed because it is too large
Load Diff
@@ -226,10 +226,10 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||
showRawMpvOsd?: HandleMpvCommandFromIpcOptions['showRawMpvOsd'];
|
||||
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
|
||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
|
||||
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
|
||||
resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd'];
|
||||
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
|
||||
@@ -424,10 +424,10 @@ export function createMpvCommandRuntimeServiceDeps(
|
||||
openPlaylistBrowser: params.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||
showMpvOsd: params.showMpvOsd,
|
||||
showRawMpvOsd: params.showRawMpvOsd,
|
||||
showPlaybackFeedback: params.showPlaybackFeedback,
|
||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
|
||||
mpvSendCommand: params.mpvSendCommand,
|
||||
resolveProxyCommandOsd: params.resolveProxyCommandOsd,
|
||||
isMpvConnected: params.isMpvConnected,
|
||||
|
||||
@@ -17,10 +17,10 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showRawMpvOsd?: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
replayCurrentSubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
getMpvClient: () => MpvPropertyClientLike | null;
|
||||
isMpvConnected: () => boolean;
|
||||
@@ -42,11 +42,10 @@ export function handleMpvCommandFromIpcRuntime(
|
||||
openPlaylistBrowser: deps.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
showRawMpvOsd: deps.showRawMpvOsd,
|
||||
showPlaybackFeedback: deps.showPlaybackFeedback,
|
||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||
mpvPlayNextSubtitle: deps.playNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||
mpvSendCommand: deps.sendMpvCommand,
|
||||
resolveProxyCommandOsd: (nextCommand) =>
|
||||
resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient),
|
||||
|
||||
@@ -7,6 +7,10 @@ function readMainSource(): string {
|
||||
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
|
||||
}
|
||||
|
||||
function readSource(relPath: string): string {
|
||||
return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8');
|
||||
}
|
||||
|
||||
test('manual watched session action starts immersion tracker before marking watched', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -91,15 +95,15 @@ test('mpv startup signals start overlay loading OSD before readiness work', () =
|
||||
});
|
||||
|
||||
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
const dismissBlock = source.match(
|
||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(dismissBlock);
|
||||
assert.match(
|
||||
dismissBlock,
|
||||
/sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/,
|
||||
/sendMpvCommandRuntime\(deps\.getMpvClient\(\), \[\s*'script-message',\s*'subminer-overlay-loading-ready',\s*\]\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -146,9 +150,9 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
|
||||
});
|
||||
|
||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||
const actionBlock = source.match(
|
||||
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n\}/,
|
||||
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
@@ -157,13 +161,14 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
||||
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
||||
);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') <
|
||||
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'),
|
||||
actionBlock.indexOf('deps.initSubtitlePrefetch(') <
|
||||
actionBlock.indexOf('deps.setActiveParsedSubtitleMediaPath(nextMediaPath);'),
|
||||
);
|
||||
});
|
||||
|
||||
test('update overlay notification action triggers install flow', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
@@ -173,13 +178,16 @@ test('update overlay notification action triggers install flow', () => {
|
||||
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
||||
assert.match(source, /installWhenAvailable:\s*true/);
|
||||
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
||||
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/);
|
||||
assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/);
|
||||
assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/);
|
||||
assert.match(
|
||||
source,
|
||||
runtimeSource,
|
||||
/deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/,
|
||||
);
|
||||
assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
||||
assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
||||
});
|
||||
|
||||
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
|
||||
@@ -203,9 +211,9 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni
|
||||
});
|
||||
|
||||
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||
const actionBlock = source.match(
|
||||
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
||||
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
@@ -346,7 +354,7 @@ test('warm tokenization release can signal readiness before the first subtitle a
|
||||
});
|
||||
|
||||
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/stats-server-runtime.ts');
|
||||
const startStatsServerBlock = source.match(
|
||||
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||
)?.groups?.body;
|
||||
@@ -357,7 +365,7 @@ test('stats server Yomitan note creation honors configured Anki server override
|
||||
assert.ok(addYomitanNoteBlock);
|
||||
assert.match(
|
||||
addYomitanNoteBlock,
|
||||
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/,
|
||||
/const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/,
|
||||
);
|
||||
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||
@@ -365,11 +373,12 @@ test('stats server Yomitan note creation honors configured Anki server override
|
||||
|
||||
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||
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\}/,
|
||||
const resetBlock = runtimeSource.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
@@ -459,17 +468,17 @@ test('manual visible overlay hide dismisses loading OSD', () => {
|
||||
});
|
||||
|
||||
test('configured overlay notifications require visible ready overlay window', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
const readinessBlock = source.match(
|
||||
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
||||
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
const statusBlock = source.match(
|
||||
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(readinessBlock);
|
||||
assert.ok(statusBlock);
|
||||
assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/);
|
||||
assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/);
|
||||
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
|
||||
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
|
||||
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
||||
@@ -498,8 +507,9 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
|
||||
|
||||
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\}/,
|
||||
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||
const resetBlock = runtimeSource.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
@@ -509,6 +519,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
|
||||
assert.ok(setBlock);
|
||||
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
@@ -516,7 +527,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
|
||||
});
|
||||
|
||||
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
|
||||
const source = readMainSource();
|
||||
const source = readSource('src/main/runtime/overlay-geometry-runtime.ts');
|
||||
const afterBoundsBlock = source.match(
|
||||
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const PASSWORD_STORE_ARG = '--password-store';
|
||||
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
|
||||
|
||||
export function getPasswordStoreArg(argv: string[]): string | null {
|
||||
let resolved: string | null = null;
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg?.startsWith(PASSWORD_STORE_ARG)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === PASSWORD_STORE_ARG) {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('--')) {
|
||||
resolved = value.trim();
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const [prefix, value] = arg.split('=', 2);
|
||||
if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) {
|
||||
resolved = value.trim();
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function normalizePasswordStoreArg(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized.toLowerCase() === 'gnome') {
|
||||
return DEFAULT_LINUX_PASSWORD_STORE;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getDefaultPasswordStore(): string {
|
||||
return DEFAULT_LINUX_PASSWORD_STORE;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setMpvCurrentSecondarySubText } from './autoplay-subtitle-priming-runtime';
|
||||
|
||||
test('setMpvCurrentSecondarySubText uses client setter when available', () => {
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
currentSecondarySubText: '',
|
||||
setCurrentSecondarySubText: (text: string) => {
|
||||
calls.push(text);
|
||||
},
|
||||
};
|
||||
|
||||
setMpvCurrentSecondarySubText(client, 'secondary');
|
||||
|
||||
assert.deepEqual(calls, ['secondary']);
|
||||
assert.equal(client.currentSecondarySubText, '');
|
||||
});
|
||||
|
||||
test('setMpvCurrentSecondarySubText updates client property when setter is unavailable', () => {
|
||||
const client = {
|
||||
currentSecondarySubText: '',
|
||||
};
|
||||
|
||||
setMpvCurrentSecondarySubText(client, 'secondary');
|
||||
|
||||
assert.equal(client.currentSecondarySubText, 'secondary');
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { SubtitleCue, SubtitleData } from '../../types';
|
||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
||||
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||
|
||||
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
||||
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
|
||||
|
||||
type AutoplaySubtitlePrimingMpvClient = {
|
||||
connected?: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
currentVideoPath?: string;
|
||||
currentTimePos?: number;
|
||||
currentSecondarySubText?: string;
|
||||
setCurrentSecondarySubText?: (text: string) => void;
|
||||
};
|
||||
|
||||
type AutoplaySubtitlePrimingPrefetchService = {
|
||||
pause: () => void;
|
||||
onSeek: (timePos: number) => void;
|
||||
};
|
||||
|
||||
export interface AutoplaySubtitlePrimingRuntimeDeps {
|
||||
getCurrentMediaPath: () => string | null | undefined;
|
||||
getMpvClient: () => AutoplaySubtitlePrimingMpvClient | null;
|
||||
setCurrentSubText: (text: string) => void;
|
||||
getCurrentSubText: () => string;
|
||||
getCurrentSubtitleData: () => SubtitleData | null;
|
||||
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
|
||||
subtitleProcessingController: {
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
};
|
||||
emitSubtitlePayload: (payload: SubtitleData) => void;
|
||||
getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null;
|
||||
getLastObservedTimePos: () => number;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
emitSecondarySubtitle: (text: string) => void;
|
||||
initSubtitlePrefetch: (
|
||||
sourcePath: string,
|
||||
currentTimePos: number,
|
||||
sourceKey?: string,
|
||||
) => Promise<void>;
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
|
||||
logDebug: (message: string) => void;
|
||||
}
|
||||
|
||||
export function setMpvCurrentSecondarySubText(
|
||||
client: Pick<
|
||||
AutoplaySubtitlePrimingMpvClient,
|
||||
'currentSecondarySubText' | 'setCurrentSecondarySubText'
|
||||
>,
|
||||
text: string,
|
||||
): void {
|
||||
if (typeof client.setCurrentSecondarySubText === 'function') {
|
||||
client.setCurrentSecondarySubText(text);
|
||||
return;
|
||||
}
|
||||
client.currentSecondarySubText = text;
|
||||
}
|
||||
|
||||
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
|
||||
const { subtitleProcessingController, emitSubtitlePayload } = deps;
|
||||
|
||||
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
||||
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null =
|
||||
null;
|
||||
|
||||
function getCurrentAutoplayMediaPath(): string | null {
|
||||
return (
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getMpvClient()?.currentVideoPath?.trim() || null
|
||||
);
|
||||
}
|
||||
|
||||
function isCurrentAutoplayMediaPath(mediaPath: string): boolean {
|
||||
return getCurrentAutoplayMediaPath() === mediaPath;
|
||||
}
|
||||
|
||||
function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean {
|
||||
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
|
||||
return false;
|
||||
}
|
||||
autoplaySubtitlePrimedMediaPath = mediaPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetAutoplaySubtitlePrime(): void {
|
||||
autoplaySubtitlePrimedMediaPath = null;
|
||||
}
|
||||
|
||||
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
|
||||
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||
return false;
|
||||
}
|
||||
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
deps.setCurrentSubText(text);
|
||||
deps.getSubtitlePrefetchService()?.pause();
|
||||
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
|
||||
if (cachedPayload) {
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
emitSubtitlePayload(cachedPayload);
|
||||
return true;
|
||||
}
|
||||
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subTextRaw = await client.requestProperty('sub-text').catch((error) => {
|
||||
deps.logDebug(
|
||||
`[autoplay-subtitle-prime] failed to read sub-text: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
emitAutoplayPrimedSubtitle(mediaPath, text);
|
||||
}
|
||||
|
||||
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
setCurrentSubText: (text) => {
|
||||
deps.setCurrentSubText(text);
|
||||
},
|
||||
getCurrentSubtitleData: () => deps.getCurrentSubtitleData(),
|
||||
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
|
||||
onSubtitleChange: (text) => {
|
||||
deps.getSubtitlePrefetchService()?.pause();
|
||||
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
},
|
||||
refreshCurrentSubtitle: (text) => {
|
||||
deps.getSubtitlePrefetchService()?.pause();
|
||||
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
},
|
||||
deferUncachedRefresh: true,
|
||||
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||
setCurrentSecondarySubText: (text) => {
|
||||
const client = deps.getMpvClient();
|
||||
if (client) {
|
||||
setMpvCurrentSecondarySubText(client, text);
|
||||
}
|
||||
},
|
||||
emitSecondarySubtitle: (text) => {
|
||||
deps.emitSecondarySubtitle(text);
|
||||
},
|
||||
logDebug: (message) => {
|
||||
deps.logDebug(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||
if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer);
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||
}
|
||||
|
||||
function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||
if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||
return;
|
||||
}
|
||||
if (!deps.getVisibleOverlayVisible() || !deps.getCurrentSubText().trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => {
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||
if (!deps.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
const text = deps.getCurrentSubText();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
deps.getSubtitlePrefetchService()?.pause();
|
||||
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
|
||||
}
|
||||
|
||||
async function primeAutoplaySubtitleFromParsedCues(
|
||||
mediaPath: string,
|
||||
cues: SubtitleCue[],
|
||||
): Promise<void> {
|
||||
if (
|
||||
cues.length === 0 ||
|
||||
autoplaySubtitlePrimedMediaPath === mediaPath ||
|
||||
!isCurrentAutoplayMediaPath(mediaPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = deps.getMpvClient();
|
||||
const timePosRaw = await client?.requestProperty('time-pos').catch(() => null);
|
||||
const currentTimeSeconds = Number(
|
||||
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||
);
|
||||
const cue = selectAutoplayStartupCue(
|
||||
cues,
|
||||
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
||||
);
|
||||
if (!cue) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
|
||||
}
|
||||
|
||||
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||
if (subtitlePrefetchRefreshTimer) {
|
||||
clearTimeout(subtitlePrefetchRefreshTimer);
|
||||
subtitlePrefetchRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSubtitleSidebarFromSource(
|
||||
sourcePath: string,
|
||||
mediaPath?: string,
|
||||
): Promise<void> {
|
||||
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
|
||||
if (!normalizedSourcePath) {
|
||||
return;
|
||||
}
|
||||
const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||
await deps.initSubtitlePrefetch(
|
||||
normalizedSourcePath,
|
||||
deps.getLastObservedTimePos(),
|
||||
normalizedSourcePath,
|
||||
);
|
||||
deps.setActiveParsedSubtitleMediaPath(nextMediaPath);
|
||||
}
|
||||
|
||||
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||
clearScheduledSubtitlePrefetchRefresh();
|
||||
subtitlePrefetchRefreshTimer = setTimeout(() => {
|
||||
subtitlePrefetchRefreshTimer = null;
|
||||
void deps.refreshSubtitlePrefetchFromActiveTrack();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
return {
|
||||
getCurrentAutoplayMediaPath,
|
||||
resetAutoplaySubtitlePrime,
|
||||
primeCurrentSubtitleForAutoplay,
|
||||
primeCurrentSubtitleForVisibleOverlay,
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
||||
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
||||
primeAutoplaySubtitleFromParsedCues,
|
||||
clearScheduledSubtitlePrefetchRefresh,
|
||||
refreshSubtitleSidebarFromSource,
|
||||
scheduleSubtitlePrefetchRefresh,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
getMpvClient: () => null,
|
||||
isMpvConnected: () => false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
detectInstalledMpvPlugin,
|
||||
filterLegacyMpvPluginFileCandidates,
|
||||
removeLegacyMpvPluginCandidates,
|
||||
resolvePackagedFirstRunPluginAssets,
|
||||
resolvePackagedRuntimePluginPath,
|
||||
@@ -220,6 +221,20 @@ test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without v
|
||||
});
|
||||
});
|
||||
|
||||
test('filterLegacyMpvPluginFileCandidates keeps only legacy file candidates', () => {
|
||||
assert.deepEqual(
|
||||
filterLegacyMpvPluginFileCandidates([
|
||||
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
|
||||
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
|
||||
{ path: '/tmp/mpv/scripts/subminer-loader.lua', kind: 'file' },
|
||||
]),
|
||||
[
|
||||
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
|
||||
{ path: '/tmp/mpv/scripts/subminer-loader.lua', kind: 'file' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await removeLegacyMpvPluginCandidates({
|
||||
|
||||
@@ -180,6 +180,12 @@ export function detectInstalledFirstRunPluginCandidates(options: {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function filterLegacyMpvPluginFileCandidates(
|
||||
candidates: InstalledFirstRunPluginCandidate[],
|
||||
): InstalledFirstRunPluginCandidate[] {
|
||||
return candidates.filter((candidate) => candidate.kind === 'file');
|
||||
}
|
||||
|
||||
function parseInstalledPluginVersion(content: string): string | null {
|
||||
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
|
||||
return match?.[1] ?? null;
|
||||
|
||||
@@ -58,8 +58,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
|
||||
@@ -101,8 +101,6 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.anilistStatus ||
|
||||
args.anilistLogout ||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildFfmpegSubtitleExtractionArgs } from './internal-subtitle-extraction';
|
||||
|
||||
test('buildFfmpegSubtitleExtractionArgs rejects output paths without an extension', () => {
|
||||
assert.throws(
|
||||
() => buildFfmpegSubtitleExtractionArgs('/tmp/video.mkv', 2, '/tmp/subtitle-output'),
|
||||
/outputPath.*file extension/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as fs from 'fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||
import { codecToExtension } from '../../subsync/utils';
|
||||
|
||||
export async function loadSubtitleSourceText(source: string): Promise<string> {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
||||
try {
|
||||
const response = await fetch(source, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download subtitle source (${response.status})`);
|
||||
}
|
||||
return await response.text();
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = resolveSubtitleSourcePath(source);
|
||||
return fs.promises.readFile(filePath, 'utf8');
|
||||
}
|
||||
|
||||
export type MpvSubtitleTrackLike = {
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
selected?: unknown;
|
||||
external?: unknown;
|
||||
codec?: unknown;
|
||||
'ff-index'?: unknown;
|
||||
'external-filename'?: unknown;
|
||||
};
|
||||
|
||||
export function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildFfmpegSubtitleExtractionArgs(
|
||||
videoPath: string,
|
||||
ffIndex: number,
|
||||
outputPath: string,
|
||||
): string[] {
|
||||
const outputFormat = path.extname(outputPath).slice(1);
|
||||
if (!outputFormat) {
|
||||
throw new Error(`outputPath must include a file extension for ffmpeg format: ${outputPath}`);
|
||||
}
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-nostdin',
|
||||
'-y',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-an',
|
||||
'-vn',
|
||||
'-i',
|
||||
videoPath,
|
||||
'-map',
|
||||
`0:${ffIndex}`,
|
||||
'-f',
|
||||
outputFormat,
|
||||
outputPath,
|
||||
];
|
||||
}
|
||||
|
||||
export async function extractInternalSubtitleTrackToTempFile(
|
||||
ffmpegPath: string,
|
||||
videoPath: string,
|
||||
track: MpvSubtitleTrackLike,
|
||||
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
|
||||
const ffIndex = parseTrackId(track['ff-index']);
|
||||
const codec = typeof track.codec === 'string' ? track.codec : null;
|
||||
const extension = codecToExtension(codec ?? undefined);
|
||||
if (ffIndex === null || extension === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
|
||||
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
ffmpegPath,
|
||||
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
|
||||
);
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
path: outputPath,
|
||||
cleanup: async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,6 @@ test('ipc bridge action main deps builders map callbacks', async () => {
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
getMpvClient: () => null,
|
||||
isMpvConnected: () => true,
|
||||
|
||||
@@ -17,7 +17,6 @@ test('handle mpv command handler forwards command and built deps', () => {
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
getMpvClient: () => null,
|
||||
isMpvConnected: () => true,
|
||||
|
||||
@@ -16,12 +16,10 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
showRawMpvOsd: (text) => calls.push(`raw-osd:${text}`),
|
||||
showPlaybackFeedback: (text) => calls.push(`feedback:${text}`),
|
||||
replayCurrentSubtitle: () => calls.push('replay'),
|
||||
playNextSubtitle: () => calls.push('next'),
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
calls.push(`shift:${direction}`);
|
||||
},
|
||||
sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`),
|
||||
getMpvClient: () => ({ connected: true, requestProperty: async () => null }),
|
||||
isMpvConnected: () => true,
|
||||
@@ -35,10 +33,10 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
void deps.openPlaylistBrowser();
|
||||
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
||||
deps.showMpvOsd('hello');
|
||||
deps.showRawMpvOsd?.('delay');
|
||||
deps.showPlaybackFeedback?.('primary');
|
||||
deps.replayCurrentSubtitle();
|
||||
deps.playNextSubtitle();
|
||||
void deps.shiftSubDelayToAdjacentSubtitle('next');
|
||||
deps.sendMpvCommand(['show-text', 'ok']);
|
||||
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
|
||||
assert.equal(deps.isMpvConnected(), true);
|
||||
@@ -50,10 +48,10 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
'youtube-picker',
|
||||
'playlist-browser',
|
||||
'osd:hello',
|
||||
'raw-osd:delay',
|
||||
'feedback:primary',
|
||||
'replay',
|
||||
'next',
|
||||
'shift:next',
|
||||
'cmd:show-text:ok',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
) {
|
||||
return (): MpvCommandFromIpcRuntimeDeps => {
|
||||
const showPlaybackFeedback = deps.showPlaybackFeedback;
|
||||
const showRawMpvOsd = deps.showRawMpvOsd;
|
||||
return {
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
@@ -13,13 +14,12 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
...(showRawMpvOsd ? { showRawMpvOsd: (text: string) => showRawMpvOsd(text) } : {}),
|
||||
...(showPlaybackFeedback
|
||||
? { showPlaybackFeedback: (text: string) => showPlaybackFeedback(text) }
|
||||
: {}),
|
||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||
playNextSubtitle: () => deps.playNextSubtitle(),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
isMpvConnected: () => deps.isMpvConnected(),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import * as os from 'os';
|
||||
import { exportLogsArchive } from './log-export';
|
||||
|
||||
export interface LogExportTrayRuntimeDeps {
|
||||
flushMpvLog: () => Promise<void>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
}
|
||||
|
||||
export function createLogExportTrayRuntime(deps: LogExportTrayRuntimeDeps): {
|
||||
exportLogsFromTray: () => Promise<void>;
|
||||
} {
|
||||
function describeUnknownError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function exportLogsFromTray(): Promise<void> {
|
||||
try {
|
||||
await deps.flushMpvLog();
|
||||
} catch (error) {
|
||||
deps.logWarn('Failed to flush mpv log before exporting logs from tray.', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = exportLogsArchive({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
appDataDir: app.getPath('appData'),
|
||||
});
|
||||
deps.logInfo(
|
||||
`Exported ${result.exportedFiles.length} sanitized log file(s) to ${result.zipPath}`,
|
||||
);
|
||||
void dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'SubMiner logs exported',
|
||||
message: 'SubMiner log export created.',
|
||||
detail: result.zipPath,
|
||||
buttons: ['OK', 'Show in Folder'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.response === 1) {
|
||||
shell.showItemInFolder(result.zipPath);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = describeUnknownError(error);
|
||||
deps.logWarn('Failed to export logs from tray.', error);
|
||||
void dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'SubMiner log export failed',
|
||||
message: 'Could not export SubMiner logs.',
|
||||
detail: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { exportLogsFromTray };
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { type BrowserWindow, screen } from 'electron';
|
||||
import type { WindowGeometry } from '../../types';
|
||||
import { hasHyprlandWindowPlacementBoundsMismatch } from '../../core/services/hyprland-window-placement';
|
||||
import { normalizeOverlayWindowBoundsForPlatform } from '../../core/services/overlay-window-bounds';
|
||||
import {
|
||||
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
|
||||
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
|
||||
syncOverlayWindowLayer,
|
||||
} from '../../core/services/overlay-window';
|
||||
import { promoteStatsOverlayAbovePlayback } from '../../core/services/stats-window.js';
|
||||
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
|
||||
import { shouldRunLinuxOverlayZOrderKeepAlive } from './linux-overlay-zorder-keepalive';
|
||||
import {
|
||||
shouldExitFullscreenOverrideForTrackedGeometry,
|
||||
type LinuxVisibleOverlayWindowMode,
|
||||
} from './linux-visible-overlay-window-mode';
|
||||
import {
|
||||
createEnforceOverlayLayerOrderHandler,
|
||||
createEnsureOverlayWindowLevelHandler,
|
||||
createUpdateVisibleOverlayBoundsHandler,
|
||||
hasLiveOverlayWindowBoundsMismatch,
|
||||
} from './overlay-window-layout';
|
||||
import {
|
||||
createBuildEnforceOverlayLayerOrderMainDepsHandler,
|
||||
createBuildEnsureOverlayWindowLevelMainDepsHandler,
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
|
||||
} from './overlay-window-layout-main-deps';
|
||||
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order';
|
||||
|
||||
const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
|
||||
|
||||
export interface OverlayGeometryRuntimeDeps {
|
||||
overlayManager: {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
};
|
||||
getTrackedWindowGeometry: () => WindowGeometry | null;
|
||||
getTrackedWindowMediaSourceId: () => string | null | undefined;
|
||||
getTrackedWindowNativeId: () => string | null | undefined;
|
||||
getStatsOverlayVisible: () => boolean;
|
||||
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
|
||||
getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode;
|
||||
getLinuxTrackedMpvFullscreen: () => boolean;
|
||||
getLinuxTrackedMpvFullscreenChangedAtMs: () => number;
|
||||
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) => void;
|
||||
getLinuxVisibleOverlayOwnerBindingKey: () => string | null;
|
||||
setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void;
|
||||
clearVisibleOverlayX11OwnerBinding: (window: BrowserWindow) => void;
|
||||
getNativeWindowHandleDecimal: (window: BrowserWindow) => string;
|
||||
enqueueVisibleOverlayX11OwnerBindingOperation: (
|
||||
window: BrowserWindow,
|
||||
args: string[],
|
||||
onError?: (error: Error) => void,
|
||||
) => void;
|
||||
scheduleWindowsVisibleOverlayZOrderSyncBurst: () => void;
|
||||
logDebug: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
|
||||
const { overlayManager } = deps;
|
||||
|
||||
let lastOverlayWindowGeometry: WindowGeometry | null = null;
|
||||
|
||||
function getOverlayGeometryFallback(): WindowGeometry {
|
||||
const cursorPoint = screen.getCursorScreenPoint();
|
||||
const display = screen.getDisplayNearestPoint(cursorPoint);
|
||||
const bounds = display.workArea;
|
||||
return {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentOverlayGeometry(): WindowGeometry {
|
||||
if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry;
|
||||
const trackerGeometry = deps.getTrackedWindowGeometry();
|
||||
if (trackerGeometry) return trackerGeometry;
|
||||
return getOverlayGeometryFallback();
|
||||
}
|
||||
|
||||
function getCurrentTrackedOverlayGeometry(): WindowGeometry | null {
|
||||
return deps.getTrackedWindowGeometry();
|
||||
}
|
||||
|
||||
function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean {
|
||||
if (!a || !b) return false;
|
||||
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
}
|
||||
|
||||
function applyOverlayRegions(geometry: WindowGeometry): void {
|
||||
lastOverlayWindowGeometry = geometry;
|
||||
maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry);
|
||||
overlayManager.setOverlayWindowBounds(geometry);
|
||||
overlayManager.setModalWindowBounds(geometry);
|
||||
}
|
||||
|
||||
function shouldExitLinuxFullscreenOverrideForGeometry(geometry: WindowGeometry): boolean {
|
||||
if (!shouldRunLinuxOverlayZOrderKeepAlive()) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
deps.getLinuxTrackedMpvFullscreenChangedAtMs() > 0 &&
|
||||
Date.now() - deps.getLinuxTrackedMpvFullscreenChangedAtMs() <
|
||||
LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const displayBounds = screen.getDisplayMatching(geometry).bounds;
|
||||
return shouldExitFullscreenOverrideForTrackedGeometry({
|
||||
currentMode: deps.getLinuxVisibleOverlayWindowMode(),
|
||||
trackedFullscreen: deps.getLinuxTrackedMpvFullscreen(),
|
||||
geometry,
|
||||
displayBounds,
|
||||
});
|
||||
}
|
||||
|
||||
function maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry: WindowGeometry): void {
|
||||
if (!shouldExitLinuxFullscreenOverrideForGeometry(geometry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.logDebug(
|
||||
'Tracked mpv geometry no longer covers its display; exiting Linux fullscreen overlay override',
|
||||
);
|
||||
deps.syncLinuxVisibleOverlayMpvFullscreenMode(false);
|
||||
}
|
||||
|
||||
function hasHyprlandOverlayWindowPlacementMismatch(geometry: WindowGeometry): boolean {
|
||||
if (process.platform !== 'linux') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [overlayManager.getMainWindow(), overlayManager.getModalWindow()].some((window) => {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
return hasHyprlandWindowPlacementBoundsMismatch({
|
||||
title: window.getTitle(),
|
||||
bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen, window),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||
getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry,
|
||||
shouldRefreshUnchangedGeometry: (geometry) =>
|
||||
shouldExitLinuxFullscreenOverrideForGeometry(geometry) ||
|
||||
(process.platform === 'linux' &&
|
||||
(hasLiveOverlayWindowBoundsMismatch(
|
||||
[overlayManager.getMainWindow(), overlayManager.getModalWindow()],
|
||||
geometry,
|
||||
) ||
|
||||
hasHyprlandOverlayWindowPlacementMismatch(geometry))),
|
||||
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||
afterSetOverlayWindowBounds: () => {
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
deps.scheduleWindowsVisibleOverlayZOrderSyncBurst();
|
||||
return;
|
||||
}
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
restoreLinuxOverlayWindowShape(mainWindow);
|
||||
}
|
||||
ensureOverlayWindowLevel(mainWindow);
|
||||
},
|
||||
});
|
||||
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
|
||||
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
||||
updateVisibleOverlayBoundsMainDeps,
|
||||
);
|
||||
|
||||
const buildEnsureOverlayWindowLevelMainDepsHandler =
|
||||
createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||
shouldSuppressOverlayWindowLevel: (window) => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
return (
|
||||
(deps.getStatsOverlayVisible() && window === mainWindow) ||
|
||||
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
|
||||
window,
|
||||
mainWindow,
|
||||
separateWindows: deps.getOverlayForegroundSeparateWindows(),
|
||||
})
|
||||
);
|
||||
},
|
||||
ensureOverlayWindowLevelCore: (window) =>
|
||||
ensureOverlayWindowLevelCore(window as BrowserWindow),
|
||||
afterEnsureOverlayWindowLevel: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow);
|
||||
}
|
||||
promoteStatsOverlayAbovePlayback();
|
||||
},
|
||||
});
|
||||
const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler();
|
||||
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
|
||||
ensureOverlayWindowLevelMainDeps,
|
||||
);
|
||||
|
||||
function syncPrimaryOverlayWindowLayer(layer: 'visible'): void {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
syncOverlayWindowLayer(mainWindow, layer);
|
||||
}
|
||||
|
||||
function moveVisibleOverlayAboveTrackedPlaybackWindow(window: BrowserWindow): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
if (window !== overlayManager.getMainWindow()) return;
|
||||
|
||||
bindVisibleOverlayToTrackedX11Window(window);
|
||||
|
||||
const mediaSourceId = deps.getTrackedWindowMediaSourceId();
|
||||
if (!mediaSourceId) return;
|
||||
|
||||
try {
|
||||
window.moveAbove(mediaSourceId);
|
||||
} catch (error) {
|
||||
deps.logDebug(
|
||||
'Failed to move visible overlay above tracked playback window:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function bindVisibleOverlayToTrackedX11Window(window: BrowserWindow): void {
|
||||
const targetWindowId = deps.getTrackedWindowNativeId();
|
||||
if (!targetWindowId) {
|
||||
if (deps.getLinuxVisibleOverlayOwnerBindingKey() !== null) {
|
||||
deps.clearVisibleOverlayX11OwnerBinding(window);
|
||||
}
|
||||
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const overlayWindowId = deps.getNativeWindowHandleDecimal(window);
|
||||
const bindingKey = `${overlayWindowId}:${targetWindowId}`;
|
||||
if (deps.getLinuxVisibleOverlayOwnerBindingKey() === bindingKey) {
|
||||
return;
|
||||
}
|
||||
deps.setLinuxVisibleOverlayOwnerBindingKey(bindingKey);
|
||||
|
||||
deps.enqueueVisibleOverlayX11OwnerBindingOperation(
|
||||
window,
|
||||
[
|
||||
'-id',
|
||||
overlayWindowId,
|
||||
'-f',
|
||||
'WM_TRANSIENT_FOR',
|
||||
'32x',
|
||||
'-set',
|
||||
'WM_TRANSIENT_FOR',
|
||||
targetWindowId,
|
||||
],
|
||||
(error) => {
|
||||
if (deps.getLinuxVisibleOverlayOwnerBindingKey() === bindingKey) {
|
||||
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
|
||||
}
|
||||
deps.logDebug(
|
||||
'Failed to bind visible overlay as transient for tracked X11 playback window:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const buildEnforceOverlayLayerOrderMainDepsHandler =
|
||||
createBuildEnforceOverlayLayerOrderMainDepsHandler({
|
||||
enforceOverlayLayerOrderCore: (params) =>
|
||||
enforceOverlayLayerOrderCore({
|
||||
visibleOverlayVisible: params.visibleOverlayVisible,
|
||||
mainWindow: params.mainWindow as BrowserWindow | null,
|
||||
ensureOverlayWindowLevel: (window) =>
|
||||
params.ensureOverlayWindowLevel(window as BrowserWindow),
|
||||
}),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
|
||||
});
|
||||
const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler();
|
||||
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
|
||||
enforceOverlayLayerOrderMainDeps,
|
||||
);
|
||||
|
||||
return {
|
||||
getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry,
|
||||
resetLastOverlayWindowGeometry: () => {
|
||||
lastOverlayWindowGeometry = null;
|
||||
},
|
||||
getOverlayGeometryFallback,
|
||||
getCurrentOverlayGeometry,
|
||||
getCurrentTrackedOverlayGeometry,
|
||||
geometryMatches,
|
||||
applyOverlayRegions,
|
||||
shouldExitLinuxFullscreenOverrideForGeometry,
|
||||
maybeExitLinuxFullscreenOverrideForTrackedGeometry,
|
||||
hasHyprlandOverlayWindowPlacementMismatch,
|
||||
moveVisibleOverlayAboveTrackedPlaybackWindow,
|
||||
bindVisibleOverlayToTrackedX11Window,
|
||||
syncPrimaryOverlayWindowLayer,
|
||||
updateVisibleOverlayBounds,
|
||||
ensureOverlayWindowLevel,
|
||||
enforceOverlayLayerOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export type OverlayGeometryRuntime = ReturnType<typeof createOverlayGeometryRuntime>;
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type {
|
||||
NotificationType,
|
||||
OverlayNotificationEventPayload,
|
||||
OverlayNotificationPayload,
|
||||
ResolvedConfig,
|
||||
} from '../../types';
|
||||
import type { AnkiIntegration } from '../../anki-integration';
|
||||
import type { RuntimeOptionsManager } from '../../runtime-options';
|
||||
import { AnkiConnectClient } from '../../anki-connect';
|
||||
import { DEFAULT_CONFIG } from '../../config';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { showDesktopNotification } from '../../core/utils';
|
||||
import {
|
||||
isOverlayWindowContentReady,
|
||||
sendMpvCommandRuntime,
|
||||
type MpvIpcClient,
|
||||
} from '../../core/services';
|
||||
import { createOverlayLoadingOsdController } from './overlay-loading-osd';
|
||||
import { createMaybeStartOverlayLoadingOsdHandler } from './overlay-loading-osd-start';
|
||||
import { withConfiguredOverlayNotificationPosition } from './overlay-notification-position';
|
||||
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||
import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
notifyConfiguredStatus,
|
||||
type ConfiguredStatusNotificationOptions,
|
||||
} from './configured-status-notification';
|
||||
import { resolveOverlayReadinessNotificationType } from './notification-routing';
|
||||
|
||||
export interface OverlayNotificationsRuntimeDeps {
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getMainOverlayWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
getMpvClient: () => MpvIpcClient | null;
|
||||
getAnkiIntegration: () => AnkiIntegration | null;
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
|
||||
}
|
||||
|
||||
export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRuntimeDeps): {
|
||||
isVisibleOverlayContentReady: () => boolean;
|
||||
getConfiguredStatusNotificationType: () => NotificationType;
|
||||
flushQueuedOverlayNotifications: () => void;
|
||||
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
||||
dismissOverlayNotification: (id: string) => void;
|
||||
openAnkiCardFromNotification: (noteId: number) => Promise<void>;
|
||||
toggleNotificationHistoryPanel: () => void;
|
||||
showConfiguredStatusNotification: (
|
||||
message: string,
|
||||
options?: ConfiguredStatusNotificationOptions,
|
||||
) => void;
|
||||
showConfiguredPlaybackFeedback: (
|
||||
message: string,
|
||||
options?: ConfiguredStatusNotificationOptions,
|
||||
) => void;
|
||||
showSubsyncStatusNotification: (message: string) => void;
|
||||
showYoutubeFlowStatusNotification: (message: string) => void;
|
||||
showOverlayLoadingStatusNotification: () => void;
|
||||
dismissOverlayLoadingStatusNotification: () => void;
|
||||
maybeStartOverlayLoadingOsd: (mediaPath?: string | null) => void;
|
||||
} {
|
||||
function isVisibleOverlayContentReady(): boolean {
|
||||
const overlayWindow = deps.getMainOverlayWindow();
|
||||
return Boolean(
|
||||
deps.getVisibleOverlayVisible() &&
|
||||
overlayWindow &&
|
||||
isOverlayWindowReadyForNotification(overlayWindow),
|
||||
);
|
||||
}
|
||||
|
||||
function getConfiguredStatusNotificationType(): NotificationType {
|
||||
const configuredType = deps.getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
|
||||
}
|
||||
|
||||
function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
|
||||
if (window.isDestroyed() || !isOverlayWindowContentReady(window)) {
|
||||
return false;
|
||||
}
|
||||
if (window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const currentURL = window.webContents.getURL();
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
}
|
||||
|
||||
const overlayNotificationDelivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => isVisibleOverlayContentReady(),
|
||||
send: (payload) => {
|
||||
deps.broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
|
||||
},
|
||||
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
});
|
||||
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
|
||||
null;
|
||||
|
||||
function flushQueuedOverlayNotifications(): void {
|
||||
overlayNotificationDelivery.flush();
|
||||
}
|
||||
|
||||
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
|
||||
overlayNotificationDelivery.send(payload);
|
||||
}
|
||||
|
||||
function showOverlayNotification(payload: OverlayNotificationPayload): void {
|
||||
sendOverlayNotificationEvent(
|
||||
withConfiguredOverlayNotificationPosition(payload, deps.getResolvedConfig()),
|
||||
);
|
||||
}
|
||||
|
||||
function dismissOverlayNotification(id: string): void {
|
||||
sendOverlayNotificationEvent({ id, dismiss: true });
|
||||
}
|
||||
|
||||
async function openAnkiCardFromNotification(noteId: number): Promise<void> {
|
||||
const activeIntegrationOpen = deps.getAnkiIntegration()?.openNoteInAnki(noteId);
|
||||
if (activeIntegrationOpen) {
|
||||
await activeIntegrationOpen;
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedConfig = deps.getResolvedConfig();
|
||||
const effectiveAnkiConfig =
|
||||
deps.getRuntimeOptionsManager()?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
|
||||
resolvedConfig.ankiConnect;
|
||||
const fallbackClient = new AnkiConnectClient(
|
||||
effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url,
|
||||
);
|
||||
await fallbackClient.openNoteInBrowser(noteId);
|
||||
}
|
||||
|
||||
function toggleNotificationHistoryPanel(): void {
|
||||
deps.broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
|
||||
}
|
||||
|
||||
function showConfiguredStatusNotification(
|
||||
message: string,
|
||||
options: ConfiguredStatusNotificationOptions = {},
|
||||
): void {
|
||||
notifyConfiguredStatus(
|
||||
message,
|
||||
{
|
||||
getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||
isOverlayReady: () => isVisibleOverlayContentReady(),
|
||||
showOsd: (text) => deps.showMpvOsd(text),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, notificationOptions) =>
|
||||
showDesktopNotification(title, notificationOptions),
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
function showConfiguredPlaybackFeedback(
|
||||
message: string,
|
||||
options: ConfiguredStatusNotificationOptions = {},
|
||||
): void {
|
||||
showConfiguredStatusNotification(message, {
|
||||
...getPlaybackFeedbackNotificationOptions(message),
|
||||
...options,
|
||||
delivery: 'feedback',
|
||||
});
|
||||
}
|
||||
|
||||
function showSubsyncStatusNotification(message: string): void {
|
||||
const syncing = message.startsWith('Subsync: syncing');
|
||||
const failed = message.toLowerCase().includes('failed');
|
||||
showConfiguredStatusNotification(message, {
|
||||
id: 'subsync-status',
|
||||
title: 'Subsync',
|
||||
variant: failed ? 'error' : syncing ? 'progress' : 'info',
|
||||
persistent: syncing,
|
||||
desktop: !syncing,
|
||||
});
|
||||
}
|
||||
|
||||
function showYoutubeFlowStatusNotification(message: string): void {
|
||||
const progress =
|
||||
message.startsWith('Downloading subtitles') ||
|
||||
message.startsWith('Loading subtitles') ||
|
||||
message.startsWith('Getting subtitles') ||
|
||||
message === 'Opening YouTube video';
|
||||
showConfiguredStatusNotification(message, {
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
variant: progress ? 'progress' : 'info',
|
||||
persistent: progress,
|
||||
desktop: !progress,
|
||||
});
|
||||
}
|
||||
|
||||
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
|
||||
if (!overlayLoadingOsdController) {
|
||||
overlayLoadingOsdController = createOverlayLoadingOsdController({
|
||||
showOsd: (message) => {
|
||||
deps.showMpvOsd(message);
|
||||
},
|
||||
clearOsd: () => {
|
||||
sendMpvCommandRuntime(deps.getMpvClient(), ['show-text', '', '1']);
|
||||
},
|
||||
setInterval: (callback, delayMs) => {
|
||||
const timer = setInterval(callback, delayMs);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
},
|
||||
clearInterval: (timer) => {
|
||||
clearInterval(timer as ReturnType<typeof setInterval>);
|
||||
},
|
||||
});
|
||||
}
|
||||
return overlayLoadingOsdController;
|
||||
}
|
||||
|
||||
function showOverlayLoadingStatusNotification(): void {
|
||||
getOverlayLoadingOsdController().start();
|
||||
}
|
||||
|
||||
function dismissOverlayLoadingStatusNotification(): void {
|
||||
getOverlayLoadingOsdController().stop();
|
||||
sendMpvCommandRuntime(deps.getMpvClient(), [
|
||||
'script-message',
|
||||
'subminer-overlay-loading-ready',
|
||||
]);
|
||||
dismissOverlayNotification('overlay-loading-status');
|
||||
}
|
||||
|
||||
const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({
|
||||
getVisibleOverlayRequested: () => deps.getVisibleOverlayVisible(),
|
||||
isOverlayContentReady: () => isVisibleOverlayContentReady(),
|
||||
startOverlayLoadingOsd: () => {
|
||||
showOverlayLoadingStatusNotification();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isVisibleOverlayContentReady,
|
||||
getConfiguredStatusNotificationType,
|
||||
flushQueuedOverlayNotifications,
|
||||
showOverlayNotification,
|
||||
dismissOverlayNotification,
|
||||
openAnkiCardFromNotification,
|
||||
toggleNotificationHistoryPanel,
|
||||
showConfiguredStatusNotification,
|
||||
showConfiguredPlaybackFeedback,
|
||||
showSubsyncStatusNotification,
|
||||
showYoutubeFlowStatusNotification,
|
||||
showOverlayLoadingStatusNotification,
|
||||
dismissOverlayLoadingStatusNotification,
|
||||
maybeStartOverlayLoadingOsd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import type { CompiledSessionBinding, ResolvedConfig } from '../../types';
|
||||
import { createSessionBindingsRuntime } from './session-bindings-runtime';
|
||||
|
||||
test('persistSessionBindings logs and does not publish bindings when artifact write fails', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-bindings-runtime-'));
|
||||
const configDir = path.join(root, 'config-file');
|
||||
fs.writeFileSync(configDir, 'not a directory');
|
||||
const calls: string[] = [];
|
||||
const runtime = createSessionBindingsRuntime({
|
||||
configDir,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
stats: { toggleKey: 's', markWatchedKey: 'w' },
|
||||
}) as ResolvedConfig,
|
||||
getMpvClient: () => null,
|
||||
setSessionBindings: () => calls.push('setSessionBindings'),
|
||||
setSessionBindingsInitialized: () => calls.push('setSessionBindingsInitialized'),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
});
|
||||
|
||||
try {
|
||||
assert.throws(
|
||||
() => runtime.persistSessionBindings([] as CompiledSessionBinding[]),
|
||||
/ENOTDIR|EEXIST/,
|
||||
);
|
||||
assert.deepEqual(calls, ['warn:[session-bindings] Failed to write session bindings artifact']);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { sendMpvCommandRuntime, type MpvRuntimeClientLike } from '../../core/services';
|
||||
import {
|
||||
buildPluginSessionBindingsArtifact,
|
||||
compileSessionBindings,
|
||||
} from '../../core/services/session-bindings';
|
||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
||||
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
|
||||
|
||||
export interface SessionBindingsRuntimeDeps {
|
||||
configDir: string;
|
||||
getKeybindings: () => Keybinding[];
|
||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
||||
setSessionBindingsInitialized: (initialized: boolean) => void;
|
||||
logWarn: (message: string) => void;
|
||||
}
|
||||
|
||||
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
||||
persistSessionBindings: (
|
||||
bindings: CompiledSessionBinding[],
|
||||
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
||||
) => void;
|
||||
refreshCurrentSessionBindings: () => void;
|
||||
} {
|
||||
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
||||
if (process.platform === 'darwin') return 'darwin';
|
||||
if (process.platform === 'win32') return 'win32';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
function compileCurrentSessionBindings(): {
|
||||
bindings: CompiledSessionBinding[];
|
||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'];
|
||||
} {
|
||||
return compileSessionBindings({
|
||||
keybindings: deps.getKeybindings(),
|
||||
shortcuts: deps.getConfiguredShortcuts(),
|
||||
statsToggleKey: deps.getResolvedConfig().stats.toggleKey,
|
||||
statsMarkWatchedKey: deps.getResolvedConfig().stats.markWatchedKey,
|
||||
platform: resolveSessionBindingPlatform(),
|
||||
rawConfig: deps.getResolvedConfig(),
|
||||
});
|
||||
}
|
||||
|
||||
function persistSessionBindings(
|
||||
bindings: CompiledSessionBinding[],
|
||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||
): void {
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings,
|
||||
warnings,
|
||||
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
});
|
||||
try {
|
||||
writeSessionBindingsArtifact(deps.configDir, artifact);
|
||||
} catch (error) {
|
||||
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
||||
throw error;
|
||||
}
|
||||
deps.setSessionBindings(bindings);
|
||||
deps.setSessionBindingsInitialized(true);
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (mpvClient?.connected) {
|
||||
sendMpvCommandRuntime(mpvClient, ['script-message', 'subminer-reload-session-bindings']);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCurrentSessionBindings(): void {
|
||||
const compiled = compileCurrentSessionBindings();
|
||||
for (const warning of compiled.warnings) {
|
||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||
}
|
||||
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||
}
|
||||
|
||||
return { persistSessionBindings, refreshCurrentSessionBindings };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
isSelfOwnedBackgroundStatsDaemonState,
|
||||
shouldClearAppStateStatsServerOnStop,
|
||||
} from './stats-server-runtime';
|
||||
|
||||
test('detects self-owned background stats daemon state', () => {
|
||||
assert.equal(
|
||||
isSelfOwnedBackgroundStatsDaemonState({ pid: process.pid, port: 6969, startedAtMs: 1 }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('stats server app-state reference should be cleared after private server stop', () => {
|
||||
assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true);
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import path from 'node:path';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import {
|
||||
addYomitanNoteViaSearch,
|
||||
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||
} from '../../core/services';
|
||||
import { startStatsServer } from '../../core/services/stats-server';
|
||||
import { createLogger } from '../../logger';
|
||||
import type { ResolvedConfig } from '../../types/config';
|
||||
import type { AppState } from '../state';
|
||||
import {
|
||||
isBackgroundStatsServerProcessAlive,
|
||||
readBackgroundStatsServerState,
|
||||
removeBackgroundStatsServerState,
|
||||
resolveBackgroundStatsServerUrl,
|
||||
writeBackgroundStatsServerState,
|
||||
} from './stats-daemon';
|
||||
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
|
||||
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
|
||||
|
||||
export function isSelfOwnedBackgroundStatsDaemonState(state: {
|
||||
pid: number;
|
||||
port?: number;
|
||||
startedAtMs?: number;
|
||||
}): boolean {
|
||||
return state.pid === process.pid;
|
||||
}
|
||||
|
||||
export function shouldClearAppStateStatsServerOnStop(options: {
|
||||
hadStatsServer: boolean;
|
||||
}): boolean {
|
||||
return options.hadStatsServer;
|
||||
}
|
||||
|
||||
export interface StatsServerRuntimeDeps {
|
||||
userDataPath: string;
|
||||
statsDistPath: string;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getImmersionTracker: () => AppState['immersionTracker'];
|
||||
setAppStateStatsServer: (server: AppState['statsServer']) => void;
|
||||
getMpvSocketPath: () => AppState['mpvSocketPath'];
|
||||
getYomitanExt: () => AppState['yomitanExt'];
|
||||
getYomitanSession: () => AppState['yomitanSession'];
|
||||
getYomitanParserWindow: () => AppState['yomitanParserWindow'];
|
||||
setYomitanParserWindow: (w: BrowserWindow | null) => void;
|
||||
getYomitanParserReadyPromise: () => AppState['yomitanParserReadyPromise'];
|
||||
setYomitanParserReadyPromise: (p: Promise<void> | null) => void;
|
||||
getYomitanParserInitPromise: () => AppState['yomitanParserInitPromise'];
|
||||
setYomitanParserInitPromise: (p: Promise<boolean> | null) => void;
|
||||
getYomitanAnkiDeckName: () => Promise<string>;
|
||||
getAnilistRateLimiter: () => NonNullable<
|
||||
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
|
||||
>;
|
||||
resolveAnkiNoteId: (noteId: number) => number;
|
||||
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
setStatsStartupInProgress: (inProgress: boolean) => void;
|
||||
}
|
||||
|
||||
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
stopStatsServer: () => void;
|
||||
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
};
|
||||
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
||||
} {
|
||||
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
||||
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
|
||||
|
||||
function readLiveBackgroundStatsDaemonState(): {
|
||||
pid: number;
|
||||
port: number;
|
||||
startedAtMs: number;
|
||||
} | null {
|
||||
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
||||
if (!state) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
if (state.pid === process.pid && !statsServer) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function clearOwnedBackgroundStatsDaemonState(): void {
|
||||
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
||||
if (state?.pid === process.pid) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
}
|
||||
}
|
||||
|
||||
function stopStatsServer(): void {
|
||||
if (!statsServer) {
|
||||
return;
|
||||
}
|
||||
statsServer.close();
|
||||
statsServer = null;
|
||||
if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) {
|
||||
deps.setAppStateStatsServer(null);
|
||||
}
|
||||
clearOwnedBackgroundStatsDaemonState();
|
||||
}
|
||||
|
||||
const startLocalStatsServer = (): void => {
|
||||
const tracker = deps.getImmersionTracker();
|
||||
if (!tracker) {
|
||||
throw new Error('Immersion tracker failed to initialize.');
|
||||
}
|
||||
if (!statsServer) {
|
||||
const yomitanDeps = {
|
||||
getYomitanExt: () => deps.getYomitanExt(),
|
||||
getYomitanSession: () => deps.getYomitanSession(),
|
||||
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
||||
setYomitanParserWindow: (w: BrowserWindow | null) => {
|
||||
deps.setYomitanParserWindow(w);
|
||||
},
|
||||
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
|
||||
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
|
||||
deps.setYomitanParserReadyPromise(p);
|
||||
},
|
||||
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
|
||||
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
|
||||
deps.setYomitanParserInitPromise(p);
|
||||
},
|
||||
};
|
||||
const yomitanLogger = createLogger('main:yomitan-stats');
|
||||
statsServer = startStatsServer({
|
||||
port: deps.getResolvedConfig().stats.serverPort,
|
||||
staticDir: deps.statsDistPath,
|
||||
tracker,
|
||||
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
|
||||
mpvSocketPath: deps.getMpvSocketPath(),
|
||||
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
|
||||
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
|
||||
getSecondarySubtitleLanguages: () =>
|
||||
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
|
||||
anilistRateLimiter: deps.getAnilistRateLimiter(),
|
||||
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
||||
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
||||
addYomitanNote: async (word: string) => {
|
||||
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||
deck: ankiConnectConfig.deck,
|
||||
});
|
||||
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
|
||||
}
|
||||
return result.noteId;
|
||||
},
|
||||
});
|
||||
deps.setAppStateStatsServer(statsServer);
|
||||
}
|
||||
deps.setAppStateStatsServer(statsServer);
|
||||
};
|
||||
|
||||
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
|
||||
currentPid: process.pid,
|
||||
readBackgroundState: () => readBackgroundStatsServerState(statsDaemonStatePath),
|
||||
removeBackgroundState: () => {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
},
|
||||
isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid),
|
||||
hasLocalStatsServer: () => statsServer !== null,
|
||||
startLocalStatsServer,
|
||||
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
|
||||
});
|
||||
|
||||
const ensureBackgroundStatsServerStarted = (): {
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
} => {
|
||||
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
||||
if (liveDaemon && liveDaemon.pid !== process.pid) {
|
||||
return {
|
||||
url: resolveBackgroundStatsServerUrl(liveDaemon),
|
||||
runningInCurrentProcess: false,
|
||||
};
|
||||
}
|
||||
|
||||
deps.setStatsStartupInProgress(true);
|
||||
try {
|
||||
deps.ensureImmersionTrackerStarted();
|
||||
} finally {
|
||||
deps.setStatsStartupInProgress(false);
|
||||
}
|
||||
|
||||
const port = deps.getResolvedConfig().stats.serverPort;
|
||||
const result = ensureStatsServerStarted();
|
||||
if (result.source === 'local') {
|
||||
writeBackgroundStatsServerState(statsDaemonStatePath, {
|
||||
pid: process.pid,
|
||||
port,
|
||||
startedAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
|
||||
};
|
||||
|
||||
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
||||
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
||||
if (!state) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(state.pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
|
||||
throw new Error(
|
||||
`Insufficient permissions to stop background stats server (pid ${state.pid}).`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||
return { ok: true, stale: false };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
throw new Error('Timed out stopping background stats server.');
|
||||
};
|
||||
|
||||
return {
|
||||
stopStatsServer,
|
||||
ensureStatsServerStarted,
|
||||
ensureBackgroundStatsServerStarted,
|
||||
stopBackgroundStatsServer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
|
||||
|
||||
test('runSupportAssetUpdatesForLauncherResult logs support-asset errors and preserves launcher result', async () => {
|
||||
const warnings: string[] = [];
|
||||
const launcherResult = { status: 'updated' } as const;
|
||||
const result = await runSupportAssetUpdatesForLauncherResult({
|
||||
launcherResult,
|
||||
updateSupportAssets: async () => {
|
||||
throw new Error('archive failed');
|
||||
},
|
||||
logWarn: (message, details) => {
|
||||
warnings.push(`${message}:${details instanceof Error ? details.message : String(details)}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, launcherResult);
|
||||
assert.deepEqual(warnings, ['Support asset update failed after launcher update:archive failed']);
|
||||
});
|
||||
|
||||
test('runSupportAssetUpdatesForLauncherResult uses support asset description in skip warnings', async () => {
|
||||
const warnings: string[] = [];
|
||||
const launcherResult = { status: 'updated' } as const;
|
||||
|
||||
const result = await runSupportAssetUpdatesForLauncherResult({
|
||||
launcherResult,
|
||||
assetDescription: 'Support asset update',
|
||||
updateSupportAssets: async () => [
|
||||
{ status: 'protected', command: 'install-theme' },
|
||||
{ status: 'hash-mismatch', message: 'checksum failed' },
|
||||
],
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, launcherResult);
|
||||
assert.deepEqual(warnings, [
|
||||
'Support asset update requires manual command: install-theme',
|
||||
'Support asset update skipped: checksum failed',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { app, dialog } from 'electron';
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import type { UpdateChannel, UpdatesConfig } from '../../../types/config';
|
||||
import type { OverlayNotificationPayload } from '../../../types/notification';
|
||||
import { createElectronAppUpdater, isNativeUpdaterSupported } from './app-updater';
|
||||
import { createCurlFetch, createGlobalFetch } from './fetch-adapter';
|
||||
import { createCurlHttpExecutor } from './curl-http-executor';
|
||||
import { createFetchHttpExecutor } from './fetch-http-executor';
|
||||
import {
|
||||
fetchLatestStableRelease,
|
||||
fetchReleaseAssetBuffer,
|
||||
fetchReleaseAssetText,
|
||||
findReleaseAsset,
|
||||
parseSha256Sums,
|
||||
type GitHubRelease,
|
||||
} from './release-assets';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
|
||||
import { updateLauncherFromRelease } from './launcher-updater';
|
||||
import { notifyUpdateAvailable } from './update-notifications';
|
||||
import { createUpdateDialogPresenter } from './update-dialogs';
|
||||
import { createFileUpdateStateStore, createUpdateService } from './update-service';
|
||||
import { updateSupportAssetsFromRelease } from './support-assets';
|
||||
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
|
||||
|
||||
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
|
||||
|
||||
export interface UpdateServiceRuntimeDeps {
|
||||
userDataPath: string;
|
||||
getUpdatesConfig: () => Required<UpdatesConfig>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
withStatsWindowLayerSuspendedForNativeDialog: <T>(showDialog: () => Promise<T>) => Promise<T>;
|
||||
}
|
||||
|
||||
export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||
getUpdateService: () => ReturnType<typeof createUpdateService>;
|
||||
} {
|
||||
const updateStateStore = createFileUpdateStateStore(
|
||||
path.join(deps.userDataPath, 'update-state.json'),
|
||||
);
|
||||
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
||||
const globalFetchForUpdater = createGlobalFetch();
|
||||
const curlFetch = createCurlFetch();
|
||||
|
||||
function createNativeUpdaterHttpExecutor() {
|
||||
if (process.platform === 'win32') {
|
||||
return createFetchHttpExecutor();
|
||||
}
|
||||
return createCurlHttpExecutor();
|
||||
}
|
||||
|
||||
function getFetchForUpdater() {
|
||||
if (process.platform === 'win32') return globalFetchForUpdater;
|
||||
return curlFetch;
|
||||
}
|
||||
|
||||
async function updateLauncherFromSelectedRelease(
|
||||
launcherPath?: string,
|
||||
channel: UpdateChannel = deps.getUpdatesConfig().channel,
|
||||
release: GitHubRelease | null = null,
|
||||
) {
|
||||
const fetchForUpdater = getFetchForUpdater();
|
||||
if (!release) {
|
||||
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
|
||||
}
|
||||
const sumsAsset = findReleaseAsset(release, 'SHA256SUMS.txt');
|
||||
if (!sumsAsset) {
|
||||
return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' };
|
||||
}
|
||||
const sums = parseSha256Sums(
|
||||
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
|
||||
);
|
||||
const launcherResult = await updateLauncherFromRelease({
|
||||
release,
|
||||
sha256Sums: sums,
|
||||
launcherPath,
|
||||
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
|
||||
});
|
||||
return runSupportAssetUpdatesForLauncherResult({
|
||||
launcherResult,
|
||||
assetDescription: 'Support asset update',
|
||||
updateSupportAssets: () =>
|
||||
updateSupportAssetsFromRelease({
|
||||
release,
|
||||
sha256Sums: sums,
|
||||
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
|
||||
}),
|
||||
logWarn: (message, details) => deps.logWarn(message, details),
|
||||
});
|
||||
}
|
||||
|
||||
function getUpdateService() {
|
||||
if (updateService) return updateService;
|
||||
const appUpdater = createElectronAppUpdater({
|
||||
currentVersion: app.getVersion(),
|
||||
isPackaged: app.isPackaged,
|
||||
log: (message) => deps.logInfo(message),
|
||||
getChannel: () => deps.getUpdatesConfig().channel,
|
||||
configureHttpExecutor: createNativeUpdaterHttpExecutor,
|
||||
disableDifferentialDownload: true,
|
||||
isNativeUpdaterSupported: () =>
|
||||
isNativeUpdaterSupported({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
execPath: process.execPath,
|
||||
env: process.env,
|
||||
log: (message) => deps.logWarn(message),
|
||||
}),
|
||||
});
|
||||
const updateDialogPresenter = createUpdateDialogPresenter({
|
||||
platform: process.platform,
|
||||
focusApp: async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.focus({ steal: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await app.dock?.show();
|
||||
} catch (error) {
|
||||
deps.logWarn('Failed to show macOS dock before update dialog', error);
|
||||
}
|
||||
// app.focus({ steal: true }) alone does not reliably activate the process
|
||||
// when SubMiner was reached via `subminer -u` (single-instance forwarding
|
||||
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
|
||||
// which is the only path that reliably brings the running app forward.
|
||||
await new Promise<void>((resolve) => {
|
||||
execFile(
|
||||
'/usr/bin/osascript',
|
||||
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
|
||||
{ timeout: 2000 },
|
||||
(error) => {
|
||||
if (error) {
|
||||
deps.logWarn(
|
||||
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
app.focus({ steal: true });
|
||||
},
|
||||
withStatsWindowLayerSuspended: (showDialog) =>
|
||||
deps.withStatsWindowLayerSuspendedForNativeDialog(showDialog),
|
||||
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||
});
|
||||
updateService = createUpdateService({
|
||||
getConfig: () => deps.getUpdatesConfig(),
|
||||
getCurrentVersion: () => app.getVersion(),
|
||||
now: () => Date.now(),
|
||||
readState: () => updateStateStore.readState(),
|
||||
writeState: (state) => updateStateStore.writeState(state),
|
||||
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||
shouldFetchReleaseMetadata: ({ request, appUpdate }) =>
|
||||
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request),
|
||||
fetchLatestStableRelease: (channel) =>
|
||||
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
|
||||
updateLauncher: (launcherPath, channel, release) =>
|
||||
updateLauncherFromSelectedRelease(launcherPath, channel, release),
|
||||
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
|
||||
showUpdateAvailableDialog: (version) =>
|
||||
updateDialogPresenter.showUpdateAvailableDialog(version),
|
||||
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
|
||||
showManualUpdateRequiredDialog: (version) =>
|
||||
updateDialogPresenter.showManualUpdateRequiredDialog(version),
|
||||
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
||||
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
|
||||
quitAndInstall: () => appUpdater.quitAndInstall(),
|
||||
notifyUpdateAvailable: (version) =>
|
||||
notifyUpdateAvailable(
|
||||
{ notificationType: deps.getUpdatesConfig().notificationType, version },
|
||||
{
|
||||
showSystemNotification: (title, body) => deps.showDesktopNotification(title, { body }),
|
||||
showOverlayNotification: (payload) => deps.showOverlayNotification(payload),
|
||||
showOsdNotification: (message) => {
|
||||
deps.showMpvOsd(message);
|
||||
},
|
||||
log: (message) => deps.logWarn(message),
|
||||
},
|
||||
),
|
||||
log: (message) => deps.logWarn(message),
|
||||
});
|
||||
return updateService;
|
||||
}
|
||||
|
||||
return { getUpdateService };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export async function runSupportAssetUpdatesForLauncherResult<
|
||||
TLauncherResult,
|
||||
TSupportResult extends { status: string; command?: string; message?: string },
|
||||
>(options: {
|
||||
launcherResult: TLauncherResult;
|
||||
assetDescription?: string;
|
||||
updateSupportAssets: () => Promise<TSupportResult[]>;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
}): Promise<TLauncherResult> {
|
||||
const assetDescription = options.assetDescription ?? 'Support asset update';
|
||||
try {
|
||||
const supportResults = await options.updateSupportAssets();
|
||||
for (const result of supportResults) {
|
||||
if (result.status === 'protected' && result.command) {
|
||||
options.logWarn(`${assetDescription} requires manual command: ${result.command}`);
|
||||
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
|
||||
options.logWarn(`${assetDescription} skipped: ${result.message ?? result.status}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
options.logWarn('Support asset update failed after launcher update', error);
|
||||
}
|
||||
return options.launcherResult;
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
import { type BrowserWindow, screen } from 'electron';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
|
||||
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
|
||||
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
||||
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
|
||||
import type { BaseWindowTracker } from '../../window-trackers';
|
||||
import {
|
||||
bindWindowsOverlayAboveMpv,
|
||||
clearWindowsOverlayOwner,
|
||||
findWindowsMpvTargetWindowHandle,
|
||||
getWindowsForegroundProcessName,
|
||||
setWindowsOverlayOwner,
|
||||
} from '../../window-trackers/windows-helper';
|
||||
import {
|
||||
applyLinuxOverlayInputShape,
|
||||
applyLinuxOverlayPointerInteractionMousePassthrough,
|
||||
ensureLinuxOverlayPointerInteractionLoop,
|
||||
type ForegroundSuppressionGraceState,
|
||||
mapOverlayMeasurementForPointerInteraction,
|
||||
resolveForegroundSuppressionWithGrace,
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement,
|
||||
tickLinuxOverlayPointerInteraction,
|
||||
} from './linux-overlay-pointer-interaction';
|
||||
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
|
||||
import {
|
||||
ensureLinuxOverlayZOrderKeepAliveLoop,
|
||||
shouldRunLinuxOverlayZOrderKeepAlive,
|
||||
tickLinuxOverlayZOrderKeepAlive,
|
||||
} from './linux-overlay-zorder-keepalive';
|
||||
import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
|
||||
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
|
||||
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
|
||||
import { hasLiveSeparateWindow } from './settings-window-z-order';
|
||||
|
||||
export interface VisibleOverlayInteractionRuntimeDeps {
|
||||
overlayManager: {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
};
|
||||
overlayContentMeasurementStore: {
|
||||
clear: (layer: 'visible') => void;
|
||||
getLatestByLayer: (layer: 'visible') => OverlayContentMeasurement | null;
|
||||
};
|
||||
logger: {
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
getModalInputExclusive: () => boolean;
|
||||
getStatsOverlayVisible: () => boolean;
|
||||
setStatsOverlayVisible: (visible: boolean) => void;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
getMpvSocketPath: () => string;
|
||||
getBackendOverride: () => string | null;
|
||||
getInitialArgs: () => CliArgs | null;
|
||||
getOverlayRuntimeInitialized: () => boolean;
|
||||
getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode;
|
||||
setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void;
|
||||
bindVisibleOverlayToTrackedX11Window: (window: BrowserWindow) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
refreshCurrentSubtitle: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
resetLastOverlayWindowGeometry: () => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
|
||||
}
|
||||
|
||||
export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInteractionRuntimeDeps) {
|
||||
const { overlayManager, overlayContentMeasurementStore, logger } = deps;
|
||||
|
||||
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
||||
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||
const LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 1_500;
|
||||
// Ignore transient "neither mpv nor overlay is the active window" blips before suppressing
|
||||
// subtitle pointer interaction. Right after playback starts the overlay can briefly become the
|
||||
// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s).
|
||||
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500;
|
||||
const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500;
|
||||
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
|
||||
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
|
||||
const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = {
|
||||
lossSinceMs: null,
|
||||
};
|
||||
let visibleOverlayInteractionActive = false;
|
||||
let linuxOverlayInputShapeActive = false;
|
||||
let linuxVisibleOverlayStartupInputPrimed = false;
|
||||
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
|
||||
// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
|
||||
// moves off measured subtitle/sidebar rects onto the popup.
|
||||
let linuxOverlayInteractiveHint = false;
|
||||
let macOSVisibleOverlayForegroundProbeActive = false;
|
||||
let macOSVisibleOverlayForegroundProbeToken = 0;
|
||||
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const linuxVisibleOverlayOwnerBindingQueues = new WeakMap<BrowserWindow, Promise<void>>();
|
||||
|
||||
const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({
|
||||
setStatsOverlayVisibleState: (visible) => {
|
||||
deps.setStatsOverlayVisible(visible);
|
||||
},
|
||||
resetVisibleOverlayInteraction: () => {
|
||||
visibleOverlayInteractionActive = false;
|
||||
},
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||
});
|
||||
|
||||
function resetVisibleOverlayInputState(): void {
|
||||
visibleOverlayInteractionActive = false;
|
||||
linuxOverlayInputShapeActive = false;
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
linuxOverlayInteractiveHint = false;
|
||||
overlayContentMeasurementStore.clear('visible');
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (process.platform === 'linux' && mainWindow && !mainWindow.isDestroyed()) {
|
||||
restoreLinuxOverlayWindowShape(mainWindow);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreVisibleOverlayWindowShapeForShow(): void {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
restoreLinuxOverlayWindowShape(overlayManager.getMainWindow());
|
||||
}
|
||||
|
||||
function clearVisibleOverlayBlurRefreshTimeouts(): void {
|
||||
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
visibleOverlayBlurRefreshTimeouts = [];
|
||||
}
|
||||
|
||||
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
windowsVisibleOverlayZOrderRetryTimeouts = [];
|
||||
}
|
||||
|
||||
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
|
||||
if (token !== macOSVisibleOverlayForegroundProbeToken) {
|
||||
return;
|
||||
}
|
||||
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||
macOSVisibleOverlayForegroundProbeTimeout = null;
|
||||
}
|
||||
if (!macOSVisibleOverlayForegroundProbeActive) {
|
||||
return;
|
||||
}
|
||||
macOSVisibleOverlayForegroundProbeActive = false;
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
}
|
||||
|
||||
function startMacOSVisibleOverlayForegroundProbe(): void {
|
||||
if (process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
const tracker = deps.getWindowTracker();
|
||||
if (!tracker) {
|
||||
return;
|
||||
}
|
||||
|
||||
macOSVisibleOverlayForegroundProbeActive = true;
|
||||
const token = ++macOSVisibleOverlayForegroundProbeToken;
|
||||
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||
}
|
||||
macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => {
|
||||
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||
}, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS);
|
||||
|
||||
void tracker
|
||||
.refreshNow()
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to refresh macOS frontmost app after overlay blur', error);
|
||||
})
|
||||
.finally(() => {
|
||||
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||
});
|
||||
}
|
||||
|
||||
function getNativeWindowHandleDecimal(window: BrowserWindow): string {
|
||||
const handle = window.getNativeWindowHandle();
|
||||
return handle.length >= 8
|
||||
? handle.readBigUInt64LE(0).toString()
|
||||
: BigInt(handle.readUInt32LE(0)).toString();
|
||||
}
|
||||
|
||||
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
|
||||
return getNativeWindowHandleDecimal(window);
|
||||
}
|
||||
|
||||
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
|
||||
const handle = window.getNativeWindowHandle();
|
||||
return handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
|
||||
}
|
||||
|
||||
function enqueueVisibleOverlayX11OwnerBindingOperation(
|
||||
window: BrowserWindow,
|
||||
args: string[],
|
||||
onError?: (error: Error) => void,
|
||||
): void {
|
||||
const previous = linuxVisibleOverlayOwnerBindingQueues.get(window) ?? Promise.resolve();
|
||||
const operation = previous
|
||||
.catch(() => {})
|
||||
.then(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (window.isDestroyed()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
execFile('xprop', args, { timeout: 1500 }, (error) => {
|
||||
if (error) {
|
||||
onError?.(error);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
const queued = operation.finally(() => {
|
||||
if (linuxVisibleOverlayOwnerBindingQueues.get(window) === queued) {
|
||||
linuxVisibleOverlayOwnerBindingQueues.delete(window);
|
||||
}
|
||||
});
|
||||
linuxVisibleOverlayOwnerBindingQueues.set(window, queued);
|
||||
}
|
||||
|
||||
function clearVisibleOverlayX11OwnerBinding(window: BrowserWindow): void {
|
||||
if (window.isDestroyed()) return;
|
||||
enqueueVisibleOverlayX11OwnerBindingOperation(window, [
|
||||
'-id',
|
||||
getNativeWindowHandleDecimal(window),
|
||||
'-remove',
|
||||
'WM_TRANSIENT_FOR',
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveWindowsOverlayBindTargetHandle(
|
||||
targetMpvSocketPath?: string | null,
|
||||
): number | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (targetMpvSocketPath) {
|
||||
const windowTracker = deps.getWindowTracker() as {
|
||||
getTargetWindowHandle?: () => number | null;
|
||||
} | null;
|
||||
const trackedHandle = windowTracker?.getTargetWindowHandle?.();
|
||||
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
|
||||
return trackedHandle;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return findWindowsMpvTargetWindowHandle();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createOverlayWindowTracker(
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) {
|
||||
const initialArgs = deps.getInitialArgs();
|
||||
if (initialArgs && isHeadlessInitialCommand(initialArgs)) {
|
||||
return null;
|
||||
}
|
||||
return createWindowTrackerCore(override, targetMpvSocketPath);
|
||||
}
|
||||
|
||||
function bindVisibleOverlayOwner(): void {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (process.platform === 'linux') {
|
||||
deps.bindVisibleOverlayToTrackedX11Window(mainWindow);
|
||||
return;
|
||||
}
|
||||
if (process.platform !== 'win32') return;
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||
const targetSocketPath = deps.getMpvSocketPath();
|
||||
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath);
|
||||
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
|
||||
return;
|
||||
}
|
||||
if (targetSocketPath) {
|
||||
return;
|
||||
}
|
||||
const tracker = deps.getWindowTracker();
|
||||
const mpvResult = tracker
|
||||
? (() => {
|
||||
try {
|
||||
const win32 =
|
||||
require('../../window-trackers/win32') as typeof import('../../window-trackers/win32');
|
||||
const poll = win32.findMpvWindows();
|
||||
const focused = poll.matches.find((m) => m.isForeground);
|
||||
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
if (!mpvResult) return;
|
||||
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
|
||||
logger.warn('Failed to set overlay owner via koffi');
|
||||
}
|
||||
}
|
||||
|
||||
function releaseVisibleOverlayOwner(): void {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (process.platform === 'linux') {
|
||||
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
clearVisibleOverlayX11OwnerBinding(mainWindow);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||
if (!clearWindowsOverlayOwner(overlayHwnd)) {
|
||||
logger.warn('Failed to clear overlay owner via koffi');
|
||||
}
|
||||
}
|
||||
|
||||
function startOverlayWindowTrackerForCurrentSocket(): void {
|
||||
startOverlayWindowTrackerCore({
|
||||
backendOverride: deps.getBackendOverride(),
|
||||
getMpvSocketPath: () => deps.getMpvSocketPath(),
|
||||
createWindowTracker: createOverlayWindowTracker,
|
||||
setWindowTracker: (tracker) => {
|
||||
deps.setWindowTracker(tracker);
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.updateVisibleOverlayBounds(geometry),
|
||||
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||
refreshCurrentSubtitle: () => {
|
||||
deps.refreshCurrentSubtitle();
|
||||
},
|
||||
getOverlayWindows: () => deps.getOverlayWindows(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
bindOverlayOwner: () => bindVisibleOverlayOwner(),
|
||||
releaseOverlayOwner: () => releaseVisibleOverlayOwner(),
|
||||
});
|
||||
}
|
||||
|
||||
function retargetOverlayWindowTrackerForMpvSocket(
|
||||
nextSocketPath: string,
|
||||
previousSocketPath: string,
|
||||
): void {
|
||||
if (nextSocketPath === previousSocketPath || !deps.getOverlayRuntimeInitialized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousTracker = deps.getWindowTracker();
|
||||
if (previousTracker) {
|
||||
try {
|
||||
previousTracker.stop();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to stop previous overlay window tracker before retargeting', error);
|
||||
}
|
||||
}
|
||||
|
||||
releaseVisibleOverlayOwner();
|
||||
deps.setWindowTracker(null);
|
||||
deps.setTrackerNotReadyWarningShown(false);
|
||||
deps.resetLastOverlayWindowGeometry();
|
||||
startOverlayWindowTrackerForCurrentSocket();
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.syncOverlayShortcuts();
|
||||
logger.info(
|
||||
`Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (
|
||||
!mainWindow ||
|
||||
mainWindow.isDestroyed() ||
|
||||
!mainWindow.isVisible() ||
|
||||
!overlayManager.getVisibleOverlayVisible()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowTracker = deps.getWindowTracker();
|
||||
if (!windowTracker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||
windowTracker.isTargetWindowMinimized()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(deps.getMpvSocketPath());
|
||||
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
|
||||
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function requestWindowsVisibleOverlayZOrderSync(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (windowsVisibleOverlayZOrderSyncInFlight) {
|
||||
windowsVisibleOverlayZOrderSyncQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
windowsVisibleOverlayZOrderSyncInFlight = true;
|
||||
void syncWindowsVisibleOverlayToMpvZOrder()
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to bind Windows overlay z-order to mpv', error);
|
||||
})
|
||||
.finally(() => {
|
||||
windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
if (!windowsVisibleOverlayZOrderSyncQueued) {
|
||||
return;
|
||||
}
|
||||
|
||||
windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
requestWindowsVisibleOverlayZOrderSync();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
clearWindowsVisibleOverlayZOrderRetryTimeouts();
|
||||
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) {
|
||||
const retryTimeout = setTimeout(() => {
|
||||
windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter(
|
||||
(timeout) => timeout !== retryTimeout,
|
||||
);
|
||||
requestWindowsVisibleOverlayZOrderSync();
|
||||
}, delayMs);
|
||||
windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean {
|
||||
return (
|
||||
process.platform === 'win32' &&
|
||||
lastWindowsVisibleOverlayBlurredAtMs > 0 &&
|
||||
Date.now() - lastWindowsVisibleOverlayBlurredAtMs <=
|
||||
WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
|
||||
);
|
||||
}
|
||||
|
||||
function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean {
|
||||
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowTracker = deps.getWindowTracker();
|
||||
if (!windowTracker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||
windowTracker.isTargetWindowMinimized()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const overlayFocused = mainWindow.isFocused();
|
||||
const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false;
|
||||
return !overlayFocused && !trackerFocused;
|
||||
}
|
||||
|
||||
function maybePollWindowsVisibleOverlayForegroundProcess(): void {
|
||||
if (!shouldPollWindowsVisibleOverlayForegroundProcess()) {
|
||||
lastWindowsVisibleOverlayForegroundProcessName = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const processName = getWindowsForegroundProcessName();
|
||||
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
|
||||
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
|
||||
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
|
||||
|
||||
if (normalizedProcessName !== previousProcessName) {
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
}
|
||||
if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') {
|
||||
requestWindowsVisibleOverlayZOrderSync();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||
if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
||||
maybePollWindowsVisibleOverlayForegroundProcess();
|
||||
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||
if (windowsVisibleOverlayForegroundPollInterval === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(windowsVisibleOverlayForegroundPollInterval);
|
||||
windowsVisibleOverlayForegroundPollInterval = null;
|
||||
}
|
||||
|
||||
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||
}
|
||||
startMacOSVisibleOverlayForegroundProbe();
|
||||
clearVisibleOverlayBlurRefreshTimeouts();
|
||||
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||
const refreshTimeout = setTimeout(() => {
|
||||
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
|
||||
(timeout) => timeout !== refreshTimeout,
|
||||
);
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
}, delayMs);
|
||||
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||
|
||||
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
||||
|
||||
function getLinuxOverlayPointerMeasurement() {
|
||||
const measurement = overlayContentMeasurementStore.getLatestByLayer('visible');
|
||||
return mapOverlayMeasurementForPointerInteraction(measurement);
|
||||
}
|
||||
|
||||
function shouldSuspendLinuxOverlayPointerInteraction(): boolean {
|
||||
return deps.getModalInputExclusive() || deps.getStatsOverlayVisible();
|
||||
}
|
||||
|
||||
function shouldSuppressLinuxOverlayPointerInteraction(): boolean {
|
||||
return resolveForegroundSuppressionWithGrace({
|
||||
hasForegroundSeparateWindow: hasLiveSeparateWindow(
|
||||
deps.getOverlayForegroundSeparateWindows(),
|
||||
),
|
||||
isTrackingMpvWindow: Boolean(deps.getWindowTracker()?.isTracking()),
|
||||
isMpvWindowFocused: deps.getWindowTracker()?.isTargetWindowFocused?.() !== false,
|
||||
isOverlayWindowFocused: overlayManager.getMainWindow()?.isFocused() === true,
|
||||
nowMs: Date.now(),
|
||||
graceMs: LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS,
|
||||
state: linuxPointerForegroundSuppressionGrace,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldUseLinuxOverlayInputShape(): boolean {
|
||||
// Electron's setShape is a *bounding* shape: outside the given rects no pixels are drawn, so
|
||||
// it clips the visible subtitle (and makes a dragged subtitle vanish behind the shaped
|
||||
// region). There is no input-only region API on Linux, so selective hit-testing is handled by
|
||||
// the main-process cursor poll instead. Keep this off to avoid clipping the overlay.
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasLinuxVisibleOverlayStartupInputGrace(): boolean {
|
||||
return (
|
||||
process.platform === 'linux' &&
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs > 0 &&
|
||||
Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs
|
||||
);
|
||||
}
|
||||
|
||||
function clearLinuxVisibleOverlayStartupInputGrace(): void {
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||
}
|
||||
|
||||
function resetLinuxVisibleOverlayStartupInputPrimer(): void {
|
||||
linuxVisibleOverlayStartupInputPrimed = false;
|
||||
clearLinuxVisibleOverlayStartupInputGrace();
|
||||
}
|
||||
|
||||
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
|
||||
if (!shouldUseLinuxOverlayInputShape()) {
|
||||
linuxOverlayInputShapeActive = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = applyLinuxOverlayInputShape({
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||
getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
|
||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||
});
|
||||
linuxOverlayInputShapeActive = result.active;
|
||||
return result.handled;
|
||||
}
|
||||
|
||||
function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
|
||||
visibleOverlayInteractionActive = active;
|
||||
if (
|
||||
process.platform === 'linux' &&
|
||||
applyLinuxOverlayPointerInteractionMousePassthrough({
|
||||
active,
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
}
|
||||
|
||||
function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
if (linuxVisibleOverlayStartupInputPrimed) return;
|
||||
if (shouldUseLinuxOverlayInputShape()) return;
|
||||
if (
|
||||
!shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
linuxVisibleOverlayStartupInputPrimed = true;
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs =
|
||||
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
|
||||
updateLinuxOverlayPointerInteractionActive(true);
|
||||
}
|
||||
|
||||
const linuxOverlayZOrderKeepAliveDeps = {
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
isTrackingMpvWindow: () => Boolean(deps.getWindowTracker()?.isTracking()),
|
||||
isMpvWindowFocused: () => deps.getWindowTracker()?.isTargetWindowFocused?.() !== false,
|
||||
isOverlayWindowFocused: () => overlayManager.getMainWindow()?.isFocused() === true,
|
||||
shouldSuppressReassert: () =>
|
||||
deps.getModalInputExclusive() ||
|
||||
deps.getStatsOverlayVisible() ||
|
||||
hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows()) ||
|
||||
(visibleOverlayInteractionActive && overlayManager.getMainWindow()?.isFocused() !== true),
|
||||
raiseMpvWindow: () => {
|
||||
if (
|
||||
lastLinuxVisibleOverlayFollowedMpvAtMs > 0 &&
|
||||
Date.now() - lastLinuxVisibleOverlayFollowedMpvAtMs <=
|
||||
LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
|
||||
) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
lastLinuxVisibleOverlayFollowedMpvAtMs = Date.now();
|
||||
return deps.getWindowTracker()?.raiseTargetWindow?.() ?? Promise.resolve(false);
|
||||
},
|
||||
releaseOverlayLayerOrder: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.setAlwaysOnTop(false);
|
||||
mainWindow.setFullScreen?.(false);
|
||||
mainWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
|
||||
if (
|
||||
deps.getLinuxVisibleOverlayWindowMode() === 'fullscreen-override' &&
|
||||
mainWindow.isVisible()
|
||||
) {
|
||||
mainWindow.hide();
|
||||
}
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
deps.enforceOverlayLayerOrder();
|
||||
},
|
||||
focusOverlayWindow: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isFocused()) return;
|
||||
mainWindow.focus();
|
||||
},
|
||||
};
|
||||
|
||||
function requestLinuxOverlayZOrderFollow(): void {
|
||||
if (!shouldRunLinuxOverlayZOrderKeepAlive()) return;
|
||||
void tickLinuxOverlayZOrderKeepAlive(linuxOverlayZOrderKeepAliveDeps).catch((error) => {
|
||||
logger.debug(
|
||||
'Failed to follow tracked mpv behind focused overlay:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ensureLinuxOverlayZOrderKeepAliveLoop(linuxOverlayZOrderKeepAliveDeps);
|
||||
|
||||
const linuxOverlayPointerInteractionDeps = {
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getCursorScreenPoint: () =>
|
||||
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
|
||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||
getRendererInteractiveHint: () =>
|
||||
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
|
||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
|
||||
getInteractionActive: () => visibleOverlayInteractionActive,
|
||||
setInteractionActive: updateLinuxOverlayPointerInteractionActive,
|
||||
};
|
||||
|
||||
function tickLinuxOverlayPointerInteractionNow(): void {
|
||||
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) {
|
||||
return;
|
||||
}
|
||||
tickLinuxOverlayPointerInteraction(linuxOverlayPointerInteractionDeps);
|
||||
}
|
||||
|
||||
ensureLinuxOverlayPointerInteractionLoop(linuxOverlayPointerInteractionDeps);
|
||||
|
||||
return {
|
||||
handleStatsOverlayVisibilityChanged,
|
||||
resetVisibleOverlayInputState,
|
||||
restoreVisibleOverlayWindowShapeForShow,
|
||||
startMacOSVisibleOverlayForegroundProbe,
|
||||
getNativeWindowHandleDecimal,
|
||||
getWindowsNativeWindowHandle,
|
||||
getWindowsNativeWindowHandleNumber,
|
||||
enqueueVisibleOverlayX11OwnerBindingOperation,
|
||||
clearVisibleOverlayX11OwnerBinding,
|
||||
createOverlayWindowTracker,
|
||||
bindVisibleOverlayOwner,
|
||||
releaseVisibleOverlayOwner,
|
||||
startOverlayWindowTrackerForCurrentSocket,
|
||||
retargetOverlayWindowTrackerForMpvSocket,
|
||||
requestWindowsVisibleOverlayZOrderSync,
|
||||
scheduleWindowsVisibleOverlayZOrderSyncBurst,
|
||||
hasWindowsVisibleOverlayFocusHandoffGrace,
|
||||
ensureWindowsVisibleOverlayForegroundPollLoop,
|
||||
clearWindowsVisibleOverlayForegroundPollLoop,
|
||||
scheduleVisibleOverlayBlurRefresh,
|
||||
getLinuxOverlayPointerMeasurement,
|
||||
hasLinuxVisibleOverlayStartupInputGrace,
|
||||
clearLinuxVisibleOverlayStartupInputGrace,
|
||||
resetLinuxVisibleOverlayStartupInputPrimer,
|
||||
applyLinuxOverlayInputShapeFromLatestMeasurement,
|
||||
updateLinuxOverlayPointerInteractionActive,
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
|
||||
requestLinuxOverlayZOrderFollow,
|
||||
tickLinuxOverlayPointerInteractionNow,
|
||||
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||
setVisibleOverlayInteractionActive: (active: boolean) => {
|
||||
visibleOverlayInteractionActive = active;
|
||||
},
|
||||
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
|
||||
getLastWindowsVisibleOverlayForegroundProcessName: () =>
|
||||
lastWindowsVisibleOverlayForegroundProcessName,
|
||||
getMacOSVisibleOverlayForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
|
||||
setLinuxOverlayInteractiveHint: (interactive: boolean) => {
|
||||
linuxOverlayInteractiveHint = interactive;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type VisibleOverlayInteractionRuntime = ReturnType<
|
||||
typeof createVisibleOverlayInteractionRuntime
|
||||
>;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
detectInstalledMpvPlugin,
|
||||
filterLegacyMpvPluginFileCandidates,
|
||||
removeLegacyMpvPluginCandidates,
|
||||
resolvePackagedRuntimePluginPath,
|
||||
} from './first-run-setup-plugin';
|
||||
|
||||
export interface WindowsMpvPluginDetectionRuntimeDeps {
|
||||
mainDirname: string;
|
||||
logWarn: (message: string) => void;
|
||||
}
|
||||
|
||||
export function createWindowsMpvPluginDetectionRuntime(
|
||||
deps: WindowsMpvPluginDetectionRuntimeDeps,
|
||||
): {
|
||||
resolveBundledMpvRuntimePluginEntrypoint: () => string | undefined;
|
||||
detectWindowsInstalledMpvPlugin: (
|
||||
mpvExecutablePath: string,
|
||||
) => ReturnType<typeof detectInstalledMpvPlugin>;
|
||||
logInstalledMpvPluginDetected: (detection: {
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
}) => void;
|
||||
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch: (
|
||||
mpvPath: string,
|
||||
detection: { path: string | null; version: string | null },
|
||||
) => Promise<'removed' | 'continue' | 'cancel'>;
|
||||
} {
|
||||
function resolveBundledMpvRuntimePluginEntrypoint(): string | undefined {
|
||||
return (
|
||||
resolvePackagedRuntimePluginPath({
|
||||
dirname: deps.mainDirname,
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
}) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
function detectWindowsInstalledMpvPlugin(mpvExecutablePath: string) {
|
||||
return detectInstalledMpvPlugin({
|
||||
platform: 'win32',
|
||||
homeDir: os.homedir(),
|
||||
appDataDir: app.getPath('appData'),
|
||||
mpvExecutablePath,
|
||||
});
|
||||
}
|
||||
|
||||
function logInstalledMpvPluginDetected(detection: {
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
}) {
|
||||
if (!detection.path) return;
|
||||
deps.logWarn(
|
||||
`SubMiner detected an installed mpv plugin at ${detection.path}. This mpv session will use the installed plugin. Remove it to use the bundled runtime plugin automatically. Detected plugin version: ${detection.version ?? 'unknown or legacy'}.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(
|
||||
mpvPath: string,
|
||||
detection: { path: string | null; version: string | null },
|
||||
): Promise<'removed' | 'continue' | 'cancel'> {
|
||||
const response = await dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: [
|
||||
'SubMiner detected an installed mpv plugin at:',
|
||||
detection.path ?? 'unknown path',
|
||||
'',
|
||||
"This mpv session will use the installed plugin unless it is removed. Remove it now to use SubMiner's bundled runtime plugin automatically.",
|
||||
`Detected plugin version: ${detection.version ?? 'unknown or legacy'}`,
|
||||
].join('\n'),
|
||||
detail:
|
||||
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash.',
|
||||
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
});
|
||||
|
||||
if (response.response === 2) {
|
||||
return 'cancel';
|
||||
}
|
||||
if (response.response === 1) {
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
const result = await removeLegacyMpvPluginCandidates({
|
||||
candidates: filterLegacyMpvPluginFileCandidates(
|
||||
detectInstalledFirstRunPluginCandidates({
|
||||
platform: 'win32',
|
||||
homeDir: os.homedir(),
|
||||
appDataDir: app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
}),
|
||||
),
|
||||
trashItem: (candidatePath) => shell.trashItem(candidatePath),
|
||||
});
|
||||
if (result.ok) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Legacy mpv plugin removed',
|
||||
message:
|
||||
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
|
||||
});
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Could not remove legacy mpv plugin',
|
||||
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
|
||||
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
|
||||
});
|
||||
return 'cancel';
|
||||
}
|
||||
|
||||
return {
|
||||
resolveBundledMpvRuntimePluginEntrypoint,
|
||||
detectWindowsInstalledMpvPlugin,
|
||||
logInstalledMpvPluginDetected,
|
||||
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildYomitanAnkiSettingsKey } from './yomitan-anki-server-sync';
|
||||
|
||||
test('buildYomitanAnkiSettingsKey includes force override policy', () => {
|
||||
assert.notEqual(
|
||||
buildYomitanAnkiSettingsKey({
|
||||
targetUrl: 'http://127.0.0.1:8766',
|
||||
targetDeck: 'Mining',
|
||||
forceOverride: false,
|
||||
}),
|
||||
buildYomitanAnkiSettingsKey({
|
||||
targetUrl: 'http://127.0.0.1:8766',
|
||||
targetDeck: 'Mining',
|
||||
forceOverride: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
} from './yomitan-anki-server';
|
||||
|
||||
export interface YomitanAnkiServerSyncRuntimeDeps {
|
||||
isExternalReadOnlyMode: () => boolean;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getYomitanParserRuntimeDeps: () => Parameters<typeof syncYomitanDefaultAnkiServerCore>[1];
|
||||
logError: (message: string, ...args: unknown[]) => void;
|
||||
logInfo: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function buildYomitanAnkiSettingsKey(options: {
|
||||
targetUrl: string;
|
||||
targetDeck: string;
|
||||
forceOverride: boolean;
|
||||
}): string {
|
||||
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`;
|
||||
}
|
||||
|
||||
export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): {
|
||||
syncYomitanDefaultProfileAnkiServer: () => Promise<void>;
|
||||
} {
|
||||
let lastSyncedYomitanAnkiSettingsKey: string | null = null;
|
||||
|
||||
function getPreferredYomitanAnkiServerUrl(): string {
|
||||
return getPreferredYomitanAnkiServerUrlRuntime(deps.getResolvedConfig().ankiConnect);
|
||||
}
|
||||
|
||||
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||
if (deps.isExternalReadOnlyMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
||||
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||
const targetDeck = ankiConnectConfig?.deck?.trim() ?? '';
|
||||
const forceOverride = ankiConnectConfig
|
||||
? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig)
|
||||
: false;
|
||||
const targetSettingsKey = buildYomitanAnkiSettingsKey({
|
||||
targetUrl,
|
||||
targetDeck,
|
||||
forceOverride,
|
||||
});
|
||||
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const synced = await syncYomitanDefaultAnkiServerCore(
|
||||
targetUrl,
|
||||
deps.getYomitanParserRuntimeDeps(),
|
||||
{
|
||||
error: (message, ...args) => {
|
||||
deps.logError(message, ...args);
|
||||
},
|
||||
info: (message, ...args) => {
|
||||
deps.logInfo(message, ...args);
|
||||
},
|
||||
},
|
||||
{
|
||||
forceOverride,
|
||||
deck: targetDeck,
|
||||
},
|
||||
);
|
||||
|
||||
if (synced) {
|
||||
lastSyncedYomitanAnkiSettingsKey = targetSettingsKey;
|
||||
}
|
||||
}
|
||||
|
||||
return { syncYomitanDefaultProfileAnkiServer };
|
||||
}
|
||||
@@ -980,8 +980,6 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
|
||||
test('default keybindings dispatch through overlay keyboard handling', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
const specialActionIds: Record<string, string> = {
|
||||
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
|
||||
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
|
||||
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
||||
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
|
||||
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
|
||||
|
||||
@@ -96,18 +96,17 @@ function describeCommand(command: (string | number)[]): string {
|
||||
if (command[1] < 0) return 'Jump to previous subtitle';
|
||||
return 'Reload current subtitle timing';
|
||||
}
|
||||
if (first === 'sub-step' && typeof command[1] === 'number') {
|
||||
if (command[1] > 0) return 'Shift subtitle delay to next cue';
|
||||
if (command[1] < 0) return 'Shift subtitle delay to previous cue';
|
||||
return 'Reload current subtitle timing';
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
|
||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
|
||||
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
|
||||
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
|
||||
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
||||
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
|
||||
return 'Shift subtitle delay to next cue';
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
|
||||
return 'Shift subtitle delay to previous cue';
|
||||
}
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
const [, rawId, rawDirection] = first.split(':');
|
||||
return `Cycle runtime option ${rawId || 'option'} ${
|
||||
@@ -131,6 +130,7 @@ function sectionForCommand(command: (string | number)[]): string {
|
||||
first === 'cycle' ||
|
||||
first === 'seek' ||
|
||||
first === 'sub-seek' ||
|
||||
first === 'sub-step' ||
|
||||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
|
||||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
|
||||
) {
|
||||
@@ -227,10 +227,6 @@ function describeSessionAction(
|
||||
return 'Replay current subtitle';
|
||||
case 'playNextSubtitle':
|
||||
return 'Play next subtitle';
|
||||
case 'shiftSubDelayPrevLine':
|
||||
return 'Shift subtitle delay to previous cue';
|
||||
case 'shiftSubDelayNextLine':
|
||||
return 'Shift subtitle delay to next cue';
|
||||
case 'cycleRuntimeOption':
|
||||
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
|
||||
payload?.direction === -1 ? 'previous' : 'next'
|
||||
@@ -271,8 +267,6 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
return 'Modals and tools';
|
||||
case 'replayCurrentSubtitle':
|
||||
case 'playNextSubtitle':
|
||||
case 'shiftSubDelayPrevLine':
|
||||
case 'shiftSubDelayNextLine':
|
||||
return 'Playback and navigation';
|
||||
case 'cycleRuntimeOption':
|
||||
return 'Runtime settings';
|
||||
|
||||
@@ -3,7 +3,6 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
|
||||
import { createRendererState } from '../state.js';
|
||||
import {
|
||||
buildSessionHelpSections,
|
||||
@@ -17,13 +16,10 @@ test('session help describes sub-seek commands as subtitle-line navigation', ()
|
||||
assert.equal(describeSessionHelpCommand(['sub-seek', -1]), 'Jump to previous subtitle');
|
||||
});
|
||||
|
||||
test('session help describes subtitle-delay shift special commands separately from sub-seek', () => {
|
||||
test('session help describes native subtitle-delay step commands separately from sub-seek', () => {
|
||||
assert.equal(describeSessionHelpCommand(['sub-step', 1]), 'Shift subtitle delay to next cue');
|
||||
assert.equal(
|
||||
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]),
|
||||
'Shift subtitle delay to next cue',
|
||||
);
|
||||
assert.equal(
|
||||
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]),
|
||||
describeSessionHelpCommand(['sub-step', -1]),
|
||||
'Shift subtitle delay to previous cue',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -43,8 +43,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'openPlaylistBrowser',
|
||||
'replayCurrentSubtitle',
|
||||
'playNextSubtitle',
|
||||
'shiftSubDelayPrevLine',
|
||||
'shiftSubDelayNextLine',
|
||||
'cycleRuntimeOption',
|
||||
];
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ export type SessionActionId =
|
||||
| 'openPlaylistBrowser'
|
||||
| 'replayCurrentSubtitle'
|
||||
| 'playNextSubtitle'
|
||||
| 'shiftSubDelayPrevLine'
|
||||
| 'shiftSubDelayNextLine'
|
||||
| 'cycleRuntimeOption';
|
||||
|
||||
export interface SessionKeySpec {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"happy-dom": "^20.10.2",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^6.3.0",
|
||||
@@ -239,16 +240,24 @@
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
@@ -291,6 +300,8 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
@@ -307,6 +318,8 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"happy-dom": ["happy-dom@20.10.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
@@ -399,12 +412,18 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||
|
||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
|
||||
|
||||
+2
-1
@@ -15,11 +15,12 @@
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"happy-dom": "^20.10.2",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { AnilistSelector } from './AnilistSelector';
|
||||
|
||||
interface TestWindow extends Window {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
|
||||
function installDom(): () => void {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
const previousISReactActEnvironment = (
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT;
|
||||
const window = new Window() as TestWindow;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: window.HTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: previousWindow,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: previousDocument,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: previousHTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = previousISReactActEnvironment;
|
||||
};
|
||||
}
|
||||
|
||||
function renderSelector(root: Root, props: { animeId: number; initialQuery: string }): void {
|
||||
root.render(
|
||||
<AnilistSelector
|
||||
animeId={props.animeId}
|
||||
initialQuery={props.initialQuery}
|
||||
onClose={() => {}}
|
||||
onLinked={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function inputValue(container: Element): string {
|
||||
const input = container.querySelector('input');
|
||||
assert.ok(input);
|
||||
return input.value;
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('AnilistSelector resyncs normalized query and searches when the initial anime changes', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const originalSearchAnilist = apiClient.searchAnilist;
|
||||
const secondSearch = deferred<Awaited<ReturnType<typeof apiClient.searchAnilist>>>();
|
||||
const searchCalls: string[] = [];
|
||||
|
||||
apiClient.searchAnilist = (async (query: string) => {
|
||||
searchCalls.push(query);
|
||||
if (query === 'My Hero Academia') {
|
||||
return secondSearch.promise;
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
episodes: 1,
|
||||
season: null,
|
||||
seasonYear: null,
|
||||
description: null,
|
||||
coverImage: null,
|
||||
title: { romaji: 'First Result', english: null, native: null },
|
||||
},
|
||||
];
|
||||
}) as typeof apiClient.searchAnilist;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
renderSelector(root, { animeId: 1, initialQuery: 'Sword Art Online Season 1' });
|
||||
});
|
||||
|
||||
assert.equal(inputValue(container), 'Sword Art Online');
|
||||
assert.deepEqual(searchCalls, ['Sword Art Online']);
|
||||
assert.match(container.textContent ?? '', /First Result/);
|
||||
|
||||
await act(async () => {
|
||||
renderSelector(root, { animeId: 2, initialQuery: 'My Hero Academia: Season 3' });
|
||||
});
|
||||
|
||||
assert.equal(inputValue(container), 'My Hero Academia');
|
||||
assert.deepEqual(searchCalls, ['Sword Art Online', 'My Hero Academia']);
|
||||
assert.doesNotMatch(container.textContent ?? '', /First Result/);
|
||||
assert.match(container.textContent ?? '', /Searching/);
|
||||
|
||||
await act(async () => {
|
||||
secondSearch.resolve([
|
||||
{
|
||||
id: 2,
|
||||
episodes: 2,
|
||||
season: null,
|
||||
seasonYear: null,
|
||||
description: null,
|
||||
coverImage: null,
|
||||
title: { romaji: 'Second Result', english: null, native: null },
|
||||
},
|
||||
]);
|
||||
await secondSearch.promise;
|
||||
});
|
||||
|
||||
assert.match(container.textContent ?? '', /Second Result/);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
apiClient.searchAnilist = originalSearchAnilist;
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { normalizeAnilistSearchQuery } from '../../lib/anilist-search-query';
|
||||
|
||||
interface AnilistMedia {
|
||||
id: number;
|
||||
@@ -24,7 +25,7 @@ export function AnilistSelector({
|
||||
onClose,
|
||||
onLinked,
|
||||
}: AnilistSelectorProps) {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [query, setQuery] = useState(() => normalizeAnilistSearchQuery(initialQuery));
|
||||
const [results, setResults] = useState<AnilistMedia[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [linking, setLinking] = useState<number | null>(null);
|
||||
@@ -33,17 +34,24 @@ export function AnilistSelector({
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
if (initialQuery) doSearch(initialQuery);
|
||||
}, []);
|
||||
const normalizedInitialQuery = normalizeAnilistSearchQuery(initialQuery);
|
||||
setQuery(normalizedInitialQuery);
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
setLinking(null);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (normalizedInitialQuery) doSearch(normalizedInitialQuery);
|
||||
}, [initialQuery, animeId]);
|
||||
|
||||
const doSearch = async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
const searchQuery = normalizeAnilistSearchQuery(q);
|
||||
if (!searchQuery) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiClient.searchAnilist(q.trim());
|
||||
const data = await apiClient.searchAnilist(searchQuery);
|
||||
setResults(data);
|
||||
} catch {
|
||||
setResults([]);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeAnilistSearchQuery } from './anilist-search-query';
|
||||
|
||||
test('normalizeAnilistSearchQuery removes appended season scope from anime titles', () => {
|
||||
assert.equal(normalizeAnilistSearchQuery('Sword Art Online Season 1'), 'Sword Art Online');
|
||||
assert.equal(normalizeAnilistSearchQuery('KonoSuba Season 02'), 'KonoSuba');
|
||||
});
|
||||
|
||||
test('normalizeAnilistSearchQuery removes bracketed season scope without dropping real title text', () => {
|
||||
assert.equal(normalizeAnilistSearchQuery('KonoSuba (Season 2)'), 'KonoSuba');
|
||||
assert.equal(normalizeAnilistSearchQuery('KonoSuba - Season 2'), 'KonoSuba');
|
||||
});
|
||||
|
||||
test('normalizeAnilistSearchQuery removes colon-delimited season scope from anime titles', () => {
|
||||
assert.equal(normalizeAnilistSearchQuery('My Hero Academia: Season 3'), 'My Hero Academia');
|
||||
assert.equal(normalizeAnilistSearchQuery('Title: Season 01'), 'Title');
|
||||
});
|
||||
|
||||
test('normalizeAnilistSearchQuery keeps inputs when stripping season scope would erase title', () => {
|
||||
assert.equal(normalizeAnilistSearchQuery('Season 1'), 'Season 1');
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export function normalizeAnilistSearchQuery(query: string): string {
|
||||
const trimmed = query.trim().replace(/\s+/g, ' ');
|
||||
const withoutSeason = trimmed
|
||||
.replace(/\s*[\[(]\s*Season\s+0?\d+\s*[\])]\s*$/i, '')
|
||||
.replace(/\s*[-:]\s*Season\s+0?\d+\s*$/i, '')
|
||||
.replace(/\s+Season\s+0?\d+\s*$/i, '')
|
||||
.trim();
|
||||
return withoutSeason.length > 0 ? withoutSeason : trimmed;
|
||||
}
|
||||
Reference in New Issue
Block a user