mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 19:21:35 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
52f6182548
|
|||
|
4e8abcc25e
|
|||
|
3a97239ade
|
|||
|
a0dde4ee3e
|
|||
| fe4dacc1e7 | |||
| b08cd0db35 | |||
| bffb1c5982 | |||
| 5b8848518a |
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
### Changed
|
||||
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||
|
||||
### Fixed
|
||||
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.1 (2026-08-01)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "7.28.0",
|
||||
"undici": "7.29.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -36,8 +36,9 @@
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"app-builder-lib": "26.15.3",
|
||||
"brace-expansion": "5.0.8",
|
||||
"brace-expansion": "5.0.9",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"fast-uri": "3.1.5",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.0",
|
||||
@@ -46,6 +47,7 @@
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7",
|
||||
"undici": "7.29.0",
|
||||
},
|
||||
"packages": {
|
||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
@@ -266,7 +268,7 @@
|
||||
|
||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
||||
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
@@ -404,7 +406,7 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
||||
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
@@ -714,7 +716,7 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
@@ -754,8 +756,6 @@
|
||||
|
||||
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||
|
||||
"@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
||||
|
||||
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
@@ -808,8 +808,6 @@
|
||||
|
||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
||||
|
||||
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
||||
|
||||
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added an in-app changelog modal, opened from the tray ("View Changelog") or the "What's New" button on the update-available notification, which now stays on screen so "Update" is still reachable after reading the notes. It renders inside the player bounds when a video is playing and in its own window otherwise, the same as the help modal.
|
||||
- The changelog is fetched from the newest published release, so release notes for versions newer than the installed build are visible; a failed download falls back to the changelog bundled with the install and says so in the modal.
|
||||
- Versions are foldable: the current `0.x` line is expanded and older lines are folded, matching the docs-site changelog. A badge marks the installed version and newer versions are tagged "New".
|
||||
- Keyboard: `J`/`K` or arrows move between versions, `Enter` folds/unfolds, `R` refetches, `Esc` closes.
|
||||
@@ -75,8 +75,8 @@ src/
|
||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
||||
# character dictionary, playlist browser, subtitle sidebar,
|
||||
# YouTube track picker, controller config/debug/select)
|
||||
# changelog, character dictionary, playlist browser, subtitle
|
||||
# sidebar, YouTube track picker, controller config/debug/select)
|
||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||
settings/ # Settings window UI (model, controls, markup)
|
||||
types/ # Domain type modules (anki, config, integrations, ...)
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
**Changed**
|
||||
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||
|
||||
**Fixed**
|
||||
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.1 (2026-08-01)
|
||||
|
||||
**Added**
|
||||
|
||||
@@ -1196,9 +1196,9 @@ See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, lang
|
||||
|
||||
### Subtitle Sync
|
||||
|
||||
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||
|
||||
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using a secondary subtitle as reference
|
||||
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using another subtitle as reference; it can also take the local video file as reference (alass extracts the audio itself)
|
||||
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
|
||||
|
||||
```json
|
||||
|
||||
@@ -161,10 +161,13 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
||||
|
||||
1. Open the subsync modal from the overlay.
|
||||
2. Select the sync engine (alass or ffsubsync).
|
||||
3. For alass, select a reference subtitle track from the video.
|
||||
4. SubMiner runs the sync and reloads the corrected subtitle.
|
||||
3. For alass, pick the **reference** - the subtitle with correct timing. This defaults to the secondary subtitle track. The loaded video file can also be used as the reference (alass extracts the audio itself), but it is never the default.
|
||||
4. Pick the **out-of-sync subtitle** - the track that gets retimed. This defaults to the active primary subtitle track and applies to both engines.
|
||||
5. SubMiner runs the sync and reloads the corrected subtitle into the slot the out-of-sync track came from: retiming the secondary track keeps it secondary and leaves the primary track selected.
|
||||
|
||||
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
||||
The reference and the out-of-sync subtitle must be different tracks; the reference list hides whichever track is selected as the target.
|
||||
|
||||
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
|
||||
|
||||
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ Install ffsubsync or configure the path:
|
||||
|
||||
If subtitle sync fails (the error message is prefixed with the engine name):
|
||||
|
||||
- Ensure the reference subtitle track exists in the video (alass requires a source track).
|
||||
- Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
|
||||
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
||||
- Try running the sync tool manually to see detailed error output.
|
||||
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
||||
|
||||
+5
-1
@@ -145,11 +145,13 @@ The tray menu includes `Export Logs`, which creates the same sanitized local-dat
|
||||
|
||||
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
||||
|
||||
The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification.
|
||||
|
||||
### Logging and App Mode
|
||||
|
||||
- `--log-level` controls logger verbosity.
|
||||
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
||||
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
|
||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
||||
@@ -368,6 +370,8 @@ Press `V` to cycle the primary SubMiner subtitle bar through hidden → visible
|
||||
|
||||
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
|
||||
|
||||
The changelog modal (tray > `View Changelog`) works the same way: it renders over mpv when a video is playing and in its own window otherwise. Use `J`/`K` or the arrow keys to move between versions, `Enter` to fold or unfold one, `R` to refetch, and `Esc` to close.
|
||||
|
||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||
|
||||
### Drag-and-Drop
|
||||
|
||||
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
|
||||
- **Client auth:** none
|
||||
- **Reconnects:** client-managed
|
||||
|
||||
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes.
|
||||
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. Annotation-only upgrades do not repeat the same line on this basic stream.
|
||||
|
||||
#### Message shape
|
||||
|
||||
@@ -96,6 +96,8 @@ Use the annotation websocket for custom clients that want the same structured to
|
||||
|
||||
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
||||
|
||||
On a tokenization cache miss, this stream first sends the cue as plain text with an empty `tokens` array, then sends the annotated replacement when tokenization finishes. Treat each message as the complete current state, replacing the previous payload.
|
||||
|
||||
#### Message shape
|
||||
|
||||
```json
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Subtitle Overlay Priming
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-06-14
|
||||
Last verified: 2026-08-04
|
||||
Owner: Kyle Yasuda
|
||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||
|
||||
@@ -47,18 +47,31 @@ subtitles do not draw.
|
||||
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
||||
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
||||
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
||||
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
|
||||
payload.
|
||||
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
|
||||
synchronously, then replaces it with the tokenized payload when ready.
|
||||
|
||||
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
||||
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
||||
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
||||
prefetch work and re-centers prefetch around the live playback time.
|
||||
|
||||
## Live Cue Delivery
|
||||
|
||||
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
||||
live work does not contend for Yomitan state.
|
||||
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
|
||||
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
||||
replace the current cue.
|
||||
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
|
||||
or character images, but cue visibility does not wait for that work.
|
||||
|
||||
## Emitted State
|
||||
|
||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
|
||||
annotated subtitle payload to overlay windows and subtitle websocket listeners.
|
||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
|
||||
websocket listeners receive both the immediate plain cue and its later annotation upgrade.
|
||||
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
|
||||
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
|
||||
when text and cue timing match.
|
||||
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
||||
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
||||
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||
|
||||
+10
-4
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.1",
|
||||
"version": "0.19.2",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -84,8 +84,9 @@
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"app-builder-lib": "26.15.3",
|
||||
"brace-expansion": "5.0.8",
|
||||
"brace-expansion": "5.0.9",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"fast-uri": "3.1.5",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.0",
|
||||
@@ -93,7 +94,8 @@
|
||||
"minimatch": "10.2.5",
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7"
|
||||
"tmp": "0.2.7",
|
||||
"undici": "7.29.0"
|
||||
},
|
||||
"keywords": [
|
||||
"anki",
|
||||
@@ -125,7 +127,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.6.0",
|
||||
"electron-builder": "26.15.3",
|
||||
"undici": "7.28.0",
|
||||
"undici": "7.29.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -258,6 +260,10 @@
|
||||
{
|
||||
"from": "dist/launcher/subminer",
|
||||
"to": "launcher/subminer"
|
||||
},
|
||||
{
|
||||
"from": "CHANGELOG.md",
|
||||
"to": "CHANGELOG.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- Kiku/Lapis Word Card Type Setting
|
||||
- A new setting (Mining/Anki > Kiku/Lapis Features > "Word Card Type") lets you choose which card-type flag gets marked on Kiku/Lapis word cards, including a click-card option that SubMiner couldn't set before.
|
||||
- Handy if you only want click cards flagged instead of the default word-and-sentence marking.
|
||||
- Choosing a card type now clears any other flags automatically, so a note can't end up marked as two types at once.
|
||||
|
||||
### Fixed
|
||||
- Yomitan Popup on macOS
|
||||
- Fixed the popup going unresponsive after mining a card — clicks outside it no longer leak through to mpv, and the overlay no longer flickers hidden and shown.
|
||||
- Scrolling over the popup now scrolls its definitions instead of seeking the video.
|
||||
- YouTube Playlist Links
|
||||
- Opening a video from a playlist URL (like a Watch Later link with `list=`/`index=`) no longer times out while loading subtitles, metadata, or playback info.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(anki): add configurable word card type for Kiku/Lapis by @ksyasuda in #175
|
||||
- fix(overlay): keep Yomitan popup interactive on macOS/Windows by @ksyasuda in #177
|
||||
- fix(youtube): prevent playlist URLs from stalling yt-dlp probes by @ksyasuda in #180
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -1,5 +1,9 @@
|
||||
export { Texthooker } from './texthooker';
|
||||
export { hasMpvWebsocketPlugin, SubtitleWebSocket } from './subtitle-ws';
|
||||
export {
|
||||
hasMpvWebsocketPlugin,
|
||||
isSubtitleAnnotationUpgrade,
|
||||
SubtitleWebSocket,
|
||||
} from './subtitle-ws';
|
||||
export { registerGlobalShortcuts } from './shortcut';
|
||||
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
|
||||
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
|
||||
import type {
|
||||
ChangelogSnapshot,
|
||||
CompiledSessionBinding,
|
||||
ControllerConfigUpdate,
|
||||
PlaylistBrowserMutationResult,
|
||||
@@ -122,6 +123,7 @@ export interface IpcServiceDeps {
|
||||
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||
@@ -297,6 +299,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||
@@ -418,6 +421,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
entries: [],
|
||||
})),
|
||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||
getChangelogSnapshot: options.getChangelogSnapshot,
|
||||
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
||||
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
|
||||
@@ -820,6 +824,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.appendClipboardVideoToQueue();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getChangelogSnapshot, async (_event, payload: unknown) => {
|
||||
const refresh =
|
||||
typeof payload === 'object' && payload !== null && 'refresh' in payload
|
||||
? (payload as { refresh?: unknown }).refresh === true
|
||||
: false;
|
||||
if (!deps.getChangelogSnapshot) {
|
||||
throw new Error('Changelog service is unavailable.');
|
||||
}
|
||||
return await deps.getChangelogSnapshot({ refresh });
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => {
|
||||
return await deps.getPlaylistBrowserSnapshot();
|
||||
});
|
||||
|
||||
@@ -205,7 +205,7 @@ test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handl
|
||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
|
||||
});
|
||||
|
||||
test('runStartupBootstrapRuntime enables quiet background mode by default', () => {
|
||||
test('runStartupBootstrapRuntime lets config govern background log level by default', () => {
|
||||
const calls: string[] = [];
|
||||
const args = makeArgs({ background: true });
|
||||
|
||||
@@ -222,7 +222,7 @@ test('runStartupBootstrapRuntime enables quiet background mode by default', () =
|
||||
});
|
||||
|
||||
assert.equal(result.backgroundMode, true);
|
||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
|
||||
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
|
||||
});
|
||||
|
||||
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ export function runStartupBootstrapRuntime(
|
||||
|
||||
if (initialArgs.logLevel) {
|
||||
deps.setLogLevel(initialArgs.logLevel, 'cli');
|
||||
} else if (initialArgs.background || initialArgs.update) {
|
||||
} else if (initialArgs.update) {
|
||||
deps.setLogLevel('warn', 'cli');
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
runSubsyncManual,
|
||||
triggerSubsyncFromConfig,
|
||||
} from './subsync';
|
||||
import type { SubsyncManualPayload } from '../../types';
|
||||
|
||||
function makeDeps(
|
||||
overrides: Partial<TriggerSubsyncFromConfigDeps> = {},
|
||||
@@ -76,7 +77,7 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
payloadTrackCount = payload.subtitleTracks.length;
|
||||
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
@@ -88,9 +89,9 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.equal(payloadTrackCount, 2);
|
||||
assert.equal(ffsubsyncAvailable, true);
|
||||
assert.ok(osd.includes('Subsync: choose engine and source'));
|
||||
assert.ok(osd.includes('Subsync: choose engine and subtitles'));
|
||||
assert.equal(inProgressState, false);
|
||||
});
|
||||
|
||||
@@ -140,7 +141,7 @@ test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
payloadTrackCount = payload.subtitleTracks.length;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
@@ -152,9 +153,9 @@ test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.equal(payloadTrackCount, 2);
|
||||
assert.equal(spinnerRan, false);
|
||||
assert.deepEqual(osd, ['Subsync: choose engine and source']);
|
||||
assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
|
||||
@@ -195,12 +196,71 @@ test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async (
|
||||
},
|
||||
}),
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
payloadTrackCount = payload.subtitleTracks.length;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.equal(payloadTrackCount, 2);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig keeps both active tracks when they share a file', async () => {
|
||||
let payload: SubsyncManualPayload | null = null;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return '/tmp/video.mkv';
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') {
|
||||
// mpv appends a duplicate entry when the same file is re-added, so
|
||||
// the primary and secondary slots can point at one path.
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
type: 'sub',
|
||||
selected: true,
|
||||
external: true,
|
||||
'external-filename': '/tmp/ref.srt',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'sub',
|
||||
selected: true,
|
||||
external: true,
|
||||
'external-filename': '/tmp/ref.srt',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'sub',
|
||||
selected: false,
|
||||
external: true,
|
||||
'external-filename': '/tmp/ref.srt',
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
openManualPicker: (nextPayload) => {
|
||||
payload = nextPayload;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(payload);
|
||||
const resolved = payload as SubsyncManualPayload;
|
||||
assert.deepEqual(
|
||||
resolved.subtitleTracks.map((track) => track.id),
|
||||
[1, 2],
|
||||
);
|
||||
assert.equal(resolved.defaultReferenceTrackId, 2);
|
||||
assert.equal(resolved.defaultTargetTrackId, 1);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
|
||||
@@ -217,15 +277,157 @@ test('triggerSubsyncFromConfig reports failures to OSD', async () => {
|
||||
assert.ok(osd.some((line) => line.startsWith('Subsync failed: MPV not connected')));
|
||||
});
|
||||
|
||||
test('runSubsyncManual requires a source track for alass', async () => {
|
||||
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: null }, makeDeps());
|
||||
test('runSubsyncManual requires a reference track for alass', async () => {
|
||||
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: null }, makeDeps());
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
message: 'Select a subtitle source track for alass',
|
||||
message: 'Select a reference subtitle track for alass',
|
||||
});
|
||||
});
|
||||
|
||||
test('runSubsyncManual rejects alass when reference and target are the same track', async () => {
|
||||
const result = await runSubsyncManual(
|
||||
{ engine: 'alass', referenceTrackId: 2, targetTrackId: 2 },
|
||||
makeDeps(),
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
message: 'Reference and out-of-sync subtitles must be different tracks',
|
||||
});
|
||||
});
|
||||
|
||||
test('runSubsyncManual rejects an unknown target track', async () => {
|
||||
const result = await runSubsyncManual(
|
||||
{ engine: 'alass', referenceTrackId: 2, targetTrackId: 99 },
|
||||
makeDeps(),
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
message: 'Select the out-of-sync subtitle track to retime',
|
||||
});
|
||||
});
|
||||
|
||||
test('runSubsyncManual rejects the video reference for remote media', async () => {
|
||||
const result = await runSubsyncManual(
|
||||
{ engine: 'alass', referenceMode: 'video' },
|
||||
makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv';
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return null;
|
||||
if (name === 'track-list') {
|
||||
return [{ id: 1, type: 'sub', selected: true, lang: 'jpn' }];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.message, /cannot use a stream URL as reference/);
|
||||
});
|
||||
|
||||
test('openSubsyncManualPicker defaults the reference to the secondary subtitle track', async () => {
|
||||
let payload: SubsyncManualPayload | null = null;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return '/tmp/video.mkv';
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return 3;
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
|
||||
{
|
||||
id: 2,
|
||||
type: 'sub',
|
||||
selected: false,
|
||||
external: true,
|
||||
lang: 'eng',
|
||||
'external-filename': '/tmp/other.srt',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'sub',
|
||||
selected: true,
|
||||
external: true,
|
||||
lang: 'eng',
|
||||
'external-filename': '/tmp/secondary.srt',
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
openManualPicker: (nextPayload) => {
|
||||
payload = nextPayload;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(payload);
|
||||
const resolved = payload as SubsyncManualPayload;
|
||||
assert.deepEqual(
|
||||
resolved.subtitleTracks.map((track) => track.id),
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.equal(resolved.defaultReferenceTrackId, 3);
|
||||
assert.equal(resolved.defaultTargetTrackId, 1);
|
||||
assert.equal(resolved.videoReferenceAvailable, true);
|
||||
});
|
||||
|
||||
test('openSubsyncManualPicker never defaults to a reference missing from the track list', async () => {
|
||||
let payload: SubsyncManualPayload | null = null;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return '/tmp/video.mkv';
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
|
||||
// Secondary track with no usable file path: filtered out of the picker.
|
||||
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': '' },
|
||||
{ id: 3, type: 'sub', selected: false, lang: 'eng' },
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
openManualPicker: (nextPayload) => {
|
||||
payload = nextPayload;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(payload);
|
||||
const resolved = payload as SubsyncManualPayload;
|
||||
assert.deepEqual(
|
||||
resolved.subtitleTracks.map((track) => track.id),
|
||||
[1, 3],
|
||||
);
|
||||
assert.equal(resolved.defaultReferenceTrackId, 3);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => {
|
||||
const osd: string[] = [];
|
||||
const inProgress: boolean[] = [];
|
||||
@@ -242,7 +444,7 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
|
||||
inProgress.push(value);
|
||||
},
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
payloadTrackCount = payload.subtitleTracks.length;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
@@ -251,8 +453,8 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
|
||||
);
|
||||
|
||||
assert.deepEqual(inProgress, [false]);
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.deepEqual(osd, ['Subsync: choose engine and source']);
|
||||
assert.equal(payloadTrackCount, 2);
|
||||
assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
|
||||
});
|
||||
|
||||
function writeExecutableScript(filePath: string, content: string): void {
|
||||
@@ -333,7 +535,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
||||
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
|
||||
@@ -346,7 +548,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
||||
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
||||
assert.equal(ffOutputFlagIndex >= 0, true);
|
||||
assert.equal(ffArgs[ffOutputFlagIndex + 1], toShellPath(primaryPath));
|
||||
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
||||
assert.equal(sentCommands[0]?.[0], 'sub-add');
|
||||
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||
});
|
||||
|
||||
@@ -399,7 +601,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
||||
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
|
||||
@@ -453,7 +655,7 @@ test('runSubsyncManual reports ffsubsync command failures with details', async (
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
||||
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.message.startsWith('ffsubsync synchronization failed'), true);
|
||||
@@ -518,7 +720,7 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
|
||||
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(typeof result.message, 'string');
|
||||
@@ -528,6 +730,179 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
||||
assert.equal(alassArgs[1], toShellPath(primaryPath));
|
||||
});
|
||||
|
||||
function makeAlassSelectionDeps(tmpDir: string): {
|
||||
deps: TriggerSubsyncFromConfigDeps;
|
||||
alassLogPath: string;
|
||||
videoPath: string;
|
||||
primaryPath: string;
|
||||
sourcePath: string;
|
||||
sentCommands: Array<Array<string | number>>;
|
||||
} {
|
||||
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
||||
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||
const primaryPath = path.join(tmpDir, 'primary.srt');
|
||||
const sourcePath = path.join(tmpDir, 'source.srt');
|
||||
|
||||
fs.writeFileSync(videoPath, 'video');
|
||||
fs.writeFileSync(primaryPath, 'sub');
|
||||
fs.writeFileSync(sourcePath, 'sub2');
|
||||
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
|
||||
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
|
||||
writeExecutableScript(
|
||||
alassPath,
|
||||
`#!/bin/sh\n: > "${toShellPath(alassLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(alassLogPath)}"; done\n: > "$3"\nexit 0\n`,
|
||||
);
|
||||
|
||||
const trackList: Array<Record<string, unknown>> = [
|
||||
{ id: 1, type: 'sub', selected: true, external: true, 'external-filename': primaryPath },
|
||||
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': sourcePath },
|
||||
];
|
||||
const sentCommands: Array<Array<string | number>> = [];
|
||||
const deps = makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: (payload) => {
|
||||
sentCommands.push(payload.command);
|
||||
if (payload.command[0] === 'sub-add' || payload.command[0] === 'sub_add') {
|
||||
trackList.push({
|
||||
id: trackList.length + 1,
|
||||
type: 'sub',
|
||||
selected: false,
|
||||
external: true,
|
||||
'external-filename': payload.command[1],
|
||||
});
|
||||
}
|
||||
},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return videoPath;
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return trackList;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
replace: false,
|
||||
}),
|
||||
});
|
||||
|
||||
return { deps, alassLogPath, videoPath, primaryPath, sourcePath, sentCommands };
|
||||
}
|
||||
|
||||
test('runSubsyncManual uses the video file as alass reference when requested', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-video-ref-'));
|
||||
const { deps, alassLogPath, videoPath, primaryPath } = makeAlassSelectionDeps(tmpDir);
|
||||
|
||||
const result = await runSubsyncManual({ engine: 'alass', referenceMode: 'video' }, deps);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
|
||||
assert.equal(alassArgs[0], toShellPath(videoPath));
|
||||
assert.equal(alassArgs[1], toShellPath(primaryPath));
|
||||
});
|
||||
|
||||
test('runSubsyncManual retimes the selected target track instead of the primary', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-target-'));
|
||||
const { deps, alassLogPath, primaryPath, sourcePath, sentCommands } =
|
||||
makeAlassSelectionDeps(tmpDir);
|
||||
|
||||
const result = await runSubsyncManual(
|
||||
{ engine: 'alass', referenceTrackId: 1, targetTrackId: 2 },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
|
||||
assert.equal(alassArgs[0], toShellPath(primaryPath));
|
||||
assert.equal(alassArgs[1], toShellPath(sourcePath));
|
||||
assert.equal(sentCommands[0]?.[0], 'sub-add');
|
||||
assert.equal(sentCommands[0]?.[2], 'auto');
|
||||
});
|
||||
|
||||
test('runSubsyncManual keeps a retimed secondary track in the secondary slot', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-secondary-slot-'));
|
||||
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||
const primaryPath = path.join(tmpDir, 'ja.srt');
|
||||
const secondaryPath = path.join(tmpDir, 'en.srt');
|
||||
const retimedPath = path.join(tmpDir, 'en_retimed.srt');
|
||||
|
||||
fs.writeFileSync(videoPath, 'video');
|
||||
fs.writeFileSync(primaryPath, 'ja');
|
||||
fs.writeFileSync(secondaryPath, 'en');
|
||||
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
|
||||
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
|
||||
writeExecutableScript(alassPath, '#!/bin/sh\n: > "$3"\nexit 0\n');
|
||||
|
||||
const trackList: Array<Record<string, unknown>> = [
|
||||
{ id: 1, type: 'sub', selected: true, external: true, 'external-filename': primaryPath },
|
||||
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': secondaryPath },
|
||||
];
|
||||
const sentCommands: Array<Array<string | number>> = [];
|
||||
const deps = makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: (payload) => {
|
||||
sentCommands.push(payload.command);
|
||||
if (payload.command[0] === 'sub-add') {
|
||||
trackList.push({
|
||||
id: 3,
|
||||
type: 'sub',
|
||||
selected: false,
|
||||
external: true,
|
||||
'external-filename': payload.command[1],
|
||||
});
|
||||
}
|
||||
},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return videoPath;
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return trackList;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
replace: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSubsyncManual(
|
||||
{ engine: 'alass', referenceTrackId: 1, targetTrackId: 2 },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(sentCommands[0], ['sub-add', retimedPath, 'auto']);
|
||||
assert.deepEqual(sentCommands[1], ['set_property', 'secondary-sub-delay', 0]);
|
||||
assert.deepEqual(sentCommands[2], ['set_property', 'secondary-sid', 3]);
|
||||
assert.equal(
|
||||
sentCommands.some((command) => command[1] === 'sub-delay'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
sentCommands.some((command) => command[1] === 'sid'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
sentCommands.some((command) => command[1] === 'sid'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
|
||||
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||
@@ -589,11 +964,11 @@ test('runSubsyncManual keeps internal alass source file alive until sync finishe
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
|
||||
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.message, 'Subtitle synchronized with alass');
|
||||
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
||||
assert.equal(sentCommands[0]?.[0], 'sub-add');
|
||||
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||
});
|
||||
|
||||
@@ -645,7 +1020,7 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
||||
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
|
||||
|
||||
+201
-40
@@ -21,6 +21,11 @@ interface FileExtractionResult {
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
type SubtitleSlot = 'primary' | 'secondary';
|
||||
|
||||
const SYNCED_TRACK_LOOKUP_ATTEMPTS = 5;
|
||||
const SYNCED_TRACK_LOOKUP_RETRY_MS = 100;
|
||||
|
||||
function summarizeCommandFailure(command: string, result: CommandResult): string {
|
||||
const parts = [
|
||||
`code=${result.code ?? 'n/a'}`,
|
||||
@@ -90,16 +95,28 @@ function getSourceTrackIdentity(track: MpvTrack): string {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] {
|
||||
const deduped = new Map<string, MpvTrack>();
|
||||
function isPinned(track: MpvTrack, pinnedIds: Set<number>): boolean {
|
||||
return typeof track.id === 'number' && pinnedIds.has(track.id);
|
||||
}
|
||||
|
||||
// Pinned tracks (the active primary/secondary) always survive, even when two of
|
||||
// them point at the same file; only unpinned duplicates are collapsed.
|
||||
function dedupeSubtitleTracks(tracks: MpvTrack[], pinnedIds: Set<number>): MpvTrack[] {
|
||||
const pinnedIdentities = new Set(
|
||||
tracks.filter((track) => isPinned(track, pinnedIds)).map(getSourceTrackIdentity),
|
||||
);
|
||||
const winners = new Map<string, MpvTrack>();
|
||||
for (const track of tracks) {
|
||||
if (isPinned(track, pinnedIds)) continue;
|
||||
const identity = getSourceTrackIdentity(track);
|
||||
const existing = deduped.get(identity);
|
||||
if (pinnedIdentities.has(identity)) continue;
|
||||
const existing = winners.get(identity);
|
||||
if (!existing || (track.selected && !existing.selected)) {
|
||||
deduped.set(identity, track);
|
||||
winners.set(identity, track);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
const kept = new Set(winners.values());
|
||||
return tracks.filter((track) => isPinned(track, pinnedIds) || kept.has(track));
|
||||
}
|
||||
|
||||
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
|
||||
@@ -142,20 +159,21 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
|
||||
}
|
||||
|
||||
const secondaryTrack = subtitleTracks.find((track) => track.id === secondarySid) ?? null;
|
||||
const sourceTracks = subtitleTracks
|
||||
.filter((track) => track.id !== sid)
|
||||
.filter((track) => {
|
||||
if (!track.external) return true;
|
||||
const filename = track['external-filename'];
|
||||
return typeof filename === 'string' && filename.length > 0;
|
||||
});
|
||||
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
|
||||
const usableTracks = subtitleTracks.filter((track) => {
|
||||
if (typeof track.id !== 'number') return false;
|
||||
if (!track.external) return true;
|
||||
const filename = track['external-filename'];
|
||||
return typeof filename === 'string' && filename.length > 0;
|
||||
});
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
primaryTrack,
|
||||
secondaryTrack,
|
||||
sourceTracks: uniqueSourceTracks,
|
||||
subtitleTracks: dedupeSubtitleTracks(
|
||||
usableTracks,
|
||||
new Set([sid, secondarySid].filter((id): id is number => typeof id === 'number')),
|
||||
),
|
||||
audioStreamIndex: client.currentAudioStreamIndex,
|
||||
};
|
||||
}
|
||||
@@ -271,41 +289,104 @@ async function runFfsubsyncSync(
|
||||
return runCommand(ffsubsyncPath, args);
|
||||
}
|
||||
|
||||
function loadSyncedSubtitle(client: MpvClientLike, pathToLoad: string): void {
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// mpv may echo the path back with different separators, and Windows paths are
|
||||
// case-insensitive, so compare normalized forms instead of raw strings.
|
||||
function normalizeSubtitlePathForCompare(value: string): string {
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
async function findAddedSubtitleTrackId(
|
||||
client: MpvClientLike,
|
||||
pathToLoad: string,
|
||||
): Promise<number | null> {
|
||||
const wanted = normalizeSubtitlePathForCompare(pathToLoad);
|
||||
// sub-add is queued, so the track may not appear in the first track-list reply.
|
||||
for (let attempt = 0; attempt < SYNCED_TRACK_LOOKUP_ATTEMPTS; attempt += 1) {
|
||||
let tracks: MpvTrack[] = [];
|
||||
try {
|
||||
const trackListRaw = await client.requestProperty('track-list');
|
||||
tracks = Array.isArray(trackListRaw) ? normalizeTrackIds(trackListRaw as MpvTrack[]) : [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Re-adding a file mpv already knows appends a duplicate entry; the newest
|
||||
// one holds the retimed content, so prefer the last match.
|
||||
const matches = tracks.filter((track) => {
|
||||
if (track.type !== 'sub') return false;
|
||||
const filename = track['external-filename'];
|
||||
return typeof filename === 'string' && normalizeSubtitlePathForCompare(filename) === wanted;
|
||||
});
|
||||
const added = matches[matches.length - 1];
|
||||
if (added && typeof added.id === 'number') {
|
||||
return added.id;
|
||||
}
|
||||
if (attempt < SYNCED_TRACK_LOOKUP_ATTEMPTS - 1) {
|
||||
await delay(SYNCED_TRACK_LOOKUP_RETRY_MS);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSyncedSubtitle(
|
||||
client: MpvClientLike,
|
||||
pathToLoad: string,
|
||||
slot: SubtitleSlot,
|
||||
): Promise<void> {
|
||||
if (!client.connected) {
|
||||
throw new Error('MPV disconnected while loading subtitle');
|
||||
}
|
||||
client.send({ command: ['sub_add', pathToLoad] });
|
||||
|
||||
if (slot === 'secondary') {
|
||||
// Keep the primary track untouched: load without selecting, then point
|
||||
// secondary-sid at the freshly added track.
|
||||
client.send({ command: ['sub-add', pathToLoad, 'auto'] });
|
||||
client.send({ command: ['set_property', 'secondary-sub-delay', 0] });
|
||||
const addedTrackId = await findAddedSubtitleTrackId(client, pathToLoad);
|
||||
if (addedTrackId === null) {
|
||||
throw new Error('Synchronized subtitle did not appear in the mpv track list');
|
||||
}
|
||||
client.send({ command: ['set_property', 'secondary-sid', addedTrackId] });
|
||||
return;
|
||||
}
|
||||
|
||||
client.send({ command: ['sub-add', pathToLoad] });
|
||||
client.send({ command: ['set_property', 'sub-delay', 0] });
|
||||
}
|
||||
|
||||
async function subsyncToReference(
|
||||
engine: 'alass' | 'ffsubsync',
|
||||
referenceFilePath: string,
|
||||
targetTrack: MpvTrack,
|
||||
context: SubsyncContext,
|
||||
resolved: SubsyncResolvedConfig,
|
||||
client: MpvClientLike,
|
||||
slot: SubtitleSlot,
|
||||
): Promise<SubsyncResult> {
|
||||
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
|
||||
const primaryExtraction = await extractSubtitleTrackToFile(
|
||||
const targetExtraction = await extractSubtitleTrackToFile(
|
||||
ffmpegPath,
|
||||
context.videoPath,
|
||||
context.primaryTrack,
|
||||
targetTrack,
|
||||
);
|
||||
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
|
||||
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
|
||||
const replaceTarget = resolved.replace !== false && !targetExtraction.temporary;
|
||||
const outputPath = buildRetimedPath(targetExtraction.path, replaceTarget);
|
||||
|
||||
try {
|
||||
let result: CommandResult;
|
||||
if (engine === 'alass') {
|
||||
const alassPath = ensureExecutablePath(resolved.alassPath, 'alass');
|
||||
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath);
|
||||
result = await runAlassSync(alassPath, referenceFilePath, targetExtraction.path, outputPath);
|
||||
} else {
|
||||
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
|
||||
result = await runFfsubsyncSync(
|
||||
ffsubsyncPath,
|
||||
context.videoPath,
|
||||
primaryExtraction.path,
|
||||
targetExtraction.path,
|
||||
outputPath,
|
||||
context.audioStreamIndex,
|
||||
);
|
||||
@@ -319,13 +400,13 @@ async function subsyncToReference(
|
||||
};
|
||||
}
|
||||
|
||||
loadSyncedSubtitle(client, outputPath);
|
||||
await loadSyncedSubtitle(client, outputPath, slot);
|
||||
return {
|
||||
ok: true,
|
||||
message: `Subtitle synchronized with ${engine}`,
|
||||
};
|
||||
} finally {
|
||||
cleanupTemporaryFile(primaryExtraction);
|
||||
cleanupTemporaryFile(targetExtraction);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +418,25 @@ function validateFfsubsyncReference(videoPath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTargetTrack(
|
||||
request: SubsyncManualRunRequest,
|
||||
context: SubsyncContext,
|
||||
): MpvTrack | null {
|
||||
if (request.targetTrackId === undefined || request.targetTrackId === null) {
|
||||
return context.primaryTrack;
|
||||
}
|
||||
return getTrackById(context.subtitleTracks, request.targetTrackId);
|
||||
}
|
||||
|
||||
// Retiming the secondary track must not steal the primary slot: the synced file
|
||||
// goes back where the out-of-sync one was.
|
||||
function resolveTargetSlot(targetTrack: MpvTrack, context: SubsyncContext): SubtitleSlot {
|
||||
if (typeof targetTrack.id !== 'number') return 'primary';
|
||||
if (targetTrack.id === context.primaryTrack.id) return 'primary';
|
||||
if (context.secondaryTrack && targetTrack.id === context.secondaryTrack.id) return 'secondary';
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
export async function runSubsyncManual(
|
||||
request: SubsyncManualRunRequest,
|
||||
deps: SubsyncCoreDeps,
|
||||
@@ -345,6 +445,12 @@ export async function runSubsyncManual(
|
||||
const context = await gatherSubsyncContext(client);
|
||||
const resolved = deps.getResolvedConfig();
|
||||
|
||||
const targetTrack = resolveTargetTrack(request, context);
|
||||
if (!targetTrack) {
|
||||
return { ok: false, message: 'Select the out-of-sync subtitle track to retime' };
|
||||
}
|
||||
const targetSlot = resolveTargetSlot(targetTrack, context);
|
||||
|
||||
if (request.engine === 'ffsubsync') {
|
||||
try {
|
||||
validateFfsubsyncReference(context.videoPath);
|
||||
@@ -354,22 +460,64 @@ export async function runSubsyncManual(
|
||||
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
|
||||
return subsyncToReference(
|
||||
'ffsubsync',
|
||||
context.videoPath,
|
||||
targetTrack,
|
||||
context,
|
||||
resolved,
|
||||
client,
|
||||
targetSlot,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceTrack = getTrackById(context.sourceTracks, request.sourceTrackId ?? null);
|
||||
if (!sourceTrack) {
|
||||
return { ok: false, message: 'Select a subtitle source track for alass' };
|
||||
if (request.referenceMode === 'video') {
|
||||
if (isRemoteMediaPath(context.videoPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
'alass cannot use a stream URL as reference. Pick a reference subtitle track instead.',
|
||||
};
|
||||
}
|
||||
return subsyncToReference(
|
||||
'alass',
|
||||
context.videoPath,
|
||||
targetTrack,
|
||||
context,
|
||||
resolved,
|
||||
client,
|
||||
targetSlot,
|
||||
);
|
||||
}
|
||||
|
||||
const referenceTrack = getTrackById(context.subtitleTracks, request.referenceTrackId ?? null);
|
||||
if (!referenceTrack) {
|
||||
return { ok: false, message: 'Select a reference subtitle track for alass' };
|
||||
}
|
||||
if (referenceTrack.id === targetTrack.id) {
|
||||
return { ok: false, message: 'Reference and out-of-sync subtitles must be different tracks' };
|
||||
}
|
||||
|
||||
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
|
||||
let sourceExtraction: FileExtractionResult | null = null;
|
||||
let referenceExtraction: FileExtractionResult | null = null;
|
||||
try {
|
||||
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
||||
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
||||
referenceExtraction = await extractSubtitleTrackToFile(
|
||||
ffmpegPath,
|
||||
context.videoPath,
|
||||
referenceTrack,
|
||||
);
|
||||
return await subsyncToReference(
|
||||
'alass',
|
||||
referenceExtraction.path,
|
||||
targetTrack,
|
||||
context,
|
||||
resolved,
|
||||
client,
|
||||
targetSlot,
|
||||
);
|
||||
} finally {
|
||||
if (sourceExtraction) {
|
||||
cleanupTemporaryFile(sourceExtraction);
|
||||
if (referenceExtraction) {
|
||||
cleanupTemporaryFile(referenceExtraction);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,14 +525,27 @@ export async function runSubsyncManual(
|
||||
export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps): Promise<void> {
|
||||
const client = getMpvClientForSubsync(deps);
|
||||
const context = await gatherSubsyncContext(client);
|
||||
const subtitleTracks = context.subtitleTracks
|
||||
.filter((track) => typeof track.id === 'number')
|
||||
.map((track) => ({
|
||||
id: track.id as number,
|
||||
label: formatTrackLabel(track),
|
||||
}));
|
||||
const primaryTrackId =
|
||||
typeof context.primaryTrack.id === 'number' ? context.primaryTrack.id : null;
|
||||
const secondaryTrackId =
|
||||
typeof context.secondaryTrack?.id === 'number' ? context.secondaryTrack.id : null;
|
||||
const payload: SubsyncManualPayload = {
|
||||
subtitleTracks,
|
||||
// The secondary track can be filtered or deduped out of the emitted list,
|
||||
// so only default to it when the picker actually offers it.
|
||||
defaultReferenceTrackId:
|
||||
subtitleTracks.find((track) => track.id === secondaryTrackId)?.id ??
|
||||
subtitleTracks.find((track) => track.id !== primaryTrackId)?.id ??
|
||||
null,
|
||||
defaultTargetTrackId: primaryTrackId,
|
||||
videoReferenceAvailable: !isRemoteMediaPath(context.videoPath),
|
||||
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
|
||||
sourceTracks: context.sourceTracks
|
||||
.filter((track) => typeof track.id === 'number')
|
||||
.map((track) => ({
|
||||
id: track.id as number,
|
||||
label: formatTrackLabel(track),
|
||||
})),
|
||||
};
|
||||
deps.openManualPicker(payload);
|
||||
}
|
||||
@@ -397,7 +558,7 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
|
||||
|
||||
try {
|
||||
await openSubsyncManualPicker(deps);
|
||||
deps.showMpvOsd('Subsync: choose engine and source');
|
||||
deps.showMpvOsd('Subsync: choose engine and subtitles');
|
||||
} catch (error) {
|
||||
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
|
||||
} finally {
|
||||
|
||||
@@ -74,7 +74,6 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
|
||||
preCacheTokenization: (text, data) => {
|
||||
cached.set(text, data);
|
||||
},
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -91,32 +90,38 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
|
||||
assert.ok(cached.has('line-2'));
|
||||
});
|
||||
|
||||
test('prefetch service stops when cache is full', async () => {
|
||||
test('prefetch service warms every cue even when the cache evicts along the way', async () => {
|
||||
const cues = makeCues(20);
|
||||
let tokenizeCalls = 0;
|
||||
let cacheSize = 0;
|
||||
const tokenized: string[] = [];
|
||||
// Stand-in for the LRU: only the last 5 entries survive, so later cues evict earlier ones.
|
||||
const cache = new Set<string>();
|
||||
|
||||
const service = createSubtitlePrefetchService({
|
||||
cues,
|
||||
tokenizeSubtitle: async (text) => {
|
||||
tokenizeCalls += 1;
|
||||
tokenized.push(text);
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: () => {
|
||||
cacheSize += 1;
|
||||
preCacheTokenization: (text) => {
|
||||
cache.add(text);
|
||||
while (cache.size > 5) {
|
||||
const oldest = cache.values().next().value;
|
||||
if (oldest === undefined) break;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
},
|
||||
isCacheFull: () => cacheSize >= 5,
|
||||
hasCachedTokenization: (text) => cache.has(text),
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
service.start(0);
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
await flushMicrotasks();
|
||||
}
|
||||
service.stop();
|
||||
|
||||
// Should have stopped at 5 (cache full), not tokenized all 20
|
||||
assert.ok(tokenizeCalls <= 6, `Expected <= 6 tokenize calls, got ${tokenizeCalls}`);
|
||||
assert.equal(tokenized.length, 20, `Expected all 20 cues warmed, got ${tokenized.length}`);
|
||||
assert.equal(new Set(tokenized).size, 20, 'Each cue is tokenized at most once per run');
|
||||
});
|
||||
|
||||
test('prefetch service can be stopped mid-flight', async () => {
|
||||
@@ -130,7 +135,6 @@ test('prefetch service can be stopped mid-flight', async () => {
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -159,7 +163,6 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
|
||||
preCacheTokenization: (text) => {
|
||||
cachedTexts.push(text);
|
||||
},
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -183,7 +186,7 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
|
||||
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
|
||||
});
|
||||
|
||||
test('prefetch service still warms the priority window when cache is full', async () => {
|
||||
test('prefetch service warms the priority window ahead of the rest of the file', async () => {
|
||||
const cues = makeCues(20);
|
||||
const cachedTexts: string[] = [];
|
||||
|
||||
@@ -193,7 +196,6 @@ test('prefetch service still warms the priority window when cache is full', asyn
|
||||
preCacheTokenization: (text) => {
|
||||
cachedTexts.push(text);
|
||||
},
|
||||
isCacheFull: () => true,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -217,7 +219,6 @@ test('prefetch service pause/resume halts and continues tokenization', async ()
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -255,7 +256,6 @@ test('prefetch service skips cues already present in tokenization cache', async
|
||||
},
|
||||
preCacheTokenization: () => {},
|
||||
hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1',
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
@@ -285,7 +285,6 @@ test('prefetch service deduplicates repeated cue text within a run', async () =>
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface SubtitlePrefetchServiceDeps {
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
hasCachedTokenization?: (text: string) => boolean;
|
||||
isCacheFull: () => boolean;
|
||||
priorityWindowSize?: number;
|
||||
}
|
||||
|
||||
@@ -57,11 +56,14 @@ export function createSubtitlePrefetchService(
|
||||
let paused = false;
|
||||
let currentRunId = 0;
|
||||
|
||||
// A run is a single bounded pass over one file's cues, deduped by `warmedKeys` and by
|
||||
// `hasCachedTokenization`, so the worst case is one tokenization per cue. The cache is
|
||||
// an LRU and bounds its own memory, so a full cache is not a reason to stop warming;
|
||||
// stopping there used to leave the tail of longer media permanently uncached.
|
||||
async function tokenizeCueList(
|
||||
cuesToProcess: SubtitleCue[],
|
||||
runId: number,
|
||||
warmedKeys: Set<string>,
|
||||
options: { allowWhenCacheFull?: boolean } = {},
|
||||
): Promise<void> {
|
||||
for (const cue of cuesToProcess) {
|
||||
if (stopped || runId !== currentRunId) {
|
||||
@@ -77,10 +79,6 @@ export function createSubtitlePrefetchService(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.allowWhenCacheFull && deps.isCacheFull()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = normalizeSubtitleCacheKey(cue.text);
|
||||
if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) {
|
||||
if (cacheKey) {
|
||||
@@ -110,7 +108,7 @@ export function createSubtitlePrefetchService(
|
||||
|
||||
// Phase 1: Priority window
|
||||
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
|
||||
await tokenizeCueList(priorityCues, runId, warmedKeys, { allowWhenCacheFull: true });
|
||||
await tokenizeCueList(priorityCues, runId, warmedKeys);
|
||||
|
||||
if (stopped || runId !== currentRunId) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import { createSubtitleProcessingController } from './subtitle-processing-controller';
|
||||
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('new subtitle emits plain immediately without parallel tokenization or a stale overwrite', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const resolvers = new Map<string, (value: SubtitleData | null) => void>();
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) =>
|
||||
await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolvers.set(text, resolve);
|
||||
}),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('first');
|
||||
controller.onSubtitleChange('second');
|
||||
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'first', tokens: null },
|
||||
{ text: 'second', tokens: null },
|
||||
]);
|
||||
assert.equal(resolvers.has('second'), false);
|
||||
|
||||
const resolveFirst = resolvers.get('first');
|
||||
assert.ok(resolveFirst);
|
||||
resolveFirst({ text: 'first', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'first', tokens: null },
|
||||
{ text: 'second', tokens: null },
|
||||
]);
|
||||
assert.equal(resolvers.has('second'), true);
|
||||
|
||||
const resolveSecond = resolvers.get('second');
|
||||
assert.ok(resolveSecond);
|
||||
resolveSecond({ text: 'second', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'first', tokens: null },
|
||||
{ text: 'second', tokens: null },
|
||||
{ text: 'second', tokens: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle clears immediately while previous tokenization remains pending', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async () =>
|
||||
await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolveTokenization = resolve;
|
||||
}),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('first');
|
||||
controller.onSubtitleChange('');
|
||||
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'first', tokens: null },
|
||||
{ text: '', tokens: null },
|
||||
]);
|
||||
|
||||
assert.ok(resolveTokenization);
|
||||
resolveTokenization({ text: 'first', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'first', tokens: null },
|
||||
{ text: '', tokens: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('returning to an uncached completed line emits it while another line is pending', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let resolvePending: ((value: SubtitleData | null) => void) | undefined;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
if (text === 'A') {
|
||||
return { text, tokens: [] };
|
||||
}
|
||||
return await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolvePending = resolve;
|
||||
});
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('A');
|
||||
await flushMicrotasks();
|
||||
controller.invalidateTokenizationCache();
|
||||
controller.onSubtitleChange('B');
|
||||
controller.onSubtitleChange('A');
|
||||
|
||||
assert.deepEqual(emitted.at(-1), { text: 'A', tokens: null });
|
||||
assert.ok(resolvePending);
|
||||
resolvePending({ text: 'B', tokens: [] });
|
||||
});
|
||||
|
||||
test('ABA subtitle changes reuse the matching first tokenization only after A is current again', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const tokenizeCalls: string[] = [];
|
||||
const resolvers: Array<(value: SubtitleData | null) => void> = [];
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
tokenizeCalls.push(text);
|
||||
return await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
});
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('A');
|
||||
controller.onSubtitleChange('B');
|
||||
controller.onSubtitleChange('A');
|
||||
const resolveFirst = resolvers[0];
|
||||
assert.ok(resolveFirst);
|
||||
resolveFirst({ text: 'A', tokens: [{ value: 1 } as never] });
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(tokenizeCalls, ['A']);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'A', tokens: null },
|
||||
{ text: 'B', tokens: null },
|
||||
{ text: 'A', tokens: null },
|
||||
{ text: 'A', tokens: [{ value: 1 } as never] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('cached next subtitle does not downgrade to plain while processing is busy', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) =>
|
||||
await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolveTokenization = () => resolve({ text, tokens: [] });
|
||||
}),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.preCacheTokenization('cached', { text: 'cached', tokens: [] });
|
||||
controller.onSubtitleChange('pending');
|
||||
controller.onSubtitleChange('cached');
|
||||
|
||||
assert.deepEqual(emitted, [{ text: 'pending', tokens: null }]);
|
||||
assert.ok(resolveTokenization);
|
||||
resolveTokenization({ text: 'pending', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'pending', tokens: null },
|
||||
{ text: 'cached', tokens: [] },
|
||||
]);
|
||||
});
|
||||
@@ -7,18 +7,138 @@ function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('subtitle processing emits tokenized payload when tokenization succeeds', async () => {
|
||||
test('subtitle processing emits plain payload immediately on cache miss, then tokenized payload', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('字幕');
|
||||
assert.deepEqual(emitted, [{ text: '字幕', tokens: null }]);
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: '字幕', tokens: null },
|
||||
{ text: '字幕', tokens: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('cache invalidation during pending tokenization does not re-emit the plain payload', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const resolvers: Array<(value: SubtitleData | null) => void> = [];
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) =>
|
||||
await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolvers.push(() => resolve({ text, tokens: [{ value: resolvers.length } as never] }));
|
||||
}),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('行');
|
||||
assert.deepEqual(emitted, [{ text: '行', tokens: null }]);
|
||||
|
||||
controller.invalidateTokenizationCache();
|
||||
resolvers[0]?.({ text: '行', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
// Retry for the new generation is now pending; still no duplicate plain emit.
|
||||
assert.deepEqual(emitted, [{ text: '行', tokens: null }]);
|
||||
|
||||
resolvers[1]?.({ text: '行', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: '行', tokens: null },
|
||||
{ text: '行', tokens: [{ value: 2 } as never] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('failed refresh does not downgrade an already emitted tokenized subtitle', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let tokenizeCalls = 0;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
tokenizeCalls += 1;
|
||||
if (tokenizeCalls > 1) {
|
||||
throw new Error('tokenizer gone');
|
||||
}
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('行');
|
||||
await flushMicrotasks();
|
||||
controller.invalidateTokenizationCache();
|
||||
controller.refreshCurrentSubtitle();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(tokenizeCalls, 2);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: '行', tokens: null },
|
||||
{ text: '行', tokens: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('null-tokenization refresh does not downgrade an already emitted tokenized subtitle', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let tokenizeCalls = 0;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
tokenizeCalls += 1;
|
||||
return tokenizeCalls > 1 ? null : { text, tokens: [] };
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('行');
|
||||
await flushMicrotasks();
|
||||
controller.invalidateTokenizationCache();
|
||||
controller.refreshCurrentSubtitle();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(tokenizeCalls, 2);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: '行', tokens: null },
|
||||
{ text: '行', tokens: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle processing does not emit plain payload for cached lines', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.preCacheTokenization('字幕', { text: '字幕', tokens: [] });
|
||||
controller.onSubtitleChange('字幕');
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
|
||||
});
|
||||
|
||||
test('subtitle processing shows plain line while tokenization is still pending', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) =>
|
||||
await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolveTokenization = () => resolve({ text, tokens: [] });
|
||||
}),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('遅い行');
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, [{ text: '遅い行', tokens: null }]);
|
||||
|
||||
assert.ok(resolveTokenization);
|
||||
resolveTokenization({ text: '遅い行', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: '遅い行', tokens: null },
|
||||
{ text: '遅い行', tokens: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let firstResolve: ((value: SubtitleData | null) => void) | undefined;
|
||||
@@ -41,7 +161,11 @@ test('subtitle processing drops stale tokenization and delivers latest subtitle
|
||||
await flushMicrotasks();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(emitted, [{ text: 'second', tokens: [] }]);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'first', tokens: null },
|
||||
{ text: 'second', tokens: null },
|
||||
{ text: 'second', tokens: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle processing skips duplicate subtitle emission', async () => {
|
||||
@@ -60,7 +184,10 @@ test('subtitle processing skips duplicate subtitle emission', async () => {
|
||||
controller.onSubtitleChange('same');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(emitted.length, 1);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'same', tokens: null },
|
||||
{ text: 'same', tokens: [] },
|
||||
]);
|
||||
assert.equal(tokenizeCalls, 1);
|
||||
});
|
||||
|
||||
@@ -84,7 +211,9 @@ test('subtitle processing reuses cached tokenization for repeated subtitle text'
|
||||
|
||||
assert.equal(tokenizeCalls, 2);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'first', tokens: null },
|
||||
{ text: 'first', tokens: [] },
|
||||
{ text: 'second', tokens: null },
|
||||
{ text: 'second', tokens: [] },
|
||||
{ text: 'first', tokens: [] },
|
||||
]);
|
||||
@@ -100,7 +229,48 @@ test('subtitle processing falls back to plain subtitle when tokenization returns
|
||||
controller.onSubtitleChange('fallback');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(
|
||||
emitted,
|
||||
[{ text: 'fallback', tokens: null }],
|
||||
'plain payload should not be re-emitted when tokenization yields nothing new',
|
||||
);
|
||||
});
|
||||
|
||||
test('null tokenization is not cached and a later cue retries tokenization', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const callsByText = new Map<string, number>();
|
||||
let failNext = true;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
callsByText.set(text, (callsByText.get(text) ?? 0) + 1);
|
||||
if (text === 'fallback' && failNext) {
|
||||
failNext = false;
|
||||
return null;
|
||||
}
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('fallback');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(callsByText.get('fallback'), 1);
|
||||
assert.equal(
|
||||
controller.hasCachedSubtitle('fallback'),
|
||||
false,
|
||||
'plain fallback must not be cached when tokenization yields nothing',
|
||||
);
|
||||
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
|
||||
|
||||
controller.onSubtitleChange('other');
|
||||
await flushMicrotasks();
|
||||
controller.onSubtitleChange('fallback');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(callsByText.get('fallback'), 2, 'later cue should retry tokenization');
|
||||
assert.equal(controller.hasCachedSubtitle('fallback'), true);
|
||||
assert.deepEqual(emitted.at(-1), { text: 'fallback', tokens: [] });
|
||||
});
|
||||
|
||||
test('subtitle processing ignores duplicate current subtitle refresh without cache invalidation', async () => {
|
||||
@@ -120,7 +290,10 @@ test('subtitle processing ignores duplicate current subtitle refresh without cac
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(tokenizeCalls, 1);
|
||||
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'same', tokens: null },
|
||||
{ text: 'same', tokens: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle processing coalesces refresh requests while current subtitle is processing', async () => {
|
||||
@@ -146,7 +319,10 @@ test('subtitle processing coalesces refresh requests while current subtitle is p
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(tokenizeCalls, 1);
|
||||
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'same', tokens: null },
|
||||
{ text: 'same', tokens: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle processing refresh re-tokenizes after cache invalidation', async () => {
|
||||
@@ -168,6 +344,7 @@ test('subtitle processing refresh re-tokenizes after cache invalidation', async
|
||||
|
||||
assert.equal(tokenizeCalls, 2);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'same', tokens: null },
|
||||
{ text: 'same', tokens: [{ value: 1 } as never] },
|
||||
{ text: 'same', tokens: [{ value: 2 } as never] },
|
||||
]);
|
||||
@@ -183,7 +360,10 @@ test('subtitle processing refresh can use explicit text override', async () => {
|
||||
controller.refreshCurrentSubtitle('initial');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(emitted, [{ text: 'initial', tokens: [] }]);
|
||||
assert.deepEqual(emitted, [
|
||||
{ text: 'initial', tokens: null },
|
||||
{ text: 'initial', tokens: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle processing cache invalidation only affects future subtitle events', async () => {
|
||||
@@ -205,10 +385,10 @@ test('subtitle processing cache invalidation only affects future subtitle events
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(callsByText.get('same'), 1);
|
||||
assert.equal(emitted.length, 3);
|
||||
assert.equal(emitted.length, 5);
|
||||
|
||||
controller.invalidateTokenizationCache();
|
||||
assert.equal(emitted.length, 3);
|
||||
assert.equal(emitted.length, 5);
|
||||
|
||||
controller.onSubtitleChange('different');
|
||||
await flushMicrotasks();
|
||||
@@ -308,25 +488,54 @@ test('hasCachedSubtitle checks prefetched entries without consuming them', async
|
||||
assert.equal(controller.hasCachedSubtitle('猫\nです'), false);
|
||||
});
|
||||
|
||||
test('isCacheFull returns false when cache is below limit', () => {
|
||||
test('cache keeps every entry while below the limit', () => {
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: null }),
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: () => {},
|
||||
cacheLimit: 8,
|
||||
});
|
||||
|
||||
assert.equal(controller.isCacheFull(), false);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from({ length: 8 }, (_, i) => controller.hasCachedSubtitle(`line-${i}`)),
|
||||
Array.from({ length: 8 }, () => true),
|
||||
);
|
||||
});
|
||||
|
||||
test('isCacheFull returns true when cache reaches limit', async () => {
|
||||
test('cache evicts least recently used entries once the limit is reached', () => {
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: () => {},
|
||||
cacheLimit: 3,
|
||||
});
|
||||
|
||||
for (const line of ['a', 'b', 'c']) {
|
||||
controller.preCacheTokenization(line, { text: line, tokens: [] });
|
||||
}
|
||||
// Touching 'a' makes 'b' the eviction candidate.
|
||||
controller.consumeCachedSubtitle('a');
|
||||
controller.preCacheTokenization('d', { text: 'd', tokens: [] });
|
||||
|
||||
assert.equal(controller.hasCachedSubtitle('b'), false);
|
||||
assert.deepEqual(
|
||||
['a', 'c', 'd'].map((line) => controller.hasCachedSubtitle(line)),
|
||||
[true, true, true],
|
||||
);
|
||||
});
|
||||
|
||||
test('default cache limit covers a full-length title without evicting', () => {
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: () => {},
|
||||
});
|
||||
|
||||
// Fill cache to the 256 limit
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
for (let i = 0; i < 2000; i += 1) {
|
||||
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
|
||||
}
|
||||
|
||||
assert.equal(controller.isCacheFull(), true);
|
||||
assert.equal(controller.hasCachedSubtitle('line-0'), true);
|
||||
assert.equal(controller.hasCachedSubtitle('line-1999'), true);
|
||||
});
|
||||
|
||||
@@ -5,8 +5,17 @@ export interface SubtitleProcessingControllerDeps {
|
||||
emitSubtitle: (payload: SubtitleData) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
now?: () => number;
|
||||
cacheLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure memory bound on the LRU, not a coverage limit: prefetching runs to the end of a
|
||||
* file regardless of cache pressure. Sized to hold a feature-length title (a 24-minute
|
||||
* episode runs 300-400 lines, a 2-hour film ~2000) plus room for lines that repeat across
|
||||
* episodes of a series, so openings and endings stay warm between titles.
|
||||
*/
|
||||
export const DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT = 2500;
|
||||
|
||||
export interface SubtitleProcessingController {
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshCurrentSubtitle: (textOverride?: string) => void;
|
||||
@@ -14,7 +23,6 @@ export interface SubtitleProcessingController {
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
hasCachedSubtitle: (text: string) => boolean;
|
||||
isCacheFull: () => boolean;
|
||||
}
|
||||
|
||||
export function normalizeSubtitleCacheKey(text: string): string {
|
||||
@@ -24,9 +32,15 @@ export function normalizeSubtitleCacheKey(text: string): string {
|
||||
export function createSubtitleProcessingController(
|
||||
deps: SubtitleProcessingControllerDeps,
|
||||
): SubtitleProcessingController {
|
||||
const SUBTITLE_TOKENIZATION_CACHE_LIMIT = 256;
|
||||
const SUBTITLE_TOKENIZATION_CACHE_LIMIT =
|
||||
deps.cacheLimit && deps.cacheLimit > 0
|
||||
? deps.cacheLimit
|
||||
: DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT;
|
||||
let latestText = '';
|
||||
let lastEmittedText = '';
|
||||
// Tracks the latest provisional plain emit across rapid changes and loop retries
|
||||
// so the same line is never shown plain twice.
|
||||
let lastPlainEmittedText: string | null = null;
|
||||
let cacheGeneration = 0;
|
||||
let lastEmittedGeneration = 0;
|
||||
let processing = false;
|
||||
@@ -70,9 +84,12 @@ export function createSubtitleProcessingController(
|
||||
const startedAtMs = now();
|
||||
|
||||
if (!text.trim()) {
|
||||
deps.emitSubtitle({ text, tokens: null });
|
||||
if (lastPlainEmittedText !== text) {
|
||||
deps.emitSubtitle({ text, tokens: null });
|
||||
}
|
||||
lastEmittedText = text;
|
||||
lastEmittedGeneration = generation;
|
||||
lastPlainEmittedText = null;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -82,11 +99,25 @@ export function createSubtitleProcessingController(
|
||||
if (cachedTokenized) {
|
||||
output = cachedTokenized;
|
||||
} else {
|
||||
// Cache miss: show the plain line on time; the tokenized payload
|
||||
// upgrades it once ready. Skipped on refreshes of an already
|
||||
// emitted line so downstream consumers never see a downgrade.
|
||||
if (text !== lastEmittedText && text !== lastPlainEmittedText) {
|
||||
deps.emitSubtitle({ text, tokens: null });
|
||||
lastPlainEmittedText = text;
|
||||
}
|
||||
const tokenized = await deps.tokenizeSubtitle(text);
|
||||
// A null result is a transient tokenizer failure, not a verdict on
|
||||
// the line: caching the plain fallback would pin it untokenized for
|
||||
// every later occurrence.
|
||||
if (tokenized) {
|
||||
output = tokenized;
|
||||
// A result computed before an invalidation must not repopulate the
|
||||
// fresh cache, or the retry below would serve the stale entry.
|
||||
if (generation === cacheGeneration) {
|
||||
setCachedTokenization(text, tokenized);
|
||||
}
|
||||
}
|
||||
setCachedTokenization(text, output);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
|
||||
@@ -107,9 +138,16 @@ export function createSubtitleProcessingController(
|
||||
continue;
|
||||
}
|
||||
|
||||
deps.emitSubtitle(output);
|
||||
// An untokenized result adds nothing when this line was already shown,
|
||||
// either provisionally or as an earlier full emit (failed refresh) —
|
||||
// emitting it would duplicate or downgrade what is on screen.
|
||||
const plainAlreadyShown = lastPlainEmittedText === text || lastEmittedText === text;
|
||||
if (!(output.tokens === null && output.text === text && plainAlreadyShown)) {
|
||||
deps.emitSubtitle(output);
|
||||
}
|
||||
lastEmittedText = text;
|
||||
lastEmittedGeneration = generation;
|
||||
lastPlainEmittedText = null;
|
||||
deps.logDebug?.(
|
||||
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
|
||||
);
|
||||
@@ -136,6 +174,14 @@ export function createSubtitleProcessingController(
|
||||
return;
|
||||
}
|
||||
latestText = text;
|
||||
if (
|
||||
processing &&
|
||||
text !== lastPlainEmittedText &&
|
||||
!tokenizationCache.has(normalizeSubtitleCacheKey(text))
|
||||
) {
|
||||
deps.emitSubtitle({ text, tokens: null });
|
||||
lastPlainEmittedText = text;
|
||||
}
|
||||
processLatest();
|
||||
},
|
||||
refreshCurrentSubtitle: (textOverride?: string) => {
|
||||
@@ -169,13 +215,11 @@ export function createSubtitleProcessingController(
|
||||
latestText = text;
|
||||
lastEmittedText = text;
|
||||
lastEmittedGeneration = cacheGeneration;
|
||||
lastPlainEmittedText = null;
|
||||
return cached;
|
||||
},
|
||||
hasCachedSubtitle: (text: string) => {
|
||||
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
|
||||
},
|
||||
isCacheFull: () => {
|
||||
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isSubtitleAnnotationUpgrade,
|
||||
serializeInitialSubtitleWebsocketMessage,
|
||||
serializeSubtitleMarkup,
|
||||
serializeSubtitleWebsocketMessage,
|
||||
@@ -13,6 +14,40 @@ const frequencyOptions = {
|
||||
mode: 'banded' as const,
|
||||
};
|
||||
|
||||
test('annotation upgrade requires matching text and cue timing', () => {
|
||||
const current: SubtitleData = {
|
||||
text: '字幕',
|
||||
tokens: null,
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
isSubtitleAnnotationUpgrade(current, {
|
||||
...current,
|
||||
tokens: [],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isSubtitleAnnotationUpgrade(current, {
|
||||
...current,
|
||||
tokens: [],
|
||||
startTime: 11,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isSubtitleAnnotationUpgrade(current, {
|
||||
...current,
|
||||
text: '次の字幕',
|
||||
tokens: [],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(isSubtitleAnnotationUpgrade(current, current), false);
|
||||
});
|
||||
|
||||
test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => {
|
||||
const payload: SubtitleData = {
|
||||
text: 'a < b\nx & y',
|
||||
|
||||
@@ -20,6 +20,20 @@ export type SubtitleWebsocketFrequencyOptions = {
|
||||
|
||||
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
|
||||
|
||||
export function isSubtitleAnnotationUpgrade(
|
||||
current: SubtitleData | null,
|
||||
next: SubtitleData,
|
||||
): boolean {
|
||||
return (
|
||||
current !== null &&
|
||||
current.tokens === null &&
|
||||
next.tokens !== null &&
|
||||
current.text === next.text &&
|
||||
current.startTime === next.startTime &&
|
||||
current.endTime === next.endTime
|
||||
);
|
||||
}
|
||||
|
||||
type SubtitleWebsocketMessageOptions = {
|
||||
payloadMode?: SubtitleWebsocketPayloadMode;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseChangelog, resolveChangelogGroupKey } from './changelog-parse';
|
||||
|
||||
const SAMPLE = `# Changelog
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
### Changed
|
||||
- Subsync: picks both tracks now.
|
||||
|
||||
### Fixed
|
||||
- Overlay: shows the plain line immediately.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Patched \`undici\`.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.1 (2026-08-01)
|
||||
|
||||
### Added
|
||||
- **Word Card Type:**
|
||||
- Adds a setting.
|
||||
- Flags clear each other.
|
||||
|
||||
## v0.18.0 (2026-07-01)
|
||||
|
||||
### Fixed
|
||||
- Something older.
|
||||
`;
|
||||
|
||||
test('changelog parser reads versions, dates, and sections in file order', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => `${entry.version}@${entry.date}`),
|
||||
['0.19.2@2026-08-04', '0.19.1@2026-08-01', '0.18.0@2026-07-01'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
entries[0]?.sections.map((section) => section.heading),
|
||||
['Changed', 'Fixed', 'Internal'],
|
||||
);
|
||||
assert.deepEqual(entries[0]?.sections[1]?.items, [
|
||||
{ text: 'Overlay: shows the plain line immediately.', children: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser flags sections inside the details block as internal', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
const sections = entries[0]?.sections ?? [];
|
||||
|
||||
assert.deepEqual(
|
||||
sections.map((section) => section.internal),
|
||||
[false, false, true],
|
||||
);
|
||||
assert.deepEqual(sections[2]?.items, [{ text: 'Patched `undici`.', children: [] }]);
|
||||
});
|
||||
|
||||
test('changelog parser groups entries by major.minor', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.groupKey),
|
||||
['0.19', '0.19', '0.18'],
|
||||
);
|
||||
assert.equal(resolveChangelogGroupKey('1.2.3'), '1.2');
|
||||
});
|
||||
|
||||
test('changelog parser keeps bullets that precede any section heading', () => {
|
||||
const entries = parseChangelog('## v0.1.0 (2025-01-01)\n\n- Initial release.\n');
|
||||
|
||||
assert.deepEqual(entries[0]?.sections, [
|
||||
{
|
||||
heading: 'Changes',
|
||||
items: [{ text: 'Initial release.', children: [] }],
|
||||
internal: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser drops empty sections and tolerates missing dates', () => {
|
||||
const entries = parseChangelog('## v0.2.0\n\n### Added\n\n### Fixed\n- One fix.\n');
|
||||
|
||||
assert.equal(entries[0]?.date, '');
|
||||
assert.deepEqual(
|
||||
entries[0]?.sections.map((section) => section.heading),
|
||||
['Fixed'],
|
||||
);
|
||||
});
|
||||
|
||||
test('changelog parser keeps indented sub-bullets nested under their lead bullet', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
const added = entries[1]?.sections.find((section) => section.heading === 'Added');
|
||||
|
||||
assert.deepEqual(added?.items, [
|
||||
{
|
||||
text: '**Word Card Type:**',
|
||||
children: [
|
||||
{ text: 'Adds a setting.', children: [] },
|
||||
{ text: 'Flags clear each other.', children: [] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser nests three bullet levels and rejoins wrapped lines', () => {
|
||||
const entries = parseChangelog(
|
||||
[
|
||||
'## v0.9.0 (2025-05-05)',
|
||||
'',
|
||||
'### Added',
|
||||
'- Top level',
|
||||
' - Second level',
|
||||
' - Third level',
|
||||
' continued on the next line',
|
||||
' - Back to second level',
|
||||
'- Another top level',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.deepEqual(entries[0]?.sections[0]?.items, [
|
||||
{
|
||||
text: 'Top level',
|
||||
children: [
|
||||
{
|
||||
text: 'Second level',
|
||||
children: [{ text: 'Third level continued on the next line', children: [] }],
|
||||
},
|
||||
{ text: 'Back to second level', children: [] },
|
||||
],
|
||||
},
|
||||
{ text: 'Another top level', children: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser reads prerelease and build metadata version headings', () => {
|
||||
const entries = parseChangelog(
|
||||
[
|
||||
'## v0.16.0 (2026-06-01)',
|
||||
'',
|
||||
'### Added',
|
||||
'- New in 0.16.',
|
||||
'',
|
||||
'## v0.15.0-rc.1+build.2 (2026-05-29)',
|
||||
'',
|
||||
'### Added',
|
||||
'- Release candidate note.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// An unrecognized heading does not just vanish: its notes fold into the
|
||||
// previous release, so the version list has to stay exact.
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.version),
|
||||
['0.16.0', '0.15.0-rc.1+build.2'],
|
||||
);
|
||||
assert.equal(entries[1]?.date, '2026-05-29');
|
||||
assert.equal(entries[1]?.groupKey, '0.15');
|
||||
assert.equal(entries[0]?.sections.length, 1);
|
||||
// The prerelease body has to land on its own entry, not fold into 0.16.0.
|
||||
assert.deepEqual(entries[1]?.sections, [
|
||||
{
|
||||
heading: 'Added',
|
||||
items: [{ text: 'Release candidate note.', children: [] }],
|
||||
internal: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser handles the repo CHANGELOG.md', () => {
|
||||
const markdown = fs.readFileSync(path.join(process.cwd(), 'CHANGELOG.md'), 'utf8');
|
||||
const entries = parseChangelog(markdown);
|
||||
|
||||
assert.ok(entries.length > 3);
|
||||
for (const entry of entries) {
|
||||
assert.match(entry.version, /^\d+\.\d+\.\d+/);
|
||||
assert.ok(entry.sections.length > 0, `expected sections for v${entry.version}`);
|
||||
for (const section of entry.sections) {
|
||||
for (const item of section.items) {
|
||||
assert.ok(item.text.length > 0, `empty bullet in v${entry.version}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Older entries group notes under a bold lead bullet; nesting must survive.
|
||||
const breaking = entries
|
||||
.find((entry) => entry.version === '0.15.0')
|
||||
?.sections.find((section) => section.heading === 'Breaking Changes');
|
||||
assert.deepEqual(
|
||||
breaking?.items.map((item) => `${item.text}:${item.children.length}`),
|
||||
['**Subsync:**:2', '**N+1 Highlighting:**:2'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ChangelogEntry, ChangelogItem, ChangelogSection } from '../../types/changelog';
|
||||
|
||||
// Prerelease and build metadata are matched separately: a single `[-+]`-led
|
||||
// group cannot span `-rc.1+build.2`, and an unmatched heading silently folds
|
||||
// that release's notes into the previous entry.
|
||||
const VERSION_HEADING =
|
||||
/^##\s+v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\s*(?:\(([^)]*)\))?\s*$/;
|
||||
const SECTION_HEADING = /^###\s+(.+?)\s*$/;
|
||||
const BULLET = /^(\s*)[-*]\s+(.*)$/;
|
||||
|
||||
/**
|
||||
* Entries are grouped by `major.minor` so the whole current minor line renders
|
||||
* expanded, matching how docs-site/changelog.md splits current vs previous.
|
||||
*/
|
||||
export function resolveChangelogGroupKey(version: string): string {
|
||||
const match = version.match(/^(\d+)\.(\d+)/);
|
||||
if (!match) return version;
|
||||
return `${match[1]}.${match[2]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the repo CHANGELOG.md into version entries. Bullets keep their inline
|
||||
* markdown and their nesting: older entries group related notes under a bold
|
||||
* lead bullet with indented children, and flattening them loses that structure.
|
||||
*/
|
||||
export function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||
const entries: ChangelogEntry[] = [];
|
||||
let entry: ChangelogEntry | null = null;
|
||||
let section: ChangelogSection | null = null;
|
||||
let internal = false;
|
||||
// Open bullets from outermost to innermost, used to place the next bullet.
|
||||
let openItems: Array<{ indent: number; item: ChangelogItem }> = [];
|
||||
|
||||
function startSection(heading: string): void {
|
||||
section = { heading, items: [], internal };
|
||||
openItems = [];
|
||||
entry?.sections.push(section);
|
||||
}
|
||||
|
||||
function addBullet(indent: number, text: string): void {
|
||||
if (!section) {
|
||||
// Bullets before any "###" heading (older entries) land in a generic group.
|
||||
startSection('Changes');
|
||||
}
|
||||
const item: ChangelogItem = { text, children: [] };
|
||||
|
||||
while (openItems.length > 0 && (openItems[openItems.length - 1]?.indent ?? 0) >= indent) {
|
||||
openItems.pop();
|
||||
}
|
||||
const parent = openItems[openItems.length - 1];
|
||||
if (parent) {
|
||||
parent.item.children.push(item);
|
||||
} else {
|
||||
section?.items.push(item);
|
||||
}
|
||||
openItems.push({ indent, item });
|
||||
}
|
||||
|
||||
function appendContinuation(text: string): void {
|
||||
const current = openItems[openItems.length - 1];
|
||||
if (!current) return;
|
||||
current.item.text = `${current.item.text} ${text}`;
|
||||
}
|
||||
|
||||
for (const rawLine of markdown.split(/\r?\n/)) {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
|
||||
const versionMatch = trimmed.match(VERSION_HEADING);
|
||||
if (versionMatch) {
|
||||
const version = versionMatch[1] ?? '';
|
||||
entry = {
|
||||
version,
|
||||
date: versionMatch[2]?.trim() ?? '',
|
||||
groupKey: resolveChangelogGroupKey(version),
|
||||
sections: [],
|
||||
};
|
||||
entries.push(entry);
|
||||
section = null;
|
||||
internal = false;
|
||||
openItems = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry) continue;
|
||||
|
||||
if (trimmed.startsWith('<details')) {
|
||||
internal = true;
|
||||
section = null;
|
||||
openItems = [];
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('</details')) {
|
||||
internal = false;
|
||||
section = null;
|
||||
openItems = [];
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('<summary')) continue;
|
||||
|
||||
const sectionMatch = trimmed.match(SECTION_HEADING);
|
||||
if (sectionMatch) {
|
||||
startSection(sectionMatch[1] ?? '');
|
||||
continue;
|
||||
}
|
||||
|
||||
const bulletMatch = line.match(BULLET);
|
||||
if (bulletMatch) {
|
||||
addBullet((bulletMatch[1] ?? '').length, bulletMatch[2] ?? '');
|
||||
continue;
|
||||
}
|
||||
|
||||
// An indented non-bullet line continues the bullet above it, including
|
||||
// across a blank line: that is CommonMark's continuation paragraph, and
|
||||
// dropping the open bullets here would silently discard the text.
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (/^\s/.test(line)) {
|
||||
appendContinuation(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.map((item) => ({
|
||||
...item,
|
||||
sections: item.sections.filter((entrySection) => entrySection.items.length > 0),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Loose semver ordering shared by the updater and the changelog UI.
|
||||
* Returns >0 when `a` is newer, <0 when older, 0 when equal.
|
||||
*/
|
||||
export function compareSemverLike(a: string, b: string): number {
|
||||
const parse = (
|
||||
value: string,
|
||||
): {
|
||||
core: number[];
|
||||
prerelease: Array<number | string>;
|
||||
} => {
|
||||
// Build metadata ("+build.2") is not part of precedence per semver, and
|
||||
// leaving it attached makes it leak into the prerelease comparison.
|
||||
const normalized = value.replace(/^v/i, '').split('+', 1)[0] ?? '';
|
||||
const [coreText = '', ...prereleaseParts] = normalized.split('-');
|
||||
const core = coreText
|
||||
.split('.')
|
||||
.slice(0, 3)
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
while (core.length < 3) core.push(0);
|
||||
const prereleaseText = prereleaseParts.join('-');
|
||||
return {
|
||||
core,
|
||||
prerelease: prereleaseText
|
||||
? prereleaseText.split('.').map((part) => {
|
||||
const numeric = Number.parseInt(part, 10);
|
||||
return /^\d+$/.test(part) ? numeric : part;
|
||||
})
|
||||
: [],
|
||||
};
|
||||
};
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
|
||||
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
||||
if (left.prerelease.length === 0) return 1;
|
||||
if (right.prerelease.length === 0) return -1;
|
||||
|
||||
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const leftPart = left.prerelease[i];
|
||||
const rightPart = right.prerelease[i];
|
||||
if (leftPart === undefined && rightPart === undefined) return 0;
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||
return leftPart - rightPart;
|
||||
}
|
||||
if (typeof leftPart === 'number') return -1;
|
||||
if (typeof rightPart === 'number') return 1;
|
||||
return leftPart > rightPart ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
+48
-3
@@ -295,6 +295,7 @@ import {
|
||||
importYomitanDictionaryFromZip,
|
||||
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
|
||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||
isSubtitleAnnotationUpgrade,
|
||||
isOverlayWindowContentReady,
|
||||
jellyfinTicksToSecondsRuntime,
|
||||
listJellyfinItemsRuntime,
|
||||
@@ -467,6 +468,8 @@ import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku
|
||||
import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
|
||||
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
|
||||
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
||||
import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open';
|
||||
import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime';
|
||||
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
|
||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||
@@ -505,6 +508,7 @@ import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'
|
||||
import {
|
||||
INSTALL_UPDATE_ACTION_ID,
|
||||
UPDATE_AVAILABLE_NOTIFICATION_ID,
|
||||
VIEW_CHANGELOG_ACTION_ID,
|
||||
} from './main/runtime/update/update-notifications';
|
||||
import { createOverlayNotificationsRuntime } from './main/runtime/overlay-notifications-runtime';
|
||||
import {
|
||||
@@ -1817,6 +1821,8 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||
}
|
||||
function emitSubtitlePayload(payload: SubtitleData): void {
|
||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||
const currentSubtitleData = appState.currentSubtitleData;
|
||||
const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload);
|
||||
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
||||
const frequencyOptions = {
|
||||
enabled: frequencyDictionary.enabled,
|
||||
@@ -1825,7 +1831,9 @@ function emitSubtitlePayload(payload: SubtitleData): void {
|
||||
};
|
||||
appState.currentSubtitleData = timedPayload;
|
||||
overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload);
|
||||
subtitleWsService.broadcast(timedPayload, frequencyOptions);
|
||||
if (!isAnnotationUpgrade) {
|
||||
subtitleWsService.broadcast(timedPayload, frequencyOptions);
|
||||
}
|
||||
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
|
||||
subtitlePrefetchService?.resume();
|
||||
@@ -1955,7 +1963,6 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||
subtitleProcessingController.preCacheTokenization(text, data);
|
||||
},
|
||||
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
|
||||
isCacheFull: () => subtitleProcessingController.isCacheFull(),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||
@@ -1982,15 +1989,22 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
|
||||
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
|
||||
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
|
||||
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
});
|
||||
|
||||
const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getLastObservedTimePos: () => lastObservedTimePos,
|
||||
shouldKeepExistingCuesOnMissingSource: (videoPath) => isYoutubeMediaPath(videoPath),
|
||||
// Remote media has no extractable on-disk track to fall back to, so a transient
|
||||
// resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would
|
||||
// otherwise drop a working cue list for the rest of the episode.
|
||||
shouldKeepExistingCuesOnMissingSource: (videoPath) =>
|
||||
isYoutubeMediaPath(videoPath) || isRemoteMediaPath(videoPath),
|
||||
subtitlePrefetchInitController,
|
||||
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
|
||||
const subtitlePrefetchRuntime = {
|
||||
@@ -2817,6 +2831,14 @@ function openSessionHelpOverlay(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function openChangelogOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openChangelogModalRuntime,
|
||||
'Changelog overlay unavailable.',
|
||||
'Failed to open changelog overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
function openCharacterDictionaryManagerOverlay(): void {
|
||||
openCharacterDictionaryManagerWithConfigGate({
|
||||
isCharacterDictionaryEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled,
|
||||
@@ -2994,6 +3016,8 @@ const {
|
||||
streamIndex,
|
||||
delaySeconds,
|
||||
}),
|
||||
initSubtitlePrefetch: (sourcePath) =>
|
||||
subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath),
|
||||
logDebug: (message, error) => {
|
||||
logger.debug(message, error);
|
||||
},
|
||||
@@ -4366,6 +4390,7 @@ const {
|
||||
refreshDiscordPresence: () => {
|
||||
discordPresenceRuntime.publishDiscordPresence();
|
||||
},
|
||||
logSubtitleProcessingDebug: (message: string) => logger.debug(message),
|
||||
ensureImmersionTrackerInitialized: () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
},
|
||||
@@ -5081,6 +5106,18 @@ flushPendingMpvLogWrites = () => {
|
||||
void flushMpvLog();
|
||||
};
|
||||
|
||||
const { getChangelogSnapshot } = createChangelogRuntime({
|
||||
getInstalledVersion: () => app.getVersion(),
|
||||
getUpdateChannel: () => configService.getConfig().updates.channel,
|
||||
resourcesPath: process.resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
dirname: __dirname,
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
fileExists: (candidate) => fs.existsSync(candidate),
|
||||
readFile: (candidate) => fs.readFileSync(candidate, 'utf8'),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
|
||||
const { getUpdateService } = createUpdateServiceRuntime({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
getUpdatesConfig: () => configService.getConfig().updates,
|
||||
@@ -5449,6 +5486,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
logger.warn('Failed to install update from overlay notification action:', error);
|
||||
});
|
||||
}
|
||||
if (
|
||||
notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID &&
|
||||
actionId === VIEW_CHANGELOG_ACTION_ID
|
||||
) {
|
||||
openChangelogOverlay();
|
||||
}
|
||||
if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) {
|
||||
void openAnkiCardFromNotification(noteId).catch((error) => {
|
||||
logger.warn('Failed to open Anki card from overlay notification action:', error);
|
||||
@@ -5714,6 +5757,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
return result;
|
||||
},
|
||||
appendClipboardVideoToQueue: () => appendClipboardVideoToQueueHandler(),
|
||||
getChangelogSnapshot: (options) => getChangelogSnapshot(options),
|
||||
...playlistBrowserMainDeps,
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
},
|
||||
@@ -6104,6 +6148,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||
openChangelogModal: () => openChangelogOverlay(),
|
||||
openTexthookerInBrowser: () =>
|
||||
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
||||
showTexthookerPage: () => shouldShowTexthookerTrayEntry(configService.getConfig()),
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
removeCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['removeCharacterDictionaryManagedEntry'];
|
||||
moveCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['moveCharacterDictionaryManagedEntry'];
|
||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||
getChangelogSnapshot?: IpcDepsRuntimeOptions['getChangelogSnapshot'];
|
||||
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
||||
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
||||
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
|
||||
@@ -302,6 +303,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
removeCharacterDictionaryManagedEntry: params.removeCharacterDictionaryManagedEntry,
|
||||
moveCharacterDictionaryManagedEntry: params.moveCharacterDictionaryManagedEntry,
|
||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||
getChangelogSnapshot: params.getChangelogSnapshot,
|
||||
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
||||
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
|
||||
|
||||
@@ -176,6 +176,29 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('remote media keeps parsed cues when the active subtitle source cannot be resolved', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/createRefreshSubtitlePrefetchFromActiveTrackHandler\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /isYoutubeMediaPath\(videoPath\) \|\| isRemoteMediaPath\(videoPath\)/);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/preloadJellyfinExternalSubtitlesMainDeps:\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/initSubtitlePrefetch: \(sourcePath\) =>\s*subtitlePrefetchRuntime\.refreshSubtitleSidebarFromSource\(sourcePath\),/,
|
||||
);
|
||||
});
|
||||
|
||||
test('update overlay notification action triggers install flow', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
@@ -590,6 +613,35 @@ test('subtitle broadcasts share one frequency options snapshot per emitted paylo
|
||||
);
|
||||
});
|
||||
|
||||
test('annotation upgrades skip the duplicate basic websocket event', () => {
|
||||
const source = readMainSource();
|
||||
const emitBlock = source.match(
|
||||
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(emitBlock);
|
||||
assert.match(
|
||||
emitBlock,
|
||||
/const isAnnotationUpgrade = isSubtitleAnnotationUpgrade\(currentSubtitleData, timedPayload\);/,
|
||||
);
|
||||
assert.match(
|
||||
emitBlock,
|
||||
/if \(!isAnnotationUpgrade\) \{\s+subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);\s+\}/,
|
||||
);
|
||||
assert.equal(
|
||||
(emitBlock.match(/overlayManager\.broadcastToOverlayWindows\('subtitle:set'/g) ?? []).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
emitBlock.match(
|
||||
/annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\)/g,
|
||||
) ?? []
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('websocket frequency options callbacks each read one configuration snapshot', () => {
|
||||
const source = readMainSource();
|
||||
const subtitleBlock = source.match(
|
||||
|
||||
@@ -326,7 +326,13 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
||||
});
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'subsync:open-manual',
|
||||
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
||||
{
|
||||
ffsubsyncAvailable: true,
|
||||
videoReferenceAvailable: true,
|
||||
subtitleTracks: [],
|
||||
defaultReferenceTrackId: null,
|
||||
defaultTargetTrackId: null,
|
||||
},
|
||||
{
|
||||
restoreOnModalClose: 'subsync',
|
||||
},
|
||||
@@ -560,7 +566,13 @@ test('modal runtime notifies callers when modal input state becomes active/inact
|
||||
});
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'subsync:open-manual',
|
||||
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
||||
{
|
||||
ffsubsyncAvailable: true,
|
||||
videoReferenceAvailable: true,
|
||||
subtitleTracks: [],
|
||||
defaultReferenceTrackId: null,
|
||||
defaultTargetTrackId: null,
|
||||
},
|
||||
{
|
||||
restoreOnModalClose: 'subsync',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const CHANGELOG_MODAL: OverlayHostedModal = 'changelog';
|
||||
const CHANGELOG_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openChangelogModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: CHANGELOG_MODAL,
|
||||
timeoutMs: CHANGELOG_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Changelog modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.changelogOpen,
|
||||
modal: CHANGELOG_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { readBundledChangelog, resolveBundledChangelogPath } from './bundled-changelog';
|
||||
|
||||
test('bundled changelog path prefers the packaged resources copy', () => {
|
||||
const resolved = resolveBundledChangelogPath({
|
||||
resourcesPath: '/res',
|
||||
appPath: '/app',
|
||||
dirname: '/app/dist/main',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: (candidate) => candidate === '/res/CHANGELOG.md',
|
||||
});
|
||||
|
||||
assert.equal(resolved, '/res/CHANGELOG.md');
|
||||
});
|
||||
|
||||
test('bundled changelog path falls back to the repo root during development', () => {
|
||||
const resolved = resolveBundledChangelogPath({
|
||||
resourcesPath: '/res',
|
||||
appPath: '/app',
|
||||
dirname: '/repo/dist/main',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: (candidate) => candidate === '/repo/dist/main/../../CHANGELOG.md',
|
||||
});
|
||||
|
||||
assert.equal(resolved, '/repo/dist/main/../../CHANGELOG.md');
|
||||
});
|
||||
|
||||
test('bundled changelog returns null when no copy is installed', () => {
|
||||
const result = readBundledChangelog({
|
||||
resolvePath: () => null,
|
||||
readFile: () => {
|
||||
throw new Error('should not read');
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('bundled changelog logs and returns null when the file cannot be read', () => {
|
||||
const warnings: string[] = [];
|
||||
const result = readBundledChangelog({
|
||||
resolvePath: () => '/res/CHANGELOG.md',
|
||||
readFile: () => {
|
||||
throw new Error('EACCES');
|
||||
},
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0] ?? '', /EACCES/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export function resolveBundledChangelogPath(deps: {
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
dirname: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (path: string) => boolean;
|
||||
}): string | null {
|
||||
const candidates = [
|
||||
deps.joinPath(deps.resourcesPath, 'CHANGELOG.md'),
|
||||
deps.joinPath(deps.appPath, 'CHANGELOG.md'),
|
||||
deps.joinPath(deps.dirname, '..', 'CHANGELOG.md'),
|
||||
deps.joinPath(deps.dirname, '..', '..', 'CHANGELOG.md'),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => deps.fileExists(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export function readBundledChangelog(deps: {
|
||||
resolvePath: () => string | null;
|
||||
readFile: (path: string) => string;
|
||||
logWarn: (message: string) => void;
|
||||
}): string | null {
|
||||
const changelogPath = deps.resolvePath();
|
||||
if (!changelogPath) return null;
|
||||
try {
|
||||
return deps.readFile(changelogPath);
|
||||
} catch (error) {
|
||||
deps.logWarn(
|
||||
`Failed to read bundled changelog at ${changelogPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
CHANGELOG_REQUEST_TIMEOUT_MS,
|
||||
createChangelogRuntime,
|
||||
withRequestTimeout,
|
||||
} from './changelog-runtime';
|
||||
import type { FetchLike, FetchResponseLike } from '../update/release-assets';
|
||||
|
||||
function okResponse(body: string): FetchResponseLike {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => JSON.parse(body),
|
||||
text: async () => body,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
};
|
||||
}
|
||||
|
||||
test('request timeout wrapper attaches an abort signal to every request', async () => {
|
||||
const seen: Array<{ url: string; init?: Record<string, unknown> }> = [];
|
||||
const wrapped = withRequestTimeout(async (url, init) => {
|
||||
seen.push({ url, init });
|
||||
return okResponse('body');
|
||||
}, 1_234);
|
||||
|
||||
await wrapped('https://example.test/a');
|
||||
await wrapped('https://example.test/b', { headers: { 'User-Agent': 'SubMiner' } });
|
||||
|
||||
assert.equal(seen.length, 2);
|
||||
for (const request of seen) {
|
||||
assert.ok(request.init?.signal instanceof AbortSignal, 'each request carries a signal');
|
||||
assert.equal((request.init?.signal as AbortSignal).aborted, false);
|
||||
}
|
||||
// Existing init is preserved rather than replaced.
|
||||
assert.deepEqual(seen[1]?.init?.headers, { 'User-Agent': 'SubMiner' });
|
||||
});
|
||||
|
||||
test('request timeout wrapper aborts a request that never settles', async () => {
|
||||
let observed: AbortSignal | undefined;
|
||||
const wrapped = withRequestTimeout((_url, init) => {
|
||||
observed = init?.signal as AbortSignal;
|
||||
return new Promise<FetchResponseLike>(() => {
|
||||
// Never resolves, standing in for a stalled connection.
|
||||
});
|
||||
}, 10);
|
||||
|
||||
void wrapped('https://example.test/stalled');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
|
||||
assert.equal(observed?.aborted, true);
|
||||
});
|
||||
|
||||
test('changelog runtime times out both requests instead of hanging the modal', async () => {
|
||||
const signals: Array<AbortSignal | undefined> = [];
|
||||
const failing: FetchLike = (_url, init) => {
|
||||
signals.push(init?.signal as AbortSignal | undefined);
|
||||
return Promise.reject(new Error('stalled'));
|
||||
};
|
||||
|
||||
const runtime = createChangelogRuntime({
|
||||
getInstalledVersion: () => '0.19.2',
|
||||
getUpdateChannel: () => 'stable',
|
||||
resourcesPath: '/res',
|
||||
appPath: '/app',
|
||||
dirname: '/app/dist/main',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: () => false,
|
||||
readFile: () => '',
|
||||
logWarn: () => {},
|
||||
createFetch: () => failing,
|
||||
});
|
||||
|
||||
const snapshot = await runtime.getChangelogSnapshot();
|
||||
|
||||
// Release lookup and changelog download both go through the timeout wrapper.
|
||||
assert.equal(signals.length, 2);
|
||||
assert.ok(signals.every((signal) => signal instanceof AbortSignal));
|
||||
// No bundled copy is readable here, so the failure surfaces rather than hangs.
|
||||
assert.match(snapshot.error ?? '', /stalled/);
|
||||
});
|
||||
|
||||
test('changelog request timeout is finite', () => {
|
||||
assert.ok(Number.isFinite(CHANGELOG_REQUEST_TIMEOUT_MS));
|
||||
assert.ok(CHANGELOG_REQUEST_TIMEOUT_MS > 0);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ChangelogSnapshot } from '../../../types/changelog';
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
import { createCurlFetch, createGlobalFetch } from '../update/fetch-adapter';
|
||||
import { fetchLatestStableRelease, type FetchLike } from '../update/release-assets';
|
||||
import { readBundledChangelog, resolveBundledChangelogPath } from './bundled-changelog';
|
||||
import { createChangelogSource } from './changelog-source';
|
||||
|
||||
export interface ChangelogRuntimeDeps {
|
||||
getInstalledVersion: () => string;
|
||||
getUpdateChannel: () => UpdateChannel;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
dirname: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (path: string) => boolean;
|
||||
readFile: (path: string) => string;
|
||||
logWarn: (message: string) => void;
|
||||
/** Injected in tests; production picks curl on POSIX and global fetch on Windows. */
|
||||
createFetch?: () => FetchLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* curl enforces its own `--max-time`, but the global-fetch transport has no
|
||||
* deadline: without this a stalled connection leaves the modal on "Loading
|
||||
* changelog..." with no way back except closing it.
|
||||
*/
|
||||
export const CHANGELOG_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function withRequestTimeout(fetchImpl: FetchLike, timeoutMs: number): FetchLike {
|
||||
return (url, init) => {
|
||||
if (typeof AbortSignal?.timeout !== 'function') return fetchImpl(url, init);
|
||||
return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
||||
};
|
||||
}
|
||||
|
||||
export function createChangelogRuntime(deps: ChangelogRuntimeDeps): {
|
||||
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
} {
|
||||
// curl matches the updater's transport choice: Electron's global fetch is
|
||||
// unreliable for GitHub on some Linux builds.
|
||||
const fetchImpl = withRequestTimeout(
|
||||
deps.createFetch?.() ??
|
||||
(process.platform === 'win32' ? createGlobalFetch() : createCurlFetch()),
|
||||
CHANGELOG_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const source = createChangelogSource({
|
||||
fetchLatestReleaseTag: async () => {
|
||||
const release = await fetchLatestStableRelease({
|
||||
fetch: fetchImpl,
|
||||
channel: deps.getUpdateChannel(),
|
||||
});
|
||||
return release?.tag_name ?? null;
|
||||
},
|
||||
fetchText: async (url) => {
|
||||
const response = await fetchImpl(url, {
|
||||
headers: { 'User-Agent': 'SubMiner changelog' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Changelog request failed with ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
},
|
||||
readBundledChangelog: () =>
|
||||
readBundledChangelog({
|
||||
resolvePath: () =>
|
||||
resolveBundledChangelogPath({
|
||||
resourcesPath: deps.resourcesPath,
|
||||
appPath: deps.appPath,
|
||||
dirname: deps.dirname,
|
||||
joinPath: deps.joinPath,
|
||||
fileExists: deps.fileExists,
|
||||
}),
|
||||
readFile: deps.readFile,
|
||||
logWarn: deps.logWarn,
|
||||
}),
|
||||
getInstalledVersion: deps.getInstalledVersion,
|
||||
now: () => Date.now(),
|
||||
logWarn: deps.logWarn,
|
||||
});
|
||||
|
||||
return {
|
||||
getChangelogSnapshot: (options?: { refresh?: boolean }) => source.getSnapshot(options),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ChangelogSnapshot, ChangelogSourceKind } from '../../../types/changelog';
|
||||
import { parseChangelog } from '../../../core/utils/changelog-parse';
|
||||
import { compareSemverLike } from '../update/release-assets';
|
||||
|
||||
export function buildChangelogSnapshot(
|
||||
markdown: string,
|
||||
options: {
|
||||
installedVersion: string;
|
||||
source: ChangelogSourceKind;
|
||||
releaseTag?: string;
|
||||
warning?: string;
|
||||
},
|
||||
): ChangelogSnapshot {
|
||||
const entries = parseChangelog(markdown);
|
||||
const latest = entries.reduce<string | null>(
|
||||
(best, entry) =>
|
||||
best === null || compareSemverLike(entry.version, best) > 0 ? entry.version : best,
|
||||
null,
|
||||
);
|
||||
const latestEntry = entries.find((entry) => entry.version === latest) ?? entries[0] ?? null;
|
||||
|
||||
return {
|
||||
entries,
|
||||
installedVersion: options.installedVersion,
|
||||
latestVersion: latest,
|
||||
expandedGroupKey: latestEntry?.groupKey ?? null,
|
||||
source: options.source,
|
||||
...(options.releaseTag ? { releaseTag: options.releaseTag } : {}),
|
||||
...(options.warning ? { warning: options.warning } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEmptyChangelogSnapshot(options: {
|
||||
installedVersion: string;
|
||||
error: string;
|
||||
}): ChangelogSnapshot {
|
||||
return {
|
||||
entries: [],
|
||||
installedVersion: options.installedVersion,
|
||||
latestVersion: null,
|
||||
expandedGroupKey: null,
|
||||
source: 'bundled',
|
||||
error: options.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildRawChangelogUrl, createChangelogSource } from './changelog-source';
|
||||
import { buildChangelogSnapshot } from './changelog-snapshot';
|
||||
|
||||
const REMOTE = `# Changelog
|
||||
|
||||
## v0.20.0 (2026-09-01)
|
||||
|
||||
### Added
|
||||
- Remote only entry.
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
### Fixed
|
||||
- Installed entry.
|
||||
`;
|
||||
|
||||
const BUNDLED = `# Changelog
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
### Fixed
|
||||
- Installed entry.
|
||||
`;
|
||||
|
||||
function createDeps(overrides: Partial<Parameters<typeof createChangelogSource>[0]> = {}) {
|
||||
return {
|
||||
fetchLatestReleaseTag: async () => 'v0.20.0',
|
||||
fetchText: async () => REMOTE,
|
||||
readBundledChangelog: () => BUNDLED,
|
||||
getInstalledVersion: () => '0.19.2',
|
||||
now: () => 1_000,
|
||||
logWarn: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('changelog source reads the changelog at the latest release tag', async () => {
|
||||
const urls: string[] = [];
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
fetchText: async (url: string) => {
|
||||
urls.push(url);
|
||||
return REMOTE;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = await source.getSnapshot();
|
||||
|
||||
assert.deepEqual(urls, [
|
||||
'https://raw.githubusercontent.com/ksyasuda/SubMiner/v0.20.0/CHANGELOG.md',
|
||||
]);
|
||||
assert.equal(snapshot.source, 'remote');
|
||||
assert.equal(snapshot.releaseTag, 'v0.20.0');
|
||||
assert.equal(snapshot.latestVersion, '0.20.0');
|
||||
assert.equal(snapshot.installedVersion, '0.19.2');
|
||||
assert.equal(snapshot.expandedGroupKey, '0.20');
|
||||
});
|
||||
|
||||
test('changelog source falls back to the default branch when no release tag resolves', async () => {
|
||||
const urls: string[] = [];
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
fetchLatestReleaseTag: async () => null,
|
||||
fetchText: async (url: string) => {
|
||||
urls.push(url);
|
||||
return REMOTE;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = await source.getSnapshot();
|
||||
|
||||
assert.deepEqual(urls, ['https://raw.githubusercontent.com/ksyasuda/SubMiner/main/CHANGELOG.md']);
|
||||
assert.equal(snapshot.source, 'remote');
|
||||
assert.equal(snapshot.releaseTag, undefined);
|
||||
});
|
||||
|
||||
test('changelog source falls back to the bundled changelog when the download fails', async () => {
|
||||
const warnings: string[] = [];
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
fetchText: async () => {
|
||||
throw new Error('offline');
|
||||
},
|
||||
logWarn: (message: string) => warnings.push(message),
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = await source.getSnapshot();
|
||||
|
||||
assert.equal(snapshot.source, 'bundled');
|
||||
assert.match(snapshot.warning ?? '', /offline/);
|
||||
assert.equal(snapshot.latestVersion, '0.19.2');
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
|
||||
test('changelog source reports an error when no changelog can be loaded', async () => {
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
fetchText: async () => {
|
||||
throw new Error('offline');
|
||||
},
|
||||
readBundledChangelog: () => null,
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = await source.getSnapshot();
|
||||
|
||||
assert.deepEqual(snapshot.entries, []);
|
||||
assert.match(snapshot.error ?? '', /offline/);
|
||||
assert.equal(snapshot.installedVersion, '0.19.2');
|
||||
});
|
||||
|
||||
test('changelog source caches remote results and refreshes on demand', async () => {
|
||||
let fetches = 0;
|
||||
let clock = 0;
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
now: () => clock,
|
||||
fetchText: async () => {
|
||||
fetches += 1;
|
||||
return REMOTE;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await source.getSnapshot();
|
||||
await source.getSnapshot();
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
await source.getSnapshot({ refresh: true });
|
||||
assert.equal(fetches, 2);
|
||||
|
||||
clock = 11 * 60 * 1000;
|
||||
await source.getSnapshot();
|
||||
assert.equal(fetches, 3);
|
||||
});
|
||||
|
||||
test('changelog source retries the network after a bundled fallback', async () => {
|
||||
let fetches = 0;
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
fetchText: async () => {
|
||||
fetches += 1;
|
||||
throw new Error('offline');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await source.getSnapshot();
|
||||
await source.getSnapshot();
|
||||
|
||||
assert.equal(fetches, 2);
|
||||
});
|
||||
|
||||
test('changelog source treats an empty remote changelog as a failure', async () => {
|
||||
const source = createChangelogSource(createDeps({ fetchText: async () => ' ' }));
|
||||
|
||||
const snapshot = await source.getSnapshot();
|
||||
|
||||
assert.equal(snapshot.source, 'bundled');
|
||||
});
|
||||
|
||||
test('changelog source falls back when the remote body parses to no releases', () => {
|
||||
const warnings: string[] = [];
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
// A 200 that is not a changelog, e.g. a redirect landing page.
|
||||
fetchText: async () => '<!doctype html><html><body>Moved</body></html>',
|
||||
logWarn: (message: string) => warnings.push(message),
|
||||
}),
|
||||
);
|
||||
|
||||
return source.getSnapshot().then((snapshot) => {
|
||||
assert.equal(snapshot.source, 'bundled');
|
||||
assert.equal(snapshot.entries.length, 1);
|
||||
assert.match(snapshot.warning ?? '', /no releases/);
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('raw changelog urls encode the release ref', () => {
|
||||
assert.equal(
|
||||
buildRawChangelogUrl('v1.0.0', 'owner', 'repo'),
|
||||
'https://raw.githubusercontent.com/owner/repo/v1.0.0/CHANGELOG.md',
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot expansion uses the newest version even when file order is unsorted', () => {
|
||||
const snapshot = buildChangelogSnapshot(
|
||||
'## v0.18.0 (2026-01-01)\n\n### Fixed\n- Old.\n\n## v0.19.0 (2026-02-01)\n\n### Fixed\n- New.\n',
|
||||
{ installedVersion: '0.18.0', source: 'bundled' },
|
||||
);
|
||||
|
||||
assert.equal(snapshot.latestVersion, '0.19.0');
|
||||
assert.equal(snapshot.expandedGroupKey, '0.19');
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ChangelogSnapshot } from '../../../types/changelog';
|
||||
import { buildChangelogSnapshot, buildEmptyChangelogSnapshot } from './changelog-snapshot';
|
||||
|
||||
const DEFAULT_OWNER = 'ksyasuda';
|
||||
const DEFAULT_REPO = 'SubMiner';
|
||||
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface ChangelogSourceDeps {
|
||||
/** Resolves the release the changelog should be read from, or null when unknown. */
|
||||
fetchLatestReleaseTag: () => Promise<string | null>;
|
||||
fetchText: (url: string) => Promise<string>;
|
||||
/** Reads the CHANGELOG.md shipped with the install; null when unavailable. */
|
||||
readBundledChangelog: () => string | null;
|
||||
getInstalledVersion: () => string;
|
||||
now: () => number;
|
||||
logWarn: (message: string) => void;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
cacheTtlMs?: number;
|
||||
}
|
||||
|
||||
export function buildRawChangelogUrl(ref: string, owner: string, repo: string): string {
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(ref)}/CHANGELOG.md`;
|
||||
}
|
||||
|
||||
function summarize(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function createChangelogSource(deps: ChangelogSourceDeps): {
|
||||
getSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
} {
|
||||
const owner = deps.owner ?? DEFAULT_OWNER;
|
||||
const repo = deps.repo ?? DEFAULT_REPO;
|
||||
const cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||
|
||||
let cached: { snapshot: ChangelogSnapshot; fetchedAt: number } | null = null;
|
||||
let inFlight: Promise<ChangelogSnapshot> | null = null;
|
||||
|
||||
function fallbackToBundled(warning: string): ChangelogSnapshot {
|
||||
const bundled = deps.readBundledChangelog();
|
||||
if (bundled === null) {
|
||||
return buildEmptyChangelogSnapshot({
|
||||
installedVersion: deps.getInstalledVersion(),
|
||||
error: warning,
|
||||
});
|
||||
}
|
||||
return buildChangelogSnapshot(bundled, {
|
||||
installedVersion: deps.getInstalledVersion(),
|
||||
source: 'bundled',
|
||||
warning,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSnapshot(): Promise<ChangelogSnapshot> {
|
||||
let releaseTag: string | null = null;
|
||||
try {
|
||||
releaseTag = await deps.fetchLatestReleaseTag();
|
||||
} catch (error) {
|
||||
deps.logWarn(`Changelog release lookup failed: ${summarize(error)}`);
|
||||
}
|
||||
|
||||
// Without a release tag the default branch still gives the newest published
|
||||
// changelog, so try it before falling back to the bundled copy.
|
||||
const ref = releaseTag ?? 'main';
|
||||
try {
|
||||
const markdown = await deps.fetchText(buildRawChangelogUrl(ref, owner, repo));
|
||||
if (markdown.trim().length === 0) {
|
||||
throw new Error('Remote changelog was empty.');
|
||||
}
|
||||
const snapshot = buildChangelogSnapshot(markdown, {
|
||||
installedVersion: deps.getInstalledVersion(),
|
||||
source: 'remote',
|
||||
...(releaseTag ? { releaseTag } : {}),
|
||||
});
|
||||
// A 200 that isn't a changelog (a redirect landing page, a renamed repo)
|
||||
// parses to nothing; the bundled copy beats showing an empty modal.
|
||||
if (snapshot.entries.length === 0) {
|
||||
throw new Error('Remote changelog contained no releases.');
|
||||
}
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
const message = summarize(error);
|
||||
deps.logWarn(`Changelog download failed (${ref}): ${message}`);
|
||||
return fallbackToBundled(`Showing the bundled changelog: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async getSnapshot(options?: { refresh?: boolean }): Promise<ChangelogSnapshot> {
|
||||
const refresh = options?.refresh === true;
|
||||
if (!refresh && cached && deps.now() - cached.fetchedAt < cacheTtlMs) {
|
||||
return cached.snapshot;
|
||||
}
|
||||
if (inFlight) return await inFlight;
|
||||
|
||||
inFlight = loadSnapshot()
|
||||
.then((snapshot) => {
|
||||
// Only a successful remote read is worth caching; a bundled fallback
|
||||
// should retry the network on the next open.
|
||||
if (snapshot.source === 'remote') {
|
||||
cached = { snapshot, fetchedAt: deps.now() };
|
||||
} else {
|
||||
cached = null;
|
||||
}
|
||||
return snapshot;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
|
||||
return await inFlight;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { composeIpcRuntimeHandlers } from './ipc-runtime-composer';
|
||||
|
||||
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
|
||||
let registered = false;
|
||||
let receivedSourceTrackId: number | null | undefined;
|
||||
let receivedReferenceTrackId: number | null | undefined;
|
||||
|
||||
const composed = composeIpcRuntimeHandlers({
|
||||
mpvCommandMainDeps: {
|
||||
@@ -25,7 +25,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
},
|
||||
handleMpvCommandFromIpcRuntime: () => {},
|
||||
runSubsyncManualFromIpc: async (request) => {
|
||||
receivedSourceTrackId = request.sourceTrackId;
|
||||
receivedReferenceTrackId = request.referenceTrackId;
|
||||
return {
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
@@ -124,10 +124,10 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
|
||||
const result = await composed.runSubsyncManualFromIpc({
|
||||
engine: 'alass',
|
||||
sourceTrackId: 7,
|
||||
referenceTrackId: 7,
|
||||
});
|
||||
assert.deepEqual(result, { ok: true, message: 'ok' });
|
||||
assert.equal(receivedSourceTrackId, 7);
|
||||
assert.equal(receivedReferenceTrackId, 7);
|
||||
|
||||
composed.registerIpcRuntimeHandlers();
|
||||
assert.equal(registered, true);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createOverlayNotificationDelivery } from './overlay-notification-delive
|
||||
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus('Subsync: choose engine and source', {
|
||||
notifyConfiguredStatus('Subsync: choose engine and subtitles', {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
@@ -25,8 +25,8 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'overlay::SubMiner:Subsync: choose engine and source:info:auto',
|
||||
'desktop:SubMiner:Subsync: choose engine and source',
|
||||
'overlay::SubMiner:Subsync: choose engine and subtitles:info:auto',
|
||||
'desktop:SubMiner:Subsync: choose engine and subtitles',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
|
||||
? (itemId, streamIndex, delaySeconds) =>
|
||||
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
|
||||
: undefined,
|
||||
initSubtitlePrefetch: deps.initSubtitlePrefetch
|
||||
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
|
||||
: undefined,
|
||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ function makeDeps(overrides: {
|
||||
>[0]['setActiveSubtitleDelayKey'];
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
|
||||
initSubtitlePrefetch?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['initSubtitlePrefetch'];
|
||||
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
|
||||
}) {
|
||||
return {
|
||||
@@ -58,6 +61,7 @@ function makeDeps(overrides: {
|
||||
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
|
||||
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
|
||||
saveSubtitleDelay: overrides.saveSubtitleDelay,
|
||||
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
|
||||
logDebug: overrides.logDebug ?? (() => {}),
|
||||
};
|
||||
}
|
||||
@@ -134,6 +138,92 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles starts prefetch for the selected japanese track', async () => {
|
||||
const prefetched: string[] = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 5,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 6,
|
||||
lang: 'eng',
|
||||
title: 'English',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
cacheSubtitleTrack: async (track) => ({
|
||||
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
}),
|
||||
initSubtitlePrefetch: (sourcePath) => {
|
||||
prefetched.push(sourcePath);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(prefetched, ['/tmp/subminer-jellyfin-subtitles/0.srt']);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles survives prefetch start failures', async () => {
|
||||
const logs: string[] = [];
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 5,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
cacheSubtitleTrack: async (track) => ({
|
||||
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
}),
|
||||
initSubtitlePrefetch: async () => {
|
||||
throw new Error('parse failed');
|
||||
},
|
||||
logDebug: (message) => logs.push(message),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(logs, ['Failed to start subtitle prefetch for Jellyfin subtitle']);
|
||||
assert.ok(
|
||||
commands.some((command) => command[0] === 'set_property' && command[1] === 'sid'),
|
||||
'subtitle selection still happens when prefetch start fails',
|
||||
);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
|
||||
@@ -320,6 +320,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
|
||||
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||
const activeCacheDirs = new Set<string>();
|
||||
@@ -329,6 +330,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||
}
|
||||
|
||||
// mpv's sid property-change is the only thing that normally starts prefetching, so a
|
||||
// coalesced or missed event leaves the whole episode uncached. The downloaded path is
|
||||
// known here, so seed the pipeline directly instead of waiting on the observer.
|
||||
function startSubtitlePrefetchForCachedTrack(sourcePath: string): void {
|
||||
if (!deps.initSubtitlePrefetch) return;
|
||||
void Promise.resolve()
|
||||
.then(() => deps.initSubtitlePrefetch!(sourcePath))
|
||||
.catch((error) => {
|
||||
deps.logDebug('Failed to start subtitle prefetch for Jellyfin subtitle', error);
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupActiveCache(): void {
|
||||
const dirs = [...activeCacheDirs];
|
||||
if (dirs.length === 0) return;
|
||||
@@ -438,6 +451,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
}
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
|
||||
} else {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
|
||||
@@ -70,6 +70,24 @@ test('subtitle change handler broadcasts cached annotated payload immediately wh
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle change handler logs debug when a cached payload is emitted immediately', () => {
|
||||
const debugs: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: () => {},
|
||||
getImmediateSubtitlePayload: (text) => (text ? { text, tokens: [] } : null),
|
||||
broadcastSubtitle: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
logDebug: (message) => debugs.push(message),
|
||||
});
|
||||
|
||||
handler({ text: 'キャッシュ済みの行' });
|
||||
handler({ text: '' });
|
||||
|
||||
assert.equal(debugs.length, 1);
|
||||
assert.match(debugs[0]!, /cached subtitle/);
|
||||
});
|
||||
|
||||
test('subtitle change handler emits cached annotation after forwarding the subtitle change', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
|
||||
@@ -20,11 +20,15 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
logDebug?: (message: string) => void;
|
||||
}) {
|
||||
return ({ text }: { text: string }): void => {
|
||||
deps.setCurrentSubText(text);
|
||||
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
|
||||
if (immediatePayload) {
|
||||
deps.logDebug?.(
|
||||
`[subtitle-processing] emitted cached subtitle immediately (${text.length} chars)`,
|
||||
);
|
||||
deps.onSubtitleChange(text);
|
||||
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
|
||||
} else {
|
||||
|
||||
@@ -47,6 +47,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
logSubtitleProcessingDebug?: (message: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
|
||||
setCurrentSubAssText: (text: string) => void;
|
||||
@@ -123,6 +124,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
: undefined,
|
||||
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
|
||||
onSubtitleChange: (text) => deps.onSubtitleChange(text),
|
||||
logDebug: deps.logSubtitleProcessingDebug
|
||||
? (message) => deps.logSubtitleProcessingDebug?.(message)
|
||||
: undefined,
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
});
|
||||
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
|
||||
|
||||
@@ -54,6 +54,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
logSubtitleProcessingDebug?: (message: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
@@ -155,6 +156,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
broadcastSubtitle: (payload: SubtitleData) =>
|
||||
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
||||
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
||||
logSubtitleProcessingDebug: deps.logSubtitleProcessingDebug
|
||||
? (message: string) => deps.logSubtitleProcessingDebug!(message)
|
||||
: undefined,
|
||||
onSubtitleTrackChange: deps.onSubtitleTrackChange
|
||||
? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
|
||||
: undefined,
|
||||
|
||||
@@ -5,7 +5,13 @@ import type { SubsyncManualPayload } from '../../types';
|
||||
|
||||
const payload: SubsyncManualPayload = {
|
||||
ffsubsyncAvailable: true,
|
||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||
videoReferenceAvailable: true,
|
||||
subtitleTracks: [
|
||||
{ id: 1, label: 'Internal #1 - jpn (active)' },
|
||||
{ id: 2, label: 'External #2 - eng' },
|
||||
],
|
||||
defaultReferenceTrackId: 2,
|
||||
defaultTargetTrackId: 1,
|
||||
};
|
||||
|
||||
test('subsync manual open prefers dedicated modal window on first attempt', async () => {
|
||||
|
||||
@@ -54,7 +54,6 @@ test('latest subtitle prefetch init wins over stale async loads', async () => {
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
@@ -99,7 +98,6 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
@@ -137,7 +135,6 @@ test('subtitle prefetch init publishes parsed cues and clears them on cancel', a
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (cues) => {
|
||||
@@ -181,7 +178,6 @@ test('subtitle prefetch init publishes the provided stable source key instead of
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (_cues, source) => {
|
||||
@@ -222,7 +218,6 @@ test('subtitle prefetch init clears parsed cues when initialization fails', asyn
|
||||
}),
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
isCacheFull: () => false,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
onParsedSubtitleCuesChanged: (cues) => {
|
||||
@@ -234,3 +229,25 @@ test('subtitle prefetch init clears parsed cues when initialization fails', asyn
|
||||
|
||||
assert.deepEqual(cueUpdates, [null]);
|
||||
});
|
||||
|
||||
test('subtitle prefetch init logs a warning when the source parses to zero cues', async () => {
|
||||
const warnings: string[] = [];
|
||||
const controller = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => null,
|
||||
setCurrentService: () => {},
|
||||
loadSubtitleSourceText: async () => 'not really subtitles',
|
||||
parseSubtitleCues: (): SubtitleCue[] => [],
|
||||
createSubtitlePrefetchService: () => {
|
||||
throw new Error('should not create a service without cues');
|
||||
},
|
||||
tokenizeSubtitle: async () => null,
|
||||
preCacheTokenization: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
|
||||
await controller.initSubtitlePrefetch('/tmp/broken.ass', 0);
|
||||
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface SubtitlePrefetchInitControllerDeps {
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
hasCachedTokenization?: (text: string) => boolean;
|
||||
isCacheFull: () => boolean;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
||||
@@ -59,6 +58,9 @@ export function createSubtitlePrefetchInitController(
|
||||
const cues = deps.parseSubtitleCues(content, sourcePath);
|
||||
if (revision !== initRevision || cues.length === 0) {
|
||||
if (revision === initRevision) {
|
||||
deps.logWarn(
|
||||
`[subtitle-prefetch] parsed 0 cues from ${sourcePath}; prefetch disabled for this source`,
|
||||
);
|
||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||
}
|
||||
return;
|
||||
@@ -69,7 +71,6 @@ export function createSubtitlePrefetchInitController(
|
||||
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
|
||||
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
|
||||
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
|
||||
isCacheFull: () => deps.isCacheFull(),
|
||||
});
|
||||
|
||||
if (revision !== initRevision) {
|
||||
|
||||
@@ -130,3 +130,121 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from r
|
||||
assert.equal(resolved, null);
|
||||
assert.equal(extracted, false);
|
||||
});
|
||||
|
||||
test('subtitle prefetch refresh logs a warning when source resolution throws', async () => {
|
||||
const warnings: string[] = [];
|
||||
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => (name === 'path' ? '/media/video.mkv' : null),
|
||||
}),
|
||||
getLastObservedTimePos: () => 0,
|
||||
subtitlePrefetchInitController: {
|
||||
cancelPendingInit: () => {},
|
||||
initSubtitlePrefetch: async () => {},
|
||||
},
|
||||
resolveActiveSubtitleSidebarSource: async () => {
|
||||
throw new Error('ffmpeg ENOENT');
|
||||
},
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /\[subtitle-prefetch\].*ffmpeg ENOENT/);
|
||||
});
|
||||
|
||||
test('subtitle prefetch refresh logs debug when mpv client is not connected', async () => {
|
||||
const debugs: string[] = [];
|
||||
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => null,
|
||||
getLastObservedTimePos: () => 0,
|
||||
subtitlePrefetchInitController: {
|
||||
cancelPendingInit: () => {},
|
||||
initSubtitlePrefetch: async () => {},
|
||||
},
|
||||
resolveActiveSubtitleSidebarSource: async () => null,
|
||||
logDebug: (message) => debugs.push(message),
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
assert.equal(debugs.length, 1);
|
||||
assert.match(debugs[0]!, /\[subtitle-prefetch\].*not connected/);
|
||||
});
|
||||
|
||||
test('subtitle prefetch refresh logs debug when no subtitle source resolves', async () => {
|
||||
const debugs: string[] = [];
|
||||
const cancels: number[] = [];
|
||||
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => (name === 'path' ? '/media/video.mkv' : null),
|
||||
}),
|
||||
getLastObservedTimePos: () => 0,
|
||||
subtitlePrefetchInitController: {
|
||||
cancelPendingInit: () => {
|
||||
cancels.push(1);
|
||||
},
|
||||
initSubtitlePrefetch: async () => {},
|
||||
},
|
||||
resolveActiveSubtitleSidebarSource: async () => null,
|
||||
logDebug: (message) => debugs.push(message),
|
||||
});
|
||||
|
||||
await refresh();
|
||||
|
||||
assert.deepEqual(cancels, [1]);
|
||||
assert.equal(debugs.length, 1);
|
||||
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle source/);
|
||||
});
|
||||
|
||||
test('subtitle source resolver logs debug when internal track extraction is unavailable', async () => {
|
||||
const debugs: string[] = [];
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => 'ffmpeg',
|
||||
extractInternalSubtitleTrack: async () => null,
|
||||
logDebug: (message) => debugs.push(message),
|
||||
});
|
||||
|
||||
const resolved = await resolveSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: {
|
||||
type: 'sub',
|
||||
id: 3,
|
||||
'ff-index': 7,
|
||||
codec: 'hdmv_pgs_subtitle',
|
||||
},
|
||||
trackListRaw: [],
|
||||
sidRaw: 3,
|
||||
videoPath: '/media/video.mkv',
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
assert.equal(debugs.length, 1);
|
||||
assert.match(debugs[0]!, /\[subtitle-prefetch\].*extraction.*hdmv_pgs_subtitle/);
|
||||
});
|
||||
|
||||
test('subtitle source resolver logs debug when no active subtitle track is selected', async () => {
|
||||
const debugs: string[] = [];
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => 'ffmpeg',
|
||||
extractInternalSubtitleTrack: async () => {
|
||||
throw new Error('should not extract without a track');
|
||||
},
|
||||
logDebug: (message) => debugs.push(message),
|
||||
});
|
||||
|
||||
const resolved = await resolveSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: null,
|
||||
trackListRaw: [],
|
||||
sidRaw: null,
|
||||
videoPath: '/media/video.mkv',
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
assert.equal(debugs.length, 1);
|
||||
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle track/);
|
||||
});
|
||||
|
||||
@@ -86,6 +86,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
videoPath: string,
|
||||
track: MpvSubtitleTrackLike,
|
||||
) => Promise<{ path: string; cleanup: () => Promise<void> } | null>;
|
||||
logDebug?: (message: string) => void;
|
||||
}) {
|
||||
return async (input: {
|
||||
currentExternalFilenameRaw: unknown;
|
||||
@@ -104,6 +105,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
|
||||
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
|
||||
if (!track) {
|
||||
deps.logDebug?.('[subtitle-prefetch] no active subtitle track selected yet');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -114,6 +116,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
}
|
||||
|
||||
if (isRemoteMediaPath(input.videoPath)) {
|
||||
deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -123,6 +126,9 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
track,
|
||||
);
|
||||
if (!extracted) {
|
||||
deps.logDebug?.(
|
||||
`[subtitle-prefetch] internal subtitle extraction unavailable (codec=${String(track.codec ?? 'unknown')}, ff-index=${String(track['ff-index'] ?? 'unknown')})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -144,10 +150,13 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
resolveActiveSubtitleSidebarSource: (
|
||||
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
||||
) => Promise<ActiveSubtitleSidebarSource | null>;
|
||||
logDebug?: (message: string) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
deps.logDebug?.('[subtitle-prefetch] skipped refresh: mpv client not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,6 +171,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
]);
|
||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||
if (!videoPath) {
|
||||
deps.logDebug?.('[subtitle-prefetch] skipped refresh: no media path');
|
||||
deps.subtitlePrefetchInitController.cancelPendingInit();
|
||||
return;
|
||||
}
|
||||
@@ -175,8 +185,14 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
|
||||
deps.logDebug?.(
|
||||
'[subtitle-prefetch] no active subtitle source resolved; keeping existing cues',
|
||||
);
|
||||
return;
|
||||
}
|
||||
deps.logDebug?.(
|
||||
'[subtitle-prefetch] no active subtitle source resolved; cancelling prefetch',
|
||||
);
|
||||
deps.subtitlePrefetchInitController.cancelPendingInit();
|
||||
return;
|
||||
}
|
||||
@@ -190,8 +206,12 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
} finally {
|
||||
await resolvedSource.cleanup?.();
|
||||
}
|
||||
} catch {
|
||||
// Skip refresh when the track query fails.
|
||||
} catch (error) {
|
||||
deps.logWarn?.(
|
||||
`[subtitle-prefetch] failed to refresh from active track: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openChangelogModal: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
@@ -120,6 +121,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
||||
initializeOverlayRuntime: () => calls.push('init'),
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openChangelogModal: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => false,
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
platform?: string;
|
||||
openSessionHelp: () => void;
|
||||
openChangelog: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
openFirstRunSetup: () => void;
|
||||
@@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
openSessionHelpModal: () => void;
|
||||
openChangelogModal: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: () => boolean;
|
||||
showFirstRunSetup: () => boolean;
|
||||
@@ -87,6 +89,12 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
}
|
||||
deps.openSessionHelpModal();
|
||||
},
|
||||
openChangelog: () => {
|
||||
if (!deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
deps.openChangelogModal();
|
||||
},
|
||||
openTexthookerInBrowser: () => {
|
||||
deps.openTexthookerInBrowser();
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
initializeOverlayRuntime: () => calls.push('init'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openChangelogModal: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
@@ -50,6 +51,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
||||
platform: menuDeps.platform,
|
||||
openSessionHelp: () => calls.push('open-help'),
|
||||
openChangelog: () => calls.push('open-changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('open-texthooker'),
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => calls.push('open-setup'),
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
platform?: string;
|
||||
openSessionHelp: () => void;
|
||||
openChangelog: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
openFirstRunSetup: () => void;
|
||||
@@ -50,6 +51,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
openSessionHelpModal: () => void;
|
||||
openChangelogModal: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: () => boolean;
|
||||
showFirstRunSetup: () => boolean;
|
||||
@@ -74,6 +76,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
||||
openSessionHelpModal: deps.openSessionHelpModal,
|
||||
openChangelogModal: deps.openChangelogModal,
|
||||
openTexthookerInBrowser: deps.openTexthookerInBrowser,
|
||||
showTexthookerPage: deps.showTexthookerPage,
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
|
||||
@@ -25,6 +25,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => overlayInitialized,
|
||||
openSessionHelpModal: () => {},
|
||||
openChangelogModal: () => {},
|
||||
openTexthookerInBrowser: () => {},
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
|
||||
@@ -30,6 +30,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => calls.push('help'),
|
||||
openChangelog: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
@@ -49,36 +50,53 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.equal(template.length, 14);
|
||||
assert.equal(
|
||||
template.some((entry) => entry.label === 'Open Runtime Options'),
|
||||
false,
|
||||
// Resolve by label, not index: adding a menu entry should not force every
|
||||
// later assertion in this test to be renumbered.
|
||||
const entryFor = (label: string) => {
|
||||
const entry = template.find((candidate) => candidate.label === label);
|
||||
assert.ok(entry, `expected a "${label}" tray entry`);
|
||||
return entry;
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
template.map((entry) => entry.label ?? `<${entry.type}>`),
|
||||
[
|
||||
'Open Help',
|
||||
'View Changelog',
|
||||
'Open Texthooker',
|
||||
'Complete Setup',
|
||||
'Open SubMiner Setup',
|
||||
'Open Yomitan Settings',
|
||||
'Open SubMiner Settings',
|
||||
'Sync Stats && History',
|
||||
'Export Logs',
|
||||
'Configure Jellyfin',
|
||||
'Jellyfin Discovery',
|
||||
'Configure AniList',
|
||||
'Check for Updates',
|
||||
'<separator>',
|
||||
'Quit',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
template.some((entry) => entry.label === 'Open Overlay'),
|
||||
false,
|
||||
);
|
||||
assert.equal(template[0]!.label, 'Open Help');
|
||||
assert.equal(template[3]!.label, 'Open SubMiner Setup');
|
||||
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
|
||||
assert.equal(discovery?.type, 'checkbox');
|
||||
assert.equal(discovery?.checked, false);
|
||||
discovery?.click?.({ checked: true });
|
||||
template[0]!.click?.();
|
||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||
template[1]!.click?.();
|
||||
assert.equal(template[5]!.label, 'Open SubMiner Settings');
|
||||
assert.equal(template[6]!.label, 'Sync Stats && History');
|
||||
template[6]!.click?.();
|
||||
assert.equal(template[7]!.label, 'Export Logs');
|
||||
template[7]!.click?.();
|
||||
assert.equal(template[11]!.label, 'Check for Updates');
|
||||
template[11]!.click?.();
|
||||
template[12]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[13]!.click?.();
|
||||
|
||||
const discovery = entryFor('Jellyfin Discovery');
|
||||
assert.equal(discovery.type, 'checkbox');
|
||||
assert.equal(discovery.checked, false);
|
||||
discovery.click?.({ checked: true });
|
||||
|
||||
entryFor('Open Help').click?.();
|
||||
entryFor('View Changelog').click?.();
|
||||
entryFor('Open Texthooker').click?.();
|
||||
entryFor('Sync Stats && History').click?.();
|
||||
entryFor('Export Logs').click?.();
|
||||
entryFor('Check for Updates').click?.();
|
||||
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
||||
entryFor('Quit').click?.();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery:true',
|
||||
'help',
|
||||
'changelog',
|
||||
'texthooker',
|
||||
'sync-ui',
|
||||
'export-logs',
|
||||
@@ -91,6 +109,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
test('tray menu template omits first-run setup entry when setup is complete', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => undefined,
|
||||
openChangelog: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => undefined,
|
||||
@@ -120,6 +139,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
test('tray menu template omits texthooker entry when texthooker page is disabled', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => undefined,
|
||||
openChangelog: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: false,
|
||||
openFirstRunSetup: () => undefined,
|
||||
@@ -147,6 +167,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
||||
test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => undefined,
|
||||
openChangelog: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => undefined,
|
||||
@@ -175,6 +196,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
platform: 'linux',
|
||||
openSessionHelp: () => undefined,
|
||||
openChangelog: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => undefined,
|
||||
|
||||
@@ -32,6 +32,7 @@ export function resolveTrayIconPathRuntime(deps: {
|
||||
export type TrayMenuActionHandlers = {
|
||||
platform?: string;
|
||||
openSessionHelp: () => void;
|
||||
openChangelog: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
openFirstRunSetup: () => void;
|
||||
@@ -72,6 +73,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
label: 'Open Help',
|
||||
click: handlers.openSessionHelp,
|
||||
},
|
||||
{
|
||||
label: 'View Changelog',
|
||||
click: handlers.openChangelog,
|
||||
},
|
||||
...(handlers.showTexthookerPage
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -51,6 +51,13 @@ test('compareSemverLike orders prerelease identifiers within the same base versi
|
||||
assert.equal(compareSemverLike('0.15.0', '0.15.0-rc.1') > 0, true);
|
||||
});
|
||||
|
||||
test('compareSemverLike ignores build metadata, which carries no precedence', () => {
|
||||
assert.equal(compareSemverLike('0.15.0+build.2', '0.15.0+build.1'), 0);
|
||||
assert.equal(compareSemverLike('0.15.0-rc.1+build.2', '0.15.0-rc.1+build.1'), 0);
|
||||
assert.equal(compareSemverLike('0.15.1+build.1', '0.15.0+build.9') > 0, true);
|
||||
assert.equal(compareSemverLike('0.15.0+build.1', '0.15.0-rc.1') > 0, true);
|
||||
});
|
||||
|
||||
test('findReleaseAsset finds exact asset names only', () => {
|
||||
const release = {
|
||||
tag_name: 'v0.14.1',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
import { compareSemverLike } from '../../../core/utils/semver-compare';
|
||||
|
||||
export { compareSemverLike };
|
||||
|
||||
export interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
@@ -130,57 +133,3 @@ export function parseReleaseVersion(
|
||||
if (!release) return null;
|
||||
return release.tag_name.replace(/^v/i, '');
|
||||
}
|
||||
|
||||
export function compareSemverLike(a: string, b: string): number {
|
||||
const parse = (
|
||||
value: string,
|
||||
): {
|
||||
core: number[];
|
||||
prerelease: Array<number | string>;
|
||||
} => {
|
||||
const normalized = value.replace(/^v/i, '');
|
||||
const [coreText = '', ...prereleaseParts] = normalized.split('-');
|
||||
const core = coreText
|
||||
.split('.')
|
||||
.slice(0, 3)
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
while (core.length < 3) core.push(0);
|
||||
const prereleaseText = prereleaseParts.join('-');
|
||||
return {
|
||||
core,
|
||||
prerelease: prereleaseText
|
||||
? prereleaseText.split('.').map((part) => {
|
||||
const numeric = Number.parseInt(part, 10);
|
||||
return /^\d+$/.test(part) ? numeric : part;
|
||||
})
|
||||
: [],
|
||||
};
|
||||
};
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
|
||||
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
||||
if (left.prerelease.length === 0) return 1;
|
||||
if (right.prerelease.length === 0) return -1;
|
||||
|
||||
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const leftPart = left.prerelease[i];
|
||||
const rightPart = right.prerelease[i];
|
||||
if (leftPart === undefined && rightPart === undefined) return 0;
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||
return leftPart - rightPart;
|
||||
}
|
||||
if (typeof leftPart === 'number') return -1;
|
||||
if (typeof rightPart === 'number') return 1;
|
||||
return leftPart > rightPart ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ test('notifyUpdateAvailable routes notification surfaces from config', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifyUpdateAvailable adds an install action to overlay update notifications', async () => {
|
||||
test('notifyUpdateAvailable adds install and changelog actions to overlay update notifications', async () => {
|
||||
const payloads: OverlayNotificationPayload[] = [];
|
||||
|
||||
await notifyUpdateAvailable(
|
||||
@@ -53,7 +53,10 @@ test('notifyUpdateAvailable adds an install action to overlay update notificatio
|
||||
|
||||
const payload = payloads[0];
|
||||
assert.ok(payload);
|
||||
assert.deepEqual(payload.actions, [{ id: 'install-update', label: 'Update' }]);
|
||||
assert.deepEqual(payload.actions, [
|
||||
{ id: 'install-update', label: 'Update' },
|
||||
{ id: 'view-changelog', label: "What's New", keepOpen: true },
|
||||
]);
|
||||
assert.equal(payload.id, 'subminer-update-available');
|
||||
assert.equal(payload.persistent, true);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OverlayNotificationPayload } from '../../../types/notification';
|
||||
|
||||
export const UPDATE_AVAILABLE_NOTIFICATION_ID = 'subminer-update-available';
|
||||
export const INSTALL_UPDATE_ACTION_ID = 'install-update';
|
||||
export const VIEW_CHANGELOG_ACTION_ID = 'view-changelog';
|
||||
|
||||
export interface UpdateNotificationDeps {
|
||||
showSystemNotification: (title: string, body: string) => void;
|
||||
@@ -25,7 +26,10 @@ export async function notifyUpdateAvailable(
|
||||
body: message,
|
||||
variant: 'info',
|
||||
persistent: true,
|
||||
actions: [{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' }],
|
||||
actions: [
|
||||
{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' },
|
||||
{ id: VIEW_CHANGELOG_ACTION_ID, label: "What's New", keepOpen: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (options.notificationType === 'osd' || options.notificationType === 'osd-system') {
|
||||
|
||||
@@ -68,6 +68,7 @@ import type {
|
||||
YoutubePickerResolveResult,
|
||||
OverlayNotificationEventPayload,
|
||||
OverlayNotificationPosition,
|
||||
ChangelogSnapshot,
|
||||
} from './types';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
|
||||
@@ -166,6 +167,7 @@ function createLatestValueIpcListenerWithPayload<T>(
|
||||
|
||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
||||
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
||||
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
||||
);
|
||||
@@ -447,6 +449,9 @@ const electronAPI: ElectronAPI = {
|
||||
},
|
||||
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
||||
onOpenSessionHelp: onOpenSessionHelpEvent,
|
||||
onOpenChangelog: onOpenChangelogEvent,
|
||||
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
|
||||
onOpenControllerSelect: onOpenControllerSelectEvent,
|
||||
onOpenControllerDebug: onOpenControllerDebugEvent,
|
||||
onOpenJimaku: onOpenJimakuEvent,
|
||||
|
||||
@@ -456,6 +456,7 @@ function createKeyboardHandlerHarness() {
|
||||
let openControllerSelectCount = 0;
|
||||
let openControllerDebugCount = 0;
|
||||
let playlistBrowserKeydownCount = 0;
|
||||
let changelogKeydownCount = 0;
|
||||
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
@@ -504,6 +505,10 @@ function createKeyboardHandlerHarness() {
|
||||
return true;
|
||||
},
|
||||
handleSessionHelpKeydown: () => false,
|
||||
handleChangelogKeydown: () => {
|
||||
changelogKeydownCount += 1;
|
||||
return true;
|
||||
},
|
||||
openSessionHelpModal: () => {},
|
||||
openControllerSelectModal: () => {
|
||||
openControllerSelectCount += 1;
|
||||
@@ -522,6 +527,7 @@ function createKeyboardHandlerHarness() {
|
||||
openControllerSelectCount: () => openControllerSelectCount,
|
||||
openControllerDebugCount: () => openControllerDebugCount,
|
||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||
changelogKeydownCount: () => changelogKeydownCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
@@ -1404,6 +1410,50 @@ test('keyboard mode: playlist browser modal handles h before lookup controls', a
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: changelog modal handles h/l fold keys before lookup controls', async () => {
|
||||
const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.changelogModalOpen = true;
|
||||
ctx.state.keyboardSelectedWordIndex = 2;
|
||||
|
||||
// H and L fold/unfold changelog entries; they must not move the subtitle
|
||||
// word selection or seek mpv behind the open modal.
|
||||
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
|
||||
testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
|
||||
|
||||
assert.equal(changelogKeydownCount(), 2);
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: changelog modal handles arrow keys before yomitan popup', async () => {
|
||||
const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.changelogModalOpen = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||
|
||||
assert.equal(changelogKeydownCount(), 1);
|
||||
assert.equal(
|
||||
testGlobals.commandEvents.some(
|
||||
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export function createKeyboardHandlers(
|
||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleChangelogKeydown: (e: KeyboardEvent) => boolean;
|
||||
openSessionHelpModal: (opening: {
|
||||
bindingKey: 'KeyH' | 'KeyK';
|
||||
fallbackUsed: boolean;
|
||||
@@ -1095,6 +1096,14 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Ahead of the keyboard-driven lookup controls: the changelog modal binds
|
||||
// arrows/H/L for folding, and those would otherwise move the subtitle word
|
||||
// selection (and seek mpv) behind the open modal.
|
||||
if (ctx.state.changelogModalOpen) {
|
||||
options.handleChangelogKeydown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleKeyboardDrivenModeLookupControls(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
|
||||
+27
-3
@@ -359,9 +359,13 @@
|
||||
ffsubsync
|
||||
</label>
|
||||
</div>
|
||||
<label id="subsyncSourceLabel" class="subsync-field">
|
||||
<span>Source Subtitle (for alass)</span>
|
||||
<select id="subsyncSourceSelect"></select>
|
||||
<label id="subsyncReferenceLabel" class="subsync-field">
|
||||
<span>Reference (correct timing, for alass)</span>
|
||||
<select id="subsyncReferenceSelect"></select>
|
||||
</label>
|
||||
<label id="subsyncTargetLabel" class="subsync-field">
|
||||
<span>Out-of-sync Subtitle (gets retimed)</span>
|
||||
<select id="subsyncTargetSelect"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="subsyncStatus" class="runtime-options-status"></div>
|
||||
@@ -463,6 +467,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="changelogModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content changelog-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Changelog</div>
|
||||
<div class="changelog-header-actions">
|
||||
<button id="changelogRefresh" class="changelog-refresh" type="button">Refresh</button>
|
||||
<button id="changelogClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="changelog-meta">
|
||||
<span id="changelogInstalled" class="changelog-installed"></span>
|
||||
<span id="changelogSource" class="changelog-source"></span>
|
||||
</div>
|
||||
<div id="changelogWarning" class="changelog-warning"></div>
|
||||
<div id="changelogStatus" class="changelog-status"></div>
|
||||
<div id="changelogList" class="changelog-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content playlist-browser-content">
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ChangelogEntry, ChangelogSnapshot } from '../../types/changelog';
|
||||
import {
|
||||
createChangelogEntryNode,
|
||||
describeChangelogSource,
|
||||
resolveEntryBadge,
|
||||
shouldEntryStartExpanded,
|
||||
tokenizeInlineMarkdown,
|
||||
} from './changelog-render';
|
||||
|
||||
type FakeNode = {
|
||||
tagName: string;
|
||||
className: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
tabIndex: number;
|
||||
dataset: Record<string, string>;
|
||||
children: FakeNode[];
|
||||
textContent: string;
|
||||
};
|
||||
|
||||
function createFakeNode(tagName: string): FakeNode {
|
||||
const node: FakeNode = {
|
||||
tagName: tagName.toLowerCase(),
|
||||
className: '',
|
||||
title: '',
|
||||
open: false,
|
||||
tabIndex: 0,
|
||||
dataset: {},
|
||||
children: [],
|
||||
textContent: '',
|
||||
};
|
||||
return Object.assign(node, {
|
||||
appendChild: (child: FakeNode) => {
|
||||
node.children.push(child);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function withFakeDocument<T>(run: () => T): T {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
createElement: (tagName: string) => createFakeNode(tagName),
|
||||
createTextNode: (value: string) => ({
|
||||
tagName: '#text',
|
||||
textContent: value,
|
||||
children: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (previous) {
|
||||
Object.defineProperty(globalThis, 'document', previous);
|
||||
} else {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(node: FakeNode): FakeNode[] {
|
||||
return [node, ...(node.children ?? []).flatMap((child) => flatten(child as FakeNode))];
|
||||
}
|
||||
|
||||
const ENTRY: ChangelogEntry = {
|
||||
version: '0.19.2',
|
||||
date: '2026-08-04',
|
||||
groupKey: '0.19',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Fixed',
|
||||
items: [
|
||||
{
|
||||
text: '**Overlay:**',
|
||||
children: [
|
||||
{ text: 'Fixed `something`.', children: [] },
|
||||
{ text: 'Fixed another thing.', children: [] },
|
||||
],
|
||||
},
|
||||
{ text: 'Standalone fix.', children: [] },
|
||||
],
|
||||
internal: false,
|
||||
},
|
||||
{
|
||||
heading: 'Internal',
|
||||
items: [{ text: 'Patched deps.', children: [] }],
|
||||
internal: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('inline markdown tokenizer splits code, bold, and links', () => {
|
||||
assert.deepEqual(tokenizeInlineMarkdown('Patched `undici` and **brace**.'), [
|
||||
{ kind: 'text', value: 'Patched ' },
|
||||
{ kind: 'code', value: 'undici' },
|
||||
{ kind: 'text', value: ' and ' },
|
||||
{ kind: 'strong', value: 'brace' },
|
||||
{ kind: 'text', value: '.' },
|
||||
]);
|
||||
assert.deepEqual(tokenizeInlineMarkdown('See [docs](https://example.com).'), [
|
||||
{ kind: 'text', value: 'See ' },
|
||||
{ kind: 'link', value: 'docs', href: 'https://example.com' },
|
||||
{ kind: 'text', value: '.' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('inline markdown tokenizer leaves plain text untouched', () => {
|
||||
assert.deepEqual(tokenizeInlineMarkdown('No markup here'), [
|
||||
{ kind: 'text', value: 'No markup here' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('entry badges mark the installed version and newer releases', () => {
|
||||
assert.equal(resolveEntryBadge('0.19.2', '0.19.2'), 'installed');
|
||||
assert.equal(resolveEntryBadge('0.20.0', '0.19.2'), 'newer');
|
||||
assert.equal(resolveEntryBadge('0.19.1', '0.19.2'), null);
|
||||
});
|
||||
|
||||
test('only entries in the newest major.minor line start expanded', () => {
|
||||
const snapshot = { expandedGroupKey: '0.19' } as Pick<ChangelogSnapshot, 'expandedGroupKey'>;
|
||||
|
||||
assert.equal(shouldEntryStartExpanded(ENTRY, snapshot), true);
|
||||
assert.equal(shouldEntryStartExpanded({ ...ENTRY, groupKey: '0.18' }, snapshot), false);
|
||||
assert.equal(shouldEntryStartExpanded(ENTRY, { expandedGroupKey: null }), false);
|
||||
});
|
||||
|
||||
test('entry node renders a collapsible section with badge and internal block', () => {
|
||||
const node = withFakeDocument(() =>
|
||||
createChangelogEntryNode(ENTRY, { expanded: true, badge: 'installed', index: 0 }),
|
||||
) as unknown as FakeNode;
|
||||
|
||||
assert.equal(node.tagName, 'details');
|
||||
assert.equal(node.open, true);
|
||||
assert.equal(node.dataset.changelogVersion, '0.19.2');
|
||||
|
||||
const nodes = flatten(node);
|
||||
const summary = nodes.find((child) => child.className === 'changelog-entry-summary');
|
||||
assert.ok(summary);
|
||||
assert.equal(summary?.dataset.changelogIndex, '0');
|
||||
assert.equal(
|
||||
nodes.find((child) => child.className === 'changelog-entry-version')?.textContent,
|
||||
'v0.19.2',
|
||||
);
|
||||
assert.equal(
|
||||
nodes.find((child) => child.className?.includes('changelog-entry-badge-installed'))
|
||||
?.textContent,
|
||||
'Installed',
|
||||
);
|
||||
assert.equal(
|
||||
nodes.filter((child) => child.className === 'changelog-internal').length,
|
||||
1,
|
||||
'internal sections stay behind their own fold',
|
||||
);
|
||||
});
|
||||
|
||||
test('entry node renders sub-bullets as a nested list under their lead bullet', () => {
|
||||
const node = withFakeDocument(() =>
|
||||
createChangelogEntryNode(ENTRY, { expanded: true, badge: null, index: 0 }),
|
||||
) as unknown as FakeNode;
|
||||
|
||||
const lists = flatten(node).filter((child) => child.className?.startsWith('changelog-items'));
|
||||
const topLevel = lists.find((list) => list.className === 'changelog-items');
|
||||
const nested = lists.filter((list) => list.className?.includes('changelog-items-nested'));
|
||||
|
||||
assert.ok(topLevel);
|
||||
assert.equal(topLevel?.children.length, 2, 'lead bullet and standalone fix stay siblings');
|
||||
assert.equal(nested.length, 1, 'children render in exactly one nested list');
|
||||
assert.equal(nested[0]?.children.length, 2);
|
||||
|
||||
// The nested list hangs off its parent <li>, not off the section.
|
||||
const leadItem = topLevel?.children[0];
|
||||
assert.equal(
|
||||
leadItem?.children.some((child) => child.className?.includes('changelog-items-nested')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('entry node renders collapsed when it is outside the current line', () => {
|
||||
const node = withFakeDocument(() =>
|
||||
createChangelogEntryNode(ENTRY, { expanded: false, badge: null, index: 3 }),
|
||||
) as unknown as FakeNode;
|
||||
|
||||
assert.equal(node.open, false);
|
||||
assert.equal(
|
||||
flatten(node).some((child) => child.className?.startsWith('changelog-entry-badge')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('source description names the release the changelog came from', () => {
|
||||
const base: ChangelogSnapshot = {
|
||||
entries: [],
|
||||
installedVersion: '0.19.2',
|
||||
latestVersion: null,
|
||||
expandedGroupKey: null,
|
||||
source: 'remote',
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
describeChangelogSource({ ...base, releaseTag: 'v0.20.0' }),
|
||||
'Latest release v0.20.0',
|
||||
);
|
||||
assert.equal(describeChangelogSource(base), 'Latest published changelog');
|
||||
assert.equal(describeChangelogSource({ ...base, source: 'bundled' }), 'Bundled changelog');
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import type {
|
||||
ChangelogEntry,
|
||||
ChangelogItem,
|
||||
ChangelogSection,
|
||||
ChangelogSnapshot,
|
||||
} from '../../types/changelog';
|
||||
import { compareSemverLike } from '../../core/utils/semver-compare';
|
||||
|
||||
const SECTION_ICON: Record<string, string> = {
|
||||
Added: '✦',
|
||||
Changed: '⟲',
|
||||
Fixed: '✔',
|
||||
Docs: '▤',
|
||||
Internal: '⚙',
|
||||
'Breaking Changes': '⚠',
|
||||
Changes: '•',
|
||||
};
|
||||
|
||||
export type ChangelogEntryBadge = 'installed' | 'newer' | null;
|
||||
|
||||
export function resolveEntryBadge(
|
||||
entryVersion: string,
|
||||
installedVersion: string,
|
||||
): ChangelogEntryBadge {
|
||||
const comparison = compareSemverLike(entryVersion, installedVersion);
|
||||
if (comparison === 0) return 'installed';
|
||||
if (comparison > 0) return 'newer';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version entries in the newest major.minor line start expanded, mirroring the
|
||||
* docs-site changelog where older lines sit behind "Previous Versions".
|
||||
*/
|
||||
export function shouldEntryStartExpanded(
|
||||
entry: ChangelogEntry,
|
||||
snapshot: Pick<ChangelogSnapshot, 'expandedGroupKey'>,
|
||||
): boolean {
|
||||
if (!snapshot.expandedGroupKey) return false;
|
||||
return entry.groupKey === snapshot.expandedGroupKey;
|
||||
}
|
||||
|
||||
type InlineToken =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'code'; value: string }
|
||||
| { kind: 'strong'; value: string }
|
||||
| { kind: 'link'; value: string; href: string };
|
||||
|
||||
/**
|
||||
* Minimal inline-markdown tokenizer for changelog bullets: backtick code,
|
||||
* bold, and links. Anything else stays literal text.
|
||||
*/
|
||||
export function tokenizeInlineMarkdown(text: string): InlineToken[] {
|
||||
const tokens: InlineToken[] = [];
|
||||
const pattern = /`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push({ kind: 'text', value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
if (match[1] !== undefined) {
|
||||
tokens.push({ kind: 'code', value: match[1] });
|
||||
} else if (match[2] !== undefined) {
|
||||
tokens.push({ kind: 'strong', value: match[2] });
|
||||
} else if (match[3] !== undefined && match[4] !== undefined) {
|
||||
tokens.push({ kind: 'link', value: match[3], href: match[4] });
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push({ kind: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function appendInlineMarkdown(target: HTMLElement, text: string): void {
|
||||
for (const token of tokenizeInlineMarkdown(text)) {
|
||||
if (token.kind === 'text') {
|
||||
target.appendChild(document.createTextNode(token.value));
|
||||
continue;
|
||||
}
|
||||
if (token.kind === 'code') {
|
||||
const code = document.createElement('code');
|
||||
code.className = 'changelog-code';
|
||||
code.textContent = token.value;
|
||||
target.appendChild(code);
|
||||
continue;
|
||||
}
|
||||
if (token.kind === 'strong') {
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = token.value;
|
||||
target.appendChild(strong);
|
||||
continue;
|
||||
}
|
||||
// Links stay inert: the overlay has nowhere to navigate to.
|
||||
const link = document.createElement('span');
|
||||
link.className = 'changelog-link';
|
||||
link.textContent = token.value;
|
||||
link.title = token.href;
|
||||
target.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
function createItemList(items: ChangelogItem[], depth: number): HTMLUListElement {
|
||||
const list = document.createElement('ul');
|
||||
list.className = depth === 0 ? 'changelog-items' : 'changelog-items changelog-items-nested';
|
||||
|
||||
for (const item of items) {
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = 'changelog-item';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'changelog-item-text';
|
||||
appendInlineMarkdown(text, item.text);
|
||||
listItem.appendChild(text);
|
||||
|
||||
if (item.children.length > 0) {
|
||||
listItem.appendChild(createItemList(item.children, depth + 1));
|
||||
}
|
||||
list.appendChild(listItem);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function createSectionNode(section: ChangelogSection): HTMLElement {
|
||||
const node = document.createElement('section');
|
||||
node.className = 'changelog-section';
|
||||
|
||||
const title = document.createElement('h4');
|
||||
title.className = 'changelog-section-title';
|
||||
title.textContent = `${SECTION_ICON[section.heading] ?? '•'} ${section.heading}`;
|
||||
node.appendChild(title);
|
||||
|
||||
node.appendChild(createItemList(section.items, 0));
|
||||
return node;
|
||||
}
|
||||
|
||||
function createInternalNode(sections: ChangelogSection[]): HTMLElement {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'changelog-internal';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.className = 'changelog-internal-summary';
|
||||
summary.textContent = 'Internal changes';
|
||||
details.appendChild(summary);
|
||||
|
||||
for (const section of sections) {
|
||||
details.appendChild(createSectionNode(section));
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
export function createChangelogEntryNode(
|
||||
entry: ChangelogEntry,
|
||||
options: { expanded: boolean; badge: ChangelogEntryBadge; index: number },
|
||||
): HTMLDetailsElement {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'changelog-entry';
|
||||
details.open = options.expanded;
|
||||
details.dataset.changelogVersion = entry.version;
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.className = 'changelog-entry-summary';
|
||||
summary.dataset.changelogIndex = String(options.index);
|
||||
summary.tabIndex = -1;
|
||||
|
||||
const version = document.createElement('span');
|
||||
version.className = 'changelog-entry-version';
|
||||
version.textContent = `v${entry.version}`;
|
||||
summary.appendChild(version);
|
||||
|
||||
if (entry.date) {
|
||||
const date = document.createElement('span');
|
||||
date.className = 'changelog-entry-date';
|
||||
date.textContent = entry.date;
|
||||
summary.appendChild(date);
|
||||
}
|
||||
|
||||
if (options.badge) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `changelog-entry-badge changelog-entry-badge-${options.badge}`;
|
||||
badge.textContent = options.badge === 'installed' ? 'Installed' : 'New';
|
||||
summary.appendChild(badge);
|
||||
}
|
||||
|
||||
details.appendChild(summary);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'changelog-entry-body';
|
||||
const publicSections = entry.sections.filter((section) => !section.internal);
|
||||
const internalSections = entry.sections.filter((section) => section.internal);
|
||||
|
||||
for (const section of publicSections) {
|
||||
body.appendChild(createSectionNode(section));
|
||||
}
|
||||
if (internalSections.length > 0) {
|
||||
body.appendChild(createInternalNode(internalSections));
|
||||
}
|
||||
if (publicSections.length === 0 && internalSections.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'changelog-empty-entry';
|
||||
empty.textContent = 'No release notes recorded for this version.';
|
||||
body.appendChild(empty);
|
||||
}
|
||||
|
||||
details.appendChild(body);
|
||||
return details;
|
||||
}
|
||||
|
||||
export function describeChangelogSource(snapshot: ChangelogSnapshot): string {
|
||||
if (snapshot.source === 'remote') {
|
||||
return snapshot.releaseTag
|
||||
? `Latest release ${snapshot.releaseTag}`
|
||||
: 'Latest published changelog';
|
||||
}
|
||||
return 'Bundled changelog';
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ChangelogSnapshot } from '../../types/changelog';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createChangelogModal } from './changelog.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
toggle: (entry: string, force?: boolean) => {
|
||||
if (force === true) tokens.add(entry);
|
||||
else if (force === false) tokens.delete(entry);
|
||||
else if (tokens.has(entry)) tokens.delete(entry);
|
||||
else tokens.add(entry);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type SummaryStub = {
|
||||
classList: ReturnType<typeof createClassList>;
|
||||
tabIndex: number;
|
||||
dataset: Record<string, string>;
|
||||
getClientRects: () => Array<{ width: number; height: number }>;
|
||||
focusCount: number;
|
||||
scrollCount: number;
|
||||
focus: () => void;
|
||||
scrollIntoView: () => void;
|
||||
};
|
||||
|
||||
function createSummaryStub(index: number): SummaryStub {
|
||||
const summary: SummaryStub = {
|
||||
classList: createClassList(),
|
||||
tabIndex: -1,
|
||||
dataset: { changelogIndex: String(index) },
|
||||
getClientRects: () => [{ width: 10, height: 10 }],
|
||||
focusCount: 0,
|
||||
scrollCount: 0,
|
||||
focus: () => {
|
||||
summary.focusCount += 1;
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
summary.scrollCount += 1;
|
||||
},
|
||||
};
|
||||
return summary;
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
const listeners = new Map<string, Array<(event?: unknown) => void>>();
|
||||
return {
|
||||
value: '',
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
classList: createClassList(['hidden']),
|
||||
contains: () => false,
|
||||
setAttribute: () => {},
|
||||
addEventListener: (type: string, listener: (event?: unknown) => void) => {
|
||||
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
appendChild: () => {},
|
||||
summaries: [] as SummaryStub[],
|
||||
querySelectorAll(this: { summaries: SummaryStub[] }) {
|
||||
return this.summaries;
|
||||
},
|
||||
focus: () => {},
|
||||
dispatchEventType: (type: string, event?: unknown) => {
|
||||
for (const listener of listeners.get(type) ?? []) listener(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SNAPSHOT: ChangelogSnapshot = {
|
||||
entries: [],
|
||||
installedVersion: '0.19.2',
|
||||
latestVersion: '0.19.2',
|
||||
expandedGroupKey: '0.19',
|
||||
source: 'remote',
|
||||
releaseTag: 'v0.19.2',
|
||||
};
|
||||
|
||||
type Harness = {
|
||||
modal: ReturnType<typeof createChangelogModal>;
|
||||
dom: Record<string, ReturnType<typeof createElementStub>>;
|
||||
snapshotRequests: Array<{ refresh?: boolean } | undefined>;
|
||||
modalClosedNotifications: string[];
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
function createHarness(
|
||||
options: {
|
||||
getChangelogSnapshot?: (request?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
} = {},
|
||||
): Harness {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
|
||||
const previousElement = Object.getOwnPropertyDescriptor(globalThis, 'Element');
|
||||
const previousDetails = Object.getOwnPropertyDescriptor(globalThis, 'HTMLDetailsElement');
|
||||
|
||||
const snapshotRequests: Array<{ refresh?: boolean } | undefined> = [];
|
||||
const modalClosedNotifications: string[] = [];
|
||||
|
||||
class TestElement {}
|
||||
for (const name of ['HTMLElement', 'Element'] as const) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestElement,
|
||||
});
|
||||
}
|
||||
// getSelectedEntry() narrows with `instanceof HTMLDetailsElement`.
|
||||
class TestDetailsElement {
|
||||
open = false;
|
||||
}
|
||||
Object.defineProperty(globalThis, 'HTMLDetailsElement', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestDetailsElement,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
focusMainWindow: async () => {},
|
||||
setIgnoreMouseEvents: () => {},
|
||||
notifyOverlayModalClosed: (modal: string) => {
|
||||
modalClosedNotifications.push(modal);
|
||||
},
|
||||
getChangelogSnapshot: async (request?: { refresh?: boolean }) => {
|
||||
snapshotRequests.push(request);
|
||||
return options.getChangelogSnapshot
|
||||
? await options.getChangelogSnapshot(request)
|
||||
: SNAPSHOT;
|
||||
},
|
||||
},
|
||||
focus: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const dom = {
|
||||
overlay: createElementStub(),
|
||||
changelogModal: createElementStub(),
|
||||
changelogClose: createElementStub(),
|
||||
changelogRefresh: createElementStub(),
|
||||
changelogInstalled: createElementStub(),
|
||||
changelogSource: createElementStub(),
|
||||
changelogWarning: createElementStub(),
|
||||
changelogStatus: createElementStub(),
|
||||
changelogList: createElementStub(),
|
||||
};
|
||||
|
||||
const modal = createChangelogModal(
|
||||
{
|
||||
state: createRendererState(),
|
||||
platform: {
|
||||
overlayLayer: 'modal',
|
||||
isModalLayer: true,
|
||||
isLinuxPlatform: false,
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: true,
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
dom,
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
modal,
|
||||
dom,
|
||||
snapshotRequests,
|
||||
modalClosedNotifications,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of [
|
||||
['window', previousWindow],
|
||||
['document', previousDocument],
|
||||
['HTMLElement', previousHTMLElement],
|
||||
['Element', previousElement],
|
||||
['HTMLDetailsElement', previousDetails],
|
||||
] as const) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
} else {
|
||||
delete (globalThis as Record<string, unknown>)[name];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('changelog modal loads a snapshot on open and shows the installed version', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(harness.snapshotRequests, [undefined]);
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
|
||||
assert.equal(harness.dom.changelogSource?.textContent, 'Latest release v0.19.2');
|
||||
assert.equal(harness.dom.changelogModal?.classList.contains('hidden'), false);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal surfaces the bundled-fallback warning', async () => {
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => ({
|
||||
...SNAPSHOT,
|
||||
source: 'bundled',
|
||||
releaseTag: undefined,
|
||||
warning: 'Showing the bundled changelog: offline',
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
harness.dom.changelogWarning?.textContent,
|
||||
'Showing the bundled changelog: offline',
|
||||
);
|
||||
assert.equal(harness.dom.changelogSource?.textContent, 'Bundled changelog');
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal reports a load failure instead of hanging on the spinner text', async () => {
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => {
|
||||
throw new Error('ipc down');
|
||||
},
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.match(
|
||||
harness.dom.changelogList?.textContent ?? '',
|
||||
/Changelog failed to load: ipc down/,
|
||||
);
|
||||
assert.equal(harness.dom.changelogStatus?.textContent, 'Press Esc to close.');
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal clears stale metadata when a later open fails to load', async () => {
|
||||
let shouldFail = false;
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => {
|
||||
if (shouldFail) throw new Error('offline');
|
||||
return SNAPSHOT;
|
||||
},
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
|
||||
harness.modal.closeChangelogModal();
|
||||
|
||||
shouldFail = true;
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// The previous session's values must not linger behind the error message.
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, '');
|
||||
assert.equal(harness.dom.changelogSource?.textContent, '');
|
||||
assert.match(harness.dom.changelogList?.textContent ?? '', /Changelog failed to load: offline/);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal clears stale metadata when a refresh fails', async () => {
|
||||
let shouldFail = false;
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => {
|
||||
if (shouldFail) throw new Error('refresh offline');
|
||||
return { ...SNAPSHOT, warning: 'Showing the bundled changelog: earlier failure' };
|
||||
},
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
|
||||
|
||||
shouldFail = true;
|
||||
harness.modal.handleChangelogKeydown({
|
||||
key: 'r',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// The rendered snapshot is gone, so nothing may still describe it.
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, '');
|
||||
assert.equal(harness.dom.changelogSource?.textContent, '');
|
||||
assert.equal(harness.dom.changelogWarning?.textContent, '');
|
||||
assert.match(
|
||||
harness.dom.changelogList?.textContent ?? '',
|
||||
/Changelog failed to load: refresh offline/,
|
||||
);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal moves selection styling and focus together on J/K', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const summaries = [createSummaryStub(0), createSummaryStub(1), createSummaryStub(2)];
|
||||
harness.dom.changelogList!.summaries = summaries;
|
||||
|
||||
harness.modal.handleChangelogKeydown({ key: 'j', preventDefault: () => {} } as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.classList.contains('active')),
|
||||
[false, true, false],
|
||||
);
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.tabIndex),
|
||||
[-1, 0, -1],
|
||||
);
|
||||
assert.equal(summaries[1]?.focusCount, 1, 'the keyboard path focuses the new selection');
|
||||
assert.equal(summaries[1]?.scrollCount, 1);
|
||||
|
||||
// Wraps backwards past the start.
|
||||
harness.modal.handleChangelogKeydown({ key: 'k', preventDefault: () => {} } as KeyboardEvent);
|
||||
harness.modal.handleChangelogKeydown({ key: 'k', preventDefault: () => {} } as KeyboardEvent);
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.classList.contains('active')),
|
||||
[false, false, true],
|
||||
);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal click selection restyles without stealing focus back', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.wireDomEvents();
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const summaries = [createSummaryStub(0), createSummaryStub(1)];
|
||||
harness.dom.changelogList!.summaries = summaries;
|
||||
|
||||
// The handler guards on `instanceof Element`, so the target has to inherit
|
||||
// from the Element stand-in the harness installs.
|
||||
const elementCtor = (globalThis as unknown as { Element: { prototype: object } }).Element;
|
||||
const clickTarget = Object.assign(Object.create(elementCtor.prototype), {
|
||||
closest: (selector: string) =>
|
||||
selector === '.changelog-entry-summary' ? summaries[1] : null,
|
||||
});
|
||||
harness.dom.changelogList!.dispatchEventType('click', { target: clickTarget });
|
||||
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.classList.contains('active')),
|
||||
[false, true],
|
||||
);
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.tabIndex),
|
||||
[-1, 0],
|
||||
);
|
||||
// The browser already focused the clicked summary; re-focusing would fight it.
|
||||
assert.equal(summaries[1]?.focusCount, 0);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal folds on Enter only from the selected summary', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const detailsCtor = (
|
||||
globalThis as unknown as { HTMLDetailsElement: new () => { open: boolean } }
|
||||
).HTMLDetailsElement;
|
||||
const entry = new detailsCtor();
|
||||
entry.open = true;
|
||||
|
||||
const summaries = [createSummaryStub(0), createSummaryStub(1)];
|
||||
Object.assign(summaries[0]!, { parentElement: entry });
|
||||
harness.dom.changelogList!.summaries = summaries;
|
||||
|
||||
let prevented = 0;
|
||||
const press = (target: unknown) =>
|
||||
harness.modal.handleChangelogKeydown({
|
||||
key: 'Enter',
|
||||
target,
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
// Close button focused: the button must keep its own Enter activation.
|
||||
assert.equal(press(harness.dom.changelogClose), true);
|
||||
assert.equal(prevented, 0, 'Enter on a button is not swallowed');
|
||||
assert.equal(entry.open, true);
|
||||
|
||||
// A non-selected summary (the nested "Internal changes" fold) is left alone.
|
||||
assert.equal(press(summaries[1]), true);
|
||||
assert.equal(prevented, 0);
|
||||
assert.equal(entry.open, true);
|
||||
|
||||
// The selected summary does fold, exactly once.
|
||||
assert.equal(press(summaries[0]), true);
|
||||
assert.equal(prevented, 1);
|
||||
assert.equal(entry.open, false);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal closes on Escape and notifies the main process', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const handled = harness.modal.handleChangelogKeydown({
|
||||
key: 'Escape',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.modalClosedNotifications, ['changelog']);
|
||||
assert.equal(harness.dom.changelogModal?.classList.contains('hidden'), true);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal refetches on R and ignores keys while closed', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
assert.equal(
|
||||
harness.modal.handleChangelogKeydown({ key: 'r', preventDefault: () => {} } as KeyboardEvent),
|
||||
false,
|
||||
);
|
||||
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
harness.modal.handleChangelogKeydown({
|
||||
key: 'r',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(harness.snapshotRequests, [undefined, { refresh: true }]);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal drops a late in-flight load after close', async () => {
|
||||
const pending: Array<(snapshot: ChangelogSnapshot) => void> = [];
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: () =>
|
||||
new Promise<ChangelogSnapshot>((resolve) => {
|
||||
pending.push(resolve);
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
harness.modal.closeChangelogModal();
|
||||
pending[0]?.(SNAPSHOT);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, '');
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { ChangelogSnapshot } from '../../types/changelog';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import {
|
||||
createChangelogEntryNode,
|
||||
describeChangelogSource,
|
||||
resolveEntryBadge,
|
||||
shouldEntryStartExpanded,
|
||||
} from './changelog-render';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
export function createChangelogModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let priorFocus: Element | null = null;
|
||||
let loadToken = 0;
|
||||
|
||||
function getSummaries(): HTMLElement[] {
|
||||
return Array.from(
|
||||
ctx.dom.changelogList.querySelectorAll('.changelog-entry-summary'),
|
||||
) as HTMLElement[];
|
||||
}
|
||||
|
||||
function applySelectionStyles(index: number): HTMLElement[] {
|
||||
const summaries = getSummaries();
|
||||
ctx.state.changelogSelectedIndex = index;
|
||||
summaries.forEach((summary, idx) => {
|
||||
summary.classList.toggle('active', idx === index);
|
||||
summary.tabIndex = idx === index ? 0 : -1;
|
||||
});
|
||||
return summaries;
|
||||
}
|
||||
|
||||
function setSelected(index: number): void {
|
||||
const count = getSummaries().length;
|
||||
if (count === 0) return;
|
||||
|
||||
const wrapped = index % count;
|
||||
const next = wrapped < 0 ? wrapped + count : wrapped;
|
||||
|
||||
// Only the keyboard path moves focus; clicking already focused the summary.
|
||||
const active = applySelectionStyles(next)[next];
|
||||
if (!active) return;
|
||||
active.focus({ preventScroll: true });
|
||||
active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
|
||||
function getSelectedEntry(): HTMLDetailsElement | null {
|
||||
const summary = getSummaries()[ctx.state.changelogSelectedIndex];
|
||||
const entry = summary?.parentElement;
|
||||
return entry instanceof HTMLDetailsElement ? entry : null;
|
||||
}
|
||||
|
||||
const focus = createModalFocusGuard({
|
||||
isOpen: () => ctx.state.changelogModalOpen,
|
||||
getModalRoot: () => ctx.dom.changelogModal,
|
||||
getPreferredFocusTargets: () => getSummaries(),
|
||||
getFallbackFocusTarget: () => ctx.dom.changelogClose,
|
||||
isModalLayer: ctx.platform.isModalLayer,
|
||||
});
|
||||
|
||||
function renderSnapshot(snapshot: ChangelogSnapshot): void {
|
||||
ctx.dom.changelogList.innerHTML = '';
|
||||
ctx.dom.changelogList.classList.remove('changelog-list-empty');
|
||||
|
||||
ctx.dom.changelogInstalled.textContent = `Installed v${snapshot.installedVersion}`;
|
||||
ctx.dom.changelogSource.textContent = describeChangelogSource(snapshot);
|
||||
ctx.dom.changelogWarning.textContent = snapshot.warning ?? '';
|
||||
|
||||
if (snapshot.entries.length === 0) {
|
||||
ctx.dom.changelogList.classList.add('changelog-list-empty');
|
||||
ctx.dom.changelogList.textContent =
|
||||
snapshot.error ?? 'No changelog entries are available right now.';
|
||||
ctx.state.changelogSelectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot.entries.forEach((entry, index) => {
|
||||
ctx.dom.changelogList.appendChild(
|
||||
createChangelogEntryNode(entry, {
|
||||
expanded: shouldEntryStartExpanded(entry, snapshot),
|
||||
badge: resolveEntryBadge(entry.version, snapshot.installedVersion),
|
||||
index,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
setSelected(0);
|
||||
}
|
||||
|
||||
async function load(options?: { refresh?: boolean }): Promise<void> {
|
||||
const token = ++loadToken;
|
||||
ctx.dom.changelogStatus.textContent = options?.refresh
|
||||
? 'Refreshing changelog...'
|
||||
: 'Loading changelog...';
|
||||
|
||||
try {
|
||||
const snapshot = await window.electronAPI.getChangelogSnapshot(options);
|
||||
if (token !== loadToken || !ctx.state.changelogModalOpen) return;
|
||||
renderSnapshot(snapshot);
|
||||
ctx.dom.changelogStatus.textContent =
|
||||
'J/K or arrows to move, Enter to fold, R to refresh, Esc closes.';
|
||||
} catch (error) {
|
||||
if (token !== loadToken || !ctx.state.changelogModalOpen) return;
|
||||
const message = error instanceof Error ? error.message : 'Unknown error.';
|
||||
ctx.dom.changelogList.innerHTML = '';
|
||||
ctx.dom.changelogList.classList.add('changelog-list-empty');
|
||||
// A failed refresh replaces an already-rendered snapshot, so the metadata
|
||||
// line has to be cleared here too or it keeps describing stale entries.
|
||||
ctx.dom.changelogInstalled.textContent = '';
|
||||
ctx.dom.changelogSource.textContent = '';
|
||||
ctx.dom.changelogWarning.textContent = '';
|
||||
ctx.dom.changelogList.textContent = `Changelog failed to load: ${message}`;
|
||||
ctx.dom.changelogStatus.textContent = 'Press Esc to close.';
|
||||
}
|
||||
}
|
||||
|
||||
function openChangelogModal(): void {
|
||||
if (ctx.state.changelogModalOpen) return;
|
||||
priorFocus = document.activeElement;
|
||||
|
||||
ctx.state.changelogModalOpen = true;
|
||||
ctx.state.changelogSelectedIndex = 0;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.changelogModal.classList.remove('hidden');
|
||||
ctx.dom.changelogModal.setAttribute('aria-hidden', 'false');
|
||||
ctx.dom.changelogModal.setAttribute('tabindex', '-1');
|
||||
ctx.dom.changelogList.innerHTML = '';
|
||||
ctx.dom.changelogWarning.textContent = '';
|
||||
// Reset the metadata line too, so a failed load can't leave the previous
|
||||
// session's installed/source values on screen.
|
||||
ctx.dom.changelogInstalled.textContent = '';
|
||||
ctx.dom.changelogSource.textContent = '';
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
focus.attach();
|
||||
focus.requestOverlayFocus();
|
||||
window.focus();
|
||||
focus.enforceModalFocus();
|
||||
|
||||
void load();
|
||||
}
|
||||
|
||||
function closeChangelogModal(): void {
|
||||
if (!ctx.state.changelogModalOpen) return;
|
||||
|
||||
ctx.state.changelogModalOpen = false;
|
||||
loadToken += 1;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.changelogModal.classList.add('hidden');
|
||||
ctx.dom.changelogModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('changelog');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
focus.detach();
|
||||
|
||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
||||
priorFocus.focus({ preventScroll: true });
|
||||
} else if (ctx.dom.overlay instanceof HTMLElement) {
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
window.focus();
|
||||
}
|
||||
|
||||
function handleChangelogKeydown(e: KeyboardEvent): boolean {
|
||||
if (!ctx.state.changelogModalOpen) return false;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeChangelogModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if (key === 'r' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
void load({ refresh: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
const summaries = getSummaries();
|
||||
if (summaries.length === 0) return true;
|
||||
|
||||
if (key === 'arrowdown' || key === 'j') {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.changelogSelectedIndex + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowup' || key === 'k') {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.changelogSelectedIndex - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'enter' || key === ' ') {
|
||||
// Only the selected release summary folds from here. The Close/Refresh
|
||||
// buttons and the nested "Internal changes" fold activate themselves, and
|
||||
// swallowing Enter/Space would make them unreachable by keyboard.
|
||||
if (e.target !== summaries[ctx.state.changelogSelectedIndex]) {
|
||||
return true;
|
||||
}
|
||||
e.preventDefault();
|
||||
const entry = getSelectedEntry();
|
||||
if (entry) entry.open = !entry.open;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowleft' || key === 'h') {
|
||||
e.preventDefault();
|
||||
const entry = getSelectedEntry();
|
||||
if (entry) entry.open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowright' || key === 'l') {
|
||||
e.preventDefault();
|
||||
const entry = getSelectedEntry();
|
||||
if (entry) entry.open = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.changelogClose.addEventListener('click', () => {
|
||||
closeChangelogModal();
|
||||
});
|
||||
|
||||
ctx.dom.changelogRefresh.addEventListener('click', () => {
|
||||
void load({ refresh: true });
|
||||
});
|
||||
|
||||
ctx.dom.changelogList.addEventListener('click', (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const summary = target.closest('.changelog-entry-summary') as HTMLElement | null;
|
||||
if (!summary) return;
|
||||
const index = Number.parseInt(summary.dataset.changelogIndex ?? '', 10);
|
||||
if (!Number.isFinite(index)) return;
|
||||
applySelectionStyles(index);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeChangelogModal,
|
||||
handleChangelogKeydown,
|
||||
openChangelogModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
type Listener = (event?: unknown) => void;
|
||||
|
||||
function createRoot(contains: boolean) {
|
||||
const listeners: Array<{ type: string; listener: Listener }> = [];
|
||||
return {
|
||||
contains: () => contains,
|
||||
addEventListener: (type: string, listener: Listener) => {
|
||||
listeners.push({ type, listener });
|
||||
},
|
||||
removeEventListener: (type: string, listener: Listener) => {
|
||||
const index = listeners.findIndex(
|
||||
(entry) => entry.type === type && entry.listener === listener,
|
||||
);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
},
|
||||
listeners,
|
||||
};
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
guard: ReturnType<typeof createModalFocusGuard>;
|
||||
root: ReturnType<typeof createRoot>;
|
||||
focusMainWindowCalls: () => number;
|
||||
focused: () => string[];
|
||||
documentListeners: () => string[];
|
||||
windowListeners: () => string[];
|
||||
handlerFor: (scope: 'document' | 'window', type: string) => Listener | undefined;
|
||||
setActiveElement: (value: unknown) => void;
|
||||
advanceClock: (ms: number) => void;
|
||||
runTimers: () => void;
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
function createHarness(
|
||||
options: {
|
||||
isOpen?: () => boolean;
|
||||
isModalLayer?: boolean;
|
||||
contains?: boolean;
|
||||
preferredVisible?: boolean;
|
||||
preferredAcceptsFocus?: boolean;
|
||||
extraPreferred?: boolean;
|
||||
fallback?: 'element' | null;
|
||||
} = {},
|
||||
): Harness {
|
||||
const previous = (['window', 'document', 'HTMLElement', 'Element'] as const).map(
|
||||
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
|
||||
);
|
||||
|
||||
class TestElement {}
|
||||
for (const name of ['HTMLElement', 'Element'] as const) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestElement,
|
||||
});
|
||||
}
|
||||
|
||||
let focusMainWindowCalls = 0;
|
||||
let now = 1_000;
|
||||
const realDateNow = Date.now;
|
||||
Date.now = () => now;
|
||||
const focused: string[] = [];
|
||||
const documentListeners: Array<{ type: string; listener: Listener }> = [];
|
||||
const windowListeners: Array<{ type: string; listener: Listener }> = [];
|
||||
|
||||
const register = (registry: Array<{ type: string; listener: Listener }>) => ({
|
||||
add: (type: string, listener: Listener) => {
|
||||
registry.push({ type, listener });
|
||||
},
|
||||
remove: (type: string, listener: Listener) => {
|
||||
const index = registry.findIndex(
|
||||
(entry) => entry.type === type && entry.listener === listener,
|
||||
);
|
||||
if (index >= 0) registry.splice(index, 1);
|
||||
},
|
||||
});
|
||||
const windowRegistry = register(windowListeners);
|
||||
const documentRegistry = register(documentListeners);
|
||||
const timers: Array<() => void> = [];
|
||||
let activeElement: unknown = null;
|
||||
|
||||
const root = createRoot(options.contains ?? false);
|
||||
|
||||
const preferred = Object.assign(new TestElement(), {
|
||||
// A position:fixed element has a null offsetParent but still has rects.
|
||||
offsetParent: null,
|
||||
getClientRects: () => (options.preferredVisible === false ? [] : [{ width: 10, height: 10 }]),
|
||||
focus: () => {
|
||||
focused.push('preferred');
|
||||
// A rendered-but-unfocusable target (e.g. disabled) never becomes active.
|
||||
if (options.preferredAcceptsFocus !== false) activeElement = preferred;
|
||||
},
|
||||
});
|
||||
const secondPreferred = Object.assign(new TestElement(), {
|
||||
getClientRects: () => [{ width: 10, height: 10 }],
|
||||
focus: () => {
|
||||
focused.push('second');
|
||||
activeElement = secondPreferred;
|
||||
},
|
||||
});
|
||||
const fallback =
|
||||
options.fallback === null
|
||||
? null
|
||||
: Object.assign(new TestElement(), {
|
||||
focus: () => {
|
||||
focused.push('fallback');
|
||||
activeElement = fallback;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
focusMainWindow: async () => {
|
||||
focusMainWindowCalls += 1;
|
||||
},
|
||||
},
|
||||
focus: () => {
|
||||
focused.push('window');
|
||||
},
|
||||
addEventListener: windowRegistry.add,
|
||||
removeEventListener: windowRegistry.remove,
|
||||
setTimeout: (callback: () => void) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
get activeElement() {
|
||||
return activeElement;
|
||||
},
|
||||
addEventListener: documentRegistry.add,
|
||||
removeEventListener: documentRegistry.remove,
|
||||
},
|
||||
});
|
||||
|
||||
const guard = createModalFocusGuard({
|
||||
isOpen: options.isOpen ?? (() => true),
|
||||
getModalRoot: () => root as unknown as Element,
|
||||
getPreferredFocusTargets: () =>
|
||||
(options.extraPreferred
|
||||
? [preferred, secondPreferred]
|
||||
: [preferred]) as unknown as HTMLElement[],
|
||||
getFallbackFocusTarget: () => fallback as unknown as Element | null,
|
||||
isModalLayer: options.isModalLayer ?? true,
|
||||
});
|
||||
|
||||
return {
|
||||
guard,
|
||||
root,
|
||||
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||
focused: () => focused,
|
||||
documentListeners: () => documentListeners.map((entry) => entry.type),
|
||||
windowListeners: () => windowListeners.map((entry) => entry.type),
|
||||
handlerFor: (scope: 'document' | 'window', type: string) =>
|
||||
(scope === 'document' ? documentListeners : windowListeners).find(
|
||||
(entry) => entry.type === type,
|
||||
)?.listener,
|
||||
setActiveElement: (value: unknown) => {
|
||||
activeElement = value;
|
||||
},
|
||||
advanceClock: (ms: number) => {
|
||||
now += ms;
|
||||
},
|
||||
runTimers: () => {
|
||||
while (timers.length > 0) timers.shift()?.();
|
||||
},
|
||||
restore: () => {
|
||||
Date.now = realDateNow;
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
} else {
|
||||
delete (globalThis as Record<string, unknown>)[name];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('modal focus guard attaches once and detaches every listener', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.attach();
|
||||
harness.guard.attach();
|
||||
|
||||
assert.deepEqual(harness.documentListeners(), ['focusin']);
|
||||
assert.deepEqual(harness.windowListeners(), ['blur', 'focus']);
|
||||
assert.deepEqual(
|
||||
harness.root.listeners.map((entry) => entry.type),
|
||||
['pointerdown', 'click'],
|
||||
);
|
||||
|
||||
harness.guard.detach();
|
||||
|
||||
assert.deepEqual(harness.documentListeners(), []);
|
||||
assert.deepEqual(harness.windowListeners(), []);
|
||||
assert.deepEqual(harness.root.listeners, []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard pulls focus back when focusin lands outside the modal', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.attach();
|
||||
|
||||
const focusin = harness.handlerFor('document', 'focusin');
|
||||
assert.ok(focusin, 'attach registers a focusin handler');
|
||||
|
||||
// focusin is not cancelable, so recovery is the only observable effect.
|
||||
focusin?.({ target: {} });
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard ignores focusin while the modal is closed', () => {
|
||||
const harness = createHarness({ isOpen: () => false });
|
||||
try {
|
||||
harness.guard.attach();
|
||||
harness.handlerFor('document', 'focusin')?.({ target: {} });
|
||||
|
||||
assert.deepEqual(harness.focused(), []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard restores focus to the first rendered target', () => {
|
||||
// The target is position:fixed (null offsetParent) yet visible, so it must
|
||||
// still win over the fallback.
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard tries the next target when one refuses focus', () => {
|
||||
const harness = createHarness({ preferredAcceptsFocus: false, extraPreferred: true });
|
||||
try {
|
||||
assert.equal(harness.guard.focusFallbackTarget(), true);
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'second']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard reaches the fallback when no preferred target takes focus', () => {
|
||||
const harness = createHarness({ preferredAcceptsFocus: false });
|
||||
try {
|
||||
assert.equal(harness.guard.focusFallbackTarget(), true);
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'fallback']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard falls back when no preferred target is rendered', () => {
|
||||
const harness = createHarness({ preferredVisible: false });
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['fallback']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard focuses the window when nothing else can take focus', () => {
|
||||
const harness = createHarness({ preferredVisible: false, fallback: null });
|
||||
try {
|
||||
assert.equal(harness.guard.focusFallbackTarget(), false);
|
||||
assert.deepEqual(harness.focused(), ['window']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard leaves focus alone while it is already inside the modal', () => {
|
||||
const harness = createHarness({ contains: true });
|
||||
try {
|
||||
harness.setActiveElement(
|
||||
Object.create((globalThis as { Element: { prototype: object } }).Element.prototype),
|
||||
);
|
||||
harness.guard.enforceModalFocus();
|
||||
|
||||
assert.deepEqual(harness.focused(), []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard does nothing while the modal is closed', () => {
|
||||
const harness = createHarness({ isOpen: () => false });
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard debounces recovery so a focus fight cannot spin', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
harness.setActiveElement(null);
|
||||
|
||||
// Re-entry guard: the recovery timer has not fired yet.
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
|
||||
// Timer cleared the re-entry flag, but the debounce window still holds.
|
||||
harness.runTimers();
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred'], 'debounce still blocks the retry');
|
||||
|
||||
// Past the window, recovery resumes.
|
||||
harness.advanceClock(200);
|
||||
harness.setActiveElement(null);
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard asks the main window for focus off the modal layer only', () => {
|
||||
const onModalLayer = createHarness({ isModalLayer: true });
|
||||
try {
|
||||
onModalLayer.guard.requestOverlayFocus();
|
||||
assert.equal(onModalLayer.focusMainWindowCalls(), 0);
|
||||
} finally {
|
||||
onModalLayer.restore();
|
||||
}
|
||||
|
||||
const onOverlayLayer = createHarness({ isModalLayer: false });
|
||||
try {
|
||||
onOverlayLayer.guard.requestOverlayFocus();
|
||||
assert.equal(onOverlayLayer.focusMainWindowCalls(), 1);
|
||||
} finally {
|
||||
onOverlayLayer.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Keeps focus inside an overlay-hosted modal.
|
||||
*
|
||||
* The overlay can lose focus to mpv or to the compositor while a modal is up,
|
||||
* which leaves the modal visible but inert. Recovery is debounced (and guarded
|
||||
* against re-entry) so a focus fight with the window manager cannot spin.
|
||||
*/
|
||||
export type ModalFocusGuardDeps = {
|
||||
isOpen: () => boolean;
|
||||
/** Modal root; focus inside it counts as "still in the modal". */
|
||||
getModalRoot: () => Element;
|
||||
/** Preferred focus targets in order; the first rendered one wins. */
|
||||
getPreferredFocusTargets: () => HTMLElement[];
|
||||
/** Used when no preferred target is rendered, e.g. the close button. */
|
||||
getFallbackFocusTarget: () => Element | null;
|
||||
/** Modal-layer windows own their focus; other layers ask the main window. */
|
||||
isModalLayer: boolean;
|
||||
};
|
||||
|
||||
const FOCUS_RECOVERY_DEBOUNCE_MS = 120;
|
||||
|
||||
export function createModalFocusGuard(deps: ModalFocusGuardDeps) {
|
||||
let focusinGuard: ((event: FocusEvent) => void) | null = null;
|
||||
let windowFocusGuard: (() => void) | null = null;
|
||||
let pointerFocusGuard: ((event: Event) => void) | null = null;
|
||||
let pointerFocusRoot: Element | null = null;
|
||||
let isRecovering = false;
|
||||
let lastRecoveryAt = 0;
|
||||
|
||||
function isModalFocusTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && deps.getModalRoot().contains(target);
|
||||
}
|
||||
|
||||
function requestOverlayFocus(): void {
|
||||
if (!deps.isModalLayer) {
|
||||
// Best-effort: a rejected focus request must not surface as an unhandled
|
||||
// rejection, since this runs from blur/focus handlers.
|
||||
void Promise.resolve(window.electronAPI.focusMainWindow()).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function focusFallbackTarget(): boolean {
|
||||
requestOverlayFocus();
|
||||
|
||||
// getClientRects() rather than offsetParent: the latter is null for
|
||||
// position:fixed elements, which would skip a perfectly visible target.
|
||||
// Rendered is not the same as focusable, so keep trying until one sticks
|
||||
// instead of giving up on the first candidate that refuses focus.
|
||||
for (const target of deps.getPreferredFocusTargets()) {
|
||||
if (target.getClientRects().length === 0) continue;
|
||||
target.focus({ preventScroll: true });
|
||||
if (document.activeElement === target) return true;
|
||||
}
|
||||
|
||||
const fallback = deps.getFallbackFocusTarget();
|
||||
if (fallback instanceof HTMLElement) {
|
||||
fallback.focus({ preventScroll: true });
|
||||
return document.activeElement === fallback;
|
||||
}
|
||||
|
||||
window.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
function enforceModalFocus(): void {
|
||||
if (!deps.isOpen()) return;
|
||||
if (isModalFocusTarget(document.activeElement)) return;
|
||||
if (isRecovering) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastRecoveryAt < FOCUS_RECOVERY_DEBOUNCE_MS) return;
|
||||
|
||||
isRecovering = true;
|
||||
lastRecoveryAt = now;
|
||||
focusFallbackTarget();
|
||||
window.setTimeout(() => {
|
||||
isRecovering = false;
|
||||
}, FOCUS_RECOVERY_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** Idempotent; safe to call on every open. */
|
||||
function attach(): void {
|
||||
if (focusinGuard === null) {
|
||||
// focusin is not cancelable, so there is nothing to preventDefault here;
|
||||
// focus is taken back afterwards instead.
|
||||
focusinGuard = (event: FocusEvent) => {
|
||||
if (!deps.isOpen()) return;
|
||||
if (!isModalFocusTarget(event.target)) {
|
||||
enforceModalFocus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('focusin', focusinGuard);
|
||||
}
|
||||
|
||||
if (pointerFocusGuard === null) {
|
||||
pointerFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
// Remember the root we bound to: resolving it again on detach could
|
||||
// return a different element and leak the listeners on the old one.
|
||||
pointerFocusRoot = deps.getModalRoot();
|
||||
pointerFocusRoot.addEventListener('pointerdown', pointerFocusGuard);
|
||||
pointerFocusRoot.addEventListener('click', pointerFocusGuard);
|
||||
}
|
||||
|
||||
if (windowFocusGuard === null) {
|
||||
windowFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
window.addEventListener('blur', windowFocusGuard);
|
||||
window.addEventListener('focus', windowFocusGuard);
|
||||
}
|
||||
}
|
||||
|
||||
function detach(): void {
|
||||
if (focusinGuard) {
|
||||
document.removeEventListener('focusin', focusinGuard);
|
||||
focusinGuard = null;
|
||||
}
|
||||
|
||||
if (pointerFocusGuard) {
|
||||
pointerFocusRoot?.removeEventListener('pointerdown', pointerFocusGuard);
|
||||
pointerFocusRoot?.removeEventListener('click', pointerFocusGuard);
|
||||
pointerFocusGuard = null;
|
||||
pointerFocusRoot = null;
|
||||
}
|
||||
|
||||
if (windowFocusGuard) {
|
||||
window.removeEventListener('blur', windowFocusGuard);
|
||||
window.removeEventListener('focus', windowFocusGuard);
|
||||
windowFocusGuard = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attach,
|
||||
detach,
|
||||
enforceModalFocus,
|
||||
focusFallbackTarget,
|
||||
isModalFocusTarget,
|
||||
requestOverlayFocus,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from './session-help-sections';
|
||||
import { createSessionHelpSectionNode } from './session-help-render';
|
||||
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
export {
|
||||
buildSessionHelpSections,
|
||||
@@ -69,11 +70,6 @@ export function createSessionHelpModal(
|
||||
let helpFilterValue = '';
|
||||
let helpSections: SessionHelpSection[] = [];
|
||||
let activeTabId: SessionHelpTabId = 'essentials';
|
||||
let focusGuard: ((event: FocusEvent) => void) | null = null;
|
||||
let windowFocusGuard: (() => void) | null = null;
|
||||
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
|
||||
let isRecoveringModalFocus = false;
|
||||
let lastFocusRecoveryAt = 0;
|
||||
|
||||
function getItems(): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
@@ -102,47 +98,13 @@ export function createSessionHelpModal(
|
||||
});
|
||||
}
|
||||
|
||||
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && ctx.dom.sessionHelpModal.contains(target);
|
||||
}
|
||||
|
||||
function focusFallbackTarget(): boolean {
|
||||
if (!ctx.platform.isModalLayer) {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
const items = getItems();
|
||||
const firstItem = items.find((item) => item.offsetParent !== null);
|
||||
if (firstItem) {
|
||||
firstItem.focus({ preventScroll: true });
|
||||
return document.activeElement === firstItem;
|
||||
}
|
||||
|
||||
if (ctx.dom.sessionHelpClose instanceof HTMLElement) {
|
||||
ctx.dom.sessionHelpClose.focus({ preventScroll: true });
|
||||
return document.activeElement === ctx.dom.sessionHelpClose;
|
||||
}
|
||||
|
||||
window.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
function enforceModalFocus(): void {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (!isSessionHelpModalFocusTarget(document.activeElement)) {
|
||||
if (isRecoveringModalFocus) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastFocusRecoveryAt < 120) return;
|
||||
|
||||
isRecoveringModalFocus = true;
|
||||
lastFocusRecoveryAt = now;
|
||||
focusFallbackTarget();
|
||||
|
||||
window.setTimeout(() => {
|
||||
isRecoveringModalFocus = false;
|
||||
}, 120);
|
||||
}
|
||||
}
|
||||
const focus = createModalFocusGuard({
|
||||
isOpen: () => ctx.state.sessionHelpModalOpen,
|
||||
getModalRoot: () => ctx.dom.sessionHelpModal,
|
||||
getPreferredFocusTargets: () => getItems(),
|
||||
getFallbackFocusTarget: () => ctx.dom.sessionHelpClose,
|
||||
isModalLayer: ctx.platform.isModalLayer,
|
||||
});
|
||||
|
||||
function isFilterInputFocused(): boolean {
|
||||
return document.activeElement === ctx.dom.sessionHelpFilter;
|
||||
@@ -192,48 +154,6 @@ export function createSessionHelpModal(
|
||||
setSelected(0);
|
||||
}
|
||||
|
||||
function requestOverlayFocus(): void {
|
||||
if (!ctx.platform.isModalLayer) {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
}
|
||||
|
||||
function addPointerFocusListener(): void {
|
||||
if (modalPointerFocusGuard) return;
|
||||
|
||||
modalPointerFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
ctx.dom.sessionHelpModal.addEventListener('pointerdown', modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.addEventListener('click', modalPointerFocusGuard);
|
||||
}
|
||||
|
||||
function removePointerFocusListener(): void {
|
||||
if (!modalPointerFocusGuard) return;
|
||||
ctx.dom.sessionHelpModal.removeEventListener('pointerdown', modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.removeEventListener('click', modalPointerFocusGuard);
|
||||
modalPointerFocusGuard = null;
|
||||
}
|
||||
|
||||
function startFocusRecoveryGuards(): void {
|
||||
if (windowFocusGuard) return;
|
||||
|
||||
windowFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
window.addEventListener('blur', windowFocusGuard);
|
||||
window.addEventListener('focus', windowFocusGuard);
|
||||
}
|
||||
|
||||
function stopFocusRecoveryGuards(): void {
|
||||
if (!windowFocusGuard) return;
|
||||
window.removeEventListener('blur', windowFocusGuard);
|
||||
window.removeEventListener('focus', windowFocusGuard);
|
||||
windowFocusGuard = null;
|
||||
}
|
||||
|
||||
function showRenderError(message: string): void {
|
||||
helpSections = [];
|
||||
helpFilterValue = '';
|
||||
@@ -310,22 +230,10 @@ export function createSessionHelpModal(
|
||||
}
|
||||
ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...';
|
||||
|
||||
if (focusGuard === null) {
|
||||
focusGuard = (event: FocusEvent) => {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (!isSessionHelpModalFocusTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
enforceModalFocus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('focusin', focusGuard);
|
||||
}
|
||||
|
||||
addPointerFocusListener();
|
||||
startFocusRecoveryGuards();
|
||||
requestOverlayFocus();
|
||||
focus.attach();
|
||||
focus.requestOverlayFocus();
|
||||
window.focus();
|
||||
enforceModalFocus();
|
||||
focus.enforceModalFocus();
|
||||
|
||||
void render().then((dataLoaded) => {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
@@ -353,12 +261,7 @@ export function createSessionHelpModal(
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
if (focusGuard) {
|
||||
document.removeEventListener('focusin', focusGuard);
|
||||
focusGuard = null;
|
||||
}
|
||||
removePointerFocusListener();
|
||||
stopFocusRecoveryGuards();
|
||||
focus.detach();
|
||||
|
||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
||||
priorFocus.focus({ preventScroll: true });
|
||||
@@ -395,7 +298,7 @@ export function createSessionHelpModal(
|
||||
helpFilterValue = '';
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
applyFilterAndRender();
|
||||
focusFallbackTarget();
|
||||
focus.focusFallbackTarget();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -442,7 +345,7 @@ export function createSessionHelpModal(
|
||||
ctx.dom.sessionHelpFilter.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
focusFallbackTarget();
|
||||
focus.focusFallbackTarget();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createSubsyncModal } from './subsync.js';
|
||||
import type { SubsyncManualPayload, SubsyncManualRunRequest } from '../../types';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
@@ -52,18 +53,54 @@ function createDeferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; message: string }>) {
|
||||
function createSelectStub() {
|
||||
const options: Array<{ value: string; textContent: string }> = [];
|
||||
const events = createEventTarget();
|
||||
let innerHTML = '';
|
||||
let value = '';
|
||||
|
||||
return {
|
||||
options,
|
||||
disabled: false,
|
||||
addEventListener: events.addEventListener,
|
||||
dispatch: events.dispatch,
|
||||
get innerHTML(): string {
|
||||
return innerHTML;
|
||||
},
|
||||
set innerHTML(next: string) {
|
||||
innerHTML = next;
|
||||
if (next === '') {
|
||||
options.length = 0;
|
||||
value = '';
|
||||
}
|
||||
},
|
||||
get value(): string {
|
||||
return value;
|
||||
},
|
||||
set value(next: string) {
|
||||
value = next;
|
||||
},
|
||||
appendChild(option: { value: string; textContent: string }) {
|
||||
options.push(option);
|
||||
if (!value) value = option.value;
|
||||
return option;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTestHarness(
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<{ ok: boolean; message: string }>,
|
||||
) {
|
||||
const overlayClassList = createClassList();
|
||||
const modalClassList = createClassList();
|
||||
const statusClassList = createClassList();
|
||||
const sourceLabelClassList = createClassList();
|
||||
const referenceLabelClassList = createClassList();
|
||||
const targetLabelClassList = createClassList();
|
||||
const runButtonEvents = createEventTarget();
|
||||
const closeButtonEvents = createEventTarget();
|
||||
const engineAlassEvents = createEventTarget();
|
||||
const engineFfsubsyncEvents = createEventTarget();
|
||||
|
||||
const sourceOptions: Array<{ value: string; textContent: string }> = [];
|
||||
|
||||
const runButton = {
|
||||
disabled: false,
|
||||
addEventListener: runButtonEvents.addEventListener,
|
||||
@@ -77,6 +114,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
||||
|
||||
const subsyncEngineAlass = {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
addEventListener: engineAlassEvents.addEventListener,
|
||||
dispatch: engineAlassEvents.dispatch,
|
||||
};
|
||||
@@ -88,18 +126,8 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
||||
dispatch: engineFfsubsyncEvents.dispatch,
|
||||
};
|
||||
|
||||
const sourceSelect = {
|
||||
innerHTML: '',
|
||||
value: '',
|
||||
disabled: false,
|
||||
appendChild: (option: { value: string; textContent: string }) => {
|
||||
sourceOptions.push(option);
|
||||
if (!sourceSelect.value) {
|
||||
sourceSelect.value = option.value;
|
||||
}
|
||||
return option;
|
||||
},
|
||||
};
|
||||
const referenceSelect = createSelectStub();
|
||||
const targetSelect = createSelectStub();
|
||||
|
||||
let notifyClosedCalls = 0;
|
||||
let notifyOpenedCalls = 0;
|
||||
@@ -139,8 +167,10 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
||||
subsyncCloseButton: closeButton,
|
||||
subsyncEngineAlass,
|
||||
subsyncEngineFfsubsync,
|
||||
subsyncSourceLabel: { classList: sourceLabelClassList },
|
||||
subsyncSourceSelect: sourceSelect,
|
||||
subsyncReferenceLabel: { classList: referenceLabelClassList },
|
||||
subsyncReferenceSelect: referenceSelect,
|
||||
subsyncTargetLabel: { classList: targetLabelClassList },
|
||||
subsyncTargetSelect: targetSelect,
|
||||
subsyncRunButton: runButton,
|
||||
subsyncStatus: {
|
||||
textContent: '',
|
||||
@@ -149,7 +179,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
||||
},
|
||||
state: {
|
||||
subsyncModalOpen: false,
|
||||
subsyncSourceTracks: [],
|
||||
subsyncSubtitleTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
isOverSubtitle: false,
|
||||
},
|
||||
@@ -166,6 +196,9 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
||||
ctx,
|
||||
modal,
|
||||
runButton,
|
||||
referenceSelect,
|
||||
targetSelect,
|
||||
referenceLabelClassList,
|
||||
statusClassList,
|
||||
getNotifyClosedCalls: () => notifyClosedCalls,
|
||||
getNotifyOpenedCalls: () => notifyOpenedCalls,
|
||||
@@ -187,16 +220,28 @@ async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
const BASE_PAYLOAD: SubsyncManualPayload = {
|
||||
subtitleTracks: [
|
||||
{ id: 1, label: 'External #1 - jpn (active)' },
|
||||
{ id: 2, label: 'External #2 - eng' },
|
||||
],
|
||||
defaultReferenceTrackId: 2,
|
||||
defaultTargetTrackId: 1,
|
||||
videoReferenceAvailable: true,
|
||||
ffsubsyncAvailable: true,
|
||||
};
|
||||
|
||||
function payloadWith(overrides: Partial<SubsyncManualPayload>): SubsyncManualPayload {
|
||||
return { ...BASE_PAYLOAD, ...overrides };
|
||||
}
|
||||
|
||||
test('manual subsync failure closes during run, then reopens modal with error', async () => {
|
||||
const deferred = createDeferred<{ ok: boolean; message: string }>();
|
||||
const harness = createTestHarness(async () => deferred.promise);
|
||||
|
||||
try {
|
||||
harness.modal.wireDomEvents();
|
||||
harness.modal.openSubsyncModal({
|
||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||
ffsubsyncAvailable: true,
|
||||
});
|
||||
harness.modal.openSubsyncModal(BASE_PAYLOAD);
|
||||
|
||||
harness.runButton.dispatch('click');
|
||||
await Promise.resolve();
|
||||
@@ -219,7 +264,8 @@ test('manual subsync failure closes during run, then reopens modal with error',
|
||||
assert.equal(harness.statusClassList.contains('error'), true);
|
||||
assert.equal(harness.ctx.dom.subsyncRunButton.disabled, false);
|
||||
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
|
||||
assert.equal(harness.ctx.dom.subsyncSourceSelect.value, '2');
|
||||
assert.equal(harness.referenceSelect.value, '2');
|
||||
assert.equal(harness.targetSelect.value, '1');
|
||||
assert.equal(harness.getNotifyClosedCalls(), 1);
|
||||
assert.equal(harness.getNotifyOpenedCalls(), 1);
|
||||
} finally {
|
||||
@@ -231,15 +277,17 @@ test('subsync modal disables ffsubsync when payload marks it unavailable', () =>
|
||||
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
|
||||
|
||||
try {
|
||||
harness.modal.openSubsyncModal({
|
||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||
ffsubsyncAvailable: false,
|
||||
});
|
||||
harness.modal.openSubsyncModal(
|
||||
payloadWith({ ffsubsyncAvailable: false, videoReferenceAvailable: false }),
|
||||
);
|
||||
|
||||
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
|
||||
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false);
|
||||
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.disabled, true);
|
||||
assert.equal(harness.ctx.dom.subsyncStatus.textContent, 'Choose alass source, then run.');
|
||||
assert.equal(
|
||||
harness.ctx.dom.subsyncStatus.textContent,
|
||||
'Choose the alass reference and out-of-sync subtitle, then run.',
|
||||
);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
@@ -253,10 +301,14 @@ test('subsync modal ignores enter submission when no sync engine is available',
|
||||
});
|
||||
|
||||
try {
|
||||
harness.modal.openSubsyncModal({
|
||||
sourceTracks: [],
|
||||
ffsubsyncAvailable: false,
|
||||
});
|
||||
harness.modal.openSubsyncModal(
|
||||
payloadWith({
|
||||
subtitleTracks: [{ id: 1, label: 'External #1 - jpn (active)' }],
|
||||
defaultReferenceTrackId: null,
|
||||
videoReferenceAvailable: false,
|
||||
ffsubsyncAvailable: false,
|
||||
}),
|
||||
);
|
||||
|
||||
harness.modal.handleSubsyncKeydown({
|
||||
key: 'Enter',
|
||||
@@ -270,3 +322,102 @@ test('subsync modal ignores enter submission when no sync engine is available',
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('subsync modal defaults reference to the secondary track and target to the primary', async () => {
|
||||
let request: SubsyncManualRunRequest | null = null;
|
||||
const harness = createTestHarness(async (nextRequest) => {
|
||||
request = nextRequest;
|
||||
return { ok: true, message: 'ok' };
|
||||
});
|
||||
|
||||
try {
|
||||
harness.modal.wireDomEvents();
|
||||
harness.modal.openSubsyncModal(BASE_PAYLOAD);
|
||||
|
||||
assert.equal(harness.referenceSelect.value, '2');
|
||||
assert.equal(harness.targetSelect.value, '1');
|
||||
|
||||
harness.runButton.dispatch('click');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(request, {
|
||||
engine: 'alass',
|
||||
targetTrackId: 1,
|
||||
referenceMode: 'track',
|
||||
referenceTrackId: 2,
|
||||
});
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('subsync modal offers the video file as an alass reference and excludes the target track', () => {
|
||||
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
|
||||
|
||||
try {
|
||||
harness.modal.wireDomEvents();
|
||||
harness.modal.openSubsyncModal(BASE_PAYLOAD);
|
||||
|
||||
assert.deepEqual(
|
||||
harness.referenceSelect.options.map((option) => option.value),
|
||||
['2', 'video'],
|
||||
);
|
||||
|
||||
harness.targetSelect.value = '2';
|
||||
harness.targetSelect.dispatch('change');
|
||||
|
||||
assert.deepEqual(
|
||||
harness.referenceSelect.options.map((option) => option.value),
|
||||
['1', 'video'],
|
||||
);
|
||||
assert.equal(harness.referenceSelect.value, '1');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('subsync modal sends the video reference mode when the video file is selected', async () => {
|
||||
let request: SubsyncManualRunRequest | null = null;
|
||||
const harness = createTestHarness(async (nextRequest) => {
|
||||
request = nextRequest;
|
||||
return { ok: true, message: 'ok' };
|
||||
});
|
||||
|
||||
try {
|
||||
harness.modal.wireDomEvents();
|
||||
harness.modal.openSubsyncModal(BASE_PAYLOAD);
|
||||
|
||||
harness.referenceSelect.value = 'video';
|
||||
harness.runButton.dispatch('click');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(request, {
|
||||
engine: 'alass',
|
||||
targetTrackId: 1,
|
||||
referenceMode: 'video',
|
||||
referenceTrackId: null,
|
||||
});
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('subsync modal hides the reference picker for ffsubsync but keeps the target picker', () => {
|
||||
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
|
||||
|
||||
try {
|
||||
harness.modal.wireDomEvents();
|
||||
harness.modal.openSubsyncModal(BASE_PAYLOAD);
|
||||
|
||||
assert.equal(harness.referenceLabelClassList.contains('hidden'), false);
|
||||
|
||||
harness.ctx.dom.subsyncEngineAlass.checked = false;
|
||||
harness.ctx.dom.subsyncEngineFfsubsync.checked = true;
|
||||
harness.ctx.dom.subsyncEngineFfsubsync.dispatch('change');
|
||||
|
||||
assert.equal(harness.referenceLabelClassList.contains('hidden'), true);
|
||||
assert.equal(harness.targetSelect.value, '1');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
+151
-67
@@ -1,6 +1,14 @@
|
||||
import type { SubsyncManualPayload } from '../../types';
|
||||
import type { SubsyncManualPayload, SubsyncManualRunRequest } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
const VIDEO_REFERENCE_VALUE = 'video';
|
||||
|
||||
interface SubsyncSelection {
|
||||
engine: 'alass' | 'ffsubsync';
|
||||
referenceValue: string;
|
||||
targetTrackId: number | null;
|
||||
}
|
||||
|
||||
export function createSubsyncModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
@@ -8,27 +16,103 @@ export function createSubsyncModal(
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let ffsubsyncAvailable = true;
|
||||
let currentPayload: SubsyncManualPayload | null = null;
|
||||
|
||||
function setSubsyncStatus(message: string, isError = false): void {
|
||||
ctx.dom.subsyncStatus.textContent = message;
|
||||
ctx.dom.subsyncStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function updateSubsyncSourceVisibility(): void {
|
||||
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
||||
ctx.dom.subsyncSourceLabel.classList.toggle('hidden', !useAlass);
|
||||
function hasAlassReference(): boolean {
|
||||
if (!currentPayload) return false;
|
||||
return currentPayload.videoReferenceAvailable || currentPayload.subtitleTracks.length > 1;
|
||||
}
|
||||
|
||||
function renderSubsyncSourceTracks(): void {
|
||||
ctx.dom.subsyncSourceSelect.innerHTML = '';
|
||||
for (const track of ctx.state.subsyncSourceTracks) {
|
||||
const option = document.createElement('option');
|
||||
option.value = String(track.id);
|
||||
option.textContent = track.label;
|
||||
ctx.dom.subsyncSourceSelect.appendChild(option);
|
||||
function updateSubsyncFieldVisibility(): void {
|
||||
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
||||
ctx.dom.subsyncReferenceLabel.classList.toggle('hidden', !useAlass);
|
||||
ctx.dom.subsyncTargetLabel.classList.toggle(
|
||||
'hidden',
|
||||
ctx.state.subsyncSubtitleTracks.length === 0,
|
||||
);
|
||||
}
|
||||
|
||||
function appendOption(select: HTMLSelectElement, value: string, label: string): void {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
function getSelectedTargetTrackId(): number | null {
|
||||
const raw = Number.parseInt(ctx.dom.subsyncTargetSelect.value, 10);
|
||||
return Number.isFinite(raw) ? raw : null;
|
||||
}
|
||||
|
||||
function renderTargetTracks(preferredTrackId: number | null): void {
|
||||
const select = ctx.dom.subsyncTargetSelect;
|
||||
select.innerHTML = '';
|
||||
select.value = '';
|
||||
for (const track of ctx.state.subsyncSubtitleTracks) {
|
||||
appendOption(select, String(track.id), track.label);
|
||||
}
|
||||
ctx.dom.subsyncSourceSelect.disabled = ctx.state.subsyncSourceTracks.length === 0;
|
||||
select.disabled = ctx.state.subsyncSubtitleTracks.length === 0;
|
||||
|
||||
const preferred = ctx.state.subsyncSubtitleTracks.find(
|
||||
(track) => track.id === preferredTrackId,
|
||||
);
|
||||
const fallback = ctx.state.subsyncSubtitleTracks[0];
|
||||
const selected = preferred ?? fallback;
|
||||
if (selected) {
|
||||
select.value = String(selected.id);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReferenceTracks(preferredValue: string | null): void {
|
||||
const select = ctx.dom.subsyncReferenceSelect;
|
||||
const targetTrackId = getSelectedTargetTrackId();
|
||||
const values: string[] = [];
|
||||
|
||||
select.innerHTML = '';
|
||||
select.value = '';
|
||||
for (const track of ctx.state.subsyncSubtitleTracks) {
|
||||
if (track.id === targetTrackId) continue;
|
||||
appendOption(select, String(track.id), track.label);
|
||||
values.push(String(track.id));
|
||||
}
|
||||
if (currentPayload?.videoReferenceAvailable) {
|
||||
appendOption(select, VIDEO_REFERENCE_VALUE, 'Video file (audio reference)');
|
||||
values.push(VIDEO_REFERENCE_VALUE);
|
||||
}
|
||||
select.disabled = values.length === 0;
|
||||
|
||||
const preferred = preferredValue && values.includes(preferredValue) ? preferredValue : null;
|
||||
const defaultTrackValue =
|
||||
currentPayload?.defaultReferenceTrackId !== null &&
|
||||
currentPayload?.defaultReferenceTrackId !== undefined
|
||||
? String(currentPayload.defaultReferenceTrackId)
|
||||
: null;
|
||||
const fallback =
|
||||
defaultTrackValue && values.includes(defaultTrackValue) ? defaultTrackValue : values[0];
|
||||
const selected = preferred ?? fallback;
|
||||
if (selected) {
|
||||
select.value = selected;
|
||||
}
|
||||
}
|
||||
|
||||
function describeSubsyncState(): string {
|
||||
if (!currentPayload) return '';
|
||||
const alassReady = hasAlassReference();
|
||||
if (alassReady && currentPayload.ffsubsyncAvailable) {
|
||||
return 'Choose engine, reference and out-of-sync subtitle, then run.';
|
||||
}
|
||||
if (alassReady) {
|
||||
return 'Choose the alass reference and out-of-sync subtitle, then run.';
|
||||
}
|
||||
if (currentPayload.ffsubsyncAvailable) {
|
||||
return 'No reference available for alass. Use ffsubsync.';
|
||||
}
|
||||
return 'No sync engine available for current media.';
|
||||
}
|
||||
|
||||
function closeSubsyncModal(): void {
|
||||
@@ -46,30 +130,27 @@ export function createSubsyncModal(
|
||||
}
|
||||
}
|
||||
|
||||
function openSubsyncModal(payload: SubsyncManualPayload): void {
|
||||
function openSubsyncModal(payload: SubsyncManualPayload, selection?: SubsyncSelection): void {
|
||||
ctx.state.subsyncSubmitting = false;
|
||||
ctx.state.subsyncSourceTracks = payload.sourceTracks;
|
||||
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||
ctx.state.subsyncSubtitleTracks = payload.subtitleTracks;
|
||||
currentPayload = payload;
|
||||
|
||||
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
|
||||
ctx.dom.subsyncEngineAlass.checked = hasSources;
|
||||
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources && ffsubsyncAvailable;
|
||||
ctx.dom.subsyncEngineFfsubsync.disabled = !ffsubsyncAvailable;
|
||||
ctx.dom.subsyncRunButton.disabled = !hasSources && !ffsubsyncAvailable;
|
||||
const alassReady = hasAlassReference();
|
||||
const useAlass = selection ? selection.engine === 'alass' && alassReady : alassReady;
|
||||
ctx.dom.subsyncEngineAlass.checked = useAlass;
|
||||
ctx.dom.subsyncEngineFfsubsync.checked = !useAlass && payload.ffsubsyncAvailable;
|
||||
ctx.dom.subsyncEngineAlass.disabled = !alassReady;
|
||||
ctx.dom.subsyncEngineFfsubsync.disabled = !payload.ffsubsyncAvailable;
|
||||
ctx.dom.subsyncRunButton.disabled = !alassReady && !payload.ffsubsyncAvailable;
|
||||
|
||||
renderSubsyncSourceTracks();
|
||||
updateSubsyncSourceVisibility();
|
||||
|
||||
setSubsyncStatus(
|
||||
!ffsubsyncAvailable && hasSources
|
||||
? 'Choose alass source, then run.'
|
||||
: !ffsubsyncAvailable
|
||||
? 'No source subtitles available for alass.'
|
||||
: hasSources
|
||||
? 'Choose engine and source, then run.'
|
||||
: 'No source subtitles available for alass. Use ffsubsync.',
|
||||
false,
|
||||
renderTargetTracks(
|
||||
selection
|
||||
? (selection.targetTrackId ?? payload.defaultTargetTrackId)
|
||||
: payload.defaultTargetTrackId,
|
||||
);
|
||||
renderReferenceTracks(selection?.referenceValue ?? null);
|
||||
updateSubsyncFieldVisibility();
|
||||
setSubsyncStatus(describeSubsyncState(), false);
|
||||
|
||||
ctx.state.subsyncModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
@@ -80,25 +161,11 @@ export function createSubsyncModal(
|
||||
}
|
||||
|
||||
function reopenSubsyncModalWithError(
|
||||
sourceTracks: SubsyncManualPayload['sourceTracks'],
|
||||
engine: 'alass' | 'ffsubsync',
|
||||
sourceTrackId: number | null,
|
||||
payload: SubsyncManualPayload,
|
||||
selection: SubsyncSelection,
|
||||
message: string,
|
||||
): void {
|
||||
openSubsyncModal({ sourceTracks, ffsubsyncAvailable });
|
||||
|
||||
if (engine === 'alass' && sourceTracks.length > 0) {
|
||||
ctx.dom.subsyncEngineAlass.checked = true;
|
||||
ctx.dom.subsyncEngineFfsubsync.checked = false;
|
||||
if (Number.isFinite(sourceTrackId)) {
|
||||
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
|
||||
}
|
||||
} else if (ffsubsyncAvailable) {
|
||||
ctx.dom.subsyncEngineAlass.checked = false;
|
||||
ctx.dom.subsyncEngineFfsubsync.checked = true;
|
||||
}
|
||||
|
||||
updateSubsyncSourceVisibility();
|
||||
openSubsyncModal(payload, selection);
|
||||
setSubsyncStatus(message, true);
|
||||
window.electronAPI.notifyOverlayModalOpened('subsync');
|
||||
}
|
||||
@@ -106,6 +173,7 @@ export function createSubsyncModal(
|
||||
async function runSubsyncManualFromModal(): Promise<void> {
|
||||
if (ctx.state.subsyncSubmitting) return;
|
||||
if (ctx.dom.subsyncRunButton.disabled) return;
|
||||
if (!currentPayload) return;
|
||||
|
||||
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
||||
const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked;
|
||||
@@ -115,33 +183,46 @@ export function createSubsyncModal(
|
||||
}
|
||||
|
||||
const engine = useAlass ? 'alass' : 'ffsubsync';
|
||||
const sourceTrackId =
|
||||
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
|
||||
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
|
||||
: null;
|
||||
const referenceValue = ctx.dom.subsyncReferenceSelect.value;
|
||||
const targetTrackId = getSelectedTargetTrackId();
|
||||
|
||||
if (engine === 'alass' && !Number.isFinite(sourceTrackId)) {
|
||||
setSubsyncStatus('Select a source subtitle track for alass.', true);
|
||||
if (targetTrackId === null) {
|
||||
setSubsyncStatus('Select the out-of-sync subtitle track to retime.', true);
|
||||
return;
|
||||
}
|
||||
if (engine === 'alass' && !referenceValue) {
|
||||
setSubsyncStatus('Select a reference for alass.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTracksSnapshot = ctx.state.subsyncSourceTracks.map((track) => ({ ...track }));
|
||||
const useVideoReference = referenceValue === VIDEO_REFERENCE_VALUE;
|
||||
const request: SubsyncManualRunRequest = {
|
||||
engine,
|
||||
targetTrackId,
|
||||
};
|
||||
if (engine === 'alass') {
|
||||
request.referenceMode = useVideoReference ? 'video' : 'track';
|
||||
request.referenceTrackId = useVideoReference ? null : Number.parseInt(referenceValue, 10);
|
||||
}
|
||||
|
||||
const payloadSnapshot: SubsyncManualPayload = {
|
||||
...currentPayload,
|
||||
subtitleTracks: currentPayload.subtitleTracks.map((track) => ({ ...track })),
|
||||
};
|
||||
const selection: SubsyncSelection = { engine, referenceValue, targetTrackId };
|
||||
|
||||
ctx.state.subsyncSubmitting = true;
|
||||
ctx.dom.subsyncRunButton.disabled = true;
|
||||
closeSubsyncModal();
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.runSubsyncManual({
|
||||
engine,
|
||||
sourceTrackId,
|
||||
});
|
||||
const result = await window.electronAPI.runSubsyncManual(request);
|
||||
if (result.ok) return;
|
||||
reopenSubsyncModalWithError(sourceTracksSnapshot, engine, sourceTrackId, result.message);
|
||||
reopenSubsyncModalWithError(payloadSnapshot, selection, result.message);
|
||||
} catch (error) {
|
||||
reopenSubsyncModalWithError(
|
||||
sourceTracksSnapshot,
|
||||
engine,
|
||||
sourceTrackId,
|
||||
payloadSnapshot,
|
||||
selection,
|
||||
`Subsync failed: ${(error as Error).message}`,
|
||||
);
|
||||
} finally {
|
||||
@@ -171,10 +252,13 @@ export function createSubsyncModal(
|
||||
closeSubsyncModal();
|
||||
});
|
||||
ctx.dom.subsyncEngineAlass.addEventListener('change', () => {
|
||||
updateSubsyncSourceVisibility();
|
||||
updateSubsyncFieldVisibility();
|
||||
});
|
||||
ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => {
|
||||
updateSubsyncSourceVisibility();
|
||||
updateSubsyncFieldVisibility();
|
||||
});
|
||||
ctx.dom.subsyncTargetSelect.addEventListener('change', () => {
|
||||
renderReferenceTracks(ctx.dom.subsyncReferenceSelect.value || null);
|
||||
});
|
||||
ctx.dom.subsyncRunButton.addEventListener('click', () => {
|
||||
void runSubsyncManualFromModal();
|
||||
|
||||
@@ -297,6 +297,88 @@ test('overlay notification action buttons send action ids', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay notification keepOpen actions leave the card on screen', () => {
|
||||
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const stack = createFakeElement();
|
||||
const sentActions: string[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
createElement: (tagName: string) => createFakeElement(tagName),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
clearTimeout: () => undefined,
|
||||
setTimeout: () => 1,
|
||||
electronAPI: {
|
||||
sendOverlayNotificationAction: (_notificationId: string, actionId: string) => {
|
||||
sentActions.push(actionId);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const renderer = createOverlayNotificationRenderer({
|
||||
dom: {
|
||||
overlayNotificationStack: stack,
|
||||
},
|
||||
state: {
|
||||
isOverOverlayNotification: false,
|
||||
},
|
||||
} as never);
|
||||
|
||||
renderer.show({
|
||||
id: 'subminer-update-available',
|
||||
title: 'SubMiner update available',
|
||||
body: 'SubMiner v0.15.0 is available',
|
||||
persistent: true,
|
||||
actions: [
|
||||
{ id: 'install-update', label: 'Update' },
|
||||
{ id: 'view-changelog', label: "What's New", keepOpen: true },
|
||||
],
|
||||
});
|
||||
|
||||
const card = stack.children[0];
|
||||
if (!card) {
|
||||
assert.fail('Expected overlay notification card.');
|
||||
}
|
||||
const buttons: typeof card.children = [];
|
||||
const collect = (node: typeof card): void => {
|
||||
if (node.className === 'overlay-notification-action') buttons.push(node);
|
||||
for (const child of node.children) collect(child);
|
||||
};
|
||||
collect(card);
|
||||
assert.equal(buttons.length, 2);
|
||||
|
||||
// "What's New" opens the changelog but must not drop the Update affordance.
|
||||
buttons[1]?.dispatchEventType('click');
|
||||
assert.deepEqual(sentActions, ['view-changelog']);
|
||||
assert.equal(card.classList.contains('leaving'), false);
|
||||
|
||||
buttons[0]?.dispatchEventType('click');
|
||||
assert.deepEqual(sentActions, ['view-changelog', 'install-update']);
|
||||
assert.equal(card.classList.contains('leaving'), true);
|
||||
} finally {
|
||||
if (originalDocument) {
|
||||
Object.defineProperty(globalThis, 'document', originalDocument);
|
||||
} else {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
}
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
} else {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay notification renderer updates same-id progress without replacing the spinner', () => {
|
||||
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
|
||||
@@ -293,7 +293,9 @@ export function createOverlayNotificationRenderer(
|
||||
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, {
|
||||
noteId: action.noteId,
|
||||
});
|
||||
remove(entry.id);
|
||||
if (action.keepOpen !== true) {
|
||||
remove(entry.id);
|
||||
}
|
||||
});
|
||||
actions.append(button);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createKikuModal } from './modals/kiku.js';
|
||||
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
|
||||
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||
import { createSessionHelpModal } from './modals/session-help.js';
|
||||
import { createChangelogModal } from './modals/changelog.js';
|
||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||
@@ -149,6 +150,12 @@ const modalDescriptors = [
|
||||
close: () => sessionHelpModal.closeSessionHelpModal(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'changelog',
|
||||
isOpen: () => ctx.state.changelogModalOpen,
|
||||
close: () => changelogModal.closeChangelogModal(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
] satisfies readonly ModalDescriptor<OverlayHostedModal>[];
|
||||
|
||||
const modalRegistry = createModalRegistry(modalDescriptors);
|
||||
@@ -213,6 +220,10 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const changelogModal = createChangelogModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
shouldRestoreOpenOnStartup: async () =>
|
||||
@@ -266,6 +277,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
handleChangelogKeydown: changelogModal.handleChangelogKeydown,
|
||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||
openControllerSelectModal: () => {
|
||||
if (controllerSelectModal.openControllerSelectModal()) {
|
||||
@@ -525,6 +537,12 @@ function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.notifyOverlayModalOpened('session-help');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenChangelog(() => {
|
||||
runGuarded('changelog:open', () => {
|
||||
changelogModal.openChangelogModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('changelog');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenControllerSelect(() => {
|
||||
runGuarded('controller-select:open', () => {
|
||||
if (controllerSelectModal.openControllerSelectModal()) {
|
||||
@@ -794,6 +812,7 @@ async function init(): Promise<void> {
|
||||
controllerSelectModal.wireDomEvents();
|
||||
controllerDebugModal.wireDomEvents();
|
||||
sessionHelpModal.wireDomEvents();
|
||||
changelogModal.wireDomEvents();
|
||||
subtitleSidebarModal.wireDomEvents();
|
||||
characterDictionaryModal.wireDomEvents();
|
||||
window.addEventListener('beforeunload', () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
SubtitlePosition,
|
||||
SubtitleSidebarSnapshotConfig,
|
||||
SubtitleCue,
|
||||
SubsyncSourceTrack,
|
||||
SubsyncSubtitleTrack,
|
||||
YoutubePickerOpenPayload,
|
||||
} from '../types';
|
||||
|
||||
@@ -85,7 +85,7 @@ export type RendererState = {
|
||||
characterDictionaryStatus: string;
|
||||
|
||||
subsyncModalOpen: boolean;
|
||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
||||
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
|
||||
controllerSelectModalOpen: boolean;
|
||||
@@ -100,6 +100,8 @@ export type RendererState = {
|
||||
|
||||
sessionHelpModalOpen: boolean;
|
||||
sessionHelpSelectedIndex: number;
|
||||
changelogModalOpen: boolean;
|
||||
changelogSelectedIndex: number;
|
||||
playlistBrowserModalOpen: boolean;
|
||||
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
|
||||
playlistBrowserStatus: string;
|
||||
@@ -213,7 +215,7 @@ export function createRendererState(): RendererState {
|
||||
characterDictionaryStatus: '',
|
||||
|
||||
subsyncModalOpen: false,
|
||||
subsyncSourceTracks: [],
|
||||
subsyncSubtitleTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
controllerSelectModalOpen: false,
|
||||
@@ -228,6 +230,8 @@ export function createRendererState(): RendererState {
|
||||
|
||||
sessionHelpModalOpen: false,
|
||||
sessionHelpSelectedIndex: 0,
|
||||
changelogModalOpen: false,
|
||||
changelogSelectedIndex: 0,
|
||||
playlistBrowserModalOpen: false,
|
||||
playlistBrowserSnapshot: null,
|
||||
playlistBrowserStatus: '',
|
||||
|
||||
@@ -3182,3 +3182,267 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Changelog modal */
|
||||
.changelog-content {
|
||||
width: min(820px, 94%);
|
||||
max-height: 86%;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.changelog-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.changelog-refresh {
|
||||
min-height: 28px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid rgba(110, 115, 141, 0.3);
|
||||
background: rgba(49, 50, 68, 0.76);
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.changelog-refresh:hover,
|
||||
.changelog-refresh:focus-visible {
|
||||
border-color: rgba(138, 173, 244, 0.5);
|
||||
color: var(--ctp-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.changelog-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.changelog-installed {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(166, 218, 149, 0.45);
|
||||
background: rgba(166, 218, 149, 0.14);
|
||||
color: var(--ctp-green);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.changelog-source {
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.changelog-warning {
|
||||
min-height: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.changelog-warning:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog-status {
|
||||
min-height: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.changelog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: calc(86vh - 190px);
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.changelog-list-empty {
|
||||
color: var(--ctp-subtext0);
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.changelog-entry {
|
||||
/* Flex children shrink by default, which crushes expanded entries inside the
|
||||
scrolling list; pin them to their content height instead. */
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(110, 115, 141, 0.18);
|
||||
border-radius: 10px;
|
||||
background: rgba(54, 58, 79, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.changelog-entry[open] {
|
||||
border-color: rgba(138, 173, 244, 0.28);
|
||||
background: rgba(54, 58, 79, 0.26);
|
||||
}
|
||||
|
||||
.changelog-entry-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.changelog-entry-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog-entry-summary::before {
|
||||
content: '▸';
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 11px;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.changelog-entry[open] > .changelog-entry-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.changelog-entry-summary:hover,
|
||||
.changelog-entry-summary:focus-visible,
|
||||
.changelog-entry-summary.active {
|
||||
background: rgba(138, 173, 244, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.changelog-entry-summary.active {
|
||||
box-shadow: inset 3px 0 0 var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.changelog-entry-version {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.changelog-entry-date {
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.changelog-entry-badge {
|
||||
margin-left: auto;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.changelog-entry-badge-installed {
|
||||
border: 1px solid rgba(166, 218, 149, 0.5);
|
||||
background: rgba(166, 218, 149, 0.16);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.changelog-entry-badge-newer {
|
||||
border: 1px solid rgba(238, 212, 159, 0.55);
|
||||
background: rgba(238, 212, 159, 0.16);
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.changelog-entry-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 4px 14px 14px;
|
||||
}
|
||||
|
||||
.changelog-section-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.changelog-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.changelog-items-nested {
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
padding-left: 16px;
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
.changelog-item {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.changelog-item::marker {
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.changelog-items-nested > .changelog-item {
|
||||
font-size: 12.5px;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.changelog-item strong {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.changelog-code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
background: rgba(24, 25, 38, 0.85);
|
||||
color: var(--ctp-peach);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.changelog-link {
|
||||
color: var(--ctp-blue);
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
.changelog-internal {
|
||||
border-top: 1px dashed rgba(110, 115, 141, 0.25);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.changelog-internal-summary {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.changelog-empty-entry {
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.changelog-list {
|
||||
max-height: calc(86vh - 200px);
|
||||
}
|
||||
|
||||
.changelog-entry-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +90,10 @@ export type RendererDom = {
|
||||
subsyncCloseButton: HTMLButtonElement;
|
||||
subsyncEngineAlass: HTMLInputElement;
|
||||
subsyncEngineFfsubsync: HTMLInputElement;
|
||||
subsyncSourceLabel: HTMLLabelElement;
|
||||
subsyncSourceSelect: HTMLSelectElement;
|
||||
subsyncReferenceLabel: HTMLLabelElement;
|
||||
subsyncReferenceSelect: HTMLSelectElement;
|
||||
subsyncTargetLabel: HTMLLabelElement;
|
||||
subsyncTargetSelect: HTMLSelectElement;
|
||||
subsyncRunButton: HTMLButtonElement;
|
||||
subsyncStatus: HTMLDivElement;
|
||||
|
||||
@@ -118,6 +120,15 @@ export type RendererDom = {
|
||||
subtitleSidebarStatus: HTMLDivElement;
|
||||
subtitleSidebarList: HTMLUListElement;
|
||||
|
||||
changelogModal: HTMLDivElement;
|
||||
changelogClose: HTMLButtonElement;
|
||||
changelogRefresh: HTMLButtonElement;
|
||||
changelogInstalled: HTMLSpanElement;
|
||||
changelogSource: HTMLSpanElement;
|
||||
changelogWarning: HTMLDivElement;
|
||||
changelogStatus: HTMLDivElement;
|
||||
changelogList: HTMLDivElement;
|
||||
|
||||
sessionHelpModal: HTMLDivElement;
|
||||
sessionHelpClose: HTMLButtonElement;
|
||||
sessionHelpShortcut: HTMLDivElement;
|
||||
@@ -255,8 +266,10 @@ export function resolveRendererDom(): RendererDom {
|
||||
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
|
||||
subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'),
|
||||
subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>('subsyncEngineFfsubsync'),
|
||||
subsyncSourceLabel: getRequiredElement<HTMLLabelElement>('subsyncSourceLabel'),
|
||||
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>('subsyncSourceSelect'),
|
||||
subsyncReferenceLabel: getRequiredElement<HTMLLabelElement>('subsyncReferenceLabel'),
|
||||
subsyncReferenceSelect: getRequiredElement<HTMLSelectElement>('subsyncReferenceSelect'),
|
||||
subsyncTargetLabel: getRequiredElement<HTMLLabelElement>('subsyncTargetLabel'),
|
||||
subsyncTargetSelect: getRequiredElement<HTMLSelectElement>('subsyncTargetSelect'),
|
||||
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
||||
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
|
||||
|
||||
@@ -285,6 +298,14 @@ export function resolveRendererDom(): RendererDom {
|
||||
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
|
||||
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
|
||||
|
||||
changelogModal: getRequiredElement<HTMLDivElement>('changelogModal'),
|
||||
changelogClose: getRequiredElement<HTMLButtonElement>('changelogClose'),
|
||||
changelogRefresh: getRequiredElement<HTMLButtonElement>('changelogRefresh'),
|
||||
changelogInstalled: getRequiredElement<HTMLSpanElement>('changelogInstalled'),
|
||||
changelogSource: getRequiredElement<HTMLSpanElement>('changelogSource'),
|
||||
changelogWarning: getRequiredElement<HTMLDivElement>('changelogWarning'),
|
||||
changelogStatus: getRequiredElement<HTMLDivElement>('changelogStatus'),
|
||||
changelogList: getRequiredElement<HTMLDivElement>('changelogList'),
|
||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
||||
|
||||
@@ -14,6 +14,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
||||
'subtitle-sidebar',
|
||||
'session-help',
|
||||
'character-dictionary',
|
||||
'changelog',
|
||||
] as const;
|
||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||
|
||||
@@ -123,6 +124,7 @@ export const IPC_CHANNELS = {
|
||||
syncUiDeleteSnapshot: 'sync-ui:delete-snapshot',
|
||||
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
||||
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
||||
getChangelogSnapshot: 'changelog:get-snapshot',
|
||||
},
|
||||
event: {
|
||||
subtitleSet: 'subtitle:set',
|
||||
@@ -146,6 +148,7 @@ export const IPC_CHANNELS = {
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
sessionHelpOpen: 'session-help:open',
|
||||
changelogOpen: 'changelog:open',
|
||||
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
|
||||
controllerSelectOpen: 'controller-select:open',
|
||||
controllerDebugOpen: 'controller-debug:open',
|
||||
|
||||
@@ -295,14 +295,27 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
|
||||
|
||||
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
||||
if (!isObject(value)) return null;
|
||||
const { engine, sourceTrackId } = value;
|
||||
const { engine, referenceMode, referenceTrackId, targetTrackId } = value;
|
||||
if (engine !== 'alass' && engine !== 'ffsubsync') return null;
|
||||
if (sourceTrackId !== undefined && sourceTrackId !== null && !isInteger(sourceTrackId)) {
|
||||
if (referenceMode !== undefined && referenceMode !== 'track' && referenceMode !== 'video') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parseOptionalTrackId = (raw: unknown): number | null | undefined | false => {
|
||||
if (raw === undefined) return undefined;
|
||||
if (raw === null) return null;
|
||||
return isInteger(raw) ? raw : false;
|
||||
};
|
||||
|
||||
const parsedReferenceTrackId = parseOptionalTrackId(referenceTrackId);
|
||||
const parsedTargetTrackId = parseOptionalTrackId(targetTrackId);
|
||||
if (parsedReferenceTrackId === false || parsedTargetTrackId === false) return null;
|
||||
|
||||
return {
|
||||
engine,
|
||||
sourceTrackId: sourceTrackId === undefined ? undefined : (sourceTrackId as number | null),
|
||||
referenceMode,
|
||||
referenceTrackId: parsedReferenceTrackId,
|
||||
targetTrackId: parsedTargetTrackId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ export interface SubsyncContext {
|
||||
videoPath: string;
|
||||
primaryTrack: MpvTrack;
|
||||
secondaryTrack: MpvTrack | null;
|
||||
sourceTracks: MpvTrack[];
|
||||
/** Every usable subtitle track, including the primary one. */
|
||||
subtitleTracks: MpvTrack[];
|
||||
audioStreamIndex: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './types/anki';
|
||||
export * from './types/changelog';
|
||||
export * from './types/config';
|
||||
export * from './types/integrations';
|
||||
export * from './types/notification';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ChangelogItem = {
|
||||
/** Bullet text with inline markdown preserved. */
|
||||
text: string;
|
||||
/** Nested bullets, as written with indentation in CHANGELOG.md. */
|
||||
children: ChangelogItem[];
|
||||
};
|
||||
|
||||
export type ChangelogSection = {
|
||||
/** Section heading as written in CHANGELOG.md, e.g. "Added", "Fixed". */
|
||||
heading: string;
|
||||
items: ChangelogItem[];
|
||||
/** True when the section lived inside the collapsed "Internal changes" block. */
|
||||
internal: boolean;
|
||||
};
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
date: string;
|
||||
/** major.minor of `version`, used to decide which entries render expanded. */
|
||||
groupKey: string;
|
||||
sections: ChangelogSection[];
|
||||
};
|
||||
|
||||
export type ChangelogSourceKind = 'remote' | 'bundled';
|
||||
|
||||
export type ChangelogSnapshot = {
|
||||
entries: ChangelogEntry[];
|
||||
/** Version this build reports, e.g. "0.19.2". */
|
||||
installedVersion: string;
|
||||
/** Newest version present in the fetched changelog, or null when empty. */
|
||||
latestVersion: string | null;
|
||||
/** Group key whose entries should start expanded. */
|
||||
expandedGroupKey: string | null;
|
||||
source: ChangelogSourceKind;
|
||||
/** Release tag the remote changelog was read from, when known. */
|
||||
releaseTag?: string;
|
||||
/** Populated when the remote fetch failed and a fallback was used. */
|
||||
warning?: string;
|
||||
/** Populated when no changelog could be loaded at all. */
|
||||
error?: string;
|
||||
};
|
||||
@@ -20,6 +20,8 @@ export interface OverlayNotificationAction {
|
||||
id: string;
|
||||
label: string;
|
||||
noteId?: number;
|
||||
/** Leaves the notification on screen after the action fires (default: dismiss). */
|
||||
keepOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface OverlayNotificationPayload {
|
||||
|
||||
+19
-5
@@ -4,6 +4,7 @@ import type {
|
||||
KikuMergePreviewRequest,
|
||||
KikuMergePreviewResponse,
|
||||
} from './anki';
|
||||
import type { ChangelogSnapshot } from './changelog';
|
||||
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
||||
import type {
|
||||
CompiledSessionBinding,
|
||||
@@ -78,19 +79,28 @@ export interface MpvClient {
|
||||
send(command: { command: unknown[]; request_id?: number }): boolean;
|
||||
}
|
||||
|
||||
export interface SubsyncSourceTrack {
|
||||
export interface SubsyncSubtitleTrack {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SubsyncManualPayload {
|
||||
sourceTracks: SubsyncSourceTrack[];
|
||||
subtitleTracks: SubsyncSubtitleTrack[];
|
||||
defaultReferenceTrackId: number | null;
|
||||
defaultTargetTrackId: number | null;
|
||||
videoReferenceAvailable: boolean;
|
||||
ffsubsyncAvailable: boolean;
|
||||
}
|
||||
|
||||
export type SubsyncReferenceMode = 'track' | 'video';
|
||||
|
||||
export interface SubsyncManualRunRequest {
|
||||
engine: 'alass' | 'ffsubsync';
|
||||
sourceTrackId?: number | null;
|
||||
/** alass reference source: another subtitle track, or the loaded media file itself. */
|
||||
referenceMode?: SubsyncReferenceMode;
|
||||
referenceTrackId?: number | null;
|
||||
/** Subtitle track to retime. Defaults to the active primary track. */
|
||||
targetTrackId?: number | null;
|
||||
}
|
||||
|
||||
export interface SubsyncResult {
|
||||
@@ -498,6 +508,8 @@ export interface ElectronAPI {
|
||||
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||
onOpenSessionHelp: (callback: () => void) => void;
|
||||
onOpenChangelog: (callback: () => void) => void;
|
||||
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
onOpenControllerSelect: (callback: () => void) => void;
|
||||
onOpenControllerDebug: (callback: () => void) => void;
|
||||
onOpenJimaku: (callback: () => void) => void;
|
||||
@@ -554,7 +566,8 @@ export interface ElectronAPI {
|
||||
| 'controller-debug'
|
||||
| 'subtitle-sidebar'
|
||||
| 'session-help'
|
||||
| 'character-dictionary',
|
||||
| 'character-dictionary'
|
||||
| 'changelog',
|
||||
) => void;
|
||||
notifyOverlayModalOpened: (
|
||||
modal:
|
||||
@@ -569,7 +582,8 @@ export interface ElectronAPI {
|
||||
| 'controller-debug'
|
||||
| 'subtitle-sidebar'
|
||||
| 'session-help'
|
||||
| 'character-dictionary',
|
||||
| 'character-dictionary'
|
||||
| 'changelog',
|
||||
) => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||
|
||||
Reference in New Issue
Block a user