mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-17 03:13:30 -07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
3aee88c150
|
|||
| 70da3ee8bd | |||
|
aa8eb753f6
|
|||
|
8d73de8731
|
|||
|
5a98397efe
|
|||
| a117c5759c | |||
| ae7e6f82a8 | |||
| 1158be5b39 | |||
| 33e767458f | |||
| 94a65416ae | |||
| 0a384a22c9 | |||
|
b3b45521b6
|
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Fixed Highlight Word not bolding the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence field did not already contain bold markup.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: notifications
|
||||||
|
|
||||||
|
- Restored the SubMiner app icon on system notifications that do not provide a custom notification image.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Fixed Lapis/Kiku word cards enriched through SubMiner missing the word-and-sentence marker, which could hide sentence context on the card front.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: updates
|
||||||
|
|
||||||
|
- Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets if either one is missing.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updates
|
||||||
|
|
||||||
|
- Fixed Linux updates so the managed support-asset install now creates and refreshes both the launcher runtime plugin copy and the rofi theme alongside AppImage and launcher updates.
|
||||||
|
- Fixed Linux support-asset refreshes so unrelated SubMiner data directories are left alone and plugin copies are staged before replacing the live runtime plugin.
|
||||||
|
- Fixed first Linux launcher playback on fresh installs by auto-installing the managed runtime plugin copy and rofi theme from the bundled app before mpv starts when either support asset is missing.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed macOS Yomitan popup focus after card mining or popup reload while still allowing click-away to close the popup without a hide/reappear cycle.
|
||||||
|
- Fixed macOS Yomitan popups staying open when clicking transparent overlay space; click-away is captured for popup close, then passthrough returns to mpv.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed auto-paused Linux visible overlay startup so the overlay stays interactive during the first measurement gap, startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv `sub-text` refreshes parsed cues before synthetic warm readiness can resume playback.
|
||||||
@@ -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: overlay
|
||||||
|
|
||||||
|
- Kept the visible overlay active while mpv advances to the next playlist item, even when the next episode loads after the warm transition delay.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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.
|
||||||
|
- Scoped prerelease note reuse to the same base version so a new beta line starts from current fragments instead of stale notes from older prereleases.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: internal
|
||||||
|
area: runtime
|
||||||
|
|
||||||
|
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
|
||||||
|
- Hardened split runtime helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Fixed known-word cache refreshes without a configured deck by using AnkiConnect's valid all-notes query instead of `is:note`.
|
||||||
|
- Fixed Windows media generation after background launches by recreating missing FFmpeg temp output directories before clipping audio or images.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: dictionary
|
||||||
|
|
||||||
|
- Fixed Windows `SubMiner mpv` shortcut launches so character dictionary auto-sync can fall back to mpv's current video path when app media state is not ready yet.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed shaky Windows subtitle-bar hover/click interaction when a video attaches to an already-running background SubMiner app.
|
||||||
+31
-5
@@ -172,7 +172,7 @@
|
|||||||
"updates": {
|
"updates": {
|
||||||
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
"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
|
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
|
||||||
}, // Automatic update check behavior.
|
}, // Automatic update check behavior.
|
||||||
|
|
||||||
@@ -290,15 +290,41 @@
|
|||||||
] // Command setting.
|
] // Command setting.
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+BracketRight", // Key setting.
|
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
|
||||||
"command": [
|
"command": [
|
||||||
"__sub-delay-next-line"
|
"sub-step",
|
||||||
|
-1
|
||||||
] // Command setting.
|
] // Command setting.
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+BracketLeft", // Key setting.
|
"key": "Ctrl+Shift+ArrowRight", // Key setting.
|
||||||
"command": [
|
"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.
|
] // 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.
|
- **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.
|
- **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.
|
- **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**
|
**Fixed**
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ Configure automatic update checks and update notifications:
|
|||||||
"updates": {
|
"updates": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"checkIntervalHours": 24,
|
"checkIntervalHours": 24,
|
||||||
"notificationType": "both",
|
"notificationType": "overlay",
|
||||||
"channel": "stable"
|
"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. |
|
| `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`. |
|
| `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. |
|
| `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.
|
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:**
|
**Default keybindings:**
|
||||||
|
|
||||||
| Key | Command | Description |
|
| Key | Command | Description |
|
||||||
| -------------------- | ----------------------------- | --------------------------------------- |
|
| ----------------------- | ----------------------------- | --------------------------------------- |
|
||||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||||
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
|
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
|
||||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
| `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 |
|
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
||||||
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
|
| `Ctrl+Shift+ArrowLeft` | `["sub-step", -1]` | Shift subtitle delay to previous cue |
|
||||||
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next 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+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||||
@@ -616,11 +619,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
|||||||
{ "key": "Space", "command": null }
|
{ "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.)
|
**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.
|
**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 |
|
| Option | Values | Description |
|
||||||
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
| `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"`) |
|
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+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.
|
**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 |
|
| Option | Values | Description |
|
||||||
| ------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
| `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) |
|
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Three steps to get started:
|
|||||||
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
||||||
|
|
||||||
| Dependency | Status | What it does |
|
| Dependency | Status | What it does |
|
||||||
| -------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
||||||
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
||||||
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
||||||
@@ -300,11 +300,11 @@ subminer -u
|
|||||||
subminer --update
|
subminer --update
|
||||||
```
|
```
|
||||||
|
|
||||||
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer` plus the rofi theme at `SubMiner/themes/subminer.rasi`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
||||||
|
|
||||||
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes the managed support assets from `subminer-assets.tar.gz`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
||||||
|
|
||||||
`subminer -u` also performs the AppImage update directly from the launcher process, which is useful when SubMiner is not currently running.
|
`subminer -u` also performs the AppImage, launcher, and managed support-asset updates directly from the launcher process, which is useful when SubMiner is not currently running.
|
||||||
|
|
||||||
## How It All Fits Together
|
## How It All Fits Together
|
||||||
|
|
||||||
@@ -312,13 +312,14 @@ SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IP
|
|||||||
|
|
||||||
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
|
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
|
||||||
|
|
||||||
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher now checks for its managed runtime plugin copy and rofi theme before every mpv-managed launch and installs those support assets from the bundled app automatically if either one is missing. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
||||||
|
|
||||||
## Platform Notes
|
## Platform Notes
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
**MeCab paths (Homebrew):**
|
**MeCab paths (Homebrew):**
|
||||||
|
|
||||||
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
|
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
|
||||||
- Intel: `/usr/local/bin/mecab`
|
- Intel: `/usr/local/bin/mecab`
|
||||||
|
|
||||||
@@ -361,17 +362,21 @@ sudo chmod +x /usr/local/bin/subminer
|
|||||||
|
|
||||||
## Optional Extras
|
## Optional Extras
|
||||||
|
|
||||||
### Rofi Theme (Linux Only)
|
### Linux Support Assets
|
||||||
|
|
||||||
SubMiner ships a custom rofi theme in the release assets:
|
SubMiner ships the Linux rofi theme plus the launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.local/share/SubMiner/themes
|
mkdir -p ~/.local/share/SubMiner/themes
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||||
|
mkdir -p ~/.local/share/SubMiner/plugin
|
||||||
|
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
||||||
```
|
```
|
||||||
|
|
||||||
Override with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs the managed runtime plugin copy and rofi theme from the bundled app if either support asset is missing, so manual extraction is mainly useful for pre-seeding or custom setups.
|
||||||
|
|
||||||
|
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
||||||
|
|
||||||
Next: [Usage](/usage) - learn about the `subminer` wrapper, keybindings, and YouTube playback.
|
Next: [Usage](/usage) - learn about the `subminer` wrapper, keybindings, and YouTube playback.
|
||||||
|
|||||||
@@ -34,15 +34,19 @@ subminer -R -r -d ~/Anime # rofi picker, recursive
|
|||||||
subminer -R /directory # rofi picker, directory shortcut
|
subminer -R /directory # rofi picker, directory shortcut
|
||||||
```
|
```
|
||||||
|
|
||||||
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships a custom rofi theme bundled in the release assets tarball:
|
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme plus the Linux launcher-managed runtime plugin copy in the release assets tarball:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.local/share/SubMiner/themes
|
mkdir -p ~/.local/share/SubMiner/themes
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||||
|
mkdir -p ~/.local/share/SubMiner/plugin
|
||||||
|
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Once the `SubMiner` data dir exists, `subminer -u` refreshes both assets automatically. Normal Linux launcher playback also checks for the managed runtime plugin copy and rofi theme before mpv launch and installs them from the bundled app automatically if either one is missing.
|
||||||
|
|
||||||
The theme is auto-detected from these paths (first match wins):
|
The theme is auto-detected from these paths (first match wins):
|
||||||
|
|
||||||
- `$SUBMINER_ROFI_THEME` environment variable (absolute path)
|
- `$SUBMINER_ROFI_THEME` environment variable (absolute path)
|
||||||
@@ -113,7 +117,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||||
|
|
||||||
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, and rofi theme even when SubMiner is already running in the tray.
|
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, runtime plugin copy, and rofi theme even when SubMiner is already running in the tray.
|
||||||
|
|
||||||
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
|
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ The plugin ships as a modular Lua package under `plugin/subminer/` (entry point
|
|||||||
|
|
||||||
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
|
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
|
||||||
|
|
||||||
|
On Linux, the launcher-managed runtime plugin copy lives under the SubMiner data dir (`$XDG_DATA_HOME/SubMiner/plugin/subminer` by default, plus `/usr/local/share/SubMiner` or `/usr/share/SubMiner` for system installs). `subminer -u` and the tray updater keep that managed copy current. This is separate from mpv's global `scripts/` directory.
|
||||||
|
|
||||||
If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available.
|
If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available.
|
||||||
|
|
||||||
mpv must have IPC enabled for SubMiner to connect:
|
mpv must have IPC enabled for SubMiner to connect:
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
"updates": {
|
"updates": {
|
||||||
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
"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
|
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
|
||||||
}, // Automatic update check behavior.
|
}, // Automatic update check behavior.
|
||||||
|
|
||||||
@@ -290,15 +290,41 @@
|
|||||||
] // Command setting.
|
] // Command setting.
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+BracketRight", // Key setting.
|
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
|
||||||
"command": [
|
"command": [
|
||||||
"__sub-delay-next-line"
|
"sub-step",
|
||||||
|
-1
|
||||||
] // Command setting.
|
] // Command setting.
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+BracketLeft", // Key setting.
|
"key": "Ctrl+Shift+ArrowRight", // Key setting.
|
||||||
"command": [
|
"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.
|
] // 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.
|
These control playback and subtitle display. They require overlay window focus.
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| -------------------- | --------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------- |
|
||||||
| `Space` | Toggle mpv pause |
|
| `Space` | Toggle mpv pause |
|
||||||
| `F` | Toggle fullscreen |
|
| `F` | Toggle fullscreen |
|
||||||
| `V` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
| `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 |
|
| `ArrowDown` | Seek backward 60 seconds |
|
||||||
| `Shift+H` | Jump to previous subtitle |
|
| `Shift+H` | Jump to previous subtitle |
|
||||||
| `Shift+L` | Jump to next subtitle |
|
| `Shift+L` | Jump to next subtitle |
|
||||||
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
|
| `Ctrl+Shift+Left` | Shift subtitle delay to previous subtitle cue |
|
||||||
| `Shift+]` | Shift subtitle delay to next 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+H` | Replay current subtitle (play to end, then pause) |
|
||||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
||||||
| `Q` | Quit mpv |
|
| `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) |
|
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
||||||
|
|
||||||
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.
|
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
|
## Subtitle & Feature Shortcuts
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
| ------------------ | -------------------------------------------------------- | ----------------------------------------------- |
|
| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
|
||||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
| `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+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
|
||||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
| `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.
|
When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key within 1 second.
|
||||||
|
|
||||||
| Chord | Action |
|
| Chord | Action |
|
||||||
| ----- | -------------------------------------- |
|
| ----- | ---------------------------------------------------------- |
|
||||||
| `y-y` | Open SubMiner menu (OSD) |
|
| `y-y` | Open SubMiner menu (OSD) |
|
||||||
| `y-s` | Start overlay |
|
| `y-s` | Start overlay |
|
||||||
| `y-S` | Stop overlay |
|
| `y-S` | Stop overlay |
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
|
|||||||
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
|
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
|
||||||
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
|
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
|
||||||
|
|
||||||
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
||||||
|
|
||||||
## Live Config Reload
|
## Live Config Reload
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -61,8 +61,10 @@
|
|||||||
committed file — so review it before committing. If you add more
|
committed file — so review it before committing. If you add more
|
||||||
`changes/*.md` fragments for a later beta/RC, rerun
|
`changes/*.md` fragments for a later beta/RC, rerun
|
||||||
`bun run changelog:prerelease-notes --version <version>`; the generator uses
|
`bun run changelog:prerelease-notes --version <version>`; the generator uses
|
||||||
the existing prerelease notes as the baseline and asks Claude to merge only
|
the existing prerelease notes as the baseline only when their hidden
|
||||||
the new fragment material. Do not run `bun run changelog:build`.
|
`prerelease-base-version` marker matches the current base version, and asks
|
||||||
|
Claude to merge only the new fragment material. Do not run
|
||||||
|
`bun run changelog:build`.
|
||||||
6. Tag the commit: `git tag v<version>`.
|
6. Tag the commit: `git tag v<version>`.
|
||||||
7. Push commit + tag.
|
7. Push commit + tag.
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ Notes:
|
|||||||
- `changelog:check` now rejects tag/package version mismatches.
|
- `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: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.
|
- `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.
|
- 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.
|
- 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.
|
- 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
|
#### 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:**
|
**Parsed cue structure:**
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-06-01
|
Last verified: 2026-06-14
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||||
|
|
||||||
@@ -79,9 +79,19 @@ prefetch work and re-centers prefetch around the live playback time.
|
|||||||
window so readiness can still arrive before fallback resumes playback.
|
window so readiness can still arrive before fallback resumes playback.
|
||||||
- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
|
- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
|
||||||
waits for a fresh measured subtitle rectangle before signaling readiness.
|
waits for a fresh measured subtitle rectangle before signaling readiness.
|
||||||
|
- If the startup subtitle has no cached annotations yet, autoplay priming emits a raw first-paint
|
||||||
|
subtitle payload before background tokenization. The tokenized payload replaces it when ready, but
|
||||||
|
the visible overlay can paint and measure the line before the mpv startup gate resumes playback.
|
||||||
|
- If startup `sub-text` is temporarily empty, autoplay priming refreshes the active subtitle source
|
||||||
|
and then awaits cue-based priming before synthetic warm readiness can proceed. A parsed current or
|
||||||
|
imminent cue is treated as the startup subtitle so the visible overlay can paint and measure it
|
||||||
|
before playback resumes.
|
||||||
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
|
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
|
||||||
tokenization warmup and visible overlay content-ready. This releases playback without waiting for
|
tokenization warmup and visible overlay content-ready. This releases playback without waiting for
|
||||||
a later subtitle event that cannot happen while mpv is paused.
|
a later subtitle event that cannot happen while mpv is paused.
|
||||||
|
- After a synthetic warm readiness release, SubMiner briefly polls/refreshes the current subtitle
|
||||||
|
again. This covers Linux/mpv startup cases where `sub-text` is still empty while paused but becomes
|
||||||
|
available right after playback resumes, without waiting for the next subtitle property change.
|
||||||
|
|
||||||
## Linux/X11 Window Shape
|
## Linux/X11 Window Shape
|
||||||
|
|
||||||
@@ -95,6 +105,15 @@ prefetch work and re-centers prefetch around the live playback time.
|
|||||||
overlay window remained mapped above mpv.
|
overlay window remained mapped above mpv.
|
||||||
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
|
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
|
||||||
the Linux cursor-poll fallback, not bounding-shape clipping.
|
the Linux cursor-poll fallback, not bounding-shape clipping.
|
||||||
|
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
|
||||||
|
interaction state is already inactive. The next cursor-poll tick must still reapply
|
||||||
|
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
|
||||||
|
full-window input capture and block both mpv and overlay controls before the first subtitle
|
||||||
|
measurement.
|
||||||
|
- Visible-overlay show also starts a short Linux input grace before the first content measurement.
|
||||||
|
Native Wayland surfaces can become inert while `setIgnoreMouseEvents(true)` is active; keeping the
|
||||||
|
overlay interactive during this startup gap lets notifications and overlay mouse bindings work
|
||||||
|
until subtitle/sidebar/notification rectangles are reported.
|
||||||
|
|
||||||
## Config And Migration
|
## Config And Migration
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { runConfigCommand } from './config-command.js';
|
|||||||
import { runDictionaryCommand } from './dictionary-command.js';
|
import { runDictionaryCommand } from './dictionary-command.js';
|
||||||
import { runDoctorCommand } from './doctor-command.js';
|
import { runDoctorCommand } from './doctor-command.js';
|
||||||
import { runLogsCommand } from './logs-command.js';
|
import { runLogsCommand } from './logs-command.js';
|
||||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
import { runMpvPostAppCommand, runMpvPreAppCommand } from './mpv-command.js';
|
||||||
import { runAppPassthroughCommand } from './app-command.js';
|
import { runAppPassthroughCommand } from './app-command.js';
|
||||||
import { runStatsCommand } from './stats-command.js';
|
import { runStatsCommand } from './stats-command.js';
|
||||||
import { runUpdateCommand } from './update-command.js';
|
import { runUpdateCommand } from './update-command.js';
|
||||||
@@ -262,7 +262,9 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
|
|||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
async () => {
|
async () => {
|
||||||
await runMpvPreAppCommand(context, {
|
await runMpvPreAppCommand(context, {
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
waitForUnixSocketReady: async () => false,
|
waitForUnixSocketReady: async () => false,
|
||||||
|
resolveRuntimePluginPath: () => null,
|
||||||
launchMpvIdleDetached: async () => {},
|
launchMpvIdleDetached: async () => {},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -270,6 +272,32 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mpv idle command ensures Linux runtime plugin before detached launch', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.mpvIdle = true;
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const handled = await runMpvPostAppCommand(context, {
|
||||||
|
ensureRuntimePluginReady: async () => {
|
||||||
|
calls.push('plugin');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => {
|
||||||
|
calls.push('wait');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
launchMpvIdleDetached: async () => {
|
||||||
|
calls.push('launch');
|
||||||
|
},
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return '/tmp/plugin/main.lua';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait']);
|
||||||
|
});
|
||||||
|
|
||||||
test('dictionary command forwards --dictionary and target path to app binary', () => {
|
test('dictionary command forwards --dictionary and target path to app binary', () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args.dictionary = true;
|
context.args.dictionary = true;
|
||||||
@@ -361,7 +389,7 @@ test('update command runs direct Linux release update without launching Electron
|
|||||||
'direct:/tmp/subminer.app:/tmp/subminer:stable',
|
'direct:/tmp/subminer.app:/tmp/subminer:stable',
|
||||||
'info:AppImage update: not-found',
|
'info:AppImage update: not-found',
|
||||||
'info:Launcher update: updated',
|
'info:Launcher update: updated',
|
||||||
'info:Rofi theme update: skipped',
|
'info:Support assets update: skipped',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
resolveLauncherRuntimePluginPath,
|
resolveLauncherRuntimePluginPath,
|
||||||
} from '../mpv.js';
|
} from '../mpv.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
||||||
|
|
||||||
interface MpvCommandDeps {
|
interface MpvCommandDeps {
|
||||||
|
ensureRuntimePluginReady(context: LauncherCommandContext): Promise<void>;
|
||||||
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
|
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
|
||||||
|
resolveRuntimePluginPath(context: LauncherCommandContext): string | null;
|
||||||
launchMpvIdleDetached(
|
launchMpvIdleDetached(
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
appPath: string,
|
appPath: string,
|
||||||
@@ -18,7 +21,19 @@ interface MpvCommandDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultDeps: MpvCommandDeps = {
|
const defaultDeps: MpvCommandDeps = {
|
||||||
|
ensureRuntimePluginReady: async (context) => {
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
appPath: context.appPath ?? undefined,
|
||||||
|
scriptPath: context.scriptPath,
|
||||||
|
logLevel: context.args.logLevel,
|
||||||
|
});
|
||||||
|
},
|
||||||
waitForUnixSocketReady,
|
waitForUnixSocketReady,
|
||||||
|
resolveRuntimePluginPath: (context) =>
|
||||||
|
resolveLauncherRuntimePluginPath({
|
||||||
|
appPath: context.appPath ?? '',
|
||||||
|
scriptPath: context.scriptPath,
|
||||||
|
}),
|
||||||
launchMpvIdleDetached,
|
launchMpvIdleDetached,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,11 +73,12 @@ export async function runMpvPostAppCommand(
|
|||||||
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await deps.ensureRuntimePluginReady(context);
|
||||||
await deps.launchMpvIdleDetached(
|
await deps.launchMpvIdleDetached(
|
||||||
mpvSocketPath,
|
mpvSocketPath,
|
||||||
appPath,
|
appPath,
|
||||||
args,
|
args,
|
||||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
deps.resolveRuntimePluginPath(context),
|
||||||
{
|
{
|
||||||
...pluginRuntimeConfig,
|
...pluginRuntimeConfig,
|
||||||
backend: args.backend,
|
backend: args.backend,
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
|||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
|
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -161,6 +162,7 @@ test('youtube app-owned playback disables mpv plugin auto-start', async () => {
|
|||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
|
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -227,6 +229,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
|||||||
try {
|
try {
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -278,6 +281,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
|
|||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -351,6 +355,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
|||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -420,6 +425,7 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
|
|||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -441,3 +447,34 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
target: '/tmp/movie.mkv',
|
||||||
|
targetKind: 'file',
|
||||||
|
};
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {
|
||||||
|
calls.push('plugin');
|
||||||
|
},
|
||||||
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async () => {
|
||||||
|
calls.push('startMpv');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async () => {},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['plugin', 'startMpv']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { Args } from '../types.js';
|
|||||||
import { nowMs } from '../time.js';
|
import { nowMs } from '../time.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { ensureLauncherSetupReady } from '../setup-gate.js';
|
import { ensureLauncherSetupReady } from '../setup-gate.js';
|
||||||
|
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
||||||
import {
|
import {
|
||||||
getDefaultConfigDir,
|
getDefaultConfigDir,
|
||||||
getSetupStatePath,
|
getSetupStatePath,
|
||||||
@@ -144,6 +145,13 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||||
return runPlaybackCommandWithDeps(context, {
|
return runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady,
|
ensurePlaybackSetupReady,
|
||||||
|
ensureRuntimePluginReady: async (commandContext) => {
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
appPath: commandContext.appPath ?? undefined,
|
||||||
|
scriptPath: commandContext.scriptPath,
|
||||||
|
logLevel: commandContext.args.logLevel,
|
||||||
|
});
|
||||||
|
},
|
||||||
chooseTarget,
|
chooseTarget,
|
||||||
checkDependencies,
|
checkDependencies,
|
||||||
registerCleanup,
|
registerCleanup,
|
||||||
@@ -160,6 +168,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
|
|
||||||
type PlaybackCommandDeps = {
|
type PlaybackCommandDeps = {
|
||||||
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
|
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
|
||||||
|
ensureRuntimePluginReady: (context: LauncherCommandContext) => Promise<void>;
|
||||||
chooseTarget: (
|
chooseTarget: (
|
||||||
args: Args,
|
args: Args,
|
||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
@@ -253,6 +262,8 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await deps.ensureRuntimePluginReady(context);
|
||||||
|
|
||||||
await deps.startMpv(
|
await deps.startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
selectedTarget.kind,
|
selectedTarget.kind,
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
|||||||
return {
|
return {
|
||||||
appImage: { status: 'updated' },
|
appImage: { status: 'updated' },
|
||||||
launcher: { status: 'updated' },
|
launcher: { status: 'updated' },
|
||||||
supportAssets: [{ status: 'skipped' }],
|
supportAssets: [
|
||||||
|
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
|
||||||
|
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
|
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
|
||||||
@@ -48,7 +51,8 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
|||||||
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
|
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
|
||||||
'info:AppImage update: updated',
|
'info:AppImage update: updated',
|
||||||
'info:Launcher update: updated',
|
'info:Launcher update: updated',
|
||||||
'info:Rofi theme update: skipped',
|
'info:Support assets (theme) update: updated - Installed theme.',
|
||||||
|
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ test('runUpdateCommand skips Linux asset replacement when release is not newer',
|
|||||||
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
|
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
|
||||||
'info:AppImage update: up to date',
|
'info:AppImage update: up to date',
|
||||||
'info:Launcher update: up to date',
|
'info:Launcher update: up to date',
|
||||||
'info:Rofi theme update: up to date',
|
'info:Support assets update: up to date',
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ type DirectReleaseUpdateRequest = {
|
|||||||
type DirectReleaseUpdateResult = {
|
type DirectReleaseUpdateResult = {
|
||||||
appImage: { status: string; command?: string; message?: string };
|
appImage: { status: string; command?: string; message?: string };
|
||||||
launcher: { status: string; command?: string; message?: string };
|
launcher: { status: string; command?: string; message?: string };
|
||||||
supportAssets: Array<{ status: string; command?: string; message?: string }>;
|
supportAssets: Array<{
|
||||||
|
status: string;
|
||||||
|
component?: 'theme' | 'plugin';
|
||||||
|
command?: string;
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateCommandDeps = {
|
type UpdateCommandDeps = {
|
||||||
@@ -124,20 +129,29 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
|
|||||||
|
|
||||||
function logUpdateResult(
|
function logUpdateResult(
|
||||||
label: string,
|
label: string,
|
||||||
result: { status: string; command?: string; message?: string },
|
result: {
|
||||||
|
status: string;
|
||||||
|
component?: 'theme' | 'plugin';
|
||||||
|
command?: string;
|
||||||
|
message?: string;
|
||||||
|
},
|
||||||
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
||||||
deps: Pick<UpdateCommandDeps, 'log'>,
|
deps: Pick<UpdateCommandDeps, 'log'>,
|
||||||
): void {
|
): void {
|
||||||
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
|
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
|
||||||
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`);
|
const componentLabel = result.component ? ` (${result.component})` : '';
|
||||||
|
const detailSuffix = result.message ? ` - ${result.message}` : '';
|
||||||
|
deps.log(
|
||||||
|
'info',
|
||||||
|
configuredLogLevel,
|
||||||
|
`${label}${componentLabel} update: ${displayStatus}${detailSuffix}`,
|
||||||
|
);
|
||||||
if (result.command) {
|
if (result.command) {
|
||||||
deps.log(
|
deps.log(
|
||||||
'warn',
|
'warn',
|
||||||
configuredLogLevel,
|
configuredLogLevel,
|
||||||
`${label} update requires manual command: ${result.command}`,
|
`${label}${componentLabel} update requires manual command: ${result.command}`,
|
||||||
);
|
);
|
||||||
} else if (result.message) {
|
|
||||||
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +201,7 @@ export async function runUpdateCommand(
|
|||||||
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
|
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
|
||||||
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
|
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
|
||||||
for (const supportResult of result.supportAssets) {
|
for (const supportResult of result.supportAssets) {
|
||||||
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
|
logUpdateResult('Support assets', supportResult, logLevel, resolvedDeps);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { Args } from './types.js';
|
||||||
|
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
|
||||||
|
|
||||||
|
function createArgs(): Args {
|
||||||
|
return {
|
||||||
|
backend: 'auto',
|
||||||
|
directory: '.',
|
||||||
|
recursive: false,
|
||||||
|
profile: '',
|
||||||
|
startOverlay: false,
|
||||||
|
youtubeMode: 'download',
|
||||||
|
whisperBin: '',
|
||||||
|
whisperModel: '',
|
||||||
|
whisperVadModel: '',
|
||||||
|
whisperThreads: 0,
|
||||||
|
youtubeSubgenOutDir: '',
|
||||||
|
youtubeSubgenAudioFormat: '',
|
||||||
|
youtubeSubgenKeepTemp: false,
|
||||||
|
youtubeFixWithAi: false,
|
||||||
|
youtubePrimarySubLangs: [],
|
||||||
|
youtubeSecondarySubLangs: [],
|
||||||
|
youtubeAudioLangs: [],
|
||||||
|
youtubeWhisperSourceLanguage: '',
|
||||||
|
aiConfig: {},
|
||||||
|
useTexthooker: false,
|
||||||
|
autoStartOverlay: false,
|
||||||
|
texthookerOnly: false,
|
||||||
|
texthookerOpenBrowser: false,
|
||||||
|
useRofi: false,
|
||||||
|
logLevel: 'info',
|
||||||
|
logRotation: 7,
|
||||||
|
passwordStore: '',
|
||||||
|
target: '',
|
||||||
|
targetKind: '',
|
||||||
|
jimakuApiKey: '',
|
||||||
|
jimakuApiKeyCommand: '',
|
||||||
|
jimakuApiBaseUrl: '',
|
||||||
|
jimakuLanguagePreference: 'ja',
|
||||||
|
jimakuMaxEntryResults: 20,
|
||||||
|
jellyfin: false,
|
||||||
|
jellyfinLogin: false,
|
||||||
|
jellyfinLogout: false,
|
||||||
|
jellyfinPlay: false,
|
||||||
|
jellyfinDiscovery: false,
|
||||||
|
dictionary: false,
|
||||||
|
dictionaryCandidates: false,
|
||||||
|
dictionarySelect: false,
|
||||||
|
stats: false,
|
||||||
|
doctor: false,
|
||||||
|
doctorRefreshKnownWords: false,
|
||||||
|
logsExport: false,
|
||||||
|
version: false,
|
||||||
|
settings: false,
|
||||||
|
configPath: false,
|
||||||
|
configShow: false,
|
||||||
|
mpvIdle: false,
|
||||||
|
mpvSocket: false,
|
||||||
|
mpvStatus: false,
|
||||||
|
mpvArgs: '',
|
||||||
|
appPassthrough: false,
|
||||||
|
appArgs: [],
|
||||||
|
jellyfinServer: 'https://jellyfin.example.test',
|
||||||
|
jellyfinUsername: '',
|
||||||
|
jellyfinPassword: '',
|
||||||
|
launchMode: 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
|
||||||
|
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||||
|
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||||
|
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
|
||||||
|
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runJellyfinPlayMenuWithDeps(
|
||||||
|
'/tmp/SubMiner.AppImage',
|
||||||
|
createArgs(),
|
||||||
|
'/tmp/subminer',
|
||||||
|
'/tmp/subminer.sock',
|
||||||
|
{
|
||||||
|
loadLauncherJellyfinConfig: () => ({}),
|
||||||
|
findRofiTheme: () => null,
|
||||||
|
resolveJellyfinSelection: async () => 'item-123',
|
||||||
|
resolveJellyfinSelectionViaApp: async () => {
|
||||||
|
throw new Error('unexpected app-based selection');
|
||||||
|
},
|
||||||
|
hasStoredJellyfinSession: () => true,
|
||||||
|
requestJellyfinPreviewAuthFromApp: async () => null,
|
||||||
|
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||||
|
pathExists: () => false,
|
||||||
|
ensureRuntimePluginReady: async () => {
|
||||||
|
calls.push('plugin');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => {
|
||||||
|
calls.push('wait');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
launchMpvIdleDetached: async () => {
|
||||||
|
calls.push('launch');
|
||||||
|
},
|
||||||
|
resolveLauncherRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return '/tmp/plugin/main.lua';
|
||||||
|
},
|
||||||
|
runAppCommandWithInheritLogged: () => {
|
||||||
|
calls.push('handoff');
|
||||||
|
throw new Error('stop after handoff');
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/stop after handoff/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
|
||||||
|
} finally {
|
||||||
|
if (originalAccessToken === undefined) {
|
||||||
|
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
|
||||||
|
}
|
||||||
|
if (originalUserId === undefined) {
|
||||||
|
delete process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+87
-19
@@ -30,9 +30,54 @@ import {
|
|||||||
resolveLauncherRuntimePluginPath,
|
resolveLauncherRuntimePluginPath,
|
||||||
waitForUnixSocketReady,
|
waitForUnixSocketReady,
|
||||||
} from './mpv.js';
|
} from './mpv.js';
|
||||||
|
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
|
||||||
|
|
||||||
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
|
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
|
||||||
|
|
||||||
|
type JellyfinPlayMenuDeps = {
|
||||||
|
loadLauncherJellyfinConfig: typeof loadLauncherJellyfinConfig;
|
||||||
|
findRofiTheme: typeof findRofiTheme;
|
||||||
|
resolveJellyfinSelection: typeof resolveJellyfinSelection;
|
||||||
|
hasStoredJellyfinSession: typeof hasStoredJellyfinSession;
|
||||||
|
requestJellyfinPreviewAuthFromApp: typeof requestJellyfinPreviewAuthFromApp;
|
||||||
|
resolveLauncherMainConfigPath: typeof resolveLauncherMainConfigPath;
|
||||||
|
resolveJellyfinSelectionViaApp: typeof resolveJellyfinSelectionViaApp;
|
||||||
|
pathExists: (candidate: string) => boolean;
|
||||||
|
ensureRuntimePluginReady: (options: {
|
||||||
|
appPath: string;
|
||||||
|
scriptPath: string;
|
||||||
|
logLevel: Args['logLevel'];
|
||||||
|
}) => Promise<void>;
|
||||||
|
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||||
|
launchMpvIdleDetached: typeof launchMpvIdleDetached;
|
||||||
|
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
|
||||||
|
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
|
||||||
|
log: typeof log;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
|
||||||
|
loadLauncherJellyfinConfig,
|
||||||
|
findRofiTheme,
|
||||||
|
resolveJellyfinSelection,
|
||||||
|
hasStoredJellyfinSession,
|
||||||
|
requestJellyfinPreviewAuthFromApp,
|
||||||
|
resolveLauncherMainConfigPath,
|
||||||
|
resolveJellyfinSelectionViaApp,
|
||||||
|
pathExists: (candidate) => fs.existsSync(candidate),
|
||||||
|
ensureRuntimePluginReady: async ({ appPath, scriptPath, logLevel }) => {
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
appPath,
|
||||||
|
scriptPath,
|
||||||
|
logLevel,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady,
|
||||||
|
launchMpvIdleDetached,
|
||||||
|
resolveLauncherRuntimePluginPath,
|
||||||
|
runAppCommandWithInheritLogged,
|
||||||
|
log,
|
||||||
|
};
|
||||||
|
|
||||||
export function sanitizeServerUrl(value: string): string {
|
export function sanitizeServerUrl(value: string): string {
|
||||||
return value.trim().replace(/\/+$/, '');
|
return value.trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
@@ -974,7 +1019,17 @@ export async function runJellyfinPlayMenu(
|
|||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
mpvSocketPath: string,
|
mpvSocketPath: string,
|
||||||
): Promise<never> {
|
): Promise<never> {
|
||||||
const config = loadLauncherJellyfinConfig();
|
return runJellyfinPlayMenuWithDeps(appPath, args, scriptPath, mpvSocketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runJellyfinPlayMenuWithDeps(
|
||||||
|
appPath: string,
|
||||||
|
args: Args,
|
||||||
|
scriptPath: string,
|
||||||
|
mpvSocketPath: string,
|
||||||
|
deps: JellyfinPlayMenuDeps = defaultJellyfinPlayMenuDeps,
|
||||||
|
): Promise<never> {
|
||||||
|
const config = deps.loadLauncherJellyfinConfig();
|
||||||
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
|
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
|
||||||
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
|
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
|
||||||
const session: JellyfinSessionConfig = {
|
const session: JellyfinSessionConfig = {
|
||||||
@@ -986,58 +1041,71 @@ export async function runJellyfinPlayMenu(
|
|||||||
iconCacheDir: config.iconCacheDir || '',
|
iconCacheDir: config.iconCacheDir || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
|
const rofiTheme = args.useRofi ? deps.findRofiTheme(scriptPath) : null;
|
||||||
if (args.useRofi && !rofiTheme) {
|
if (args.useRofi && !rofiTheme) {
|
||||||
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
|
deps.log(
|
||||||
|
'warn',
|
||||||
|
args.logLevel,
|
||||||
|
'Rofi theme not found for Jellyfin picker; using rofi defaults.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
|
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
|
||||||
let itemId = '';
|
let itemId = '';
|
||||||
if (hasDirectSession) {
|
if (hasDirectSession) {
|
||||||
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
itemId = await deps.resolveJellyfinSelection(args, session, rofiTheme);
|
||||||
} else {
|
} else {
|
||||||
const configPath = resolveLauncherMainConfigPath();
|
const configPath = deps.resolveLauncherMainConfigPath();
|
||||||
if (!hasStoredJellyfinSession(configPath)) {
|
if (!deps.hasStoredJellyfinSession(configPath)) {
|
||||||
fail(
|
fail(
|
||||||
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
|
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args);
|
const previewAuth = await deps.requestJellyfinPreviewAuthFromApp(appPath, args);
|
||||||
if (previewAuth) {
|
if (previewAuth) {
|
||||||
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
|
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
|
||||||
session.accessToken = previewAuth.accessToken;
|
session.accessToken = previewAuth.accessToken;
|
||||||
session.userId = previewAuth.userId || session.userId;
|
session.userId = previewAuth.userId || session.userId;
|
||||||
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.');
|
deps.log(
|
||||||
|
'debug',
|
||||||
|
args.logLevel,
|
||||||
|
'Jellyfin preview auth bridge ready for picker image previews.',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
log(
|
deps.log(
|
||||||
'debug',
|
'debug',
|
||||||
args.logLevel,
|
args.logLevel,
|
||||||
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
|
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
|
itemId = await deps.resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
|
||||||
}
|
}
|
||||||
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
|
deps.log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
|
||||||
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
|
deps.log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
|
||||||
let mpvReady = false;
|
let mpvReady = false;
|
||||||
if (fs.existsSync(mpvSocketPath)) {
|
if (deps.pathExists(mpvSocketPath)) {
|
||||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
|
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 250);
|
||||||
}
|
}
|
||||||
if (!mpvReady) {
|
if (!mpvReady) {
|
||||||
await launchMpvIdleDetached(
|
await deps.ensureRuntimePluginReady({ appPath, scriptPath, logLevel: args.logLevel });
|
||||||
|
await deps.launchMpvIdleDetached(
|
||||||
mpvSocketPath,
|
mpvSocketPath,
|
||||||
appPath,
|
appPath,
|
||||||
args,
|
args,
|
||||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
deps.resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||||
);
|
);
|
||||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||||
}
|
}
|
||||||
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
|
deps.log(
|
||||||
|
'debug',
|
||||||
|
args.logLevel,
|
||||||
|
`MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`,
|
||||||
|
);
|
||||||
if (!mpvReady) {
|
if (!mpvReady) {
|
||||||
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
|
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
|
||||||
}
|
}
|
||||||
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
||||||
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
||||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import {
|
||||||
|
ensureLinuxRuntimePluginAvailable,
|
||||||
|
installManagedPluginAssetsViaApp,
|
||||||
|
} from './runtime-plugin-preflight';
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'darwin',
|
||||||
|
detectInstalledPlugin: () => {
|
||||||
|
calls.push('detect');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
log: () => {
|
||||||
|
calls.push('log');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
detectInstalledPlugin: () => {
|
||||||
|
calls.push('detect');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
isManagedThemeAvailable: () => {
|
||||||
|
calls.push('theme');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['detect', 'theme']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
|
detectInstalledPlugin: () => {
|
||||||
|
calls.push('detect');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return '/tmp/plugin/main.lua';
|
||||||
|
},
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
isManagedThemeAvailable: () => {
|
||||||
|
calls.push('theme');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
|
detectInstalledPlugin: () => {
|
||||||
|
calls.push('detect');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return '/tmp/plugin/main.lua';
|
||||||
|
},
|
||||||
|
isManagedThemeAvailable: () => {
|
||||||
|
calls.push('theme');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
log: (level, _configured, message) => {
|
||||||
|
calls.push(`${level}:${message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'detect',
|
||||||
|
'resolve',
|
||||||
|
'theme',
|
||||||
|
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||||
|
'install',
|
||||||
|
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||||
|
'resolve',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let resolveCount = 0;
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
|
detectInstalledPlugin: () => false,
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
resolveCount += 1;
|
||||||
|
calls.push(`resolve:${resolveCount}`);
|
||||||
|
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
||||||
|
},
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
log: (level, _configured, message) => {
|
||||||
|
calls.push(`${level}:${message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'resolve:1',
|
||||||
|
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||||
|
'install',
|
||||||
|
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||||
|
'resolve:2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
detectInstalledPlugin: () => false,
|
||||||
|
resolveRuntimePluginPath: () => null,
|
||||||
|
installManagedPluginAssets: async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'copy failed',
|
||||||
|
}),
|
||||||
|
log: () => {},
|
||||||
|
}),
|
||||||
|
/copy failed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
detectInstalledPlugin: () => false,
|
||||||
|
resolveRuntimePluginPath: () => null,
|
||||||
|
installManagedPluginAssets: async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: '/tmp/plugin/main.lua',
|
||||||
|
}),
|
||||||
|
log: () => {},
|
||||||
|
}),
|
||||||
|
/managed runtime plugin assets could not be installed/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
|
||||||
|
let waited = false;
|
||||||
|
|
||||||
|
const result = await installManagedPluginAssetsViaApp(
|
||||||
|
{
|
||||||
|
appPath: '/opt/SubMiner/subminer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
runAppCommandCaptureOutput: () => ({
|
||||||
|
status: 1,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
error: new Error('spawn failed'),
|
||||||
|
}),
|
||||||
|
waitForInstallResponse: async () => {
|
||||||
|
waited = true;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'spawn failed',
|
||||||
|
});
|
||||||
|
assert.equal(waited, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installManagedPluginAssetsViaApp does not let temp cleanup errors mask install result', async () => {
|
||||||
|
const originalRmSync = fs.rmSync;
|
||||||
|
fs.rmSync = ((targetPath, options) => {
|
||||||
|
if (String(targetPath).includes('subminer-runtime-plugin-')) {
|
||||||
|
throw new Error('cleanup failed');
|
||||||
|
}
|
||||||
|
return originalRmSync(targetPath, options);
|
||||||
|
}) as typeof fs.rmSync;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await installManagedPluginAssetsViaApp(
|
||||||
|
{
|
||||||
|
appPath: '/opt/SubMiner/subminer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
runAppCommandCaptureOutput: () => ({
|
||||||
|
status: 0,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
}),
|
||||||
|
waitForInstallResponse: async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: '/tmp/plugin/main.lua',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: '/tmp/plugin/main.lua',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync = originalRmSync;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { log as launcherLog } from './log.js';
|
||||||
|
import { runAppCommandCaptureOutput, resolveLauncherRuntimePluginPath } from './mpv.js';
|
||||||
|
import { nowMs } from './time.js';
|
||||||
|
import { sleep } from './util.js';
|
||||||
|
import { detectInstalledMpvPlugin } from '../src/main/runtime/first-run-setup-plugin.js';
|
||||||
|
import {
|
||||||
|
resolveManagedLinuxRuntimePluginPaths,
|
||||||
|
type EnsureLinuxRuntimePluginAssetsResult,
|
||||||
|
} from '../src/main/runtime/linux-runtime-plugin-assets.js';
|
||||||
|
|
||||||
|
const RESPONSE_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
type PreflightLog = (
|
||||||
|
level: 'debug' | 'info' | 'warn' | 'error',
|
||||||
|
configured: 'debug' | 'info' | 'warn' | 'error',
|
||||||
|
message: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type EnsureLinuxRuntimePluginAvailableOptions = {
|
||||||
|
appPath?: string;
|
||||||
|
scriptPath?: string;
|
||||||
|
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
homeDir?: string;
|
||||||
|
xdgConfigHome?: string;
|
||||||
|
xdgDataHome?: string;
|
||||||
|
appDataDir?: string;
|
||||||
|
detectInstalledPlugin?: () => boolean;
|
||||||
|
resolveRuntimePluginPath?: () => string | null;
|
||||||
|
isManagedThemeAvailable?: () => boolean;
|
||||||
|
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
|
||||||
|
log?: PreflightLog;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuntimePluginPreflightResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
status: 'installed' | 'already-present' | 'failed';
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveConfiguredLogLevel(
|
||||||
|
logLevel: EnsureLinuxRuntimePluginAvailableOptions['logLevel'],
|
||||||
|
): 'debug' | 'info' | 'warn' | 'error' {
|
||||||
|
return logLevel ?? 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForInstallResponse(
|
||||||
|
responsePath: string,
|
||||||
|
): Promise<RuntimePluginPreflightResponse | null> {
|
||||||
|
const deadline = nowMs() + RESPONSE_TIMEOUT_MS;
|
||||||
|
while (nowMs() < deadline) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(responsePath)) {
|
||||||
|
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as RuntimePluginPreflightResponse;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry until timeout
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstallManagedPluginAssetsViaAppDeps = {
|
||||||
|
runAppCommandCaptureOutput?: typeof runAppCommandCaptureOutput;
|
||||||
|
waitForInstallResponse?: typeof waitForInstallResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function installManagedPluginAssetsViaApp(
|
||||||
|
options: {
|
||||||
|
appPath: string;
|
||||||
|
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
},
|
||||||
|
deps: InstallManagedPluginAssetsViaAppDeps = {},
|
||||||
|
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-runtime-plugin-'));
|
||||||
|
const responsePath = path.join(tempDir, 'response.json');
|
||||||
|
const runAppCommand = deps.runAppCommandCaptureOutput ?? runAppCommandCaptureOutput;
|
||||||
|
const waitForResponse = deps.waitForInstallResponse ?? waitForInstallResponse;
|
||||||
|
try {
|
||||||
|
const appArgs = [
|
||||||
|
'--ensure-linux-runtime-plugin-assets',
|
||||||
|
'--ensure-linux-runtime-plugin-assets-response-path',
|
||||||
|
responsePath,
|
||||||
|
];
|
||||||
|
const result = runAppCommand(options.appPath, appArgs);
|
||||||
|
if (result.error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: result.error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr.trim();
|
||||||
|
const stdout = result.stdout.trim();
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error:
|
||||||
|
stderr ||
|
||||||
|
stdout ||
|
||||||
|
`Linux runtime plugin asset install command exited with status ${result.status}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = await waitForResponse(responsePath);
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
const stderr = result.stderr.trim();
|
||||||
|
const stdout = result.stdout.trim();
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error:
|
||||||
|
stderr ||
|
||||||
|
stdout ||
|
||||||
|
`Timed out waiting for Linux runtime plugin asset response after app exit status ${result.status}.`,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Avoid hiding the install failure or success result behind temp cleanup errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureLinuxRuntimePluginAvailable(
|
||||||
|
options: EnsureLinuxRuntimePluginAvailableOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
if (platform !== 'linux') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredLogLevel = resolveConfiguredLogLevel(options.logLevel);
|
||||||
|
const log = options.log ?? launcherLog;
|
||||||
|
const homeDir = options.homeDir ?? os.homedir();
|
||||||
|
const detectInstalledPlugin =
|
||||||
|
options.detectInstalledPlugin ??
|
||||||
|
(() =>
|
||||||
|
detectInstalledMpvPlugin({
|
||||||
|
platform,
|
||||||
|
homeDir,
|
||||||
|
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
||||||
|
appDataDir: options.appDataDir ?? process.env.APPDATA,
|
||||||
|
}).installed);
|
||||||
|
const installedPluginAvailable = detectInstalledPlugin();
|
||||||
|
const managedPaths = resolveManagedLinuxRuntimePluginPaths({
|
||||||
|
homeDir,
|
||||||
|
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveRuntimePluginPath =
|
||||||
|
options.resolveRuntimePluginPath ??
|
||||||
|
(() => {
|
||||||
|
if (!options.appPath) return null;
|
||||||
|
return resolveLauncherRuntimePluginPath({
|
||||||
|
appPath: options.appPath,
|
||||||
|
scriptPath: options.scriptPath,
|
||||||
|
platform,
|
||||||
|
homeDir,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const isManagedThemeAvailable =
|
||||||
|
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
|
||||||
|
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
||||||
|
if (runtimePluginAvailable && isManagedThemeAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
'info',
|
||||||
|
configuredLogLevel,
|
||||||
|
'Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||||
|
);
|
||||||
|
const installManagedPluginAssets =
|
||||||
|
options.installManagedPluginAssets ??
|
||||||
|
(() => {
|
||||||
|
if (!options.appPath) {
|
||||||
|
throw new Error(
|
||||||
|
'Linux managed runtime plugin assets could not be installed. Launch aborted before starting mpv.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return installManagedPluginAssetsViaApp({
|
||||||
|
appPath: options.appPath,
|
||||||
|
logLevel: options.logLevel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const installResult = await installManagedPluginAssets();
|
||||||
|
if (!installResult.ok) {
|
||||||
|
const message = installResult.error || 'Unknown Linux runtime plugin asset install failure.';
|
||||||
|
log(
|
||||||
|
'warn',
|
||||||
|
configuredLogLevel,
|
||||||
|
`Managed Linux runtime support asset install failed: ${message}`,
|
||||||
|
);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
'info',
|
||||||
|
configuredLogLevel,
|
||||||
|
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
|
||||||
|
);
|
||||||
|
const runtimePluginPath = resolveRuntimePluginPath();
|
||||||
|
if (runtimePluginPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
`Linux managed runtime plugin assets could not be installed. ` +
|
||||||
|
`Checked path: ${managedPaths.pluginEntrypointPath}. ` +
|
||||||
|
'Launch aborted before starting mpv.';
|
||||||
|
log('warn', configuredLogLevel, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ type SmokeCase = {
|
|||||||
artifactsDir: string;
|
artifactsDir: string;
|
||||||
binDir: string;
|
binDir: string;
|
||||||
xdgConfigHome: string;
|
xdgConfigHome: string;
|
||||||
|
xdgDataHome: string;
|
||||||
appDataDir: string;
|
appDataDir: string;
|
||||||
localAppDataDir: string;
|
localAppDataDir: string;
|
||||||
homeDir: string;
|
homeDir: string;
|
||||||
@@ -64,6 +65,7 @@ function createSmokeCase(name: string): SmokeCase {
|
|||||||
const artifactsDir = path.join(root, 'artifacts');
|
const artifactsDir = path.join(root, 'artifacts');
|
||||||
const binDir = path.join(root, 'bin');
|
const binDir = path.join(root, 'bin');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const xdgDataHome = path.join(root, 'xdg-data');
|
||||||
const appDataDir = path.join(root, 'AppData', 'Roaming');
|
const appDataDir = path.join(root, 'AppData', 'Roaming');
|
||||||
const localAppDataDir = path.join(root, 'AppData', 'Local');
|
const localAppDataDir = path.join(root, 'AppData', 'Local');
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
@@ -135,6 +137,7 @@ process.on('SIGTERM', closeAndExit);
|
|||||||
fakeAppBasePath,
|
fakeAppBasePath,
|
||||||
`#!/usr/bin/env bun
|
`#!/usr/bin/env bun
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
||||||
const startPath = ${JSON.stringify(fakeAppStartLogPath)};
|
const startPath = ${JSON.stringify(fakeAppStartLogPath)};
|
||||||
@@ -154,6 +157,25 @@ if (entry.argv.includes('--stop')) {
|
|||||||
if (entry.argv.includes('--app-ping')) {
|
if (entry.argv.includes('--app-ping')) {
|
||||||
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
|
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) {
|
||||||
|
const responseFlagIndex = entry.argv.indexOf('--ensure-linux-runtime-plugin-assets-response-path');
|
||||||
|
const responsePath = responseFlagIndex >= 0 ? entry.argv[responseFlagIndex + 1] : '';
|
||||||
|
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(process.env.HOME || '', '.local', 'share');
|
||||||
|
const dataDir = path.join(xdgDataHome, 'SubMiner');
|
||||||
|
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
|
||||||
|
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
|
||||||
|
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
|
||||||
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(themePath), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
|
||||||
|
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
|
||||||
|
fs.writeFileSync(themePath, '/* smoke theme */\\n');
|
||||||
|
if (responsePath) {
|
||||||
|
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||||
|
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
`,
|
`,
|
||||||
@@ -164,6 +186,7 @@ process.exit(0);
|
|||||||
artifactsDir,
|
artifactsDir,
|
||||||
binDir,
|
binDir,
|
||||||
xdgConfigHome,
|
xdgConfigHome,
|
||||||
|
xdgDataHome,
|
||||||
appDataDir,
|
appDataDir,
|
||||||
localAppDataDir,
|
localAppDataDir,
|
||||||
homeDir,
|
homeDir,
|
||||||
@@ -181,6 +204,7 @@ function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
|
|||||||
...process.env,
|
...process.env,
|
||||||
HOME: smokeCase.homeDir,
|
HOME: smokeCase.homeDir,
|
||||||
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
|
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
|
||||||
|
XDG_DATA_HOME: smokeCase.xdgDataHome,
|
||||||
APPDATA: smokeCase.appDataDir,
|
APPDATA: smokeCase.appDataDir,
|
||||||
LOCALAPPDATA: smokeCase.localAppDataDir,
|
LOCALAPPDATA: smokeCase.localAppDataDir,
|
||||||
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
|
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
|
||||||
@@ -495,7 +519,7 @@ test(
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'launcher start-overlay attaches to a running background app without spawning another app command',
|
'launcher start-overlay attaches to a running background app without spawning another app start command',
|
||||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||||
async () => {
|
async () => {
|
||||||
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
||||||
@@ -530,7 +554,15 @@ test(
|
|||||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||||
|
|
||||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||||
assert.equal(appEntries.length, 0);
|
if (process.platform === 'linux') {
|
||||||
|
assert.equal(appEntries.length > 0, true);
|
||||||
|
assert.equal(
|
||||||
|
appEntries.every((entry) =>
|
||||||
|
(entry.argv as string[]).includes('--ensure-linux-runtime-plugin-assets'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
assert.equal(appStartEntries.length, 0);
|
assert.equal(appStartEntries.length, 0);
|
||||||
assert.equal(appStopEntries.length, 0);
|
assert.equal(appStopEntries.length, 0);
|
||||||
assert.equal(controlEntries.length, 1);
|
assert.equal(controlEntries.length, 1);
|
||||||
@@ -587,6 +619,13 @@ test(
|
|||||||
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
|
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
|
||||||
);
|
);
|
||||||
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
assert.match(result.stdout, /managed plugin\/theme assets/i);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const node_os_1 = __importDefault(require("node:os"));
|
||||||
|
const node_child_process_1 = require("node:child_process");
|
||||||
|
const electron_1 = require("electron");
|
||||||
|
const help_1 = require("./cli/help");
|
||||||
|
const main_entry_runtime_1 = require("./main-entry-runtime");
|
||||||
|
const early_single_instance_1 = require("./main/early-single-instance");
|
||||||
|
const main_entry_launch_config_1 = require("./main-entry-launch-config");
|
||||||
|
const app_control_client_1 = require("./shared/app-control-client");
|
||||||
|
const first_run_setup_plugin_1 = require("./main/runtime/first-run-setup-plugin");
|
||||||
|
const windows_mpv_launch_1 = require("./main/runtime/windows-mpv-launch");
|
||||||
|
const stats_daemon_entry_1 = require("./stats-daemon-entry");
|
||||||
|
const fatal_error_1 = require("./main/fatal-error");
|
||||||
|
const mpv_logging_args_1 = require("./shared/mpv-logging-args");
|
||||||
|
const log_files_1 = require("./shared/log-files");
|
||||||
|
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||||
|
function appendWindowsMpvLaunchLog(message, logRotation) {
|
||||||
|
if (!(0, log_files_1.isLogFileEnabled)('app')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
(0, log_files_1.appendLogLine)(process.env.SUBMINER_APP_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('app'), `[subminer] - ${timestamp} - INFO - [main:windows-mpv-launch] ${message}`, { rotation: logRotation });
|
||||||
|
}
|
||||||
|
function applySanitizedEnv(sanitizedEnv) {
|
||||||
|
if (sanitizedEnv.NODE_NO_WARNINGS) {
|
||||||
|
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||||
|
}
|
||||||
|
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||||
|
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
delete process.env.VK_INSTANCE_LAYERS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function resolveBundledWindowsMpvPluginEntrypoint() {
|
||||||
|
return ((0, first_run_setup_plugin_1.resolvePackagedRuntimePluginPath)({
|
||||||
|
dirname: __dirname,
|
||||||
|
appPath: electron_1.app.getAppPath(),
|
||||||
|
resourcesPath: process.resourcesPath,
|
||||||
|
}) ?? undefined);
|
||||||
|
}
|
||||||
|
function buildInstalledWindowsMpvPluginMessage(pathValue, version) {
|
||||||
|
return [
|
||||||
|
'SubMiner detected an installed mpv plugin at:',
|
||||||
|
pathValue,
|
||||||
|
'',
|
||||||
|
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
|
||||||
|
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
async function promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection) {
|
||||||
|
const response = await electron_1.dialog.showMessageBox({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'SubMiner mpv plugin detected',
|
||||||
|
message: buildInstalledWindowsMpvPluginMessage(detection.path ?? 'unknown path', detection.version),
|
||||||
|
detail: 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
|
||||||
|
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 candidates = (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({
|
||||||
|
platform: 'win32',
|
||||||
|
homeDir: node_os_1.default.homedir(),
|
||||||
|
appDataDir: electron_1.app.getPath('appData'),
|
||||||
|
mpvExecutablePath: mpvPath,
|
||||||
|
});
|
||||||
|
const result = await (0, first_run_setup_plugin_1.removeLegacyMpvPluginCandidates)({
|
||||||
|
candidates,
|
||||||
|
trashItem: (candidatePath) => electron_1.shell.trashItem(candidatePath),
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
await electron_1.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 electron_1.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';
|
||||||
|
}
|
||||||
|
function createWindowsRuntimePluginPolicy() {
|
||||||
|
return {
|
||||||
|
detectInstalledMpvPlugin: (mpvPath) => (0, first_run_setup_plugin_1.detectInstalledMpvPlugin)({
|
||||||
|
platform: 'win32',
|
||||||
|
homeDir: node_os_1.default.homedir(),
|
||||||
|
appDataDir: electron_1.app.getPath('appData'),
|
||||||
|
mpvExecutablePath: mpvPath,
|
||||||
|
}),
|
||||||
|
notifyInstalledPluginDetected: (detection) => {
|
||||||
|
if (!detection.installed || !detection.path)
|
||||||
|
return;
|
||||||
|
electron_1.dialog.showMessageBoxSync({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'SubMiner mpv plugin detected',
|
||||||
|
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
process.argv = (0, main_entry_runtime_1.normalizeStartupArgv)(process.argv, process.env);
|
||||||
|
(0, main_entry_runtime_1.applyEarlyLinuxCommandLineSwitches)(electron_1.app.commandLine, process.argv);
|
||||||
|
applySanitizedEnv((0, main_entry_runtime_1.sanitizeStartupEnv)(process.env));
|
||||||
|
const userDataPath = (0, main_entry_runtime_1.configureEarlyAppPaths)(electron_1.app);
|
||||||
|
const reportFatalError = (0, fatal_error_1.createFatalErrorReporter)({
|
||||||
|
showErrorBox: (title, details) => electron_1.dialog.showErrorBox(title, details),
|
||||||
|
consoleError: (message, error) => console.error(message, error),
|
||||||
|
});
|
||||||
|
(0, fatal_error_1.registerFatalErrorHandlers)({
|
||||||
|
reportFatalError,
|
||||||
|
exit: (code) => electron_1.app.exit(code),
|
||||||
|
});
|
||||||
|
function startMainProcess() {
|
||||||
|
const gotSingleInstanceLock = (0, early_single_instance_1.requestSingleInstanceLockEarly)(electron_1.app);
|
||||||
|
if (!gotSingleInstanceLock) {
|
||||||
|
electron_1.app.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
require('./main.js');
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
reportFatalError(error, {
|
||||||
|
title: 'SubMiner startup failed',
|
||||||
|
context: 'SubMiner failed while loading the main process.',
|
||||||
|
});
|
||||||
|
electron_1.app.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function forwardStartupArgvViaAppControlIfAvailable() {
|
||||||
|
if (!(0, main_entry_runtime_1.shouldForwardStartupArgvViaAppControl)(process.argv, process.env)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const result = await (0, app_control_client_1.sendAppControlCommand)(process.argv, {
|
||||||
|
configDir: userDataPath,
|
||||||
|
timeoutMs: 500,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
electron_1.app.exit(0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!result.unavailable) {
|
||||||
|
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
|
||||||
|
electron_1.app.exit(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async function runEntryProcess() {
|
||||||
|
if ((0, main_entry_runtime_1.shouldHandleHelpOnlyAtEntry)(process.argv, process.env)) {
|
||||||
|
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeHelpEnv)(process.env);
|
||||||
|
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||||
|
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||||
|
delete process.env.VK_INSTANCE_LAYERS;
|
||||||
|
}
|
||||||
|
(0, help_1.printHelp)(DEFAULT_TEXTHOOKER_PORT);
|
||||||
|
process.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((0, main_entry_runtime_1.shouldHandleLaunchMpvAtEntry)(process.argv, process.env)) {
|
||||||
|
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeLaunchMpvEnv)(process.env);
|
||||||
|
applySanitizedEnv(sanitizedEnv);
|
||||||
|
await electron_1.app.whenReady();
|
||||||
|
const configuredMpvLaunch = (0, main_entry_launch_config_1.readConfiguredWindowsMpvLaunch)(userDataPath);
|
||||||
|
const extraArgs = (0, main_entry_runtime_1.normalizeLaunchMpvExtraArgs)(process.argv);
|
||||||
|
(0, log_files_1.applyLogFileTogglesToEnv)(configuredMpvLaunch.logFiles);
|
||||||
|
const mpvLogPath = (0, log_files_1.isLogFileEnabled)('mpv')
|
||||||
|
? process.env.SUBMINER_MPV_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('mpv')
|
||||||
|
: '';
|
||||||
|
if (mpvLogPath) {
|
||||||
|
(0, log_files_1.pruneLogDirectoryForPath)(mpvLogPath, configuredMpvLaunch.logRotation);
|
||||||
|
}
|
||||||
|
const result = await (0, windows_mpv_launch_1.launchWindowsMpv)((0, main_entry_runtime_1.normalizeLaunchMpvTargets)(process.argv), (0, windows_mpv_launch_1.createWindowsMpvLaunchDeps)({
|
||||||
|
getEnv: (name) => process.env[name],
|
||||||
|
isAppControlServerAvailable: () => (0, app_control_client_1.isAppControlServerAvailable)({
|
||||||
|
configDir: userDataPath,
|
||||||
|
timeoutMs: 350,
|
||||||
|
}),
|
||||||
|
sendAppControlCommand: (argv) => (0, app_control_client_1.sendAppControlCommand)(argv, {
|
||||||
|
configDir: userDataPath,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
}),
|
||||||
|
showError: (title, content) => {
|
||||||
|
electron_1.dialog.showErrorBox(title, content);
|
||||||
|
},
|
||||||
|
logInfo: (message) => appendWindowsMpvLaunchLog(message, configuredMpvLaunch.logRotation),
|
||||||
|
}), [...extraArgs, ...(0, mpv_logging_args_1.buildMpvLoggingArgs)(configuredMpvLaunch.logLevel, mpvLogPath, extraArgs)], process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), configuredMpvLaunch.executablePath, configuredMpvLaunch.launchMode, createWindowsRuntimePluginPolicy(), configuredMpvLaunch.pluginRuntimeConfig);
|
||||||
|
electron_1.app.exit(result.ok ? 0 : 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((0, main_entry_runtime_1.shouldHandleStatsDaemonCommandAtEntry)(process.argv, process.env)) {
|
||||||
|
await electron_1.app.whenReady();
|
||||||
|
const exitCode = await (0, stats_daemon_entry_1.runStatsDaemonControlFromProcess)(electron_1.app.getPath('userData'));
|
||||||
|
electron_1.app.exit(exitCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await forwardStartupArgvViaAppControlIfAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((0, main_entry_runtime_1.shouldDetachBackgroundLaunch)(process.argv, process.env)) {
|
||||||
|
const childArgs = (0, main_entry_runtime_1.hasTransportedStartupArgs)(process.env) ? [] : process.argv.slice(1);
|
||||||
|
const child = (0, node_child_process_1.spawn)(process.execPath, childArgs, {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
env: (0, main_entry_runtime_1.sanitizeBackgroundEnv)(process.env),
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
process.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startMainProcess();
|
||||||
|
}
|
||||||
|
void runEntryProcess().catch((error) => {
|
||||||
|
console.error('SubMiner app-control handoff failed:', error);
|
||||||
|
startMainProcess();
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=main-entry.js.map
|
||||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -51,6 +51,15 @@ function M.create(ctx)
|
|||||||
return reason == "reload" or reason == "redirect"
|
return reason == "reload" or reason == "redirect"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function has_next_playlist_item()
|
||||||
|
local playlist_count = mp.get_property_number("playlist-count")
|
||||||
|
local playlist_pos = mp.get_property_number("playlist-pos")
|
||||||
|
if type(playlist_count) ~= "number" or type(playlist_pos) ~= "number" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return playlist_count > 0 and playlist_pos >= 0 and playlist_pos < playlist_count - 1
|
||||||
|
end
|
||||||
|
|
||||||
local function clear_pending_visible_overlay_hide()
|
local function clear_pending_visible_overlay_hide()
|
||||||
local timer = state.pending_visible_overlay_hide_timer
|
local timer = state.pending_visible_overlay_hide_timer
|
||||||
if timer and timer.kill then
|
if timer and timer.kill then
|
||||||
@@ -63,6 +72,9 @@ function M.create(ctx)
|
|||||||
local resolve_auto_start_visible_overlay_enabled
|
local resolve_auto_start_visible_overlay_enabled
|
||||||
|
|
||||||
local function hide_visible_overlay_after_end_file()
|
local function hide_visible_overlay_after_end_file()
|
||||||
|
if has_next_playlist_item() then
|
||||||
|
return
|
||||||
|
end
|
||||||
if state.visible_overlay_requested == true and not resolve_auto_start_visible_overlay_enabled() then
|
if state.visible_overlay_requested == true and not resolve_auto_start_visible_overlay_enabled() then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -270,10 +270,6 @@ function M.create(ctx)
|
|||||||
return { "--replay-current-subtitle" }
|
return { "--replay-current-subtitle" }
|
||||||
elseif action_id == "playNextSubtitle" then
|
elseif action_id == "playNextSubtitle" then
|
||||||
return { "--play-next-subtitle" }
|
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
|
elseif action_id == "cycleRuntimeOption" then
|
||||||
local runtime_option_id = payload and payload.runtimeOptionId or nil
|
local runtime_option_id = payload and payload.runtimeOptionId or nil
|
||||||
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
|
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)
|
invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs)
|
||||||
end
|
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 function load_artifact()
|
||||||
local artifact_path = environment.resolve_session_bindings_artifact_path()
|
local artifact_path = environment.resolve_session_bindings_artifact_path()
|
||||||
local raw = read_file(artifact_path)
|
local raw = read_file(artifact_path)
|
||||||
@@ -385,6 +391,13 @@ function M.create(ctx)
|
|||||||
local generation = state.session_binding_generation
|
local generation = state.session_binding_generation
|
||||||
|
|
||||||
for index, binding in ipairs(artifact.bindings) do
|
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)
|
local key_names = key_spec_to_mpv_bindings(binding.key)
|
||||||
if key_names then
|
if key_names then
|
||||||
for key_index, key_name in ipairs(key_names) do
|
for key_index, key_name in ipairs(key_names) do
|
||||||
@@ -407,6 +420,7 @@ function M.create(ctx)
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
remove_binding_names(previous_binding_names)
|
remove_binding_names(previous_binding_names)
|
||||||
state.session_binding_names = next_binding_names
|
state.session_binding_names = next_binding_names
|
||||||
|
|||||||
+34
-120
@@ -1,147 +1,60 @@
|
|||||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||||
|
|
||||||
|
<!-- prerelease-base-version: 0.17.0 -->
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections.
|
|
||||||
- Includes click-to-learn keybinding controls, an AnkiConnect deck dropdown that auto-fills from Yomitan's current mining deck, and AnkiConnect-backed deck, field, and note-type pickers.
|
|
||||||
- Live-saves changes for subtitle CSS declarations, stats keys, logging level, Anki field mappings, sentence card model, and other annotation and runtime options; search narrows across all categories including on multi-word terms. AI and translation settings remain config-file only.
|
|
||||||
|
|
||||||
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification and configurable update notifications.
|
|
||||||
- The `subminer` launcher and Linux rofi theme update automatically alongside the app.
|
|
||||||
- Set `updates.channel` to `"prerelease"` to receive beta and RC builds.
|
|
||||||
|
|
||||||
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows.
|
|
||||||
- Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
|
|
||||||
- Setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths. An Open SubMiner Settings button is included on completion; the standalone setup app quits after finishing.
|
|
||||||
|
|
||||||
- **Character Portraits:** Character-name subtitle matches can now show optional inline AniList character portraits.
|
|
||||||
- Manual AniList title overrides are scoped per media directory so separate season folders keep independent character dictionary selections.
|
|
||||||
|
|
||||||
- **Log Export:** Sanitized log ZIP archives can be exported from the tray menu or by running `subminer logs -e`, with home-directory usernames redacted from the exported contents.
|
|
||||||
|
|
||||||
- **Logging Configuration:** SubMiner's logging level is now forwarded into launcher-started and Windows shortcut-started mpv sessions, controlling mpv log verbosity and plugin script logging.
|
|
||||||
- The new `logging.rotation` config sets daily log retention (default 7 days). `logging.files` toggles let you enable or disable per-component log files; mpv logs are off by default unless explicitly enabled.
|
|
||||||
|
|
||||||
- **Yomitan Popup Visibility:** The new `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan lookup popup is open.
|
|
||||||
|
|
||||||
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed app version. The new `mpv.profile` config option passes an mpv profile to SubMiner-managed mpv launches, and bundled mpv plugin startup options are now configurable from SubMiner config.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`; sidebar appearance uses `subtitleSidebar.css`.
|
- **Subtitle Delay Shortcuts:** Overlay subtitle delay controls now match mpv's native defaults.
|
||||||
- Default font stack updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`; default text shadow is stronger, JLPT underlines are thicker, and the frequency `topX` threshold defaults to `10000`.
|
- `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show the current delay on the OSD.
|
||||||
- Existing configs are migrated automatically: legacy appearance options and hover token colors fold into `subtitleStyle.css`, and user config files are preserved.
|
- The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||||
|
|
||||||
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys remain accepted with deprecation warnings.
|
- **Update Notifications:** New installs now default to overlay-only update notifications instead of overlay plus system notifications.
|
||||||
- N+1 highlighting is preserved for configs that already had it enabled; new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly.
|
|
||||||
|
|
||||||
- **Character Dictionary:** Entries are now scoped to the current AniList media and generate Japanese name aliases only, so raw romanized or English aliases no longer appear as separate results.
|
|
||||||
- A new `Ctrl/Cmd+D` manager modal lets you remove, reorder, or override loaded dictionary entries.
|
|
||||||
- The in-app AniList title selector now waits for an explicit search rather than triggering automatically; the search box is prefilled from the current filename guess.
|
|
||||||
|
|
||||||
- **Linux Updater:** Tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows update flow. System-package-managed AppImages and non-AppImage launches fall back to the GitHub-asset flow.
|
|
||||||
|
|
||||||
- **Subsync:** The subtitle sync dialog now always opens the manual picker; the `subsync.defaultMode` config option has been removed.
|
|
||||||
|
|
||||||
- **Jellyfin Setup:** The server presets dropdown is replaced by a single editable server URL field.
|
|
||||||
|
|
||||||
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
|
|
||||||
|
|
||||||
- **Runtime:** The bundled Electron runtime is updated from 39.8.6 to 42.2.0.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **macOS Overlay:** Significantly improved overlay focus and stability across a range of scenarios.
|
- **Anki Card Enrichment:** Fixed two issues where card fields were not populated correctly after mining.
|
||||||
- The overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; and opens over fullscreen mpv without switching Spaces.
|
- Highlight Word now bolds the mined word in Kiku sentence and sentence-furigana fields even when the source Yomitan sentence has no existing bold markup.
|
||||||
- Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The compiled mpv window helper is now correctly bundled, preventing the overlay from falling back to a slower startup path on first launch.
|
- Lapis and Kiku word cards enriched through SubMiner now include the word-and-sentence marker, restoring sentence context on the card front.
|
||||||
|
|
||||||
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player.
|
- **Windows Overlay:** Fixed shaky hover and click behavior on the subtitle bar when a video attaches to an already-running SubMiner instance.
|
||||||
- The overlay stays stacked above mpv after click-to-focus events and is suspended while the in-player stats window is open.
|
|
||||||
- Settings windows (SubMiner and Yomitan) now open above the subtitle overlay; the overlay hides immediately when the character dictionary modal opens, including while AniList lookup is in progress.
|
|
||||||
|
|
||||||
- **Jellyfin Playback:** Resolved a wide range of discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, and duplicate ready signals no longer re-show the overlay.
|
- **Windows Anki & Media:** Fixed two issues affecting Windows users running SubMiner in background-launch mode.
|
||||||
- Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track.
|
- Known-word cache refreshes no longer fail when no deck is configured.
|
||||||
- Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress.
|
- Audio and image clipping now works correctly by recreating missing FFmpeg temp directories before processing.
|
||||||
|
|
||||||
- **Jellyfin Subtitles:** Improved subtitle timing by preferring default embedded streams over external sidecars, stripping Jellyfin's server-selected stream from playback URLs, suppressing mpv auto-selection while SubMiner stages managed tracks, and automatically correcting Japanese-vs-English cue timeline offsets.
|
- **Windows Character Dictionary:** The character dictionary auto-sync now correctly falls back to mpv's current video path on Windows when app media state is not yet ready.
|
||||||
- Per-stream subtitle delay shifts are restored on load. Track selection now tolerates transient `track-list` read failures and numeric string track IDs on Linux.
|
|
||||||
|
|
||||||
- **Jellyfin Overlay:** The visible subtitle overlay now shows automatically during Jellyfin playback so `subtitleStyle` appearance applies, and the bundled mpv plugin is injected when SubMiner auto-launches mpv so mpv-side keybindings work without overlay focus.
|
- **Linux Support Assets:** Linux updates now create and refresh both managed support assets: the launcher runtime plugin copy and the rofi theme.
|
||||||
- The `y-t` overlay toggle is reliable and remains sticky across stream redirects.
|
- First playback on a fresh Linux install auto-installs those bundled assets before mpv starts if either one is missing.
|
||||||
- Passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv.
|
- Asset refreshes leave unrelated SubMiner data directories untouched and stage plugin copies before replacing the live runtime plugin.
|
||||||
|
|
||||||
- **Jellyfin Remote Progress:** Fixed progress sync for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows.
|
- **Linux Visible Overlay Startup:** Auto-paused visible overlay startup stays fully interactive during the first measurement gap.
|
||||||
- Play and Resume are now distinct: Play starts from the beginning while Resume starts at the saved position.
|
- Startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv subtitle reads refresh parsed cues before warm readiness resumes playback.
|
||||||
- Final progress reports use SubMiner's last known position when mpv resets during stop.
|
|
||||||
|
|
||||||
- **Jellyfin Identity:** Cast device identity is now derived from the OS hostname. Multiple SubMiner installs no longer share the same remote-session identity.
|
- **Playlist Transitions:** The visible overlay stays active while mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||||
|
|
||||||
- **Jellyfin Tray:** The discovery tray checkbox stays in sync on Linux after tray, CLI, or startup remote-session changes. Stale discovery sessions restart automatically when the server no longer lists the SubMiner cast target.
|
- **macOS Yomitan Popup Focus:** Yomitan popup focus is restored after card mining or popup reload.
|
||||||
|
- Clicking transparent overlay space now closes the popup and returns passthrough to mpv without a hide/reappear cycle.
|
||||||
|
|
||||||
- **Jellyfin Setup:** Fixed the Windows login flow with an IPC bridge and immediate progress feedback; unreachable servers time out with an inline error instead of hanging.
|
- **Stats AniList Search:** Manual AniList linking from the stats page now strips generated `Season N` suffixes before searching, so the base anime title is used.
|
||||||
|
|
||||||
- **AniList Progress:** Threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold.
|
- **Desktop Notifications:** System notifications now show the SubMiner app icon when no custom notification image is provided.
|
||||||
- Season-specific results are preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching status.
|
|
||||||
- Repeated missing-token checks no longer exhaust AniList retry attempts or create duplicate dead-letter entries for the same episode.
|
|
||||||
|
|
||||||
- **Anki:** Sentence-audio padding is now opt-in by default; animated AVIF freeze-frame duration is correctly aligned to word audio length without double-counting padding.
|
- **Release Notes:** GitHub release `What's Changed` and `New Contributors` attribution sections are preserved when CI regenerates release notes from committed changelog output.
|
||||||
- Multi-line sentence mining stays aligned for repeated subtitle text; Kiku duplicate-card detection and merge flow are fixed; clipboard card updates from YouTube use mpv's resolved stream URLs; sentence cards refresh the secondary subtitle before saving.
|
|
||||||
- Known-word cache append is fixed when no default Anki mining deck is configured but multiple known-word deck field mappings are present.
|
|
||||||
|
|
||||||
- **YouTube:** Primary subtitles are downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit.
|
|
||||||
- False load-failure notifications are suppressed. Launcher-managed playback creates the tray icon when attaching to an already-running process, and app-owned playback no longer lets the mpv plugin start a second SubMiner instance.
|
|
||||||
|
|
||||||
- **Character Dictionary:** Surname honorifics are now matched for Japanese localized aliases embedded in AniList alternative names; cached snapshots are regenerated to include them.
|
|
||||||
- Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests. Manager keyboard shortcuts are correctly forwarded to the mpv plugin.
|
|
||||||
|
|
||||||
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground.
|
|
||||||
- Builds that cannot apply native updates show a manual-install message instead of a restart prompt. Windows retains the native NSIS update path while routing updater HTTP through the main process.
|
|
||||||
|
|
||||||
- **Setup - macOS:** First-run setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths.
|
|
||||||
- `subminer app --setup` opens the setup flow even when SubMiner is already running. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed.
|
|
||||||
|
|
||||||
- **Tray App:** Fixed several lifecycle issues: the tray stays running when Yomitan settings are closed; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable.
|
|
||||||
- Settings loading no longer blocks other tray actions; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized; the session help modal closes correctly without mpv running.
|
|
||||||
- On Windows, "Open SubMiner Setup" now correctly opens the setup window after first-run setup is complete.
|
|
||||||
|
|
||||||
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused until subtitle priming and tokenization readiness complete.
|
|
||||||
- `subminer settings` on macOS no longer emits Electron menu diagnostics and exits cleanly when the window is closed. Linux first-run launcher installs build with a valid Bun shebang; `subminer app` on Linux returns control to the terminal immediately.
|
|
||||||
- On Windows, managed mpv launches from a background instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
|
|
||||||
|
|
||||||
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
|
|
||||||
- The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct Linux bounds reapplication and user-paused playback preserved through readiness gates.
|
|
||||||
|
|
||||||
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered. Annotations are corrected for Yomitan single-token compounds with internal particles like `目の前`.
|
|
||||||
|
|
||||||
- **Subtitle Annotation Prefetch:** Cached annotations and character images are ready for more live subtitle changes without delaying raw subtitle display.
|
|
||||||
|
|
||||||
- **Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so SubMiner shortcuts also work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are correctly wired through mpv. The visible overlay receives focus when entering multi-line copy/mine selection so number keys work on macOS and Windows.
|
|
||||||
|
|
||||||
- **Stats:** In-player stats layering is fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window. Jellyfin playback stats are grouped under item metadata so watched episodes merge with matching local library titles and display clean names.
|
|
||||||
|
|
||||||
- **Sidebar:** Yomitan lookup popups opened from the subtitle sidebar now correctly pause playback when popup auto-pause is enabled. Mined cards use audio and images from the clicked subtitle line rather than the current primary line.
|
|
||||||
|
|
||||||
- **Controller:** Config and debug shortcuts stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge; remaps are saved per controller profile, and individual bindings can be reset to their defaults.
|
|
||||||
|
|
||||||
- **Discord Rich Presence:** Presence no longer falls back to Jellyfin stream URLs; Jellyfin playback titles are primed before loading tokenized streams so presence shows the show/episode title.
|
|
||||||
|
|
||||||
- **WebSocket:** The regular subtitle WebSocket now sends plain text only; annotation spans and token metadata are sent exclusively on the annotation WebSocket.
|
|
||||||
|
|
||||||
- **Windows Startup:** Fatal startup errors now show a native error dialog and write details to the app log instead of exiting silently.
|
|
||||||
|
|
||||||
- **Yomitan:** Fixed popups not opening when overlay startup races the Yomitan extension load.
|
|
||||||
|
|
||||||
- **Subtitle Sync Modal:** Fixed a macOS issue where the modal would flash and disappear on the first attempt, or leave stale state after syncing.
|
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
- **Versioned Docs:** Stable docs are now published at the site root with current development docs under `/main/`.
|
- **Linux Update Flow:** Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal playback auto-installs those managed support assets if either one is missing.
|
||||||
- Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests paths incorrectly, and local dev version routes serve warmed archive files instead of redirecting to production.
|
|
||||||
|
|
||||||
- **Configuration Reference:** All previously undocumented config options are now covered, including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options. Updated known-word cache docs and examples to recommend expression/word fields.
|
## What's Changed
|
||||||
|
|
||||||
- **Architecture Docs:** Added a Playback Startup Flow diagram and a Runtime Sockets section and diagram to the IPC + Runtime Contracts page, with cross-reference pointers in the MPV Plugin and Troubleshooting pages.
|
- Replace subtitle delay actions with native mpv keybindings by @ksyasuda in #120
|
||||||
|
- fix(stats): strip Season N suffix from AniList title searches by @ksyasuda in #121
|
||||||
|
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
|
||||||
|
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
|
||||||
|
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
|
||||||
|
- Fix Windows Anki startup and overlay regressions by @ksyasuda in #128
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -151,6 +64,7 @@ See the README and docs/installation guide for full setup steps.
|
|||||||
|
|
||||||
- Linux: `SubMiner.AppImage`
|
- Linux: `SubMiner.AppImage`
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||||
|
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -605,6 +605,7 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
|
|||||||
|
|
||||||
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||||
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
|
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
|
||||||
|
assert.match(prereleaseNotes, /<!-- prerelease-base-version: 0\.11\.3 -->/);
|
||||||
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
||||||
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
||||||
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
||||||
@@ -620,6 +621,8 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
|
|||||||
const existingNotes = [
|
const existingNotes = [
|
||||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
'',
|
'',
|
||||||
|
'<!-- prerelease-base-version: 0.11.3 -->',
|
||||||
|
'',
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
'### Added',
|
'### Added',
|
||||||
'- Overlay: Previous beta entry.',
|
'- Overlay: Previous beta entry.',
|
||||||
@@ -679,6 +682,61 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an older release line', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-ignore-unmarked-old-notes');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
const existingNotes = [
|
||||||
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
|
'',
|
||||||
|
'## Highlights',
|
||||||
|
'### Added',
|
||||||
|
'- Settings Window: Previous release line entry.',
|
||||||
|
'',
|
||||||
|
'## Installation',
|
||||||
|
'',
|
||||||
|
'See the README and docs/installation guide for full setup steps.',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.17.0-beta.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
[
|
||||||
|
'type: changed',
|
||||||
|
'area: overlay',
|
||||||
|
'',
|
||||||
|
'- Replaced subtitle delay actions with native mpv keybindings.',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stub = defaultStubClaude();
|
||||||
|
const outputPath = writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.17.0-beta.1',
|
||||||
|
deps: { runClaude: stub.runClaude },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
|
||||||
|
assert.doesNotMatch(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
|
||||||
|
assert.doesNotMatch(stub.calls[0]!.input, /Settings Window: Previous release line entry/);
|
||||||
|
|
||||||
|
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||||
|
assert.match(prereleaseNotes, /### Changed\n- Polished: changed entry\./);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease bullets instead of appending fix churn', async () => {
|
test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease bullets instead of appending fix churn', async () => {
|
||||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
const workspace = createWorkspace('prerelease-net-outcome-prompt');
|
const workspace = createWorkspace('prerelease-net-outcome-prompt');
|
||||||
@@ -686,6 +744,8 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
|
|||||||
const existingNotes = [
|
const existingNotes = [
|
||||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
'',
|
'',
|
||||||
|
'<!-- prerelease-base-version: 0.12.0 -->',
|
||||||
|
'',
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
'### Added',
|
'### Added',
|
||||||
'- Config Window: Previous beta entry.',
|
'- Config Window: Previous beta entry.',
|
||||||
@@ -1122,13 +1182,22 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
|||||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||||
'utf8',
|
'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, /- 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, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
||||||
assert.match(
|
assert.match(
|
||||||
releaseNotes,
|
releaseNotes,
|
||||||
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
|
/## 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(
|
assert.doesNotMatch(
|
||||||
releaseNotes,
|
releaseNotes,
|
||||||
/ksyasuda made their first contribution/,
|
/ksyasuda made their first contribution/,
|
||||||
@@ -1137,13 +1206,96 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
|||||||
|
|
||||||
// Attribution is a release-notes concern only; the CHANGELOG stays clean.
|
// Attribution is a release-notes concern only; the CHANGELOG stays clean.
|
||||||
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
|
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/);
|
assert.doesNotMatch(changelog, /New Contributors/);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
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 () => {
|
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
|
||||||
const { writeChangelogArtifacts } = await loadModule();
|
const { writeChangelogArtifacts } = await loadModule();
|
||||||
const workspace = createWorkspace('reuse-existing-section');
|
const workspace = createWorkspace('reuse-existing-section');
|
||||||
|
|||||||
@@ -93,6 +93,40 @@ function isSupportedPrereleaseVersion(version: string): boolean {
|
|||||||
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
|
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePrereleaseBaseVersion(version: string): string {
|
||||||
|
const match = /^(\d+\.\d+\.\d+)-(?:beta|rc)\.\d+$/u.exec(normalizeVersion(version));
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match[1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPrereleaseBaseVersionMarker(version: string): string {
|
||||||
|
return `<!-- prerelease-base-version: ${resolvePrereleaseBaseVersion(version)} -->`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPrereleaseBaseVersionMarker(notes: string): string | null {
|
||||||
|
return (
|
||||||
|
/<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPrereleaseMetadata(notes: string): string {
|
||||||
|
return notes
|
||||||
|
.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined {
|
||||||
|
const existingBaseVersion = extractPrereleaseBaseVersionMarker(notes);
|
||||||
|
if (existingBaseVersion !== resolvePrereleaseBaseVersion(version)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return stripPrereleaseMetadata(notes);
|
||||||
|
}
|
||||||
|
|
||||||
function verifyRequestedVersionMatchesPackageVersion(
|
function verifyRequestedVersionMatchesPackageVersion(
|
||||||
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
||||||
): void {
|
): void {
|
||||||
@@ -433,12 +467,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[] {
|
function renderContributorsSections(contributions: Contribution[]): string[] {
|
||||||
if (contributions.length === 0) {
|
if (contributions.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = ['## What’s Changed', ''];
|
const lines: string[] = ["## What's Changed", ''];
|
||||||
for (const contribution of contributions) {
|
for (const contribution of contributions) {
|
||||||
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
|
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
|
||||||
}
|
}
|
||||||
@@ -635,14 +702,21 @@ function renderReleaseNotes(
|
|||||||
options?: {
|
options?: {
|
||||||
disclaimer?: string;
|
disclaimer?: string;
|
||||||
contributions?: Contribution[];
|
contributions?: Contribution[];
|
||||||
|
contributorSections?: string[];
|
||||||
|
metadata?: string[];
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
||||||
|
const metadata = options?.metadata?.length ? [...options.metadata, ''] : [];
|
||||||
|
const contributorSections =
|
||||||
|
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
|
||||||
return [
|
return [
|
||||||
...prefix,
|
...prefix,
|
||||||
|
...metadata,
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
changes,
|
changes,
|
||||||
'',
|
'',
|
||||||
|
...contributorSections,
|
||||||
'## Installation',
|
'## Installation',
|
||||||
'',
|
'',
|
||||||
'See the README and docs/installation guide for full setup steps.',
|
'See the README and docs/installation guide for full setup steps.',
|
||||||
@@ -656,7 +730,6 @@ function renderReleaseNotes(
|
|||||||
'',
|
'',
|
||||||
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
|
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
|
||||||
'',
|
'',
|
||||||
...renderContributorsSections(options?.contributions ?? []),
|
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,6 +741,8 @@ function writeReleaseNotesFile(
|
|||||||
disclaimer?: string;
|
disclaimer?: string;
|
||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
contributions?: Contribution[];
|
contributions?: Contribution[];
|
||||||
|
contributorSections?: string[];
|
||||||
|
metadata?: string[];
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||||
@@ -960,6 +1035,7 @@ export function generateDocsChangelog(options?: Pick<ChangelogOptions, 'cwd' | '
|
|||||||
|
|
||||||
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
|
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||||
const cwd = options?.cwd ?? process.cwd();
|
const cwd = options?.cwd ?? process.cwd();
|
||||||
|
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
|
||||||
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
|
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
|
||||||
const version = resolveVersion(options ?? {});
|
const version = resolveVersion(options ?? {});
|
||||||
const changelogPath = path.join(cwd, 'CHANGELOG.md');
|
const changelogPath = path.join(cwd, 'CHANGELOG.md');
|
||||||
@@ -970,7 +1046,14 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
|
|||||||
throw new Error(`Missing CHANGELOG section for v${version}.`);
|
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 {
|
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||||
@@ -993,7 +1076,7 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
|||||||
|
|
||||||
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
|
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
|
||||||
const existingReleaseNotes = existsSync(prereleaseNotesPath)
|
const existingReleaseNotes = existsSync(prereleaseNotesPath)
|
||||||
? readFileSync(prereleaseNotesPath, 'utf8')
|
? resolveReusablePrereleaseNotes(readFileSync(prereleaseNotesPath, 'utf8'), version)
|
||||||
: undefined;
|
: undefined;
|
||||||
const changes = polishFragmentsWithClaude(fragments, {
|
const changes = polishFragmentsWithClaude(fragments, {
|
||||||
mode: 'release-notes',
|
mode: 'release-notes',
|
||||||
@@ -1007,6 +1090,7 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
|||||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
outputPath: PRERELEASE_NOTES_PATH,
|
outputPath: PRERELEASE_NOTES_PATH,
|
||||||
contributions,
|
contributions,
|
||||||
|
metadata: [renderPrereleaseBaseVersionMarker(version)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,46 @@ local ctx = {
|
|||||||
actionType = "mpv-command",
|
actionType = "mpv-command",
|
||||||
command = { "sub-seek", 1 },
|
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 = {
|
key = {
|
||||||
code = "BracketRight",
|
code = "BracketRight",
|
||||||
@@ -323,6 +363,11 @@ local expected_mpv_bindings = {
|
|||||||
{ keys = "DOWN", command = { "seek", -60 } },
|
{ keys = "DOWN", command = { "seek", -60 } },
|
||||||
{ keys = "H", command = { "sub-seek", -1 } },
|
{ keys = "H", command = { "sub-seek", -1 } },
|
||||||
{ keys = "L", 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 = "q", command = { "quit" } },
|
||||||
{ keys = "Ctrl+w", command = { "quit" } },
|
{ keys = "Ctrl+w", command = { "quit" } },
|
||||||
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } },
|
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } },
|
||||||
@@ -340,10 +385,6 @@ for _, expected in ipairs(expected_mpv_bindings) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
local expected_cli_bindings = {
|
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+c", flag = "--open-youtube-picker" },
|
||||||
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
|
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
|
||||||
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
|
{ 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)
|
assert_true(cli_call[2] == expected.flag, "default session action should pass " .. expected.flag)
|
||||||
end
|
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")
|
local play_next = find_binding("Ctrl+L")
|
||||||
assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form")
|
assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form")
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ local function run_plugin_scenario(config)
|
|||||||
if name == "osd-height" then
|
if name == "osd-height" then
|
||||||
return config.osd_height or 720
|
return config.osd_height or 720
|
||||||
end
|
end
|
||||||
|
if name == "playlist-count" then
|
||||||
|
return config.playlist_count
|
||||||
|
end
|
||||||
|
if name == "playlist-pos" then
|
||||||
|
return config.playlist_pos
|
||||||
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -627,6 +633,46 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local scenario = {
|
||||||
|
process_list = "",
|
||||||
|
defer_timeouts = true,
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "yes",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
path = "/media/slow-episode-01.mkv",
|
||||||
|
media_title = "Slow Episode 1",
|
||||||
|
playlist_count = 2,
|
||||||
|
playlist_pos = 0,
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local recorded, err = run_plugin_scenario(scenario)
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for slow warm playlist visibility scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
recorded.script_messages["subminer-autoplay-ready"]()
|
||||||
|
fire_event(recorded, "end-file", { reason = "eof" })
|
||||||
|
fire_pending_timeouts(recorded)
|
||||||
|
scenario.path = "/media/slow-episode-02.mkv"
|
||||||
|
scenario.media_title = "Slow Episode 2"
|
||||||
|
scenario.playlist_pos = 1
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(
|
||||||
|
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0,
|
||||||
|
"slow playlist advance should preserve visible overlay state while the next episode is pending"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
count_start_calls(recorded.async_calls) == 1,
|
||||||
|
"slow playlist visibility reuse should not issue another --start command"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local scenario = {
|
local scenario = {
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function createIntegrationTestContext(
|
|||||||
knownWordsScope: string;
|
knownWordsScope: string;
|
||||||
knownWordsLastRefreshedAtMs: number;
|
knownWordsLastRefreshedAtMs: number;
|
||||||
};
|
};
|
||||||
privateState.knownWordsScope = 'is:note';
|
privateState.knownWordsScope = 'all';
|
||||||
privateState.knownWordsLastRefreshedAtMs = Date.now();
|
privateState.knownWordsLastRefreshedAtMs = Date.now();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -324,6 +324,119 @@ test('AnkiIntegration resolves merged-away note ids to the kept note id', () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function processSentenceWithConfig(
|
||||||
|
config: Partial<AnkiConnectConfig>,
|
||||||
|
mpvSentence: string,
|
||||||
|
noteFields: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
|
||||||
|
return (
|
||||||
|
integration as unknown as {
|
||||||
|
processSentence: (sentence: string, fields: Record<string, string>) => string;
|
||||||
|
}
|
||||||
|
).processSentence(mpvSentence, noteFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processSentenceFuriganaWithConfig(
|
||||||
|
config: Partial<AnkiConnectConfig>,
|
||||||
|
sentenceFurigana: string,
|
||||||
|
noteFields: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
|
||||||
|
return (
|
||||||
|
integration as unknown as {
|
||||||
|
processSentenceFurigana: (sentence: string, fields: Record<string, string>) => string;
|
||||||
|
}
|
||||||
|
).processSentenceFurigana(sentenceFurigana, noteFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('AnkiIntegration highlights mined word from expression field when sentence has no bold marker', () => {
|
||||||
|
const processed = processSentenceWithConfig(
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
highlightWord: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'先日 貴様らが潜入した キールダンジョンから―',
|
||||||
|
{
|
||||||
|
expression: '潜入',
|
||||||
|
sentence: '先日 貴様らが潜入した キールダンジョンから―',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(processed, '先日 貴様らが<b>潜入</b>した キールダンジョンから―');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration keeps existing Yomitan bold target when present', () => {
|
||||||
|
const processed = processSentenceWithConfig(
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
highlightWord: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'先日 貴様らが潜入した キールダンジョンから―',
|
||||||
|
{
|
||||||
|
expression: '潜入',
|
||||||
|
sentence: '<b>潜入した</b>',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(processed, '先日 貴様らが<b>潜入した</b> キールダンジョンから―');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration leaves sentence plain when word highlighting is disabled', () => {
|
||||||
|
const processed = processSentenceWithConfig(
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
highlightWord: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'先日 貴様らが潜入した キールダンジョンから―',
|
||||||
|
{
|
||||||
|
expression: '潜入',
|
||||||
|
sentence: '<b>潜入</b>',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration highlights mined word in sentence furigana field', () => {
|
||||||
|
const processed = processSentenceFuriganaWithConfig(
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
highlightWord: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span><span class="term">を</span>',
|
||||||
|
{
|
||||||
|
expression: '特技',
|
||||||
|
sentence: '不思議な特技を',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
processed,
|
||||||
|
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><b><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span></b><span class="term">を</span>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
|
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
|
|||||||
+114
-7
@@ -70,7 +70,7 @@ interface NoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio';
|
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
@@ -78,6 +78,66 @@ function trimToNonEmptyString(value: unknown): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripRubyReadingText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/<rt\b[^>]*>[\s\S]*?<\/rt>/gi, '')
|
||||||
|
.replace(/<rp\b[^>]*>[\s\S]*?<\/rp>/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtmlTags(value: string): string {
|
||||||
|
return value.replace(/<[^>]+>/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleFuriganaText(value: string): string {
|
||||||
|
return stripHtmlTags(stripRubyReadingText(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function boldMatchingFuriganaTerms(sentenceFurigana: string, highlightedText: string): string {
|
||||||
|
if (!sentenceFurigana || !highlightedText || /<b\b/i.test(sentenceFurigana)) {
|
||||||
|
return sentenceFurigana;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spanRegex = /<span\b[^>]*>[\s\S]*?<\/span>/gi;
|
||||||
|
const spans: Array<{ start: number; end: number; visibleStart: number; visibleEnd: number }> = [];
|
||||||
|
let visibleSentence = '';
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = spanRegex.exec(sentenceFurigana)) !== null) {
|
||||||
|
const visibleStart = visibleSentence.length;
|
||||||
|
visibleSentence += getVisibleFuriganaText(match[0] || '');
|
||||||
|
spans.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
visibleStart,
|
||||||
|
visibleEnd: visibleSentence.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spans.length === 0) {
|
||||||
|
return sentenceFurigana.replace(highlightedText, `<b>${highlightedText}</b>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightStart = visibleSentence.indexOf(highlightedText);
|
||||||
|
if (highlightStart === -1) {
|
||||||
|
return sentenceFurigana;
|
||||||
|
}
|
||||||
|
const highlightEnd = highlightStart + highlightedText.length;
|
||||||
|
const matchingSpans = spans.filter(
|
||||||
|
(span) => span.visibleEnd > highlightStart && span.visibleStart < highlightEnd,
|
||||||
|
);
|
||||||
|
if (matchingSpans.length === 0) {
|
||||||
|
return sentenceFurigana;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sentenceFurigana;
|
||||||
|
for (const span of [...matchingSpans].reverse()) {
|
||||||
|
result = `${result.slice(0, span.start)}<b>${result.slice(
|
||||||
|
span.start,
|
||||||
|
span.end,
|
||||||
|
)}</b>${result.slice(span.end)}`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function decodeURIComponentSafe(value: string): string {
|
function decodeURIComponentSafe(value: string): string {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(value);
|
return decodeURIComponent(value);
|
||||||
@@ -461,6 +521,10 @@ export class AnkiIntegration {
|
|||||||
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
|
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
|
||||||
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
|
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
|
||||||
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
|
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
|
||||||
|
processSentenceFurigana: (sentenceFurigana, noteFields) =>
|
||||||
|
this.processSentenceFurigana(sentenceFurigana, noteFields),
|
||||||
|
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
|
||||||
|
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
|
||||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||||
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
||||||
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
||||||
@@ -677,20 +741,25 @@ export class AnkiIntegration {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSentenceHighlightText(noteFields: Record<string, string>): string {
|
||||||
|
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
|
||||||
|
const existingSentence = noteFields[sentenceFieldName] || '';
|
||||||
|
return (
|
||||||
|
existingSentence.match(/<b>(.*?)<\/b>/)?.[1] ||
|
||||||
|
getPreferredWordValueFromExtractedFields(noteFields, this.config).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private processSentence(mpvSentence: string, noteFields: Record<string, string>): string {
|
private processSentence(mpvSentence: string, noteFields: Record<string, string>): string {
|
||||||
if (this.config.behavior?.highlightWord === false) {
|
if (this.config.behavior?.highlightWord === false) {
|
||||||
return mpvSentence;
|
return mpvSentence;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
|
const highlightedText = this.getSentenceHighlightText(noteFields);
|
||||||
const existingSentence = noteFields[sentenceFieldName] || '';
|
if (!highlightedText) {
|
||||||
|
|
||||||
const highlightMatch = existingSentence.match(/<b>(.*?)<\/b>/);
|
|
||||||
if (!highlightMatch || !highlightMatch[1]) {
|
|
||||||
return mpvSentence;
|
return mpvSentence;
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightedText = highlightMatch[1];
|
|
||||||
const index = mpvSentence.indexOf(highlightedText);
|
const index = mpvSentence.indexOf(highlightedText);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -702,6 +771,20 @@ export class AnkiIntegration {
|
|||||||
return `${prefix}<b>${highlightedText}</b>${suffix}`;
|
return `${prefix}<b>${highlightedText}</b>${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private processSentenceFurigana(
|
||||||
|
sentenceFurigana: string,
|
||||||
|
noteFields: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
if (this.config.behavior?.highlightWord === false) {
|
||||||
|
return sentenceFurigana;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightedText = this.getSentenceHighlightText(noteFields);
|
||||||
|
return highlightedText
|
||||||
|
? boldMatchingFuriganaTerms(sentenceFurigana, highlightedText)
|
||||||
|
: sentenceFurigana;
|
||||||
|
}
|
||||||
|
|
||||||
private consumeSubtitleMiningContext(): SubtitleMiningContext | null {
|
private consumeSubtitleMiningContext(): SubtitleMiningContext | null {
|
||||||
if (!this.consumeSubtitleMiningContextCallback) {
|
if (!this.consumeSubtitleMiningContextCallback) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1030,6 +1113,30 @@ export class AnkiIntegration {
|
|||||||
): void {
|
): void {
|
||||||
const audioFlagNames = ['IsAudioCard'];
|
const audioFlagNames = ['IsAudioCard'];
|
||||||
|
|
||||||
|
if (cardKind === 'word-and-sentence') {
|
||||||
|
const wordAndSentenceFlag = this.resolveFieldName(
|
||||||
|
availableFieldNames,
|
||||||
|
'IsWordAndSentenceCard',
|
||||||
|
);
|
||||||
|
if (!wordAndSentenceFlag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updatedFields[wordAndSentenceFlag] = 'x';
|
||||||
|
|
||||||
|
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||||
|
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[sentenceFlag] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const audioFlagName of audioFlagNames) {
|
||||||
|
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cardKind === 'sentence') {
|
if (cardKind === 'sentence') {
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||||
if (sentenceFlag) {
|
if (sentenceFlag) {
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import type { AnkiConnectConfig } from '../types/anki';
|
|||||||
|
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
|
|
||||||
|
function setWordAndSentenceCardTypeFields(
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
availableFieldNames: string[],
|
||||||
|
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
||||||
|
): void {
|
||||||
|
if (cardKind !== 'word-and-sentence') return;
|
||||||
|
|
||||||
|
const resolveFieldName = (preferredName: string): string | null =>
|
||||||
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
|
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||||
|
if (!wordAndSentenceFlag) return;
|
||||||
|
|
||||||
|
updatedFields[wordAndSentenceFlag] = 'x';
|
||||||
|
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||||
service: CardCreationService;
|
service: CardCreationService;
|
||||||
updatedFields: Record<string, string>[];
|
updatedFields: Record<string, string>[];
|
||||||
@@ -142,6 +163,72 @@ test('manual clipboard subtitle update replaces sentence audio without touching
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => {
|
||||||
|
const { service, updatedFields } = createManualUpdateService({
|
||||||
|
getConfig: () =>
|
||||||
|
({
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
audio: 'ExpressionAudio',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
overwriteAudio: false,
|
||||||
|
overwriteImage: false,
|
||||||
|
},
|
||||||
|
ai: false,
|
||||||
|
}) as AnkiConnectConfig,
|
||||||
|
client: {
|
||||||
|
addNote: async () => 0,
|
||||||
|
addTags: async () => undefined,
|
||||||
|
notesInfo: async () => [
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: '単語' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateNoteFields: async (_noteId, fields) => {
|
||||||
|
updatedFields.push(fields);
|
||||||
|
},
|
||||||
|
storeMediaFile: async () => undefined,
|
||||||
|
findNotes: async () => [42],
|
||||||
|
retrieveMediaFile: async () => '',
|
||||||
|
},
|
||||||
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
|
model: 'Sentence',
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
audioField: 'SentenceAudio',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'disabled',
|
||||||
|
kikuDeleteDuplicateInAuto: false,
|
||||||
|
}),
|
||||||
|
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateLastAddedFromClipboard('字幕');
|
||||||
|
|
||||||
|
assert.equal(updatedFields.length, 1);
|
||||||
|
assert.deepEqual(updatedFields[0], {
|
||||||
|
Sentence: '字幕',
|
||||||
|
IsWordAndSentenceCard: 'x',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
|
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
|
||||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
||||||
client: {
|
client: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AiConfig } from '../types/integrations';
|
|||||||
import { MpvClient } from '../types/runtime';
|
import { MpvClient } from '../types/runtime';
|
||||||
import { resolveSentenceBackText } from './ai';
|
import { resolveSentenceBackText } from './ai';
|
||||||
import { resolveMediaGenerationInputPath } from './media-source';
|
import { resolveMediaGenerationInputPath } from './media-source';
|
||||||
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration.card-creation');
|
const log = createLogger('anki').child('integration.card-creation');
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export interface CardCreationNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio';
|
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||||
|
|
||||||
interface CardCreationClient {
|
interface CardCreationClient {
|
||||||
addNote(
|
addNote(
|
||||||
@@ -219,7 +220,8 @@ export class CardCreationService {
|
|||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||||
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
|
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||||
|
const sentenceField = sentenceCardConfig.sentenceField;
|
||||||
|
|
||||||
const sentence = blocks.join(' ');
|
const sentence = blocks.join(' ');
|
||||||
const updatedFields: Record<string, string> = {};
|
const updatedFields: Record<string, string> = {};
|
||||||
@@ -230,6 +232,13 @@ export class CardCreationService {
|
|||||||
if (sentenceField) {
|
if (sentenceField) {
|
||||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
|
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||||
|
this.deps.setCardTypeFields(
|
||||||
|
updatedFields,
|
||||||
|
Object.keys(noteInfo.fields),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: 120_000,
|
refreshedAtMs: 120_000,
|
||||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
|
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
@@ -143,7 +143,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: 59_000,
|
refreshedAtMs: 59_000,
|
||||||
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
|
scope: '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
@@ -229,7 +229,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: 1,
|
refreshedAtMs: 1,
|
||||||
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}',
|
scope: '{"refreshMinutes":1440,"scope":"all","fieldsWord":"Word"}',
|
||||||
words: ['猫', '犬'],
|
words: ['猫', '犬'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
@@ -276,6 +276,36 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager uses empty query when no known-word deck is configured', async () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
fields: {
|
||||||
|
word: 'Word',
|
||||||
|
},
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientState.findNotesByQuery.set('', [1]);
|
||||||
|
clientState.notesInfoResult = [
|
||||||
|
{
|
||||||
|
noteId: 1,
|
||||||
|
fields: {
|
||||||
|
Word: { value: '猫' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await manager.refresh(true);
|
||||||
|
|
||||||
|
assert.equal(manager.isKnownWord('猫'), true);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('KnownWordCacheManager skips malformed note info without fields', async () => {
|
test('KnownWordCacheManager skips malformed note info without fields', async () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
fields: {
|
fields: {
|
||||||
@@ -364,7 +394,7 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
|
|||||||
scope: string;
|
scope: string;
|
||||||
words: string[];
|
words: string[];
|
||||||
};
|
};
|
||||||
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}');
|
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
|
||||||
assert.deepEqual(persisted.words, ['猫']);
|
assert.deepEqual(persisted.words, ['猫']);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||||
@@ -568,7 +598,7 @@ test('KnownWordCacheManager reports immediate append cache clears as mutations',
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: Date.now(),
|
refreshedAtMs: Date.now(),
|
||||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":"Expression"}',
|
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":"Expression"}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
'1': ['猫'],
|
'1': ['猫'],
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configuredDeck = trimToNonEmptyString(config.deck);
|
const configuredDeck = trimToNonEmptyString(config.deck);
|
||||||
return configuredDeck ? `deck:${configuredDeck}` : 'is:note';
|
return configuredDeck ? `deck:${configuredDeck}` : 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
||||||
@@ -396,7 +396,7 @@ export class KnownWordCacheManager {
|
|||||||
private buildKnownWordsQuery(): string {
|
private buildKnownWordsQuery(): string {
|
||||||
const decks = this.getKnownWordDecks();
|
const decks = this.getKnownWordDecks();
|
||||||
if (decks.length === 0) {
|
if (decks.length === 0) {
|
||||||
return 'is:note';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decks.length === 1) {
|
if (decks.length === 1) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export interface NoteFieldValueInfo {
|
||||||
|
fields: Record<string, { value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): string | null {
|
||||||
|
const resolvedFieldName = Object.keys(noteInfo.fields).find(
|
||||||
|
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
|
||||||
|
);
|
||||||
|
return resolvedFieldName ? (noteInfo.fields[resolvedFieldName]?.value ?? '') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): boolean {
|
||||||
|
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldMarkWordAndSentenceCard(
|
||||||
|
noteInfo: NoteFieldValueInfo,
|
||||||
|
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
||||||
|
): boolean {
|
||||||
|
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
||||||
|
if (wordAndSentenceValue === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (wordAndSentenceValue.trim().length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,27 @@ import {
|
|||||||
} from './note-update-workflow';
|
} from './note-update-workflow';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
|
|
||||||
|
function setWordAndSentenceCardTypeFields(
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
availableFieldNames: string[],
|
||||||
|
cardKind: 'word-and-sentence',
|
||||||
|
): void {
|
||||||
|
assert.equal(cardKind, 'word-and-sentence');
|
||||||
|
const resolveFieldName = (preferredName: string): string | null =>
|
||||||
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
|
|
||||||
|
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||||
|
if (!wordAndSentenceFlag) return;
|
||||||
|
|
||||||
|
updatedFields[wordAndSentenceFlag] = 'x';
|
||||||
|
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createWorkflowHarness() {
|
function createWorkflowHarness() {
|
||||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||||
const notifications: Array<{ noteId: number; label: string | number }> = [];
|
const notifications: Array<{ noteId: number; label: string | number }> = [];
|
||||||
@@ -40,6 +61,7 @@ function createWorkflowHarness() {
|
|||||||
getCurrentSubtitleStart: () => 12.3,
|
getCurrentSubtitleStart: () => 12.3,
|
||||||
getEffectiveSentenceCardConfig: () => ({
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
sentenceField: 'Sentence',
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
kikuEnabled: false,
|
kikuEnabled: false,
|
||||||
kikuFieldGrouping: 'disabled' as const,
|
kikuFieldGrouping: 'disabled' as const,
|
||||||
}),
|
}),
|
||||||
@@ -57,6 +79,7 @@ function createWorkflowHarness() {
|
|||||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||||
false,
|
false,
|
||||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||||
|
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||||
if (!preferred) return null;
|
if (!preferred) return null;
|
||||||
const names = Object.keys(noteInfo.fields);
|
const names = Object.keys(noteInfo.fields);
|
||||||
@@ -102,6 +125,118 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
|
|||||||
assert.equal(harness.notifications.length, 1);
|
assert.equal(harness.notifications.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'tokugi' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
SentenceFurigana: { value: '<span class="term">tokugi</span>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
harness.deps.processSentenceFurigana = (sentenceFurigana) =>
|
||||||
|
sentenceFurigana.replace('tokugi', '<b>tokugi</b>');
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence cards', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
IsWordAndSentenceCard: 'x',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: true,
|
||||||
|
kikuEnabled: false,
|
||||||
|
kikuFieldGrouping: 'disabled',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'sentence expression' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: 'x' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () => [];
|
harness.deps.client.notesInfo = async () => [];
|
||||||
@@ -119,6 +254,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
|
|||||||
let notesInfoCallCount = 0;
|
let notesInfoCallCount = 0;
|
||||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
sentenceField: 'Sentence',
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
kikuEnabled: true,
|
kikuEnabled: true,
|
||||||
kikuFieldGrouping: 'auto',
|
kikuFieldGrouping: 'auto',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -35,6 +36,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
getCurrentSubtitleStart: () => number | undefined;
|
getCurrentSubtitleStart: () => number | undefined;
|
||||||
getEffectiveSentenceCardConfig: () => {
|
getEffectiveSentenceCardConfig: () => {
|
||||||
sentenceField: string;
|
sentenceField: string;
|
||||||
|
lapisEnabled: boolean;
|
||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
};
|
};
|
||||||
@@ -58,6 +60,15 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
expression: string,
|
expression: string,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
|
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
|
||||||
|
processSentenceFurigana?: (
|
||||||
|
sentenceFurigana: string,
|
||||||
|
noteFields: Record<string, string>,
|
||||||
|
) => string;
|
||||||
|
setCardTypeFields: (
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
availableFieldNames: string[],
|
||||||
|
cardKind: 'word-and-sentence',
|
||||||
|
) => void;
|
||||||
resolveConfiguredFieldName: (
|
resolveConfiguredFieldName: (
|
||||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||||
...preferredNames: (string | undefined)[]
|
...preferredNames: (string | undefined)[]
|
||||||
@@ -189,8 +200,32 @@ export class NoteUpdateWorkflow {
|
|||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
|
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||||
|
this.deps.setCardTypeFields(
|
||||||
|
updatedFields,
|
||||||
|
Object.keys(noteInfo.fields),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
const sentenceFuriganaField = this.deps.resolveConfiguredFieldName(
|
||||||
|
noteInfo,
|
||||||
|
'SentenceFurigana',
|
||||||
|
);
|
||||||
|
const existingSentenceFurigana = sentenceFuriganaField
|
||||||
|
? noteInfo.fields[sentenceFuriganaField]?.value || ''
|
||||||
|
: '';
|
||||||
|
if (sentenceFuriganaField && existingSentenceFurigana && this.deps.processSentenceFurigana) {
|
||||||
|
const processedSentenceFurigana = this.deps.processSentenceFurigana(
|
||||||
|
existingSentenceFurigana,
|
||||||
|
fields,
|
||||||
|
);
|
||||||
|
if (processedSentenceFurigana !== existingSentenceFurigana) {
|
||||||
|
updatedFields[sentenceFuriganaField] = processedSentenceFurigana;
|
||||||
|
updatePerformed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (config.media?.generateAudio) {
|
if (config.media?.generateAudio) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+26
-4
@@ -69,6 +69,25 @@ test('parseArgs captures update command and internal launcher paths', () => {
|
|||||||
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs captures hidden Linux runtime plugin asset ensure command', () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
'--ensure-linux-runtime-plugin-assets',
|
||||||
|
'--ensure-linux-runtime-plugin-assets-response-path',
|
||||||
|
'/tmp/subminer-plugin-response.json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.ensureLinuxRuntimePluginAssets, true);
|
||||||
|
assert.equal(
|
||||||
|
args.ensureLinuxRuntimePluginAssetsResponsePath,
|
||||||
|
'/tmp/subminer-plugin-response.json',
|
||||||
|
);
|
||||||
|
assert.equal(hasExplicitCommand(args), true);
|
||||||
|
assert.equal(shouldStartApp(args), true);
|
||||||
|
assert.equal(isHeadlessInitialCommand(args), true);
|
||||||
|
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||||
|
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
|
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
|
||||||
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
|
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
|
||||||
assert.equal(args.launchMpv, true);
|
assert.equal(args.launchMpv, true);
|
||||||
@@ -101,8 +120,6 @@ test('parseArgs captures session action forwarding flags', () => {
|
|||||||
'--toggle-primary-subtitle-bar',
|
'--toggle-primary-subtitle-bar',
|
||||||
'--replay-current-subtitle',
|
'--replay-current-subtitle',
|
||||||
'--play-next-subtitle',
|
'--play-next-subtitle',
|
||||||
'--shift-sub-delay-prev-line',
|
|
||||||
'--shift-sub-delay-next-line',
|
|
||||||
'--cycle-runtime-option',
|
'--cycle-runtime-option',
|
||||||
'anki.autoUpdateNewCards:prev',
|
'anki.autoUpdateNewCards:prev',
|
||||||
'--session-action',
|
'--session-action',
|
||||||
@@ -120,8 +137,6 @@ test('parseArgs captures session action forwarding flags', () => {
|
|||||||
assert.equal(args.togglePrimarySubtitleBar, true);
|
assert.equal(args.togglePrimarySubtitleBar, true);
|
||||||
assert.equal(args.replayCurrentSubtitle, true);
|
assert.equal(args.replayCurrentSubtitle, true);
|
||||||
assert.equal(args.playNextSubtitle, 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.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
|
||||||
assert.equal(args.cycleRuntimeOptionDirection, -1);
|
assert.equal(args.cycleRuntimeOptionDirection, -1);
|
||||||
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
|
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
|
||||||
@@ -131,6 +146,13 @@ test('parseArgs captures session action forwarding flags', () => {
|
|||||||
assert.equal(shouldStartApp(args), true);
|
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', () => {
|
test('parseArgs captures internal playback feedback command', () => {
|
||||||
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
|
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
|
||||||
|
|
||||||
|
|||||||
+21
-19
@@ -41,8 +41,6 @@ export interface CliArgs {
|
|||||||
openPlaylistBrowser: boolean;
|
openPlaylistBrowser: boolean;
|
||||||
replayCurrentSubtitle: boolean;
|
replayCurrentSubtitle: boolean;
|
||||||
playNextSubtitle: boolean;
|
playNextSubtitle: boolean;
|
||||||
shiftSubDelayPrevLine: boolean;
|
|
||||||
shiftSubDelayNextLine: boolean;
|
|
||||||
playbackFeedback?: string;
|
playbackFeedback?: string;
|
||||||
cycleRuntimeOptionId?: string;
|
cycleRuntimeOptionId?: string;
|
||||||
cycleRuntimeOptionDirection?: 1 | -1;
|
cycleRuntimeOptionDirection?: 1 | -1;
|
||||||
@@ -82,6 +80,8 @@ export interface CliArgs {
|
|||||||
update?: boolean;
|
update?: boolean;
|
||||||
updateLauncherPath?: string;
|
updateLauncherPath?: string;
|
||||||
updateResponsePath?: string;
|
updateResponsePath?: string;
|
||||||
|
ensureLinuxRuntimePluginAssets?: boolean;
|
||||||
|
ensureLinuxRuntimePluginAssetsResponsePath?: string;
|
||||||
autoStartOverlay: boolean;
|
autoStartOverlay: boolean;
|
||||||
generateConfig: boolean;
|
generateConfig: boolean;
|
||||||
configPath?: string;
|
configPath?: string;
|
||||||
@@ -149,8 +149,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
openPlaylistBrowser: false,
|
openPlaylistBrowser: false,
|
||||||
replayCurrentSubtitle: false,
|
replayCurrentSubtitle: false,
|
||||||
playNextSubtitle: false,
|
playNextSubtitle: false,
|
||||||
shiftSubDelayPrevLine: false,
|
|
||||||
shiftSubDelayNextLine: false,
|
|
||||||
playbackFeedback: undefined,
|
playbackFeedback: undefined,
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
@@ -182,6 +180,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
update: false,
|
update: false,
|
||||||
updateLauncherPath: undefined,
|
updateLauncherPath: undefined,
|
||||||
updateResponsePath: undefined,
|
updateResponsePath: undefined,
|
||||||
|
ensureLinuxRuntimePluginAssets: false,
|
||||||
|
ensureLinuxRuntimePluginAssetsResponsePath: undefined,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
generateConfig: false,
|
generateConfig: false,
|
||||||
backupOverwrite: false,
|
backupOverwrite: false,
|
||||||
@@ -296,8 +296,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
|
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
|
||||||
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
|
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
|
||||||
else if (arg === '--play-next-subtitle') args.playNextSubtitle = 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=')) {
|
else if (arg.startsWith('--playback-feedback=')) {
|
||||||
const value = arg.slice('--playback-feedback='.length).trim();
|
const value = arg.slice('--playback-feedback='.length).trim();
|
||||||
if (value) args.playbackFeedback = value;
|
if (value) args.playbackFeedback = value;
|
||||||
@@ -385,7 +383,15 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
|
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
|
||||||
else if (arg === '--app-ping') args.appPing = true;
|
else if (arg === '--app-ping') args.appPing = true;
|
||||||
else if (arg === '--update') args.update = true;
|
else if (arg === '--update') args.update = true;
|
||||||
else if (arg.startsWith('--update-launcher-path=')) {
|
else if (arg === '--ensure-linux-runtime-plugin-assets') {
|
||||||
|
args.ensureLinuxRuntimePluginAssets = true;
|
||||||
|
} else if (arg.startsWith('--ensure-linux-runtime-plugin-assets-response-path=')) {
|
||||||
|
const value = arg.split('=', 2)[1];
|
||||||
|
if (value) args.ensureLinuxRuntimePluginAssetsResponsePath = value;
|
||||||
|
} else if (arg === '--ensure-linux-runtime-plugin-assets-response-path') {
|
||||||
|
const value = readValue(argv[i + 1]);
|
||||||
|
if (value) args.ensureLinuxRuntimePluginAssetsResponsePath = value;
|
||||||
|
} else if (arg.startsWith('--update-launcher-path=')) {
|
||||||
const value = arg.split('=', 2)[1];
|
const value = arg.split('=', 2)[1];
|
||||||
if (value) args.updateLauncherPath = value;
|
if (value) args.updateLauncherPath = value;
|
||||||
} else if (arg === '--update-launcher-path') {
|
} else if (arg === '--update-launcher-path') {
|
||||||
@@ -562,8 +568,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.openPlaylistBrowser ||
|
args.openPlaylistBrowser ||
|
||||||
args.replayCurrentSubtitle ||
|
args.replayCurrentSubtitle ||
|
||||||
args.playNextSubtitle ||
|
args.playNextSubtitle ||
|
||||||
args.shiftSubDelayPrevLine ||
|
|
||||||
args.shiftSubDelayNextLine ||
|
|
||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
@@ -589,13 +593,16 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.texthooker ||
|
args.texthooker ||
|
||||||
args.appPing ||
|
args.appPing ||
|
||||||
args.update ||
|
args.update ||
|
||||||
|
args.ensureLinuxRuntimePluginAssets === true ||
|
||||||
args.generateConfig ||
|
args.generateConfig ||
|
||||||
args.help
|
args.help
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHeadlessInitialCommand(args: CliArgs): boolean {
|
export function isHeadlessInitialCommand(args: CliArgs): boolean {
|
||||||
return args.refreshKnownWords || args.update === true;
|
return (
|
||||||
|
args.refreshKnownWords || args.update === true || args.ensureLinuxRuntimePluginAssets === true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||||
@@ -638,8 +645,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.openPlaylistBrowser &&
|
!args.openPlaylistBrowser &&
|
||||||
!args.replayCurrentSubtitle &&
|
!args.replayCurrentSubtitle &&
|
||||||
!args.playNextSubtitle &&
|
!args.playNextSubtitle &&
|
||||||
!args.shiftSubDelayPrevLine &&
|
|
||||||
!args.shiftSubDelayNextLine &&
|
|
||||||
args.playbackFeedback === undefined &&
|
args.playbackFeedback === undefined &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
args.sessionAction === undefined &&
|
args.sessionAction === undefined &&
|
||||||
@@ -664,6 +669,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.jellyfinPreviewAuth &&
|
!args.jellyfinPreviewAuth &&
|
||||||
!args.appPing &&
|
!args.appPing &&
|
||||||
!args.update &&
|
!args.update &&
|
||||||
|
!args.ensureLinuxRuntimePluginAssets &&
|
||||||
!args.help &&
|
!args.help &&
|
||||||
!args.autoStartOverlay &&
|
!args.autoStartOverlay &&
|
||||||
!args.generateConfig
|
!args.generateConfig
|
||||||
@@ -705,8 +711,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.openPlaylistBrowser ||
|
args.openPlaylistBrowser ||
|
||||||
args.replayCurrentSubtitle ||
|
args.replayCurrentSubtitle ||
|
||||||
args.playNextSubtitle ||
|
args.playNextSubtitle ||
|
||||||
args.shiftSubDelayPrevLine ||
|
|
||||||
args.shiftSubDelayNextLine ||
|
|
||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
@@ -719,7 +723,8 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.jellyfin ||
|
args.jellyfin ||
|
||||||
args.jellyfinPlay ||
|
args.jellyfinPlay ||
|
||||||
args.texthooker ||
|
args.texthooker ||
|
||||||
args.update
|
args.update ||
|
||||||
|
args.ensureLinuxRuntimePluginAssets
|
||||||
) {
|
) {
|
||||||
if (args.launchMpv) {
|
if (args.launchMpv) {
|
||||||
return false;
|
return false;
|
||||||
@@ -766,8 +771,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.openPlaylistBrowser &&
|
!args.openPlaylistBrowser &&
|
||||||
!args.replayCurrentSubtitle &&
|
!args.replayCurrentSubtitle &&
|
||||||
!args.playNextSubtitle &&
|
!args.playNextSubtitle &&
|
||||||
!args.shiftSubDelayPrevLine &&
|
|
||||||
!args.shiftSubDelayNextLine &&
|
|
||||||
args.playbackFeedback === undefined &&
|
args.playbackFeedback === undefined &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
args.sessionAction === undefined &&
|
args.sessionAction === undefined &&
|
||||||
@@ -794,6 +797,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.texthooker &&
|
!args.texthooker &&
|
||||||
!args.appPing &&
|
!args.appPing &&
|
||||||
!args.update &&
|
!args.update &&
|
||||||
|
!args.ensureLinuxRuntimePluginAssets &&
|
||||||
!args.help &&
|
!args.help &&
|
||||||
!args.autoStartOverlay &&
|
!args.autoStartOverlay &&
|
||||||
!args.generateConfig &&
|
!args.generateConfig &&
|
||||||
@@ -832,8 +836,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
|||||||
args.openPlaylistBrowser ||
|
args.openPlaylistBrowser ||
|
||||||
args.replayCurrentSubtitle ||
|
args.replayCurrentSubtitle ||
|
||||||
args.playNextSubtitle ||
|
args.playNextSubtitle ||
|
||||||
args.shiftSubDelayPrevLine ||
|
|
||||||
args.shiftSubDelayNextLine ||
|
|
||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.stats.autoOpenBrowser, false);
|
assert.equal(config.stats.autoOpenBrowser, false);
|
||||||
assert.equal(config.updates.enabled, true);
|
assert.equal(config.updates.enabled, true);
|
||||||
assert.equal(config.updates.checkIntervalHours, 24);
|
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.updates.channel, 'stable');
|
||||||
assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||||
assert.equal(config.mpv.backend, 'auto');
|
assert.equal(config.mpv.backend, 'auto');
|
||||||
@@ -2814,7 +2814,7 @@ test('template generator includes known keys', () => {
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
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(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
updates: {
|
updates: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
checkIntervalHours: 24,
|
checkIntervalHours: 24,
|
||||||
notificationType: 'both',
|
notificationType: 'overlay',
|
||||||
channel: 'stable',
|
channel: 'stable',
|
||||||
},
|
},
|
||||||
notifications: {
|
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+KeyH'), ['__replay-subtitle']);
|
||||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-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:',
|
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||||
PLAY_NEXT_SUBTITLE: '__play-next-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',
|
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||||
} as const;
|
} as const;
|
||||||
@@ -72,11 +70,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
|||||||
{ key: 'ArrowDown', command: ['seek', -60] },
|
{ key: 'ArrowDown', command: ['seek', -60] },
|
||||||
{ key: 'Shift+KeyH', command: ['sub-seek', -1] },
|
{ key: 'Shift+KeyH', command: ['sub-seek', -1] },
|
||||||
{ key: 'Shift+KeyL', 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: 'Ctrl+Shift+ArrowLeft', command: ['sub-step', -1] },
|
||||||
{
|
{ key: 'Ctrl+Shift+ArrowRight', command: ['sub-step', 1] },
|
||||||
key: 'Shift+BracketLeft',
|
{ key: 'KeyZ', command: ['add', 'sub-delay', -0.1] },
|
||||||
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
|
{ 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+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
|
||||||
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
|
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
|
||||||
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||||
|
|||||||
@@ -2035,6 +2035,76 @@ Aligned English subtitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card marks Kiku word mining notes as word-and-sentence cards when enabled', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
|
||||||
|
await withFakeAnkiConnect(
|
||||||
|
async (requests, url) => {
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
addYomitanNote: async () => 777,
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => null,
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: {
|
||||||
|
url,
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
image: 'Picture',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
},
|
||||||
|
isKiku: {
|
||||||
|
enabled: true,
|
||||||
|
fieldGrouping: 'disabled',
|
||||||
|
deleteDuplicateInAuto: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: '猫',
|
||||||
|
videoTitle: 'Episode 1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(body));
|
||||||
|
|
||||||
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
|
assert.equal(fields.Sentence, '<b>猫</b>を見た');
|
||||||
|
assert.equal(fields.IsWordAndSentenceCard, 'x');
|
||||||
|
assert.equal(fields.IsSentenceCard, '');
|
||||||
|
assert.equal(fields.IsAudioCard, '');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notesInfoFields: {
|
||||||
|
Expression: { value: '猫' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
Picture: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
openPlaylistBrowser: false,
|
openPlaylistBrowser: false,
|
||||||
replayCurrentSubtitle: false,
|
replayCurrentSubtitle: false,
|
||||||
playNextSubtitle: false,
|
playNextSubtitle: false,
|
||||||
shiftSubDelayPrevLine: false,
|
|
||||||
shiftSubDelayNextLine: false,
|
|
||||||
cycleRuntimeOptionId: undefined,
|
cycleRuntimeOptionId: undefined,
|
||||||
cycleRuntimeOptionDirection: undefined,
|
cycleRuntimeOptionDirection: undefined,
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
replayCurrentSubtitle: false,
|
replayCurrentSubtitle: false,
|
||||||
playNextSubtitle: false,
|
playNextSubtitle: false,
|
||||||
shiftSubDelayPrevLine: false,
|
|
||||||
shiftSubDelayNextLine: false,
|
|
||||||
playbackFeedback: undefined,
|
playbackFeedback: undefined,
|
||||||
cycleRuntimeOptionId: undefined,
|
cycleRuntimeOptionId: undefined,
|
||||||
cycleRuntimeOptionDirection: undefined,
|
cycleRuntimeOptionDirection: undefined,
|
||||||
@@ -245,6 +243,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
runUpdateCommand: async (args) => {
|
runUpdateCommand: async (args) => {
|
||||||
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
|
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
|
||||||
},
|
},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
|
||||||
|
calls.push('runEnsureLinuxRuntimePluginAssetsCommand');
|
||||||
|
},
|
||||||
printHelp: () => {
|
printHelp: () => {
|
||||||
calls.push('printHelp');
|
calls.push('printHelp');
|
||||||
},
|
},
|
||||||
@@ -626,6 +627,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
|||||||
stop: () => {},
|
stop: () => {},
|
||||||
hasMainWindow: () => true,
|
hasMainWindow: () => true,
|
||||||
runUpdateCommand: async () => {},
|
runUpdateCommand: async () => {},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||||
runYoutubePlaybackFlow: async () => {},
|
runYoutubePlaybackFlow: async () => {},
|
||||||
},
|
},
|
||||||
dispatchSessionAction: async () => {},
|
dispatchSessionAction: async () => {},
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ export interface CliCommandServiceDeps {
|
|||||||
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: (
|
||||||
|
args: CliArgs,
|
||||||
|
source: CliCommandSource,
|
||||||
|
) => Promise<void>;
|
||||||
runYoutubePlaybackFlow: (request: {
|
runYoutubePlaybackFlow: (request: {
|
||||||
url: string;
|
url: string;
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||||
@@ -182,6 +186,7 @@ interface AppCliRuntime {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +297,7 @@ export function createCliCommandDepsRuntime(
|
|||||||
runStatsCommand: options.jellyfin.runStatsCommand,
|
runStatsCommand: options.jellyfin.runStatsCommand,
|
||||||
runJellyfinCommand: options.jellyfin.runCommand,
|
runJellyfinCommand: options.jellyfin.runCommand,
|
||||||
runUpdateCommand: options.app.runUpdateCommand,
|
runUpdateCommand: options.app.runUpdateCommand,
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
||||||
printHelp: options.ui.printHelp,
|
printHelp: options.ui.printHelp,
|
||||||
hasMainWindow: options.app.hasMainWindow,
|
hasMainWindow: options.app.hasMainWindow,
|
||||||
@@ -454,6 +460,19 @@ export function handleCliCommand(
|
|||||||
deps.stopApp();
|
deps.stopApp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if (args.ensureLinuxRuntimePluginAssets) {
|
||||||
|
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||||
|
deps
|
||||||
|
.runEnsureLinuxRuntimePluginAssetsCommand(args, source)
|
||||||
|
.catch((err) => {
|
||||||
|
deps.error('runEnsureLinuxRuntimePluginAssetsCommand failed:', err);
|
||||||
|
deps.showMpvOsd(`Linux runtime plugin install failed: ${(err as Error).message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (shouldStopAfterRun) {
|
||||||
|
deps.stopApp();
|
||||||
|
}
|
||||||
|
});
|
||||||
} else if (args.toggleSecondarySub) {
|
} else if (args.toggleSecondarySub) {
|
||||||
deps.cycleSecondarySubMode();
|
deps.cycleSecondarySubMode();
|
||||||
} else if (args.triggerFieldGrouping) {
|
} else if (args.triggerFieldGrouping) {
|
||||||
@@ -537,18 +556,6 @@ export function handleCliCommand(
|
|||||||
'playNextSubtitle',
|
'playNextSubtitle',
|
||||||
'Play next subtitle failed',
|
'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) {
|
} else if (args.playbackFeedback) {
|
||||||
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
|
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
|
||||||
showFeedback(args.playbackFeedback);
|
showFeedback(args.playbackFeedback);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export {
|
|||||||
unregisterOverlayShortcutsRuntime,
|
unregisterOverlayShortcutsRuntime,
|
||||||
} from './overlay-shortcut';
|
} from './overlay-shortcut';
|
||||||
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
|
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
|
||||||
export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
|
|
||||||
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
|
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
|
||||||
export {
|
export {
|
||||||
copyCurrentSubtitle,
|
copyCurrentSubtitle,
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||||
PLAY_NEXT_SUBTITLE: '__play-next-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',
|
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||||
},
|
},
|
||||||
@@ -48,9 +46,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
mpvPlayNextSubtitle: () => {
|
mpvPlayNextSubtitle: () => {
|
||||||
calls.push('next');
|
calls.push('next');
|
||||||
},
|
},
|
||||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
|
||||||
calls.push(`shift:${direction}`);
|
|
||||||
},
|
|
||||||
mpvSendCommand: (command) => {
|
mpvSendCommand: (command) => {
|
||||||
sentCommands.push(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();
|
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||||
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
|
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
|
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
|
||||||
assert.deepEqual(osd, []);
|
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
|
||||||
assert.deepEqual(playbackFeedback, ['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();
|
const { options, calls, sentCommands, osd } = createOptions();
|
||||||
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
|
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
|
||||||
assert.deepEqual(calls, ['shift:next']);
|
assert.deepEqual(calls, []);
|
||||||
assert.deepEqual(sentCommands, []);
|
assert.deepEqual(sentCommands, [['__sub-delay-next-line']]);
|
||||||
assert.deepEqual(osd, []);
|
assert.deepEqual(osd, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ export interface HandleMpvCommandFromIpcOptions {
|
|||||||
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
||||||
REPLAY_SUBTITLE: string;
|
REPLAY_SUBTITLE: string;
|
||||||
PLAY_NEXT_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;
|
YOUTUBE_PICKER_OPEN: string;
|
||||||
PLAYLIST_BROWSER_OPEN: string;
|
PLAYLIST_BROWSER_OPEN: string;
|
||||||
};
|
};
|
||||||
@@ -25,10 +23,10 @@ export interface HandleMpvCommandFromIpcOptions {
|
|||||||
openPlaylistBrowser: () => void | Promise<void>;
|
openPlaylistBrowser: () => void | Promise<void>;
|
||||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
|
showRawMpvOsd?: (text: string) => void;
|
||||||
showPlaybackFeedback?: (text: string) => void;
|
showPlaybackFeedback?: (text: string) => void;
|
||||||
mpvReplaySubtitle: () => void;
|
mpvReplaySubtitle: () => void;
|
||||||
mpvPlayNextSubtitle: () => void;
|
mpvPlayNextSubtitle: () => void;
|
||||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
|
||||||
mpvSendCommand: (command: (string | number)[]) => void;
|
mpvSendCommand: (command: (string | number)[]) => void;
|
||||||
resolveProxyCommandOsd?: (command: (string | number)[]) => Promise<string | null>;
|
resolveProxyCommandOsd?: (command: (string | number)[]) => Promise<string | null>;
|
||||||
isMpvConnected: () => boolean;
|
isMpvConnected: () => boolean;
|
||||||
@@ -44,21 +42,30 @@ const MPV_PROPERTY_COMMANDS = new Set([
|
|||||||
'multiply',
|
'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] : '';
|
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] : '';
|
const property = typeof command[1] === 'string' ? command[1] : '';
|
||||||
if (!MPV_PROPERTY_COMMANDS.has(operation)) return null;
|
if (!MPV_PROPERTY_COMMANDS.has(operation)) return null;
|
||||||
if (property === 'sub-pos') {
|
if (property === 'sub-pos') {
|
||||||
return 'Subtitle position: ${sub-pos}';
|
return { template: 'Subtitle position: ${sub-pos}', rawMpvOsd: false };
|
||||||
}
|
}
|
||||||
if (property === 'sid') {
|
if (property === 'sid') {
|
||||||
return 'Subtitle track: ${sid}';
|
return { template: 'Subtitle track: ${sid}', rawMpvOsd: false };
|
||||||
}
|
}
|
||||||
if (property === 'secondary-sid') {
|
if (property === 'secondary-sid') {
|
||||||
return 'Secondary subtitle track: ${secondary-sid}';
|
return { template: 'Secondary subtitle track: ${secondary-sid}', rawMpvOsd: false };
|
||||||
}
|
}
|
||||||
if (property === 'sub-delay') {
|
if (property === 'sub-delay') {
|
||||||
return 'Subtitle delay: ${sub-delay}';
|
return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -67,16 +74,18 @@ function showResolvedProxyCommandOsd(
|
|||||||
command: (string | number)[],
|
command: (string | number)[],
|
||||||
options: HandleMpvCommandFromIpcOptions,
|
options: HandleMpvCommandFromIpcOptions,
|
||||||
): void {
|
): void {
|
||||||
const template = resolveProxyCommandOsdTemplate(command);
|
const feedback = resolveProxyCommandOsdTemplate(command);
|
||||||
if (!template) return;
|
if (!feedback) return;
|
||||||
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd;
|
const showFeedback = feedback.rawMpvOsd
|
||||||
|
? (options.showRawMpvOsd ?? options.showMpvOsd)
|
||||||
|
: (options.showPlaybackFeedback ?? options.showMpvOsd);
|
||||||
|
|
||||||
const emit = async () => {
|
const emit = async () => {
|
||||||
try {
|
try {
|
||||||
const resolved = await options.resolveProxyCommandOsd?.(command);
|
const resolved = await options.resolveProxyCommandOsd?.(command);
|
||||||
showFeedback(resolved || template);
|
showFeedback(resolved || feedback.template);
|
||||||
} catch {
|
} catch {
|
||||||
showFeedback(template);
|
showFeedback(feedback.template);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,20 +127,6 @@ export function handleMpvCommandFromIpc(
|
|||||||
return;
|
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 (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||||
if (!options.hasRuntimeOptionsManager()) return;
|
if (!options.hasRuntimeOptionsManager()) return;
|
||||||
const [, idToken, directionToken] = first.split(':');
|
const [, idToken, directionToken] = first.split(':');
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface MpvRuntimeClientLike {
|
|||||||
playNextSubtitle?: () => void;
|
playNextSubtitle?: () => void;
|
||||||
setSubVisibility?: (visible: boolean) => void;
|
setSubVisibility?: (visible: boolean) => void;
|
||||||
setSecondarySubVisibility?: (visible: boolean) => void;
|
setSecondarySubVisibility?: (visible: boolean) => void;
|
||||||
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showMpvOsdRuntime(
|
export function showMpvOsdRuntime(
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
|||||||
},
|
},
|
||||||
replayCurrentSubtitle: () => calls.push('replay'),
|
replayCurrentSubtitle: () => calls.push('replay'),
|
||||||
playNextSubtitle: () => calls.push('play-next'),
|
playNextSubtitle: () => calls.push('play-next'),
|
||||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
|
||||||
calls.push(`shift:${direction}`);
|
|
||||||
},
|
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
playNextPlaylistItem: () => calls.push('playlist-next'),
|
playNextPlaylistItem: () => calls.push('playlist-next'),
|
||||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export interface SessionActionExecutorDeps {
|
|||||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||||
replayCurrentSubtitle: () => void;
|
replayCurrentSubtitle: () => void;
|
||||||
playNextSubtitle: () => void;
|
playNextSubtitle: () => void;
|
||||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
|
||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
playNextPlaylistItem: () => void;
|
playNextPlaylistItem: () => void;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
@@ -124,12 +123,6 @@ export async function dispatchSessionAction(
|
|||||||
case 'playNextSubtitle':
|
case 'playNextSubtitle':
|
||||||
deps.playNextSubtitle();
|
deps.playNextSubtitle();
|
||||||
return;
|
return;
|
||||||
case 'shiftSubDelayPrevLine':
|
|
||||||
await deps.shiftSubDelayToAdjacentSubtitle('previous');
|
|
||||||
return;
|
|
||||||
case 'shiftSubDelayNextLine':
|
|
||||||
await deps.shiftSubDelayToAdjacentSubtitle('next');
|
|
||||||
return;
|
|
||||||
case 'cycleRuntimeOption': {
|
case 'cycleRuntimeOption': {
|
||||||
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
|
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
|
||||||
if (!runtimeOptionId) {
|
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', () => {
|
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
|
||||||
const expectedSpecialActions: Record<string, string> = {
|
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.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
||||||
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
|
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
|
||||||
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
|
[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', () => {
|
test('compileSessionBindings omits disabled bindings', () => {
|
||||||
const result = compileSessionBindings({
|
const result = compileSessionBindings({
|
||||||
shortcuts: createShortcuts({
|
shortcuts: createShortcuts({
|
||||||
|
|||||||
@@ -319,14 +319,6 @@ function resolveCommandBinding(
|
|||||||
if (command.length !== 1) return null;
|
if (command.length !== 1) return null;
|
||||||
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
|
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 (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||||
if (command.length !== 1) {
|
if (command.length !== 1) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
openPlaylistBrowser: false,
|
openPlaylistBrowser: false,
|
||||||
replayCurrentSubtitle: false,
|
replayCurrentSubtitle: false,
|
||||||
playNextSubtitle: false,
|
playNextSubtitle: false,
|
||||||
shiftSubDelayPrevLine: false,
|
|
||||||
shiftSubDelayNextLine: false,
|
|
||||||
cycleRuntimeOptionId: undefined,
|
cycleRuntimeOptionId: undefined,
|
||||||
cycleRuntimeOptionDirection: undefined,
|
cycleRuntimeOptionDirection: undefined,
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
|
|||||||
@@ -204,6 +204,29 @@ function getStatsWordMiningAudioFieldName(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean {
|
||||||
|
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStatsWordAndSentenceCardFields(
|
||||||
|
fields: Record<string, string>,
|
||||||
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
|
ankiConfig: AnkiConnectConfig,
|
||||||
|
): void {
|
||||||
|
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
|
||||||
|
|
||||||
|
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
|
||||||
|
if (!wordAndSentenceFlag) return;
|
||||||
|
|
||||||
|
fields[wordAndSentenceFlag] = 'x';
|
||||||
|
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
fields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getStatsDirectMiningAudioFieldNames(
|
function getStatsDirectMiningAudioFieldNames(
|
||||||
ankiConfig: AnkiConnectConfig,
|
ankiConfig: AnkiConnectConfig,
|
||||||
noteInfo: StatsServerNoteInfo | null,
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
@@ -1299,7 +1322,11 @@ export function createStatsApp(
|
|||||||
|
|
||||||
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||||
let noteInfo: StatsServerNoteInfo | null = null;
|
let noteInfo: StatsServerNoteInfo | null = null;
|
||||||
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
|
if (
|
||||||
|
audioBuffer ||
|
||||||
|
(syncAnimatedImageToWordAudio && generateImage) ||
|
||||||
|
shouldUseStatsLapisKikuCardFields(ankiConfig)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||||
noteInfo = noteInfoResult[0] ?? null;
|
noteInfo = noteInfoResult[0] ?? null;
|
||||||
@@ -1339,6 +1366,7 @@ export function createStatsApp(
|
|||||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
|
|
||||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||||
|
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
||||||
|
|||||||
@@ -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}');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveDefaultNotificationIconPath } from './notification';
|
||||||
|
|
||||||
|
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
|
||||||
|
const path = resolveDefaultNotificationIconPath({
|
||||||
|
platform: 'linux',
|
||||||
|
resourcesPath: '/opt/SubMiner/resources',
|
||||||
|
appPath: '/opt/SubMiner/resources/app.asar',
|
||||||
|
dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils',
|
||||||
|
cwd: '/opt/SubMiner',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: (candidate) => candidate === '/opt/SubMiner/resources/assets/SubMiner.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default notification icon prefers the square app icon when bundled images are available', () => {
|
||||||
|
const path = resolveDefaultNotificationIconPath({
|
||||||
|
platform: 'linux',
|
||||||
|
resourcesPath: '/opt/SubMiner/resources',
|
||||||
|
appPath: '/opt/SubMiner/resources/app.asar',
|
||||||
|
dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils',
|
||||||
|
cwd: '/opt/SubMiner',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: (candidate) =>
|
||||||
|
candidate === '/opt/SubMiner/resources/assets/SubMiner.png' ||
|
||||||
|
candidate === '/opt/SubMiner/resources/assets/SubMiner-square.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner-square.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default notification icon avoids macOS tray template assets', () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const path = resolveDefaultNotificationIconPath({
|
||||||
|
platform: 'darwin',
|
||||||
|
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
||||||
|
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
||||||
|
dirname: '/Applications/SubMiner.app/Contents/Resources/app.asar/dist/core/utils',
|
||||||
|
cwd: '/Applications/SubMiner.app/Contents/Resources',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: (candidate) => {
|
||||||
|
seen.push(candidate);
|
||||||
|
return candidate.endsWith('/assets/SubMiner-square.png');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path, '/Applications/SubMiner.app/Contents/Resources/assets/SubMiner-square.png');
|
||||||
|
assert.equal(
|
||||||
|
seen.some((candidate) => candidate.includes('SubMinerTemplate')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default notification icon resolves cwd fallback through injected deps', () => {
|
||||||
|
const resolvedPath = resolveDefaultNotificationIconPath({
|
||||||
|
platform: 'linux',
|
||||||
|
resourcesPath: '/missing/resources',
|
||||||
|
appPath: '/missing/app',
|
||||||
|
dirname: '/missing/dist/core/utils',
|
||||||
|
cwd: '/portable/SubMiner',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: (candidate) => candidate === '/portable/SubMiner/assets/SubMiner-square.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolvedPath, '/portable/SubMiner/assets/SubMiner-square.png');
|
||||||
|
});
|
||||||
@@ -1,10 +1,57 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
|
||||||
const { Notification, nativeImage } = electron;
|
const { Notification, nativeImage } = electron;
|
||||||
const logger = createLogger('core:notification');
|
const logger = createLogger('core:notification');
|
||||||
|
|
||||||
|
export function resolveDefaultNotificationIconPath(deps: {
|
||||||
|
platform: string;
|
||||||
|
resourcesPath: string;
|
||||||
|
appPath: string;
|
||||||
|
dirname: string;
|
||||||
|
cwd: string;
|
||||||
|
joinPath: (...parts: string[]) => string;
|
||||||
|
fileExists: (path: string) => boolean;
|
||||||
|
}): string | null {
|
||||||
|
const iconNames =
|
||||||
|
deps.platform === 'win32'
|
||||||
|
? ['SubMiner.ico', 'SubMiner-square.png', 'SubMiner.png']
|
||||||
|
: ['SubMiner-square.png', 'SubMiner.png'];
|
||||||
|
|
||||||
|
const baseDirs = [
|
||||||
|
deps.joinPath(deps.resourcesPath, 'assets'),
|
||||||
|
deps.joinPath(deps.appPath, 'assets'),
|
||||||
|
deps.joinPath(deps.dirname, '..', 'assets'),
|
||||||
|
deps.joinPath(deps.dirname, '..', '..', 'assets'),
|
||||||
|
deps.joinPath(deps.cwd, 'assets'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const baseDir of baseDirs) {
|
||||||
|
for (const iconName of iconNames) {
|
||||||
|
const candidate = deps.joinPath(baseDir, iconName);
|
||||||
|
if (deps.fileExists(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeDefaultNotificationIconPath(): string | null {
|
||||||
|
return resolveDefaultNotificationIconPath({
|
||||||
|
platform: process.platform,
|
||||||
|
resourcesPath: process.resourcesPath,
|
||||||
|
appPath: electron.app?.getAppPath?.() ?? process.cwd(),
|
||||||
|
dirname: __dirname,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
joinPath: (...parts) => path.join(...parts),
|
||||||
|
fileExists: (candidate) => fs.existsSync(candidate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function showDesktopNotification(
|
export function showDesktopNotification(
|
||||||
title: string,
|
title: string,
|
||||||
options: { body?: string; icon?: string },
|
options: { body?: string; icon?: string },
|
||||||
@@ -19,19 +66,20 @@ export function showDesktopNotification(
|
|||||||
notificationOptions.body = options.body;
|
notificationOptions.body = options.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.icon) {
|
const icon = options.icon ?? resolveRuntimeDefaultNotificationIconPath() ?? undefined;
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
const isFilePath =
|
const isFilePath =
|
||||||
typeof options.icon === 'string' &&
|
typeof icon === 'string' && (icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(icon));
|
||||||
(options.icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(options.icon));
|
|
||||||
|
|
||||||
if (isFilePath) {
|
if (isFilePath) {
|
||||||
if (fs.existsSync(options.icon)) {
|
if (fs.existsSync(icon)) {
|
||||||
notificationOptions.icon = options.icon;
|
notificationOptions.icon = icon;
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Notification icon file not found', options.icon);
|
logger.warn('Notification icon file not found', icon);
|
||||||
}
|
}
|
||||||
} else if (typeof options.icon === 'string' && options.icon.startsWith('data:image/')) {
|
} else if (typeof icon === 'string' && icon.startsWith('data:image/')) {
|
||||||
const base64Data = options.icon.replace(/^data:image\/\w+;base64,/, '');
|
const base64Data = icon.replace(/^data:image\/\w+;base64,/, '');
|
||||||
try {
|
try {
|
||||||
const image = nativeImage.createFromBuffer(Buffer.from(base64Data, 'base64'));
|
const image = nativeImage.createFromBuffer(Buffer.from(base64Data, 'base64'));
|
||||||
if (image.isEmpty()) {
|
if (image.isEmpty()) {
|
||||||
@@ -45,7 +93,7 @@ export function showDesktopNotification(
|
|||||||
logger.error('Failed to create notification icon from base64', err);
|
logger.error('Failed to create notification icon from base64', err);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
notificationOptions.icon = options.icon;
|
notificationOptions.icon = icon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+397
-1976
File diff suppressed because it is too large
Load Diff
@@ -2386,6 +2386,36 @@ test('buildMergedDictionary rebuilds snapshots written with an older format vers
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getManualSelectionSnapshot falls back to mpv current video path when app media path is not ready', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const mpvPath =
|
||||||
|
'C:\\Videos\\KonoSuba - God’s blessing on this wonderful world!! (2016) - S02E05.mkv';
|
||||||
|
const calls: Array<{ mediaPath: string | null; mediaTitle: string | null }> = [];
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => null,
|
||||||
|
getCurrentVideoPath: () => mpvPath,
|
||||||
|
getCurrentMediaTitle: () => null,
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async (mediaPath, mediaTitle) => {
|
||||||
|
calls.push({ mediaPath, mediaTitle });
|
||||||
|
return {
|
||||||
|
title: 'KonoSuba - God’s blessing on this wonderful world!!',
|
||||||
|
season: 2,
|
||||||
|
episode: 5,
|
||||||
|
source: 'fallback',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
now: () => 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await runtime.getManualSelectionSnapshot(undefined, '');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [{ mediaPath: mpvPath, mediaTitle: null }]);
|
||||||
|
assert.equal(snapshot.guessTitle, 'KonoSuba - God’s blessing on this wonderful world!!');
|
||||||
|
assert.equal(snapshot.candidates.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildMergedDictionary reapplies collapsible open states from current config', async () => {
|
test('buildMergedDictionary reapplies collapsible open states from current config', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ function expandUserPath(input: string): string {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimToNull(input: string | null | undefined): string | null {
|
||||||
|
const trimmed = typeof input === 'string' ? input.trim() : '';
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
function isVideoFile(filePath: string): boolean {
|
function isVideoFile(filePath: string): boolean {
|
||||||
return hasVideoExtension(path.extname(filePath));
|
return hasVideoExtension(path.extname(filePath));
|
||||||
}
|
}
|
||||||
@@ -195,8 +200,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
return dictionaryTarget.length > 0
|
return dictionaryTarget.length > 0
|
||||||
? resolveDictionaryGuessInputs(dictionaryTarget)
|
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||||
: {
|
: {
|
||||||
mediaPath: deps.getCurrentMediaPath(),
|
mediaPath:
|
||||||
mediaTitle: deps.getCurrentMediaTitle(),
|
trimToNull(deps.getCurrentMediaPath()) ?? trimToNull(deps.getCurrentVideoPath?.()),
|
||||||
|
mediaTitle: trimToNull(deps.getCurrentMediaTitle()),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export type CharacterDictionaryManualSelectionResult = {
|
|||||||
export interface CharacterDictionaryRuntimeDeps {
|
export interface CharacterDictionaryRuntimeDeps {
|
||||||
userDataPath: string;
|
userDataPath: string;
|
||||||
getCurrentMediaPath: () => string | null;
|
getCurrentMediaPath: () => string | null;
|
||||||
|
getCurrentVideoPath?: () => string | null | undefined;
|
||||||
getCurrentMediaTitle: () => string | null;
|
getCurrentMediaTitle: () => string | null;
|
||||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||||
guessAnilistMediaInfo: (
|
guessAnilistMediaInfo: (
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
||||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||||
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
|
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceDepsParams['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
@@ -124,6 +125,7 @@ function createCliCommandDepsFromContext(
|
|||||||
stop: context.stopApp,
|
stop: context.stopApp,
|
||||||
hasMainWindow: context.hasMainWindow,
|
hasMainWindow: context.hasMainWindow,
|
||||||
runUpdateCommand: context.runUpdateCommand,
|
runUpdateCommand: context.runUpdateCommand,
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: context.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
||||||
},
|
},
|
||||||
dispatchSessionAction: context.dispatchSessionAction,
|
dispatchSessionAction: context.dispatchSessionAction,
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
||||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||||
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
|
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandDepsRuntimeOptions['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
||||||
};
|
};
|
||||||
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
|
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
|
||||||
@@ -226,10 +227,10 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
|||||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||||
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
||||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||||
|
showRawMpvOsd?: HandleMpvCommandFromIpcOptions['showRawMpvOsd'];
|
||||||
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
|
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
|
||||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||||
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
|
|
||||||
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
|
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
|
||||||
resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd'];
|
resolveProxyCommandOsd?: HandleMpvCommandFromIpcOptions['resolveProxyCommandOsd'];
|
||||||
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
|
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
|
||||||
@@ -392,6 +393,7 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
stop: params.app.stop,
|
stop: params.app.stop,
|
||||||
hasMainWindow: params.app.hasMainWindow,
|
hasMainWindow: params.app.hasMainWindow,
|
||||||
runUpdateCommand: params.app.runUpdateCommand,
|
runUpdateCommand: params.app.runUpdateCommand,
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: params.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
||||||
},
|
},
|
||||||
dispatchSessionAction: params.dispatchSessionAction,
|
dispatchSessionAction: params.dispatchSessionAction,
|
||||||
@@ -424,10 +426,10 @@ export function createMpvCommandRuntimeServiceDeps(
|
|||||||
openPlaylistBrowser: params.openPlaylistBrowser,
|
openPlaylistBrowser: params.openPlaylistBrowser,
|
||||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||||
showMpvOsd: params.showMpvOsd,
|
showMpvOsd: params.showMpvOsd,
|
||||||
|
showRawMpvOsd: params.showRawMpvOsd,
|
||||||
showPlaybackFeedback: params.showPlaybackFeedback,
|
showPlaybackFeedback: params.showPlaybackFeedback,
|
||||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||||
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
||||||
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
|
|
||||||
mpvSendCommand: params.mpvSendCommand,
|
mpvSendCommand: params.mpvSendCommand,
|
||||||
resolveProxyCommandOsd: params.resolveProxyCommandOsd,
|
resolveProxyCommandOsd: params.resolveProxyCommandOsd,
|
||||||
isMpvConnected: params.isMpvConnected,
|
isMpvConnected: params.isMpvConnected,
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
|||||||
openPlaylistBrowser: () => void | Promise<void>;
|
openPlaylistBrowser: () => void | Promise<void>;
|
||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
|
showRawMpvOsd?: (text: string) => void;
|
||||||
showPlaybackFeedback?: (text: string) => void;
|
showPlaybackFeedback?: (text: string) => void;
|
||||||
replayCurrentSubtitle: () => void;
|
replayCurrentSubtitle: () => void;
|
||||||
playNextSubtitle: () => void;
|
playNextSubtitle: () => void;
|
||||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
|
||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
getMpvClient: () => MpvPropertyClientLike | null;
|
getMpvClient: () => MpvPropertyClientLike | null;
|
||||||
isMpvConnected: () => boolean;
|
isMpvConnected: () => boolean;
|
||||||
@@ -42,11 +42,10 @@ export function handleMpvCommandFromIpcRuntime(
|
|||||||
openPlaylistBrowser: deps.openPlaylistBrowser,
|
openPlaylistBrowser: deps.openPlaylistBrowser,
|
||||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||||
showMpvOsd: deps.showMpvOsd,
|
showMpvOsd: deps.showMpvOsd,
|
||||||
|
showRawMpvOsd: deps.showRawMpvOsd,
|
||||||
showPlaybackFeedback: deps.showPlaybackFeedback,
|
showPlaybackFeedback: deps.showPlaybackFeedback,
|
||||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||||
mpvPlayNextSubtitle: deps.playNextSubtitle,
|
mpvPlayNextSubtitle: deps.playNextSubtitle,
|
||||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
|
||||||
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
|
||||||
mpvSendCommand: deps.sendMpvCommand,
|
mpvSendCommand: deps.sendMpvCommand,
|
||||||
resolveProxyCommandOsd: (nextCommand) =>
|
resolveProxyCommandOsd: (nextCommand) =>
|
||||||
resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient),
|
resolveProxyCommandOsdRuntime(nextCommand, deps.getMpvClient),
|
||||||
|
|||||||
+127
-28
@@ -7,6 +7,10 @@ function readMainSource(): string {
|
|||||||
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
|
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', () => {
|
test('manual watched session action starts immersion tracker before marking watched', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const actionBlock = source.match(
|
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', () => {
|
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(
|
const dismissBlock = source.match(
|
||||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(dismissBlock);
|
assert.ok(dismissBlock);
|
||||||
assert.match(
|
assert.match(
|
||||||
dismissBlock,
|
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', () => {
|
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(
|
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;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -157,13 +161,14 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
|||||||
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') <
|
actionBlock.indexOf('deps.initSubtitlePrefetch(') <
|
||||||
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'),
|
actionBlock.indexOf('deps.setActiveParsedSubtitleMediaPath(nextMediaPath);'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update overlay notification action triggers install flow', () => {
|
test('update overlay notification action triggers install flow', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
|
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
@@ -173,13 +178,16 @@ test('update overlay notification action triggers install flow', () => {
|
|||||||
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
||||||
assert.match(source, /installWhenAvailable:\s*true/);
|
assert.match(source, /installWhenAvailable:\s*true/);
|
||||||
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
||||||
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/);
|
assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/);
|
||||||
assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
runtimeSource,
|
||||||
|
/deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
runtimeSource,
|
||||||
/new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/,
|
/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', () => {
|
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', () => {
|
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(
|
const actionBlock = source.match(
|
||||||
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -224,6 +232,31 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('autoplay subtitle prime reuses active parsed cues before synthetic warm release', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const runtimeDepsBlock = source.match(
|
||||||
|
/const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const primeSource = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||||
|
const emptyTextBlock = primeSource.match(
|
||||||
|
/if \(!text\.trim\(\) && isCurrentAutoplayMediaPath\(mediaPath\)\) \{(?<body>[\s\S]*?)\n \}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(runtimeDepsBlock);
|
||||||
|
assert.match(
|
||||||
|
runtimeDepsBlock,
|
||||||
|
/getActiveParsedSubtitleCues:\s*\(\) => appState\.activeParsedSubtitleCues/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(emptyTextBlock);
|
||||||
|
assert.ok(
|
||||||
|
emptyTextBlock.indexOf('await deps.refreshSubtitlePrefetchFromActiveTrack()') <
|
||||||
|
emptyTextBlock.indexOf(
|
||||||
|
'await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues())',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
|
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const gateBlock = source.match(
|
const gateBlock = source.match(
|
||||||
@@ -275,7 +308,7 @@ test('visible overlay content-ready does not tokenize before first measurement',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
|
test('accepted visible overlay measurement immediately refreshes pointer interaction', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const measurementBlock = source.match(
|
const measurementBlock = source.match(
|
||||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
@@ -284,6 +317,7 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
|||||||
assert.ok(measurementBlock);
|
assert.ok(measurementBlock);
|
||||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||||
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
||||||
|
assert.match(measurementBlock, /tickWindowsOverlayPointerInteractionNow\(\)/);
|
||||||
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
||||||
@@ -291,6 +325,10 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
|||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
||||||
|
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();'),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();') <
|
||||||
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -346,7 +384,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', () => {
|
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(
|
const startStatsServerBlock = source.match(
|
||||||
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
@@ -357,7 +395,7 @@ test('stats server Yomitan note creation honors configured Anki server override
|
|||||||
assert.ok(addYomitanNoteBlock);
|
assert.ok(addYomitanNoteBlock);
|
||||||
assert.match(
|
assert.match(
|
||||||
addYomitanNoteBlock,
|
addYomitanNoteBlock,
|
||||||
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/,
|
/const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/,
|
||||||
);
|
);
|
||||||
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||||
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||||
@@ -365,11 +403,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', () => {
|
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
|
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const resetBlock = source.match(
|
const resetBlock = runtimeSource.match(
|
||||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -459,17 +498,17 @@ test('manual visible overlay hide dismisses loading OSD', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('configured overlay notifications require visible ready overlay window', () => {
|
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(
|
const readinessBlock = source.match(
|
||||||
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const statusBlock = source.match(
|
const statusBlock = source.match(
|
||||||
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(readinessBlock);
|
assert.ok(readinessBlock);
|
||||||
assert.ok(statusBlock);
|
assert.ok(statusBlock);
|
||||||
assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/);
|
assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/);
|
||||||
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
|
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
|
||||||
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
|
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
|
||||||
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
||||||
@@ -488,18 +527,19 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
|
|||||||
assert.ok(toggleBlock);
|
assert.ok(toggleBlock);
|
||||||
assert.match(
|
assert.match(
|
||||||
setBlock,
|
setBlock,
|
||||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
toggleBlock,
|
toggleBlock,
|
||||||
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
|
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const resetBlock = source.match(
|
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
const resetBlock = runtimeSource.match(
|
||||||
|
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const setBlock = source.match(
|
const setBlock = source.match(
|
||||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
@@ -509,14 +549,73 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
|
|||||||
assert.ok(setBlock);
|
assert.ok(setBlock);
|
||||||
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||||
|
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
setBlock,
|
setBlock,
|
||||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
|
test('Linux visible overlay startup reapplies passive passthrough after input reset', () => {
|
||||||
|
const pointerSource = readSource('src/main/runtime/linux-overlay-pointer-interaction.ts');
|
||||||
|
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||||
|
const resetPrimerBlock = runtimeSource.match(
|
||||||
|
/function resetLinuxVisibleOverlayStartupInputPrimer\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const startGraceBlock = runtimeSource.match(
|
||||||
|
/function startLinuxVisibleOverlayStartupInputGrace\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const depsBlock = runtimeSource.match(
|
||||||
|
/const linuxOverlayPointerInteractionDeps = \{(?<body>[\s\S]*?)\n \};/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(resetPrimerBlock);
|
||||||
|
assert.ok(startGraceBlock);
|
||||||
|
assert.ok(depsBlock);
|
||||||
|
assert.match(resetPrimerBlock, /visibleOverlayInteractionActive = false;/);
|
||||||
|
assert.match(resetPrimerBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
|
||||||
|
assert.match(
|
||||||
|
startGraceBlock,
|
||||||
|
/linuxVisibleOverlayStartupInputGraceUntilMs =\s+Date\.now\(\) \+ LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;/,
|
||||||
|
);
|
||||||
|
assert.match(startGraceBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
|
||||||
|
assert.match(
|
||||||
|
depsBlock,
|
||||||
|
/isInteractionStateApplied:\s*\(\) => linuxOverlayPointerInteractionStateApplied/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
pointerSource,
|
||||||
|
/deps\.getInteractionActive\(\) === desired && deps\.isInteractionStateApplied\?\.\(\) !== false/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Linux visible overlay show starts input grace before first measurement', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
|
const setVisibleBlock = source.match(
|
||||||
|
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const toggleBlock = source.match(
|
||||||
|
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const setOverlayBlock = source.match(
|
||||||
|
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) {
|
||||||
|
assert.ok(block);
|
||||||
|
assert.ok(
|
||||||
|
block.indexOf('resetLinuxVisibleOverlayStartupInputPrimer();') <
|
||||||
|
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();'),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();') <
|
||||||
|
block.indexOf('void primeCurrentSubtitleForVisibleOverlay();'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
|
||||||
|
const source = readSource('src/main/runtime/overlay-geometry-runtime.ts');
|
||||||
const afterBoundsBlock = source.match(
|
const afterBoundsBlock = source.match(
|
||||||
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getPasswordStoreArg } from './password-store-args';
|
||||||
|
|
||||||
|
test('getPasswordStoreArg ignores split-form whitespace-only values', () => {
|
||||||
|
assert.equal(getPasswordStoreArg(['SubMiner.AppImage', '--password-store', ' ']), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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];
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith('--')) {
|
||||||
|
resolved = trimmed;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -137,6 +137,46 @@ test('autoplay ready gate requests overlay pointer recovery when media readiness
|
|||||||
assert.equal(pointerRecoveryRequests, 1);
|
assert.equal(pointerRecoveryRequests, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('autoplay ready gate reports the released autoplay signal once', async () => {
|
||||||
|
const releasedSignals: string[] = [];
|
||||||
|
|
||||||
|
const gate = createAutoplayReadyGate({
|
||||||
|
isAppOwnedFlowInFlight: () => false,
|
||||||
|
getCurrentMediaPath: () => '/media/video.mkv',
|
||||||
|
getCurrentVideoPath: () => null,
|
||||||
|
getPlaybackPaused: () => true,
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async () => true,
|
||||||
|
send: () => {},
|
||||||
|
}) as never,
|
||||||
|
signalPluginAutoplayReady: () => {},
|
||||||
|
onAutoplayReadyReleased: (signal) => {
|
||||||
|
releasedSignals.push(signal.payload.text);
|
||||||
|
},
|
||||||
|
schedule: (callback) => {
|
||||||
|
queueMicrotask(callback);
|
||||||
|
return 1 as never;
|
||||||
|
},
|
||||||
|
logDebug: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
gate.maybeSignalPluginAutoplayReady(
|
||||||
|
{ text: '__warm__', tokens: null },
|
||||||
|
{ forceWhilePaused: true },
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
gate.maybeSignalPluginAutoplayReady(
|
||||||
|
{ text: '次の字幕', tokens: null },
|
||||||
|
{ forceWhilePaused: true },
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.deepEqual(releasedSignals, ['__warm__']);
|
||||||
|
});
|
||||||
|
|
||||||
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
||||||
const commands: Array<Array<string | boolean>> = [];
|
const commands: Array<Array<string | boolean>> = [];
|
||||||
let playbackPaused = true;
|
let playbackPaused = true;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type AutoplayReadyGateDeps = {
|
|||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
signalPluginAutoplayReady: () => void;
|
signalPluginAutoplayReady: () => void;
|
||||||
requestOverlayPointerRecovery?: () => void;
|
requestOverlayPointerRecovery?: () => void;
|
||||||
|
onAutoplayReadyReleased?: (signal: AutoplayReadySignal) => void;
|
||||||
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
@@ -182,6 +183,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||||
deps.signalPluginAutoplayReady();
|
deps.signalPluginAutoplayReady();
|
||||||
deps.requestOverlayPointerRecovery?.();
|
deps.requestOverlayPointerRecovery?.();
|
||||||
|
deps.onAutoplayReadyReleased?.(signal);
|
||||||
attemptRelease(playbackGeneration, 0);
|
attemptRelease(playbackGeneration, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createAutoplaySubtitlePrimingRuntime,
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback', async () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const runtime = createAutoplaySubtitlePrimingRuntime({
|
||||||
|
getCurrentMediaPath: () => null,
|
||||||
|
getMpvClient: () => null,
|
||||||
|
setCurrentSubText: () => {},
|
||||||
|
getCurrentSubText: () => '',
|
||||||
|
getCurrentSubtitleData: () => null,
|
||||||
|
getActiveParsedSubtitleCues: () => [],
|
||||||
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
|
subtitleProcessingController: {
|
||||||
|
consumeCachedSubtitle: () => null,
|
||||||
|
onSubtitleChange: () => {},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
},
|
||||||
|
emitSubtitlePayload: () => {},
|
||||||
|
getSubtitlePrefetchService: () => null,
|
||||||
|
getLastObservedTimePos: () => 0,
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
emitSecondarySubtitle: () => {},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
refreshSubtitlePrefetchFromActiveTrack: async () => {
|
||||||
|
throw new Error('refresh failed');
|
||||||
|
},
|
||||||
|
logDebug: (message) => logs.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.scheduleSubtitlePrefetchRefresh(0);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
assert.deepEqual(logs, [
|
||||||
|
'[autoplay-subtitle-prime] subtitle prefetch refresh failed: refresh failed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv sub-text is empty', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let currentSubText = '';
|
||||||
|
let activeParsedSubtitleCues: Array<{ startTime: number; endTime: number; text: string }> = [];
|
||||||
|
const mediaPath = '/media/video.mkv';
|
||||||
|
|
||||||
|
const runtime = createAutoplaySubtitlePrimingRuntime({
|
||||||
|
getCurrentMediaPath: () => mediaPath,
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentVideoPath: mediaPath,
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
calls.push(`request:${name}`);
|
||||||
|
if (name === 'sub-text') return '';
|
||||||
|
if (name === 'time-pos') return 12;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setCurrentSubText: (text) => {
|
||||||
|
currentSubText = text;
|
||||||
|
calls.push(`set:${text}`);
|
||||||
|
},
|
||||||
|
getCurrentSubText: () => currentSubText,
|
||||||
|
getCurrentSubtitleData: () => null,
|
||||||
|
getActiveParsedSubtitleCues: () => activeParsedSubtitleCues,
|
||||||
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
|
subtitleProcessingController: {
|
||||||
|
consumeCachedSubtitle: () => null,
|
||||||
|
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||||
|
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
|
||||||
|
},
|
||||||
|
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
|
||||||
|
getSubtitlePrefetchService: () => ({
|
||||||
|
pause: () => calls.push('prefetch:pause'),
|
||||||
|
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
|
||||||
|
}),
|
||||||
|
getLastObservedTimePos: () => 12,
|
||||||
|
getVisibleOverlayVisible: () => true,
|
||||||
|
emitSecondarySubtitle: () => {},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
refreshSubtitlePrefetchFromActiveTrack: async () => {
|
||||||
|
calls.push('refresh-active-track');
|
||||||
|
activeParsedSubtitleCues = [{ startTime: 10, endTime: 20, text: '起動字幕' }];
|
||||||
|
},
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'request:sub-text',
|
||||||
|
'refresh-active-track',
|
||||||
|
'request:time-pos',
|
||||||
|
'set:起動字幕',
|
||||||
|
'prefetch:pause',
|
||||||
|
'emit:起動字幕',
|
||||||
|
'change:起動字幕',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before tokenization', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let currentSubText = '';
|
||||||
|
const mediaPath = '/media/video.mkv';
|
||||||
|
|
||||||
|
const runtime = createAutoplaySubtitlePrimingRuntime({
|
||||||
|
getCurrentMediaPath: () => mediaPath,
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentVideoPath: mediaPath,
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
calls.push(`request:${name}`);
|
||||||
|
if (name === 'sub-text') return '起動字幕';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setCurrentSubText: (text) => {
|
||||||
|
currentSubText = text;
|
||||||
|
calls.push(`set:${text}`);
|
||||||
|
},
|
||||||
|
getCurrentSubText: () => currentSubText,
|
||||||
|
getCurrentSubtitleData: () => null,
|
||||||
|
getActiveParsedSubtitleCues: () => [],
|
||||||
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
|
subtitleProcessingController: {
|
||||||
|
consumeCachedSubtitle: () => null,
|
||||||
|
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||||
|
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
|
||||||
|
},
|
||||||
|
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
|
||||||
|
getSubtitlePrefetchService: () => ({
|
||||||
|
pause: () => calls.push('prefetch:pause'),
|
||||||
|
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
|
||||||
|
}),
|
||||||
|
getLastObservedTimePos: () => 12,
|
||||||
|
getVisibleOverlayVisible: () => true,
|
||||||
|
emitSecondarySubtitle: () => {},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
refreshSubtitlePrefetchFromActiveTrack: async () => {
|
||||||
|
calls.push('refresh-active-track');
|
||||||
|
},
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'request:sub-text',
|
||||||
|
'set:起動字幕',
|
||||||
|
'prefetch:pause',
|
||||||
|
'emit:起動字幕',
|
||||||
|
'change:起動字幕',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
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;
|
||||||
|
getActiveParsedSubtitleCues: () => SubtitleCue[];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitSubtitlePayload({ text, tokens: null });
|
||||||
|
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 : '';
|
||||||
|
if (emitAutoplayPrimedSubtitle(mediaPath, text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.trim() && isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
|
await deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => {
|
||||||
|
deps.logDebug(
|
||||||
|
`[autoplay-subtitle-prime] active subtitle refresh failed after empty sub-text: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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().catch((error) => {
|
||||||
|
deps.logDebug(
|
||||||
|
`[autoplay-subtitle-prime] subtitle prefetch refresh failed: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentAutoplayMediaPath,
|
||||||
|
resetAutoplaySubtitlePrime,
|
||||||
|
primeCurrentSubtitleForAutoplay,
|
||||||
|
primeCurrentSubtitleForVisibleOverlay,
|
||||||
|
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
||||||
|
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
||||||
|
primeAutoplaySubtitleFromParsedCues,
|
||||||
|
clearScheduledSubtitlePrefetchRefresh,
|
||||||
|
refreshSubtitleSidebarFromSource,
|
||||||
|
scheduleSubtitlePrefetchRefresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -66,6 +66,9 @@ test('build cli command context deps maps handlers and values', () => {
|
|||||||
runUpdateCommand: async () => {
|
runUpdateCommand: async () => {
|
||||||
calls.push('run-update');
|
calls.push('run-update');
|
||||||
},
|
},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
|
||||||
|
calls.push('run-ensure-linux-runtime-plugin-assets');
|
||||||
|
},
|
||||||
runYoutubePlaybackFlow: async () => {
|
runYoutubePlaybackFlow: async () => {
|
||||||
calls.push('run-youtube-playback');
|
calls.push('run-youtube-playback');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
@@ -100,6 +101,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
runStatsCommand: deps.runStatsCommand,
|
runStatsCommand: deps.runStatsCommand,
|
||||||
runJellyfinCommand: deps.runJellyfinCommand,
|
runJellyfinCommand: deps.runJellyfinCommand,
|
||||||
runUpdateCommand: deps.runUpdateCommand,
|
runUpdateCommand: deps.runUpdateCommand,
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
|||||||
runStatsCommand: async () => {},
|
runStatsCommand: async () => {},
|
||||||
runJellyfinCommand: async () => {},
|
runJellyfinCommand: async () => {},
|
||||||
runUpdateCommand: async () => {},
|
runUpdateCommand: async () => {},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||||
runYoutubePlaybackFlow: async () => {},
|
runYoutubePlaybackFlow: async () => {},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
|||||||
runUpdateCommand: async () => {
|
runUpdateCommand: async () => {
|
||||||
calls.push('run-update');
|
calls.push('run-update');
|
||||||
},
|
},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
|
||||||
|
calls.push('run-ensure-linux-runtime-plugin-assets');
|
||||||
|
},
|
||||||
runYoutubePlaybackFlow: async () => {
|
runYoutubePlaybackFlow: async () => {
|
||||||
calls.push('run-youtube-playback');
|
calls.push('run-youtube-playback');
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user