mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-10 17:16:20 -07:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecc49a526
|
||
|
|
1e5d7747b4 | ||
|
|
c14c690875
|
||
|
|
84f718043a | ||
|
|
99266294b8 | ||
|
|
c055359be1
|
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.6 (2026-09-04)
|
||||
|
||||
### Added
|
||||
|
||||
- **Card Timing Review**:
|
||||
- Optional pre-generation timing review for word, sentence, and audio cards, with a speech-weighted waveform that flattens background noise so dialogue edges stand out clearly.
|
||||
- The clip end automatically snaps back to where the line's dialogue actually ends once the waveform loads, with drag and keyboard adjustments available.
|
||||
- Audio preview includes a sweeping playhead that plays the clip to its true end, even on high-latency outputs like Bluetooth headphones.
|
||||
- Previous and next subtitle lines can be pulled onto the card with `P`/`N` (or the Prev/Next steppers) and removed with Shift; the sentence preview and waveform markers update automatically.
|
||||
- Cancelling lets you keep a card without media, and the review can be toggled on or off for the session.
|
||||
- **Senren Field Grouping**:
|
||||
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, grouping sentence, furigana, audio, picture, and misc-info fields.
|
||||
- Supports the same auto/manual/disabled modes as Kiku, including the manual merge modal; only one of Senren or Kiku can be enabled at a time.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Remote Stream Mining Performance**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream at each step; the temporary file is cleaned up after ten minutes of inactivity or on exit.
|
||||
- **TsukiHime Release Filtering**: The TsukiHime modal's Japanese and secondary-language tabs now filter the release list by the subtitle languages each release actually carries, and report when no release has subtitles for the active tab.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Subtitle & Mining Accuracy**:
|
||||
- Broadcast-style captions that split one sentence across two on-screen rows (e.g. Crunchyroll Japanese subs) now merge into a single line for the sidebar and mined cards, while separate speakers, sound effects, and labeled turns still stay on their own lines.
|
||||
- Mining from the overlay no longer pulls in a lingering row from the previous caption; the mined sentence and clip timing now match what's actually on screen.
|
||||
- Multi-line copy and mining now select lines backward in timeline order after seeking, instead of in playback encounter order.
|
||||
- Copying a subtitle, mining a sentence, or recording immersion stats no longer includes the separate furigana line that broadcast ASS captions place above a word.
|
||||
- **Card Update Notifications**: Dismissed lingering overlay card-update progress when notification settings switch to OSD before an update finishes.
|
||||
- **Overlay Stability on Hyprland**: Opening a modal window (timing review, Jimaku, session help, and others) while mpv is fullscreen no longer causes the overlay to flicker while the modal loads; the overlay now stays on screen untouched until the modal is ready.
|
||||
- **Jellyfin Subtitle Sync**: Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
||||
- **Secondary Subtitle Visibility**: Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||
|
||||
## v0.19.5 (2026-08-30)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"app-builder-lib": "26.15.3",
|
||||
"brace-expansion": "5.0.9",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"fast-uri": "3.1.5",
|
||||
"fast-uri": "3.1.6",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.1",
|
||||
@@ -406,7 +406,7 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
|
||||
"fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Dismissed active overlay card-update progress when notification settings switch to OSD before an update finishes.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Broadcast-style Japanese caption tracks (Crunchyroll JA subs) that split one sentence across two positioned events now publish it as a single line, so `preserveLineBreaks: false` flattens it, the sidebar lists it once, and mined sentences are whole. Rows from two different speakers, sound effects, and labeled turns still stay on separate lines.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: mining
|
||||
|
||||
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, cancellation choices that include keeping a card without media, and a session-only runtime toggle.
|
||||
- The timing review can pull any number of previous and next subtitle lines onto the card: `P`/`N` (or the Prev/Next steppers) add lines one at a time, Shift removes them, the sentence preview highlights exactly what the card will contain, and the clip range and line boundary markers on the waveform follow the added lines automatically.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: mining
|
||||
|
||||
- Multi-line copy and mining now select backward from the current subtitle in timeline order after seeking, instead of copying lines in playback encounter order. Jumping back to a short previous line also counts as a seek with external subtitle files, so that line becomes the current one.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: anki
|
||||
|
||||
- Clarify that the configured Anki audio field receives sentence clips, including media timing review, and should be separate from Yomitan's word pronunciation field.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: anki
|
||||
|
||||
- Senren note type support for duplicate-card field grouping: enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, with grouped sentence, furigana, audio, picture, and miscInfo entries.
|
||||
- Senren field grouping supports the same auto/manual/disabled modes as Kiku, including the manual merge modal, and is mutually exclusive with Kiku (only one can be enabled at a time).
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selection keeps the excerpt in view during playback and does not seek or require mining a card.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Copying the current subtitle, Anki sentence mining from recent lines, and immersion stats no longer include the separate furigana lines that broadcast-caption ASS files place above a word; recorders now use the same furigana-free text the overlay displays.
|
||||
@@ -138,6 +138,8 @@ Field names are matched against your Anki note type case-insensitively (an exact
|
||||
|
||||
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
|
||||
|
||||
The audio field receives the sentence clip from the video, including when media timing review is enabled. In Settings, set the Anki audio field to your note type's sentence-audio field, such as `SentenceAudio`, and keep it separate from the field Yomitan uses for word pronunciation. If both write to the same field, SubMiner can overwrite the pronunciation audio. Changing the mapping affects future updates; existing cards need their word audio restored separately.
|
||||
|
||||
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
|
||||
|
||||
### Minimal Config
|
||||
@@ -158,6 +160,8 @@ If you only want sentence and audio on your cards:
|
||||
|
||||
SubMiner uses FFmpeg to generate audio and image media from the video. FFmpeg must be installed and on `PATH`.
|
||||
|
||||
For remote streams such as Jellyfin playback, SubMiner downloads the clip's time window once into a temporary Matroska file (a stream copy, no re-encoding) and reads the timing review waveform, audio preview, audio, and image from that file instead of fetching the stream again for each step. The window covers the clip plus padding, plus the visible timeline in timing review, and grows when you reveal more of the timeline. It is deleted when a different window replaces it, after ten minutes without use, or when SubMiner exits. If the download fails, media generation reads the remote stream directly as before.
|
||||
|
||||
### Audio
|
||||
|
||||
Audio is extracted from the video file using the subtitle's start and end timestamps. Padding is opt-in; keep it at `0` when you want sentence audio to start exactly at the mined sentence.
|
||||
@@ -181,7 +185,7 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
|
||||
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels. The Earlier and Later buttons reveal another two seconds of available timeline without moving the selected clip. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
||||
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Subtitles usually linger past the dialogue, so once the waveform loads an untouched clip end moves back to just after the line's last speech (plus the configured padding); the Line end rail keeps marking the subtitle timing, Reset restores it, and a line whose speech runs through its end is left alone. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip; the preview ends when the hidden player has actually played the last sample, so output latency such as Bluetooth headphones does not cut the clip short. Enter confirms, and Escape cancels. The Earlier and Later buttons reveal another two seconds of available timeline without moving the selected clip. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a mono mix, keeps only the 250 to 3500 Hz speech band, and draws each slice's loudness relative to the clip's own noise floor, so steady background music or ambience reads as a flat line while dialogue stands out. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
||||
|
||||
The review can also pull adjacent subtitle lines onto the card. Press `P` or `N` (or use the Prev and Next steppers above the sentence preview) to add the previous or next line, as many times as lines are available; Shift+`P` and Shift+`N` remove them again. The sentence preview lists every included line with the mined line highlighted, so the card's sentence field is always visible before you confirm, and the clip start or end, along with the line-start and line-end rails on the waveform, follows the outermost added line, keeping the review's audio padding. Confirming writes the combined lines to the sentence field; the Reset button drops the added lines along with any timing changes. Adjacent lines come from the parsed subtitle track when one is loaded; otherwise only lines that already played are offered, and a clip capped by `media.maxMediaDuration` keeps the full combined sentence even when the audio cannot cover every added line.
|
||||
|
||||
@@ -351,11 +355,11 @@ For Senren note types, enable `isSenren` instead. Kiku and Senren write incompat
|
||||
|
||||
### What Gets Merged
|
||||
|
||||
| Field | Merge behavior |
|
||||
| -------- | --------------------------------------------- |
|
||||
| Sentence | Both cards' sentences kept as grouped entries |
|
||||
| Audio | Both cards' `[sound:...]` entries kept |
|
||||
| Image | Both cards' images kept |
|
||||
| Field | Merge behavior |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| Sentence | Both cards' sentences kept as grouped entries |
|
||||
| Audio | Both cards' `[sound:...]` entries kept |
|
||||
| Image | Both cards' images kept |
|
||||
| MiscInfo | Both cards' source info kept as grouped entries |
|
||||
|
||||
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.6 (2026-09-04)
|
||||
|
||||
**Added**
|
||||
|
||||
- **Card Timing Review**:
|
||||
- Optional pre-generation timing review for word, sentence, and audio cards, with a speech-weighted waveform that flattens background noise so dialogue edges stand out clearly.
|
||||
- The clip end automatically snaps back to where the line's dialogue actually ends once the waveform loads, with drag and keyboard adjustments available.
|
||||
- Audio preview includes a sweeping playhead that plays the clip to its true end, even on high-latency outputs like Bluetooth headphones.
|
||||
- Previous and next subtitle lines can be pulled onto the card with `P`/`N` (or the Prev/Next steppers) and removed with Shift; the sentence preview and waveform markers update automatically.
|
||||
- Cancelling lets you keep a card without media, and the review can be toggled on or off for the session.
|
||||
- **Senren Field Grouping**:
|
||||
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, grouping sentence, furigana, audio, picture, and misc-info fields.
|
||||
- Supports the same auto/manual/disabled modes as Kiku, including the manual merge modal; only one of Senren or Kiku can be enabled at a time.
|
||||
|
||||
**Changed**
|
||||
|
||||
- **Remote Stream Mining Performance**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream at each step; the temporary file is cleaned up after ten minutes of inactivity or on exit.
|
||||
- **TsukiHime Release Filtering**: The TsukiHime modal's Japanese and secondary-language tabs now filter the release list by the subtitle languages each release actually carries, and report when no release has subtitles for the active tab.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **Subtitle & Mining Accuracy**:
|
||||
- Broadcast-style captions that split one sentence across two on-screen rows (e.g. Crunchyroll Japanese subs) now merge into a single line for the sidebar and mined cards, while separate speakers, sound effects, and labeled turns still stay on their own lines.
|
||||
- Mining from the overlay no longer pulls in a lingering row from the previous caption; the mined sentence and clip timing now match what's actually on screen.
|
||||
- Multi-line copy and mining now select lines backward in timeline order after seeking, instead of in playback encounter order.
|
||||
- Copying a subtitle, mining a sentence, or recording immersion stats no longer includes the separate furigana line that broadcast ASS captions place above a word.
|
||||
- **Card Update Notifications**: Dismissed lingering overlay card-update progress when notification settings switch to OSD before an update finishes.
|
||||
- **Overlay Stability on Hyprland**: Opening a modal window (timing review, Jimaku, session help, and others) while mpv is fullscreen no longer causes the overlay to flicker while the modal loads; the overlay now stays on screen untouched until the modal is ready.
|
||||
- **Jellyfin Subtitle Sync**: Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
||||
- **Secondary Subtitle Visibility**: Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||
|
||||
## v0.19.5 (2026-08-30)
|
||||
|
||||
**Fixed**
|
||||
|
||||
@@ -25,6 +25,8 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
|
||||
|
||||
These work when the overlay window has focus.
|
||||
|
||||
When text is selected in the [subtitle sidebar](./subtitle-sidebar.md#selecting-and-copying-dialogue), `Ctrl/Cmd+C` copies that selection without timestamps, taking priority over the current-subtitle action. `Escape` clears the sidebar selection.
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | ----------------------------------------------- | --------------------------------------- |
|
||||
| `Ctrl/Cmd+S` | Mine current subtitle as sentence card | `shortcuts.mineSentence` |
|
||||
|
||||
@@ -16,6 +16,14 @@ For typeset ASS karaoke and animated signs, SubMiner collapses generated animati
|
||||
|
||||
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
|
||||
|
||||
## Selecting and copying dialogue
|
||||
|
||||
Drag across subtitle text to select an excerpt, including across multiple rows. Scroll to extend a selection through a longer conversation. `Ctrl/Cmd+C` or the **Copy** button copies the highlighted text in subtitle order, without timestamps. Partial first and last lines are preserved, with a blank line between subtitle cues.
|
||||
|
||||
Dragging to select does not seek playback. Playback-following auto-scroll stops while you drag or have a selection, so the excerpt stays in view. Press `Escape` to clear the selection. An ordinary click with no selection still seeks to that cue.
|
||||
|
||||
Selection survives playback updates and Yomitan popup dismissal. Changing media or subtitle sources, refreshing the cue list, or closing the sidebar clears it. Copying an excerpt does not require creating an Anki card.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
Two layout modes are available via `subtitleSidebar.layout`:
|
||||
|
||||
@@ -14,7 +14,7 @@ Unlike Jimaku, TsukiHime needs no account or API key. The only requirement is th
|
||||
|
||||
## How It Works
|
||||
|
||||
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Tracks with no language tag stay visible on the secondary tab.
|
||||
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter both the release list and the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Each tab lists only the releases whose reported subtitle languages include the tab's language, so the Japanese tab hides the many releases that ship English subtitles only. Releases and tracks with no language tag stay visible on the secondary tab. If nothing on the active tab qualifies, the status line says so and points at the other tab.
|
||||
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
|
||||
|
||||
@@ -76,6 +76,7 @@ The previous `--open-animetosho` flag and `__animetosho-open` keybinding command
|
||||
## Troubleshooting
|
||||
|
||||
- **"xz binary not found"** - install `xz`/`xz-utils` with your package manager.
|
||||
- **"No releases with Japanese subtitles"** - none of the search results carry a Japanese track. Most releases only ship English subtitles; try another search, or use the [Jimaku integration](/jimaku-integration) for Japanese subtitles.
|
||||
- **"Batch releases are not supported"** - TsukiHime only exposes extracted attachments for single-file torrents. Pick the single-episode release for your episode instead of a season batch.
|
||||
- **"No text subtitle tracks in this release"** - the release only carries image-based subtitles (PGS/VobSub) or none at all; try a different release (fansub and SubsPlease-style releases almost always carry ASS tracks).
|
||||
- **Timing is off** - the subtitle came from a different release than your video file. Use the subtitle sync modal (`Ctrl+Alt+S`) or pick the release matching your file exactly.
|
||||
|
||||
@@ -37,6 +37,14 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
## Shared Contract Entry Points
|
||||
|
||||
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
|
||||
identifies the media and subtitle source so renderer selections are invalidated on source changes,
|
||||
including changes whose cue text and timings are identical. Native selection and clean clipboard
|
||||
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
|
||||
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
|
||||
The preload bridge writes selections through Electron's clipboard API so copying does not depend
|
||||
on Chromium document focus or require activating the overlay window.
|
||||
|
||||
- Config + app-state contracts: `src/types/config.ts`
|
||||
- Subtitle/token/media annotation contracts: `src/types/subtitle.ts`
|
||||
- Runtime/window/controller/Electron bridge contracts: `src/types/runtime.ts`
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.5",
|
||||
"version": "0.19.6",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -87,7 +87,7 @@
|
||||
"app-builder-lib": "26.15.3",
|
||||
"brace-expansion": "5.0.9",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"fast-uri": "3.1.5",
|
||||
"fast-uri": "3.1.6",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.1",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **Pre-Mining Timing Review**:
|
||||
- Optional review step before creating word, sentence, or audio cards, with a speech-focused waveform that filters out steady background noise so dialogue is easy to spot.
|
||||
- The clip end automatically snaps back to where dialogue actually ends, since subtitles often linger after speech stops.
|
||||
- Drag or use the keyboard to adjust clip boundaries, and preview audio with a sweeping playhead that plays to the true end even on high-latency outputs like Bluetooth headphones.
|
||||
- Pull extra previous or next subtitle lines onto the card with `P`/`N` (or the Prev/Next steppers); a live preview shows exactly what the card will contain.
|
||||
- You can cancel and still keep the card without media, and the review can be toggled on or off for the session.
|
||||
- **Senren Note Type Support**:
|
||||
- Enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, combining sentence, furigana, audio, picture, and misc-info fields.
|
||||
- Supports the same auto/manual/disabled grouping modes as Kiku, including the manual merge modal. Senren and Kiku are mutually exclusive, so only one can be enabled at a time.
|
||||
|
||||
### Changed
|
||||
- **Remote Streaming Mining**: Mining a card from a remote stream (Jellyfin and other HTTP sources) now downloads the clip window once and reuses it for the timing review waveform, audio preview, audio extraction, and screenshot, instead of re-fetching the stream for every step. No action needed; the temporary download is cleaned up automatically after ten minutes of inactivity.
|
||||
- **TsukiHime Release Picker**: The Japanese and secondary-language tabs now filter releases down to ones that actually carry subtitles for that language, and tell you when none do.
|
||||
|
||||
### Fixed
|
||||
- **Broadcast Caption Accuracy**:
|
||||
- Japanese caption tracks split across two positioned lines (e.g. Crunchyroll) now merge into one, so mined sentences, the sidebar, and line-break settings treat them as a single line; lines from different speakers or sound effects still stay separate.
|
||||
- Mining from the overlay no longer picks up a leftover line from the previous caption, so the mined sentence and clip timing match what's actually on screen.
|
||||
- Copying or mining subtitles no longer includes the separate furigana line that some broadcast subtitle files place above kanji.
|
||||
- **Multi-line Copy After Seeking**: Selecting multiple subtitle lines to copy or mine now selects backward in timeline order after a seek, rather than in playback encounter order.
|
||||
- **Overlay Stability**:
|
||||
- On Hyprland, opening a modal (timing review, Jimaku, session help, and others) over fullscreen mpv no longer makes the overlay flicker while the modal loads.
|
||||
- Switching secondary subtitle tracks no longer causes mpv's native secondary subtitles to flash on screen.
|
||||
- **Anki Update Notifications**: Switching notification settings to on-screen display while a card update is still in progress now correctly dismisses the old overlay progress indicator.
|
||||
- **Jellyfin Subtitles**: Subtitle files now load with zero delay in mpv instead of Jellyfin inferring and applying a sync offset.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(anki): add media timing review before card creation by @ksyasuda in #203
|
||||
- fix(jellyfin): stop inferring subtitle delays by @ksyasuda in #227
|
||||
- feat(anki): support Senren scene-switching field grouping by @ksyasuda in #230
|
||||
- fix(mining): copy multi-line subtitles backward from current line by @ksyasuda in #231
|
||||
- fix(subtitles): keep native secondary subtitles hidden by @ksyasuda in #232
|
||||
- fix(subtitles): drop ASS furigana from recorded cues by @ksyasuda in #233
|
||||
- fix(subtitles): merge wrapped positioned caption rows by @ksyasuda in #234
|
||||
- fix(tsukihime): filter releases by subtitle language by @ksyasuda in #235
|
||||
|
||||
## 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`.
|
||||
@@ -121,6 +121,51 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
|
||||
assert.equal(harness.notifications.length, 1);
|
||||
});
|
||||
|
||||
for (const audioField of ['SentenceAudio', 'ContextAudio']) {
|
||||
for (const action of ['confirm', 'use-original'] as const) {
|
||||
test(`NoteUpdateWorkflow respects the configured ${audioField} field with ${action} timing`, async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: { sentence: 'Sentence', audio: audioField },
|
||||
media: { generateAudio: true, generateImage: false },
|
||||
});
|
||||
harness.deps.client.notesInfo = async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
ExpressionAudio: { value: '[sound:word.mp3]' },
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
ContextAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
];
|
||||
harness.deps.captureSubtitleMediaContext = () => ({
|
||||
source: 'overlay',
|
||||
text: 'subtitle-text',
|
||||
startTime: 4,
|
||||
endTime: 6,
|
||||
});
|
||||
harness.deps.reviewMediaTiming = async () => ({
|
||||
action,
|
||||
startTime: 4.2,
|
||||
endTime: 5.8,
|
||||
});
|
||||
harness.deps.generateAudio = async () => Buffer.from('sentence audio');
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.equal(harness.updates[0]?.fields.ExpressionAudio, undefined);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
[audioField]: '[sound:audio_1.mp3]',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('NoteUpdateWorkflow uses configured fields for word-card enrichment with Lapis and Kiku enabled', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getConfig = () => ({
|
||||
|
||||
@@ -306,9 +306,11 @@ test('vocabulary charts use complete top-word and lexical rollup data', () => {
|
||||
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
|
||||
VALUES (?, ?, '', 1700000000, 1700000000, ?)`,
|
||||
);
|
||||
db.exec('BEGIN');
|
||||
for (let index = 0; index < 501; index += 1) {
|
||||
insertWord.run(`語${index}`, `語${index}`, index === 500 ? 10_000 : 1);
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
|
||||
const charts = getVocabularyChartData(db);
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ function createControllerConfigFixture() {
|
||||
|
||||
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
||||
return {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [],
|
||||
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||
config: {
|
||||
|
||||
@@ -23,6 +23,21 @@ describe('buildMediaTimingPreviewArgs', () => {
|
||||
assert.equal(args.at(-1), '/video/show.mkv');
|
||||
});
|
||||
|
||||
test('keeps source timestamps for cached remote windows', () => {
|
||||
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
||||
mediaPath: '/tmp/window.mkv',
|
||||
absoluteTimestamps: true,
|
||||
});
|
||||
|
||||
assert.ok(args.includes('--rebase-start-time=no'));
|
||||
assert.equal(
|
||||
buildMediaTimingPreviewArgs('/tmp/review.sock', { mediaPath: '/video/show.mkv' }).includes(
|
||||
'--rebase-start-time=no',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('separates an option-like media path without adding optional audio arguments', () => {
|
||||
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
||||
mediaPath: '--fullscreen',
|
||||
@@ -189,3 +204,88 @@ test('preview session bounds a connection attempt that never settles', async ()
|
||||
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
|
||||
assert.equal(connectAttempts, 1);
|
||||
});
|
||||
|
||||
function createFakeSocket() {
|
||||
const socket = new EventEmitter() as EventEmitter & {
|
||||
destroyed: boolean;
|
||||
write: (data: string) => boolean;
|
||||
end: () => void;
|
||||
destroy: () => void;
|
||||
off: EventEmitter['off'];
|
||||
};
|
||||
const writes: string[] = [];
|
||||
socket.destroyed = false;
|
||||
socket.write = (data) => {
|
||||
writes.push(data);
|
||||
return true;
|
||||
};
|
||||
socket.end = () => undefined;
|
||||
socket.destroy = () => {
|
||||
socket.destroyed = true;
|
||||
};
|
||||
return { socket, writes };
|
||||
}
|
||||
|
||||
test('preview session plays once to the clip end and reports when mpv has drained it', async () => {
|
||||
const { socket, writes } = createFakeSocket();
|
||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||
child.kill = () => true;
|
||||
const session = new MediaTimingPreviewSession({
|
||||
platform: 'linux',
|
||||
spawnProcess: () => child as never,
|
||||
connectSocket: () => {
|
||||
queueMicrotask(() => socket.emit('connect'));
|
||||
return socket as never;
|
||||
},
|
||||
removeSocketFile: () => undefined,
|
||||
createSocketPath: () => '/tmp/review.sock',
|
||||
});
|
||||
let endedCount = 0;
|
||||
session.onPlaybackEnded(() => {
|
||||
endedCount += 1;
|
||||
});
|
||||
const property = (name: string, data: boolean): string =>
|
||||
`${JSON.stringify({ event: 'property-change', name, data })}\n`;
|
||||
|
||||
await session.start({ mediaPath: '/video/show.mkv' });
|
||||
assert.deepEqual(
|
||||
writes.map((line) => JSON.parse(line).command),
|
||||
[
|
||||
['observe_property', 1, 'eof-reached'],
|
||||
['observe_property', 2, 'pause'],
|
||||
],
|
||||
);
|
||||
// The observers' initial replies describe the idle paused player, not a finished preview.
|
||||
socket.emit('data', property('eof-reached', false) + property('pause', true));
|
||||
assert.equal(endedCount, 0);
|
||||
|
||||
writes.length = 0;
|
||||
await session.play(12.25, 14.5);
|
||||
assert.deepEqual(
|
||||
writes.map((line) => JSON.parse(line).command),
|
||||
[
|
||||
['set_property', 'pause', true],
|
||||
['seek', 12.25, 'absolute+exact'],
|
||||
['set_property', 'end', '14.500'],
|
||||
['set_property', 'pause', false],
|
||||
],
|
||||
);
|
||||
|
||||
// Events may arrive split across chunks. The decoder passing `end` flips eof-reached while
|
||||
// audio still drains; only the keep-open pause that follows marks the preview as finished.
|
||||
socket.emit('data', property('eof-reached', false) + property('pause', false).slice(0, 20));
|
||||
socket.emit('data', property('pause', false).slice(20) + property('eof-reached', true));
|
||||
assert.equal(endedCount, 0);
|
||||
socket.emit('data', property('pause', true));
|
||||
assert.equal(endedCount, 1);
|
||||
socket.emit('data', property('pause', true));
|
||||
assert.equal(endedCount, 1);
|
||||
|
||||
// Stopping early pauses without an end signal, and a later real EOF is not a preview end.
|
||||
await session.play(1, 2);
|
||||
socket.emit('data', property('eof-reached', false) + property('pause', false));
|
||||
await session.stop();
|
||||
socket.emit('data', property('pause', true) + property('eof-reached', true));
|
||||
assert.equal(endedCount, 1);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
@@ -8,12 +8,21 @@ import { randomUUID } from 'crypto';
|
||||
const CONNECT_TIMEOUT_MS = 5_000;
|
||||
const CONNECT_ATTEMPT_TIMEOUT_MS = 500;
|
||||
const CONNECT_RETRY_MS = 40;
|
||||
/**
|
||||
* mpv flips eof-reached as soon as the decoder passes `end`, while its audio buffer is still
|
||||
* draining; keep-open then pauses once the buffer has played out. A preview has ended when
|
||||
* both have happened.
|
||||
*/
|
||||
const EOF_OBSERVER_ID = 1;
|
||||
const PAUSE_OBSERVER_ID = 2;
|
||||
|
||||
export interface MediaTimingPreviewStartOptions {
|
||||
mediaPath: string;
|
||||
executablePath?: string;
|
||||
audioTrackId?: number;
|
||||
volume?: number;
|
||||
/** The file keeps source timestamps (a cached remote window); seek with the original times. */
|
||||
absoluteTimestamps?: boolean;
|
||||
}
|
||||
|
||||
type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>;
|
||||
@@ -51,6 +60,9 @@ export function buildMediaTimingPreviewArgs(
|
||||
if (typeof options.volume === 'number' && Number.isFinite(options.volume)) {
|
||||
args.push(`--volume=${Math.max(0, options.volume)}`);
|
||||
}
|
||||
if (options.absoluteTimestamps) {
|
||||
args.push('--rebase-start-time=no');
|
||||
}
|
||||
args.push('--', options.mediaPath);
|
||||
return args;
|
||||
}
|
||||
@@ -89,6 +101,11 @@ export class MediaTimingPreviewSession {
|
||||
resolve: () => void;
|
||||
} | null = null;
|
||||
private disposed = false;
|
||||
private readBuffer = '';
|
||||
private playing = false;
|
||||
private eofReached = false;
|
||||
private paused = true;
|
||||
private readonly endedListeners = new Set<() => void>();
|
||||
|
||||
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
|
||||
this.deps = {
|
||||
@@ -155,6 +172,11 @@ export class MediaTimingPreviewSession {
|
||||
await this.connectWithRetry(socketPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays [startTime, endTime) once. mpv stops itself at `end` and, thanks to keep-open,
|
||||
* pauses after draining the audio device, so the listener hears the whole clip even on
|
||||
* high-latency outputs. onPlaybackEnded fires when mpv reports the end was reached.
|
||||
*/
|
||||
async play(startTime: number, endTime: number): Promise<void> {
|
||||
if (!this.socket || this.socket.destroyed) {
|
||||
throw new Error('Preview player is not ready');
|
||||
@@ -163,18 +185,68 @@ export class MediaTimingPreviewSession {
|
||||
throw new Error('Preview timing is invalid');
|
||||
}
|
||||
|
||||
this.playing = false;
|
||||
this.send(['set_property', 'pause', true]);
|
||||
this.send(['set_property', 'ab-loop-a', startTime]);
|
||||
this.send(['set_property', 'ab-loop-b', endTime]);
|
||||
this.send(['seek', startTime, 'absolute+exact']);
|
||||
// The option parser wants a time string; a raw JSON number is not accepted for `end`.
|
||||
this.send(['set_property', 'end', endTime.toFixed(3)]);
|
||||
this.send(['set_property', 'pause', false]);
|
||||
// Only the seek's eof-reached=false and the later keep-open pause count for this play.
|
||||
this.eofReached = false;
|
||||
this.paused = false;
|
||||
this.playing = true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.playing = false;
|
||||
if (!this.socket || this.socket.destroyed) return;
|
||||
this.send(['set_property', 'pause', true]);
|
||||
}
|
||||
|
||||
onPlaybackEnded(listener: () => void): void {
|
||||
this.endedListeners.add(listener);
|
||||
}
|
||||
|
||||
private finishPlayback(): void {
|
||||
if (!this.playing) return;
|
||||
this.playing = false;
|
||||
for (const listener of this.endedListeners) listener();
|
||||
}
|
||||
|
||||
private handleSocketData(chunk: Buffer | string): void {
|
||||
this.readBuffer += chunk.toString();
|
||||
let newline = this.readBuffer.indexOf('\n');
|
||||
while (newline !== -1) {
|
||||
const line = this.readBuffer.slice(0, newline).trim();
|
||||
this.readBuffer = this.readBuffer.slice(newline + 1);
|
||||
newline = this.readBuffer.indexOf('\n');
|
||||
if (!line) continue;
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'event' in message &&
|
||||
message.event === 'property-change' &&
|
||||
'name' in message &&
|
||||
'data' in message
|
||||
) {
|
||||
this.handlePropertyChange(message.name, message.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handlePropertyChange(name: unknown, data: unknown): void {
|
||||
if (name === 'eof-reached') this.eofReached = data === true;
|
||||
else if (name === 'pause') this.paused = data === true;
|
||||
else return;
|
||||
if (this.playing && this.eofReached && this.paused) this.finishPlayback();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
@@ -229,6 +301,13 @@ export class MediaTimingPreviewSession {
|
||||
throw new Error('Preview session is closed');
|
||||
}
|
||||
this.socket = socket;
|
||||
this.readBuffer = '';
|
||||
socket.on('data', (chunk: Buffer | string) => {
|
||||
if (this.socket === socket) this.handleSocketData(chunk);
|
||||
});
|
||||
socket.once('close', () => this.finishPlayback());
|
||||
this.send(['observe_property', EOF_OBSERVER_ID, 'eof-reached']);
|
||||
this.send(['observe_property', PAUSE_OBSERVER_ID, 'pause']);
|
||||
return;
|
||||
} catch {
|
||||
if (this.disposed) {
|
||||
|
||||
@@ -35,16 +35,67 @@ test('speech waveform maps the selected FFmpeg stream and visible range', () =>
|
||||
assert.match(args[args.indexOf('-af') + 1] ?? '', /c0=FC/);
|
||||
});
|
||||
|
||||
test('waveform peaks are normalized without flattening quieter sections', () => {
|
||||
test('speech waveform seeks cached windows by source timestamps', () => {
|
||||
const args = buildSpeechWaveformArgs(
|
||||
{
|
||||
mediaPath: { path: '/tmp/window.mkv', absoluteTimestamps: true, singleResolvedStream: true },
|
||||
startTime: 8,
|
||||
endTime: 15,
|
||||
},
|
||||
'downmix',
|
||||
);
|
||||
|
||||
assert.deepEqual(args.slice(args.indexOf('-ss'), args.indexOf('-t') + 2), [
|
||||
'-ss',
|
||||
'8',
|
||||
'-seek_timestamp',
|
||||
'1',
|
||||
'-i',
|
||||
'/tmp/window.mkv',
|
||||
'-t',
|
||||
'7',
|
||||
]);
|
||||
assert.equal(args.includes('-map'), false);
|
||||
});
|
||||
|
||||
test('waveform levels rise with loudness and top out at the reference level', () => {
|
||||
const peaks = computeWaveformPeaks(pcm([0, 1_000, -2_000, 4_000, -8_000, 16_000]), 3);
|
||||
|
||||
assert.equal(peaks.length, 3);
|
||||
assert.ok((peaks[0] ?? 0) > 0);
|
||||
assert.ok((peaks[0] ?? 0) < (peaks[1] ?? 0));
|
||||
assert.equal(peaks[0], 0);
|
||||
assert.ok((peaks[1] ?? 0) > 0);
|
||||
assert.ok((peaks[1] ?? 0) < (peaks[2] ?? 0));
|
||||
assert.equal(peaks[2], 1);
|
||||
});
|
||||
|
||||
test('waveform flattens steady background noise and keeps speech bursts tall', () => {
|
||||
// 20 slices of steady noise at a fixed level with an 18 dB louder "speech" burst in the middle.
|
||||
const noise = 1_000;
|
||||
const samples: number[] = [];
|
||||
for (let slice = 0; slice < 20; slice += 1) {
|
||||
const level = slice >= 8 && slice < 12 ? noise * 8 : noise;
|
||||
for (let sample = 0; sample < 50; sample += 1) {
|
||||
samples.push(sample % 2 === 0 ? level : -level);
|
||||
}
|
||||
}
|
||||
|
||||
const peaks = computeWaveformPeaks(pcm(samples), 20);
|
||||
|
||||
for (const [index, peak] of peaks.entries()) {
|
||||
if (index >= 8 && index < 12) assert.equal(peak, 1);
|
||||
else assert.equal(peak, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('waveform stays flat when the whole range is a single steady level', () => {
|
||||
const peaks = computeWaveformPeaks(
|
||||
pcm(Array.from({ length: 400 }, (_, i) => (i % 2 ? 900 : -900))),
|
||||
40,
|
||||
);
|
||||
|
||||
assert.ok(peaks.every((peak) => peak === 0));
|
||||
});
|
||||
|
||||
test('speech waveform uses a mono downmix when the source has no center activity', async () => {
|
||||
const calls: string[][] = [];
|
||||
const peaks = await generateSpeechWaveform(
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { normalizeMediaInput, type MediaInput } from '../../media-input';
|
||||
|
||||
const WAVEFORM_SAMPLE_RATE = 8_000;
|
||||
const WAVEFORM_POINT_COUNT = 480;
|
||||
const WAVEFORM_TIMEOUT_MS = 15_000;
|
||||
const MAX_WAVEFORM_BYTES = 16 * 1024 * 1024;
|
||||
const SPEECH_FILTER = 'highpass=f=120,lowpass=f=4000';
|
||||
// Keep the band where speech intelligibility lives; bass, drums, and hum sit below it.
|
||||
const SPEECH_FILTER = 'highpass=f=250,lowpass=f=3500';
|
||||
const NOISE_FLOOR_PERCENTILE = 0.2;
|
||||
const REFERENCE_PERCENTILE = 0.95;
|
||||
const NOISE_GATE_DB = 3;
|
||||
const MIN_DISPLAY_RANGE_DB = 12;
|
||||
const SILENCE_DB = -100;
|
||||
const CENTER_CHANNEL_FILTER = `pan=mono|c0=FC,${SPEECH_FILTER}`;
|
||||
const DOWNMIX_FILTER = `aformat=channel_layouts=mono,${SPEECH_FILTER}`;
|
||||
|
||||
export interface SpeechWaveformOptions {
|
||||
mediaPath: string;
|
||||
mediaPath: MediaInput;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
audioStreamIndex?: number;
|
||||
@@ -22,6 +29,7 @@ export function buildSpeechWaveformArgs(
|
||||
mode: 'center' | 'downmix',
|
||||
): string[] {
|
||||
const duration = options.endTime - options.startTime;
|
||||
const input = normalizeMediaInput(options.mediaPath);
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-nostdin',
|
||||
@@ -29,8 +37,9 @@ export function buildSpeechWaveformArgs(
|
||||
'error',
|
||||
'-ss',
|
||||
String(options.startTime),
|
||||
...input.inputArgs,
|
||||
'-i',
|
||||
options.mediaPath,
|
||||
input.path,
|
||||
'-t',
|
||||
String(duration),
|
||||
];
|
||||
@@ -108,11 +117,22 @@ function runFfmpeg(args: string[]): Promise<Buffer> {
|
||||
});
|
||||
}
|
||||
|
||||
function percentile(sortedValues: number[], fraction: number): number {
|
||||
const index = Math.min(sortedValues.length - 1, Math.floor(sortedValues.length * fraction));
|
||||
return sortedValues[index] ?? SILENCE_DB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns mono PCM into 0..1 display heights. Each point is the RMS level of its slice in
|
||||
* dB, measured against the clip's own noise floor (a low percentile of the slices), so
|
||||
* constant background noise draws flat and sustained speech stands out. Peak sampling
|
||||
* would instead follow music transients and lift the floor to nearly speech height.
|
||||
*/
|
||||
export function computeWaveformPeaks(pcm: Buffer, pointCount = WAVEFORM_POINT_COUNT): number[] {
|
||||
const sampleCount = Math.floor(pcm.byteLength / 2);
|
||||
if (sampleCount === 0 || pointCount <= 0) return [];
|
||||
const resolvedPointCount = Math.min(pointCount, sampleCount);
|
||||
const peaks = Array.from({ length: resolvedPointCount }, () => 0);
|
||||
const levelsDb = Array.from({ length: resolvedPointCount }, () => SILENCE_DB);
|
||||
|
||||
for (let point = 0; point < resolvedPointCount; point += 1) {
|
||||
const sampleStart = Math.floor((point * sampleCount) / resolvedPointCount);
|
||||
@@ -120,18 +140,25 @@ export function computeWaveformPeaks(pcm: Buffer, pointCount = WAVEFORM_POINT_CO
|
||||
sampleStart + 1,
|
||||
Math.floor(((point + 1) * sampleCount) / resolvedPointCount),
|
||||
);
|
||||
let peak = 0;
|
||||
let energy = 0;
|
||||
for (let sample = sampleStart; sample < sampleEnd; sample += 1) {
|
||||
peak = Math.max(peak, Math.abs(pcm.readInt16LE(sample * 2)) / 32_768);
|
||||
const value = pcm.readInt16LE(sample * 2) / 32_768;
|
||||
energy += value * value;
|
||||
}
|
||||
peaks[point] = peak;
|
||||
const rms = Math.sqrt(energy / (sampleEnd - sampleStart));
|
||||
levelsDb[point] = rms > 0 ? Math.max(SILENCE_DB, 20 * Math.log10(rms)) : SILENCE_DB;
|
||||
}
|
||||
|
||||
const sortedPeaks = [...peaks].sort((left, right) => left - right);
|
||||
const referenceIndex = Math.min(sortedPeaks.length - 1, Math.floor(sortedPeaks.length * 0.95));
|
||||
const referencePeak = Math.max(sortedPeaks[referenceIndex] ?? 0, 0.01);
|
||||
return peaks.map(
|
||||
(peak) => Math.round(Math.sqrt(Math.min(1, peak / referencePeak)) * 1_000) / 1_000,
|
||||
const sortedLevels = [...levelsDb].sort((left, right) => left - right);
|
||||
const floorDb = percentile(sortedLevels, NOISE_FLOOR_PERCENTILE) + NOISE_GATE_DB;
|
||||
const referenceDb = Math.max(
|
||||
percentile(sortedLevels, REFERENCE_PERCENTILE),
|
||||
floorDb + MIN_DISPLAY_RANGE_DB,
|
||||
);
|
||||
return levelsDb.map(
|
||||
(levelDb) =>
|
||||
Math.round(Math.min(1, Math.max(0, (levelDb - floorDb) / (referenceDb - floorDb))) * 1_000) /
|
||||
1_000,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,15 @@ export function handleOverlayWindowBeforeInputEvent(options: {
|
||||
if (options.kind === 'modal') return false;
|
||||
if (!options.windowVisible) return false;
|
||||
|
||||
// The renderer decides whether Copy targets selected sidebar text or the live cue.
|
||||
if (
|
||||
(options.input.control || options.input.meta) &&
|
||||
!options.input.alt &&
|
||||
!options.input.shift &&
|
||||
(options.input.code === 'KeyC' || options.input.key.toLowerCase() === 'c')
|
||||
)
|
||||
return false;
|
||||
|
||||
if (isKeyboardModeToggleInput(options.input)) {
|
||||
options.preventDefault();
|
||||
options.sendKeyboardModeToggleRequested();
|
||||
|
||||
@@ -85,6 +85,35 @@ test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () =
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('native Copy reaches the renderer before the current-subtitle fallback', () => {
|
||||
for (const modifier of [{ control: true }, { meta: true }]) {
|
||||
const handled = handleOverlayWindowBeforeInputEvent({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
input: {
|
||||
type: 'keyDown',
|
||||
key: 'c',
|
||||
code: 'KeyC',
|
||||
isAutoRepeat: false,
|
||||
isComposing: false,
|
||||
shift: false,
|
||||
control: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
location: 0,
|
||||
modifiers: [],
|
||||
...modifier,
|
||||
},
|
||||
preventDefault: () => assert.fail('Copy must reach Chromium'),
|
||||
sendKeyboardModeToggleRequested: () => assert.fail('Unexpected mode toggle'),
|
||||
sendLookupWindowToggleRequested: () => assert.fail('Unexpected lookup toggle'),
|
||||
tryHandleOverlayShortcutLocalFallback: () => assert.fail('Renderer owns Copy'),
|
||||
forwardTabToMpv: () => assert.fail('Unexpected mpv input'),
|
||||
});
|
||||
assert.equal(handled, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred skips visible overlay restacking after manual hide', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildRemoteMediaWindowArgs,
|
||||
RemoteMediaWindowCache,
|
||||
REMOTE_MEDIA_WINDOW_MAX_SECONDS,
|
||||
type RemoteMediaWindowCacheOptions,
|
||||
} from './remote-media-window-cache';
|
||||
|
||||
const SOURCE = {
|
||||
path: 'https://jellyfin.example/Videos/abc/stream?static=true',
|
||||
audioStreamIndex: 2,
|
||||
};
|
||||
|
||||
type ExecFileStub = NonNullable<RemoteMediaWindowCacheOptions['execFile']>;
|
||||
|
||||
function createStub(options: { fail?: boolean; empty?: boolean; defer?: boolean } = {}) {
|
||||
const calls: string[][] = [];
|
||||
const pendingCallbacks: Array<() => void> = [];
|
||||
const execFile: ExecFileStub = (_file, args, _options, callback) => {
|
||||
calls.push([...args]);
|
||||
const finish = (): void => {
|
||||
const outputPath = args.at(-1);
|
||||
assert.ok(outputPath);
|
||||
if (options.fail) {
|
||||
callback(Object.assign(new Error('boom'), { code: 1 }));
|
||||
return;
|
||||
}
|
||||
if (!options.empty) {
|
||||
fs.writeFileSync(outputPath, 'mkv', 'utf8');
|
||||
}
|
||||
callback(null);
|
||||
};
|
||||
if (options.defer) {
|
||||
pendingCallbacks.push(finish);
|
||||
} else {
|
||||
queueMicrotask(finish);
|
||||
}
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
execFile,
|
||||
flush: () => {
|
||||
for (const finish of pendingCallbacks.splice(0)) finish();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withCache(
|
||||
stubOptions: Parameters<typeof createStub>[0],
|
||||
cacheOptions: Omit<RemoteMediaWindowCacheOptions, 'execFile' | 'tempDir'>,
|
||||
run: (cache: RemoteMediaWindowCache, stub: ReturnType<typeof createStub>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-window-test-'));
|
||||
const stub = createStub(stubOptions);
|
||||
const cache = new RemoteMediaWindowCache({
|
||||
tempDir,
|
||||
execFile: stub.execFile,
|
||||
idleTtlMs: 0,
|
||||
logDebug: () => undefined,
|
||||
...cacheOptions,
|
||||
});
|
||||
try {
|
||||
await run(cache, stub);
|
||||
} finally {
|
||||
cache.cleanup();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function argValue(args: string[], flag: string): string | undefined {
|
||||
const index = args.indexOf(flag);
|
||||
return index === -1 ? undefined : args[index + 1];
|
||||
}
|
||||
|
||||
test('buildRemoteMediaWindowArgs stream-copies the window with source timestamps intact', () => {
|
||||
const args = buildRemoteMediaWindowArgs(
|
||||
{ ...SOURCE, inputOptions: { reconnect: true, headers: { Referer: 'https://a.example/' } } },
|
||||
{ startTime: 22.75, endTime: 33 },
|
||||
'/tmp/window.mkv',
|
||||
);
|
||||
|
||||
const inputIndex = args.indexOf('-i');
|
||||
assert.equal(args[inputIndex + 1], SOURCE.path);
|
||||
assert.ok(args.indexOf('-reconnect') < inputIndex);
|
||||
assert.ok(args.indexOf('-headers') < inputIndex);
|
||||
assert.equal(argValue(args, '-ss'), '22.75');
|
||||
assert.equal(argValue(args, '-t'), '10.25');
|
||||
assert.ok(args.indexOf('-t') < inputIndex);
|
||||
assert.deepEqual(args.slice(args.indexOf('-map'), args.indexOf('-map') + 4), [
|
||||
'-map',
|
||||
'0:v:0?',
|
||||
'-map',
|
||||
'0:2',
|
||||
]);
|
||||
assert.equal(argValue(args, '-c'), 'copy');
|
||||
assert.ok(args.includes('-copyts'));
|
||||
assert.ok(args.includes('-start_at_zero'));
|
||||
assert.equal(argValue(args, '-f'), 'matroska');
|
||||
assert.equal(args.at(-1), '/tmp/window.mkv');
|
||||
});
|
||||
|
||||
test('buildRemoteMediaWindowArgs keeps every audio stream when none is selected', () => {
|
||||
const args = buildRemoteMediaWindowArgs(
|
||||
{ path: SOURCE.path, audioStreamIndex: null },
|
||||
{ startTime: 0, endTime: 5 },
|
||||
'/tmp/window.mkv',
|
||||
);
|
||||
|
||||
assert.equal(args[args.lastIndexOf('-map') + 1], '0:a');
|
||||
});
|
||||
|
||||
test('acquire downloads once and reuses the window for covered ranges', async () => {
|
||||
await withCache({}, {}, async (cache, stub) => {
|
||||
const window = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
|
||||
|
||||
assert.equal(stub.calls.length, 1);
|
||||
assert.equal(argValue(stub.calls[0]!, '-ss'), '9.75');
|
||||
assert.equal(argValue(stub.calls[0]!, '-t'), '5.25');
|
||||
assert.equal(window.startTime, 9.75);
|
||||
assert.equal(window.endTime, 15);
|
||||
assert.equal(window.audioStreamIndex, 2);
|
||||
assert.ok(fs.existsSync(window.path));
|
||||
assert.deepEqual(window.media, {
|
||||
path: window.path,
|
||||
source: 'remote-window',
|
||||
singleResolvedStream: true,
|
||||
absoluteTimestamps: true,
|
||||
});
|
||||
|
||||
assert.equal(await cache.acquire(SOURCE, { startTime: 11, endTime: 15 }), window);
|
||||
assert.equal(await cache.lookup(SOURCE, { startTime: 12, endTime: 12 }), window);
|
||||
assert.equal(
|
||||
await cache.lookup(
|
||||
{ path: SOURCE.path, audioStreamIndex: null },
|
||||
{ startTime: 12, endTime: 13 },
|
||||
),
|
||||
window,
|
||||
);
|
||||
assert.equal(stub.calls.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('lookup never downloads and misses on other ranges, sources, or audio streams', async () => {
|
||||
await withCache({}, {}, async (cache, stub) => {
|
||||
assert.equal(await cache.lookup(SOURCE, { startTime: 10, endTime: 14 }), null);
|
||||
assert.equal(stub.calls.length, 0);
|
||||
|
||||
await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
|
||||
assert.equal(await cache.lookup(SOURCE, { startTime: 14, endTime: 16 }), null);
|
||||
assert.equal(
|
||||
await cache.lookup(
|
||||
{ path: 'https://other.example/stream', audioStreamIndex: 2 },
|
||||
{
|
||||
startTime: 11,
|
||||
endTime: 12,
|
||||
},
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
await cache.lookup(
|
||||
{ path: SOURCE.path, audioStreamIndex: 3 },
|
||||
{ startTime: 11, endTime: 12 },
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(stub.calls.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('acquire widens to the union of the old window and replaces the old file', async () => {
|
||||
await withCache({}, {}, async (cache, stub) => {
|
||||
const first = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
|
||||
const second = await cache.acquire(SOURCE, { startTime: 8, endTime: 12 });
|
||||
|
||||
assert.equal(stub.calls.length, 2);
|
||||
assert.equal(argValue(stub.calls[1]!, '-ss'), '7.75');
|
||||
assert.equal(second.startTime, 7.75);
|
||||
assert.equal(second.endTime, 15);
|
||||
assert.notEqual(second.path, first.path);
|
||||
assert.equal(fs.existsSync(first.path), false);
|
||||
assert.ok(fs.existsSync(second.path));
|
||||
assert.equal(cache.currentWindow, second);
|
||||
});
|
||||
});
|
||||
|
||||
test('acquire shares an in-flight download between concurrent callers', async () => {
|
||||
await withCache({ defer: true }, {}, async (cache, stub) => {
|
||||
const first = cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
|
||||
await Promise.resolve();
|
||||
const second = cache.acquire(SOURCE, { startTime: 11, endTime: 13 });
|
||||
const lookup = cache.lookup(SOURCE, { startTime: 12, endTime: 12 });
|
||||
await Promise.resolve();
|
||||
assert.equal(stub.calls.length, 1);
|
||||
|
||||
stub.flush();
|
||||
const [a, b, c] = await Promise.all([first, second, lookup]);
|
||||
assert.equal(a, b);
|
||||
assert.equal(a, c);
|
||||
assert.equal(stub.calls.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('acquire rejects on ffmpeg failure, leaves no file, and can retry', async () => {
|
||||
await withCache({ fail: true }, {}, async (cache, stub) => {
|
||||
await assert.rejects(
|
||||
cache.acquire(SOURCE, { startTime: 10, endTime: 14 }),
|
||||
/FFmpeg media window failed: boom/,
|
||||
);
|
||||
assert.equal(cache.currentWindow, null);
|
||||
assert.equal(await cache.lookup(SOURCE, { startTime: 10, endTime: 14 }), null);
|
||||
|
||||
await assert.rejects(cache.acquire(SOURCE, { startTime: 10, endTime: 14 }));
|
||||
assert.equal(stub.calls.length, 2);
|
||||
});
|
||||
await withCache({ empty: true }, {}, async (cache) => {
|
||||
await assert.rejects(
|
||||
cache.acquire(SOURCE, { startTime: 10, endTime: 14 }),
|
||||
/exited without creating a media window/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('acquire refuses invalid and oversized ranges without spawning ffmpeg', async () => {
|
||||
await withCache({}, {}, async (cache, stub) => {
|
||||
await assert.rejects(cache.acquire(SOURCE, { startTime: 10, endTime: 10 }), /invalid/);
|
||||
await assert.rejects(cache.acquire(SOURCE, { startTime: -1, endTime: 10 }), /invalid/);
|
||||
await assert.rejects(
|
||||
cache.acquire(SOURCE, { startTime: 0, endTime: REMOTE_MEDIA_WINDOW_MAX_SECONDS + 1 }),
|
||||
/too long/,
|
||||
);
|
||||
assert.equal(stub.calls.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('the window is deleted after the idle timeout and on cleanup', async () => {
|
||||
await withCache({}, { idleTtlMs: 20 }, async (cache) => {
|
||||
const window = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
|
||||
assert.equal(cache.currentWindow, null);
|
||||
assert.equal(fs.existsSync(window.path), false);
|
||||
|
||||
const again = await cache.acquire(SOURCE, { startTime: 10, endTime: 14 });
|
||||
cache.cleanup();
|
||||
assert.equal(fs.existsSync(again.path), false);
|
||||
assert.equal(fs.existsSync(path.dirname(again.path)), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import { execFile as nodeExecFile, type ExecFileException } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '../../logger';
|
||||
import { normalizeMediaInput, type MediaInput, type MediaInputOptions } from '../../media-input';
|
||||
|
||||
const log = createLogger('media-window');
|
||||
|
||||
export const REMOTE_MEDIA_WINDOW_TIMEOUT_MS = 120_000;
|
||||
export const REMOTE_MEDIA_WINDOW_MAX_SECONDS = 180;
|
||||
const HEAD_SLACK_SECONDS = 0.25;
|
||||
const TAIL_SLACK_SECONDS = 1;
|
||||
const DEFAULT_IDLE_TTL_MS = 10 * 60_000;
|
||||
const COVERAGE_EPSILON_SECONDS = 0.01;
|
||||
|
||||
export interface RemoteMediaWindowSource {
|
||||
path: string;
|
||||
inputOptions?: MediaInputOptions;
|
||||
/** FFmpeg stream index to keep; `null`/undefined keeps every audio stream. */
|
||||
audioStreamIndex?: number | null;
|
||||
}
|
||||
|
||||
export interface RemoteMediaWindowRange {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface RemoteMediaWindow {
|
||||
path: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
sourcePath: string;
|
||||
audioStreamIndex: number | null;
|
||||
/** Input descriptor for FFmpeg reads; timestamps stay absolute so callers keep source times. */
|
||||
media: MediaInput;
|
||||
}
|
||||
|
||||
type WindowExecFile = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: { timeout: number },
|
||||
callback: (error: ExecFileException | null) => void,
|
||||
) => void;
|
||||
|
||||
export interface RemoteMediaWindowCacheOptions {
|
||||
tempDir?: string;
|
||||
execFile?: WindowExecFile;
|
||||
idleTtlMs?: number;
|
||||
logDebug?: (message: string) => void;
|
||||
}
|
||||
|
||||
interface PendingFetch extends RemoteMediaWindowRange {
|
||||
sourcePath: string;
|
||||
audioStreamIndex: number | null;
|
||||
promise: Promise<RemoteMediaWindow>;
|
||||
}
|
||||
|
||||
export function isRemoteMediaWindowSourcePath(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value.trim());
|
||||
}
|
||||
|
||||
function describeSourceForDebugLog(sourcePath: string): string {
|
||||
try {
|
||||
return `remote:${new URL(sourcePath).hostname.toLowerCase() || 'unknown'}`;
|
||||
} catch {
|
||||
return 'remote:unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function isUsableRange(range: RemoteMediaWindowRange, allowEmpty: boolean): boolean {
|
||||
return (
|
||||
Number.isFinite(range.startTime) &&
|
||||
Number.isFinite(range.endTime) &&
|
||||
range.startTime >= 0 &&
|
||||
(allowEmpty ? range.endTime >= range.startTime : range.endTime > range.startTime)
|
||||
);
|
||||
}
|
||||
|
||||
function audioStreamMatches(
|
||||
windowIndex: number | null,
|
||||
requested: number | null | undefined,
|
||||
): boolean {
|
||||
return requested == null || windowIndex === requested;
|
||||
}
|
||||
|
||||
function covers(
|
||||
candidate: RemoteMediaWindowRange & { sourcePath: string; audioStreamIndex: number | null },
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): boolean {
|
||||
return (
|
||||
candidate.sourcePath === source.path &&
|
||||
audioStreamMatches(candidate.audioStreamIndex, source.audioStreamIndex) &&
|
||||
candidate.startTime <= range.startTime + COVERAGE_EPSILON_SECONDS &&
|
||||
candidate.endTime >= range.endTime - COVERAGE_EPSILON_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream-copies `[startTime, endTime]` of a remote source into a local Matroska file.
|
||||
* `-copyts -start_at_zero` keeps the source timestamps, so later reads seek with the
|
||||
* original times via `-seek_timestamp 1` (see `MediaInput.absoluteTimestamps`).
|
||||
*/
|
||||
export function buildRemoteMediaWindowArgs(
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
outputPath: string,
|
||||
): string[] {
|
||||
const input = normalizeMediaInput({ path: source.path, inputOptions: source.inputOptions });
|
||||
const audioMap =
|
||||
typeof source.audioStreamIndex === 'number' && Number.isInteger(source.audioStreamIndex)
|
||||
? `0:${source.audioStreamIndex}`
|
||||
: '0:a';
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-nostdin',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-ss',
|
||||
String(range.startTime),
|
||||
'-t',
|
||||
String(range.endTime - range.startTime),
|
||||
...input.inputArgs,
|
||||
'-i',
|
||||
input.path,
|
||||
'-map',
|
||||
'0:v:0?',
|
||||
'-map',
|
||||
audioMap,
|
||||
'-c',
|
||||
'copy',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-copyts',
|
||||
'-start_at_zero',
|
||||
'-f',
|
||||
'matroska',
|
||||
'-y',
|
||||
outputPath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds one downloaded window of the current remote stream so the timing review,
|
||||
* audio extraction, and screenshot all read the same local bytes instead of each
|
||||
* re-fetching the clip over HTTP. A new window replaces the old one; the file is
|
||||
* deleted after `idleTtlMs` without use, on `clear()`, or on `cleanup()`.
|
||||
*/
|
||||
export class RemoteMediaWindowCache {
|
||||
private readonly tempDir: string;
|
||||
private readonly execFile: WindowExecFile;
|
||||
private readonly idleTtlMs: number;
|
||||
private readonly logDebug: (message: string) => void;
|
||||
private current: RemoteMediaWindow | null = null;
|
||||
private pending: PendingFetch | null = null;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private sequence = 0;
|
||||
|
||||
constructor(options: RemoteMediaWindowCacheOptions = {}) {
|
||||
this.tempDir = options.tempDir ?? path.join(os.tmpdir(), 'subminer-media-windows');
|
||||
this.execFile = options.execFile ?? nodeExecFile;
|
||||
this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
||||
this.logDebug = options.logDebug ?? ((message) => log.debug(message));
|
||||
}
|
||||
|
||||
get currentWindow(): RemoteMediaWindow | null {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Returns a ready or in-flight window covering the range; never starts a download. */
|
||||
async lookup(
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<RemoteMediaWindow | null> {
|
||||
if (!isUsableRange(range, true)) return null;
|
||||
if (this.current && covers(this.current, source, range)) {
|
||||
this.touch();
|
||||
return this.current;
|
||||
}
|
||||
const pending = this.pending;
|
||||
if (pending && covers(pending, source, range)) {
|
||||
try {
|
||||
const window = await pending.promise;
|
||||
this.touch();
|
||||
return window;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns a window covering the range, downloading (and widening) one when needed. */
|
||||
async acquire(
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<RemoteMediaWindow> {
|
||||
if (!isUsableRange(range, false)) {
|
||||
throw new Error('Media window range is invalid.');
|
||||
}
|
||||
if (range.endTime - range.startTime > REMOTE_MEDIA_WINDOW_MAX_SECONDS) {
|
||||
throw new Error('Media window range is too long to download.');
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const hit = await this.lookup(source, range);
|
||||
if (hit) return hit;
|
||||
const pending = this.pending;
|
||||
if (!pending) break;
|
||||
// Another caller is already downloading; wait for it, then re-check coverage.
|
||||
await pending.promise.catch(() => null);
|
||||
}
|
||||
|
||||
return this.fetch(source, this.planFetchRange(source, range));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cancelIdleTimer();
|
||||
const current = this.current;
|
||||
this.current = null;
|
||||
if (current) this.removeFile(current.path);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.clear();
|
||||
try {
|
||||
fs.rmSync(this.tempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
log.error('Failed to cleanup media window directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private planFetchRange(
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): RemoteMediaWindowRange {
|
||||
let startTime = Math.max(0, range.startTime - HEAD_SLACK_SECONDS);
|
||||
let endTime = range.endTime + TAIL_SLACK_SECONDS;
|
||||
const current = this.current;
|
||||
if (
|
||||
current &&
|
||||
current.sourcePath === source.path &&
|
||||
audioStreamMatches(current.audioStreamIndex, source.audioStreamIndex)
|
||||
) {
|
||||
// Keep what was already downloaded when the review timeline grows in one direction.
|
||||
const unionStart = Math.min(startTime, current.startTime);
|
||||
const unionEnd = Math.max(endTime, current.endTime);
|
||||
if (unionEnd - unionStart <= REMOTE_MEDIA_WINDOW_MAX_SECONDS) {
|
||||
startTime = unionStart;
|
||||
endTime = unionEnd;
|
||||
}
|
||||
}
|
||||
return { startTime, endTime };
|
||||
}
|
||||
|
||||
private fetch(
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<RemoteMediaWindow> {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||
this.sequence += 1;
|
||||
const outputPath = path.join(this.tempDir, `window_${Date.now()}_${this.sequence}.mkv`);
|
||||
const audioStreamIndex =
|
||||
typeof source.audioStreamIndex === 'number' ? source.audioStreamIndex : null;
|
||||
const description = describeSourceForDebugLog(source.path);
|
||||
const startedAt = Date.now();
|
||||
this.logDebug(
|
||||
`[media-window] fetch start ${description} start=${range.startTime} end=${range.endTime} audioStream=${audioStreamIndex ?? 'all'}`,
|
||||
);
|
||||
|
||||
const promise = new Promise<RemoteMediaWindow>((resolve, reject) => {
|
||||
this.execFile(
|
||||
'ffmpeg',
|
||||
buildRemoteMediaWindowArgs(source, range, outputPath),
|
||||
{ timeout: REMOTE_MEDIA_WINDOW_TIMEOUT_MS },
|
||||
(error) => {
|
||||
const elapsedMs = Math.max(0, Date.now() - startedAt);
|
||||
const size = error ? 0 : this.fileSize(outputPath);
|
||||
if (error || size === 0) {
|
||||
this.removeFile(outputPath);
|
||||
const reason = error
|
||||
? error.code === 'ENOENT'
|
||||
? 'FFmpeg not found. Install FFmpeg to enable media generation.'
|
||||
: `FFmpeg media window failed: ${error.message}`
|
||||
: 'FFmpeg exited without creating a media window.';
|
||||
this.logDebug(`[media-window] fetch failed ${description} elapsedMs=${elapsedMs}`);
|
||||
reject(new Error(reason));
|
||||
return;
|
||||
}
|
||||
const window: RemoteMediaWindow = {
|
||||
path: outputPath,
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
sourcePath: source.path,
|
||||
audioStreamIndex,
|
||||
media: {
|
||||
path: outputPath,
|
||||
source: 'remote-window',
|
||||
singleResolvedStream: true,
|
||||
absoluteTimestamps: true,
|
||||
},
|
||||
};
|
||||
this.logDebug(
|
||||
`[media-window] fetch complete ${description} elapsedMs=${elapsedMs} bytes=${size}`,
|
||||
);
|
||||
this.replaceCurrent(window);
|
||||
resolve(window);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const pending: PendingFetch = {
|
||||
sourcePath: source.path,
|
||||
audioStreamIndex,
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
promise,
|
||||
};
|
||||
this.pending = pending;
|
||||
promise
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
if (this.pending === pending) this.pending = null;
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
private replaceCurrent(window: RemoteMediaWindow): void {
|
||||
const previous = this.current;
|
||||
this.current = window;
|
||||
if (previous && previous.path !== window.path) this.removeFile(previous.path);
|
||||
this.touch();
|
||||
}
|
||||
|
||||
private touch(): void {
|
||||
this.cancelIdleTimer();
|
||||
if (this.idleTtlMs <= 0 || !this.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (this.idleTimer === timer) this.idleTimer = null;
|
||||
this.clear();
|
||||
}, this.idleTtlMs);
|
||||
timer.unref?.();
|
||||
this.idleTimer = timer;
|
||||
}
|
||||
|
||||
private cancelIdleTimer(): void {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
|
||||
private fileSize(filePath: string): number {
|
||||
try {
|
||||
return fs.statSync(filePath).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private removeFile(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
log.debug(`Failed to remove media window ${filePath}:`, (error as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedCache: RemoteMediaWindowCache | null = null;
|
||||
|
||||
/** Process-wide cache so the review modal and card media generation share one download. */
|
||||
export function getSharedRemoteMediaWindowCache(): RemoteMediaWindowCache {
|
||||
sharedCache ??= new RemoteMediaWindowCache();
|
||||
return sharedCache;
|
||||
}
|
||||
+72
-12
@@ -235,7 +235,10 @@ import {
|
||||
createCycleSecondarySubModeRuntimeHandler,
|
||||
} from './main/runtime/domains/mpv';
|
||||
import { buildSubtitleTrackDiagnostics } from './main/runtime/mpv-track-diagnostics';
|
||||
import { resolveCanonicalPrimarySubtitle } from './main/runtime/primary-subtitle-text';
|
||||
import {
|
||||
resolveCanonicalPrimarySubtitle,
|
||||
resolvePrimarySubtitle,
|
||||
} from './main/runtime/primary-subtitle-text';
|
||||
import {
|
||||
createBuildCopyCurrentSubtitleMainDepsHandler,
|
||||
createBuildHandleMineSentenceDigitMainDepsHandler,
|
||||
@@ -464,6 +467,8 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
||||
import { MediaTimingPreviewSession } from './core/services/media-timing-preview';
|
||||
import { getSharedRemoteMediaWindowCache } from './core/services/remote-media-window-cache';
|
||||
import { resolveMediaGenerationInput } from './anki-integration/media-source';
|
||||
import { generateSpeechWaveform } from './core/services/media-timing-waveform';
|
||||
import {
|
||||
collectMediaTimingContextLines,
|
||||
@@ -1827,28 +1832,31 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||
}
|
||||
|
||||
function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null {
|
||||
const canonical = resolveCanonicalPrimarySubtitle({
|
||||
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
|
||||
// event, so a finished caption row lingering beside a fresh line would end up on the
|
||||
// card. The parsed view also carries the cue's own timings for the clip range.
|
||||
const resolved = resolvePrimarySubtitle({
|
||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// Same validity bar as the live capture path: an unusable canonical span must fall
|
||||
// Same validity bar as the live capture path: an unusable resolved span must fall
|
||||
// back rather than hand mining an empty line or an inverted range.
|
||||
const canonicalText = canonical?.text.trim();
|
||||
const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
|
||||
if (
|
||||
!canonical ||
|
||||
!canonicalText ||
|
||||
!Number.isFinite(canonical.startTime) ||
|
||||
!Number.isFinite(canonical.endTime) ||
|
||||
canonical.endTime <= canonical.startTime
|
||||
!resolved ||
|
||||
!resolvedText ||
|
||||
!Number.isFinite(resolved.startTime) ||
|
||||
!Number.isFinite(resolved.endTime) ||
|
||||
resolved.endTime <= resolved.startTime
|
||||
) {
|
||||
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
||||
}
|
||||
return {
|
||||
source: 'overlay',
|
||||
text: canonicalText,
|
||||
startTime: canonical.startTime,
|
||||
endTime: canonical.endTime,
|
||||
text: resolvedText,
|
||||
startTime: resolved.startTime,
|
||||
endTime: resolved.endTime,
|
||||
capturedAtMs: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -2896,6 +2904,21 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
|
||||
createPreviewSession: () => new MediaTimingPreviewSession(),
|
||||
generateWaveform: (options) => generateSpeechWaveform(options),
|
||||
resolveMediaSource: async () => {
|
||||
const resolved = await resolveMediaGenerationInput(appState.mpvClient, 'audio', {
|
||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||
remoteCacheMode: shouldRequireYoutubeMediaCacheForCurrentPlayback() ? 'required' : 'optional',
|
||||
});
|
||||
return resolved
|
||||
? {
|
||||
path: resolved.path,
|
||||
...(resolved.inputOptions ? { inputOptions: resolved.inputOptions } : {}),
|
||||
singleResolvedStream: resolved.singleResolvedStream,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
||||
getSubtitleContextLines: (range) =>
|
||||
collectMediaTimingContextLines({
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
@@ -2904,6 +2927,14 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||
endTime: range.endTime,
|
||||
}),
|
||||
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
|
||||
onPreviewEnded: (reviewId) => {
|
||||
// The review may live in either overlay window; the renderer ignores foreign review ids.
|
||||
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.webContents.send(IPC_CHANNELS.event.mediaTimingReviewPreviewEnded, reviewId);
|
||||
}
|
||||
}
|
||||
},
|
||||
showStatus: (message) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
||||
});
|
||||
@@ -3973,6 +4004,7 @@ const {
|
||||
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
||||
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
|
||||
cleanupRemoteMediaWindows: () => getSharedRemoteMediaWindowCache().cleanup(),
|
||||
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => {
|
||||
void appState.discordPresenceService?.stop();
|
||||
@@ -5692,6 +5724,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
const client = appState.mpvClient;
|
||||
if (!client?.connected) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5711,6 +5747,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||
if (!videoPath) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5725,6 +5765,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
})
|
||||
) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5741,6 +5785,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5751,6 +5799,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
try {
|
||||
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5764,6 +5816,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
||||
appState.activeParsedSubtitleMediaPath = videoPath || null;
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
@@ -5774,6 +5830,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
currentTimeSec,
|
||||
currentSubtitle,
|
||||
|
||||
@@ -828,6 +828,7 @@ test('modal fallback reveal skips showing window when content is not ready', asy
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
platform: 'darwin',
|
||||
scheduleRevealFallback: (callback) => {
|
||||
scheduledReveal = callback;
|
||||
return { scheduled: true } as never;
|
||||
@@ -1363,3 +1364,62 @@ test('modal placement reconcile cancels stale retry ladder after a newer visible
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test('Linux keeps the dedicated modal window unmapped until the renderer opens the modal, then hides the overlay before revealing it', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
const modalWindow = createMockWindow();
|
||||
const order: string[] = [];
|
||||
const hideMain = mainWindow.hide;
|
||||
mainWindow.hide = () => {
|
||||
order.push('main:hide');
|
||||
hideMain();
|
||||
};
|
||||
const showModal = modalWindow.show;
|
||||
modalWindow.show = () => {
|
||||
order.push('modal:show');
|
||||
showModal();
|
||||
};
|
||||
let revealScheduled = false;
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
scheduleRevealFallback: () => {
|
||||
revealScheduled = true;
|
||||
return { scheduled: true } as never;
|
||||
},
|
||||
clearRevealFallback: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const open = () =>
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'media-timing-review:open',
|
||||
{ reviewId: 'review' },
|
||||
{ restoreOnModalClose: 'media-timing-review', preferModalWindow: true },
|
||||
);
|
||||
|
||||
assert.equal(open(), true);
|
||||
assert.deepEqual(modalWindow.sent, [['media-timing-review:open', { reviewId: 'review' }]]);
|
||||
assert.equal(revealScheduled, false);
|
||||
assert.equal(modalWindow.getShowCount(), 0);
|
||||
assert.equal(mainWindow.getHideCount(), 0);
|
||||
|
||||
// The open retry must not map the window before the renderer answers either.
|
||||
assert.equal(open(), true);
|
||||
assert.equal(modalWindow.getShowCount(), 0);
|
||||
|
||||
runtime.notifyOverlayModalOpened('media-timing-review');
|
||||
|
||||
assert.deepEqual(order, ['main:hide', 'modal:show']);
|
||||
assert.equal(mainWindow.isVisible(), false);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
assert.equal(modalWindow.ignoreMouseEvents, false);
|
||||
});
|
||||
|
||||
@@ -90,6 +90,12 @@ export function createOverlayModalRuntimeService(
|
||||
const platform = options.platform ?? process.platform;
|
||||
const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32';
|
||||
const reuseModalWindowAfterClose = platform === 'darwin';
|
||||
// On Linux (Hyprland) every placement dispatch on a mapped window (resize, move, set_prop)
|
||||
// blanks the still-visible overlay for a few frames while mpv is fullscreen. Revealing the
|
||||
// dedicated modal window before its renderer has the modal open runs the placement ladder,
|
||||
// and the open retry, against a visible overlay, which the user sees as flicker. Keep the
|
||||
// window unmapped until the renderer acknowledges the open, then hide the overlay first.
|
||||
const deferModalRevealUntilOpened = platform === 'linux';
|
||||
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
|
||||
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
|
||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||
@@ -457,7 +463,9 @@ export function createOverlayModalRuntimeService(
|
||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
||||
const wasVisible = modalWindow.isVisible();
|
||||
if (!wasVisible) {
|
||||
if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
|
||||
if (deferModalRevealUntilOpened) {
|
||||
// notifyOverlayModalOpened reveals the window once the renderer has the modal open.
|
||||
} else if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
|
||||
showModalWindow(modalWindow);
|
||||
} else {
|
||||
scheduleModalWindowReveal(modalWindow);
|
||||
@@ -560,15 +568,23 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
|
||||
const modalWindow = deps.getModalWindow();
|
||||
const targetIsModalWindow =
|
||||
modalWindow !== null && !modalWindow.isDestroyed() && targetWindow === modalWindow;
|
||||
const handOffMainWindowToModal = (): void => {
|
||||
setMainWindowMousePassthroughForModal(true);
|
||||
setMainWindowVisibilityForModal(true);
|
||||
};
|
||||
|
||||
if (targetIsModalWindow && deferModalRevealUntilOpened) {
|
||||
handOffMainWindowToModal();
|
||||
}
|
||||
if (targetWindow.isVisible()) {
|
||||
ensureModalWindowInteractive(targetWindow);
|
||||
} else {
|
||||
showModalWindow(targetWindow);
|
||||
}
|
||||
|
||||
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
|
||||
setMainWindowMousePassthroughForModal(true);
|
||||
setMainWindowVisibilityForModal(true);
|
||||
if (targetIsModalWindow && !deferModalRevealUntilOpened) {
|
||||
handOffMainWindowToModal();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -46,12 +46,13 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 35);
|
||||
assert.equal(calls.length, 36);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
@@ -60,6 +61,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-youtube-media'));
|
||||
assert.ok(calls.includes('cleanup-remote-media-windows'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
@@ -102,6 +104,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
cleanupInternalSubtitleTrackCache: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupYoutubeMediaCache: () => void;
|
||||
cleanupRemoteMediaWindows: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
@@ -76,6 +77,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
}
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.cleanupYoutubeMediaCache();
|
||||
deps.cleanupRemoteMediaWindows();
|
||||
deps.stopDiscordPresenceService();
|
||||
return Promise.resolve(stopSyncAutoScheduler);
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
@@ -157,6 +158,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupRemoteMediaWindows: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
@@ -210,6 +212,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupRemoteMediaWindows: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
cleanupInternalSubtitleTrackCache: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupYoutubeMediaCache: () => void;
|
||||
cleanupRemoteMediaWindows: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
@@ -148,6 +149,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
|
||||
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
|
||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupRemoteMediaWindows: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
|
||||
@@ -25,7 +25,9 @@ export async function openMediaTimingReviewModal(
|
||||
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
|
||||
{
|
||||
modal: MODAL,
|
||||
timeoutMs: 1_500,
|
||||
// The review renderer regularly needs more than the 1.5 s the other modals allow; a
|
||||
// premature retry re-sends the payload and reloads the waveform for nothing.
|
||||
timeoutMs: 4_000,
|
||||
retryWarning:
|
||||
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
|
||||
import type {
|
||||
RemoteMediaWindow,
|
||||
RemoteMediaWindowRange,
|
||||
RemoteMediaWindowSource,
|
||||
} from '../../core/services/remote-media-window-cache';
|
||||
import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
|
||||
|
||||
type MediaTimingPreviewSessionLike = Pick<MediaTimingPreviewSession, 'start'>;
|
||||
import {
|
||||
buildMediaTimingReviewPayload,
|
||||
collectMediaTimingContextLines,
|
||||
@@ -69,6 +78,8 @@ async function startActiveMediaTimingReview(
|
||||
options: {
|
||||
maxMediaDuration?: number;
|
||||
decisionTimeoutMs?: number;
|
||||
generateWaveform?: () => Promise<number[]>;
|
||||
play?: () => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
const previewCalls: Array<[number, number]> = [];
|
||||
@@ -85,14 +96,16 @@ async function startActiveMediaTimingReview(
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
generateWaveform: options.generateWaveform ?? (async () => []),
|
||||
decisionTimeoutMs: options.decisionTimeoutMs,
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async (startTime, endTime) => {
|
||||
previewCalls.push([startTime, endTime]);
|
||||
await options.play?.();
|
||||
},
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
@@ -136,6 +149,7 @@ test('media timing review pauses playback, resolves exact timing, and restores p
|
||||
previewCalls.push([startTime, endTime]);
|
||||
},
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
@@ -177,12 +191,7 @@ test('media timing review pauses playback, resolves exact timing, and restores p
|
||||
});
|
||||
|
||||
test('media timing review analyzes the visible range on the selected audio stream', async () => {
|
||||
const waveformCalls: Array<{
|
||||
mediaPath: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
audioStreamIndex?: number;
|
||||
}> = [];
|
||||
const waveformCalls: SpeechWaveformOptions[] = [];
|
||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
@@ -202,6 +211,7 @@ test('media timing review analyzes the visible range on the selected audio strea
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
@@ -239,6 +249,302 @@ test('media timing review analyzes the visible range on the selected audio strea
|
||||
]);
|
||||
});
|
||||
|
||||
const REMOTE_STREAM_URL = 'https://jellyfin.example/Videos/abc/stream?static=true';
|
||||
|
||||
function createWindowStub(options: { fail?: boolean } = {}) {
|
||||
const calls: Array<{ source: RemoteMediaWindowSource; range: RemoteMediaWindowRange }> = [];
|
||||
const acquireMediaWindow = async (
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<RemoteMediaWindow> => {
|
||||
calls.push({ source, range });
|
||||
if (options.fail) throw new Error('offline');
|
||||
const windowPath = `/tmp/window-${range.startTime}-${range.endTime}.mkv`;
|
||||
return {
|
||||
path: windowPath,
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
sourcePath: source.path,
|
||||
audioStreamIndex: source.audioStreamIndex ?? null,
|
||||
media: {
|
||||
path: windowPath,
|
||||
source: 'remote-window',
|
||||
singleResolvedStream: true,
|
||||
absoluteTimestamps: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
return { calls, acquireMediaWindow };
|
||||
}
|
||||
|
||||
function createRemoteReviewRuntime(options: {
|
||||
windowStub: ReturnType<typeof createWindowStub>;
|
||||
waveformCalls: SpeechWaveformOptions[];
|
||||
previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]>;
|
||||
previewPlays: Array<[string, number, number]>;
|
||||
disposed: string[];
|
||||
openModal: (
|
||||
runtime: ReturnType<typeof createMediaTimingReviewRuntime>,
|
||||
payload: MediaTimingReviewOpenPayload,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
let runtime!: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: REMOTE_STREAM_URL,
|
||||
currentAudioStreamIndex: 2,
|
||||
requestProperty: async (name) =>
|
||||
({ pause: true, duration: 100, aid: 3, volume: 60 })[
|
||||
name as 'pause' | 'duration' | 'aid' | 'volume'
|
||||
] ?? null,
|
||||
send: () => undefined,
|
||||
}),
|
||||
getCurrentMediaPath: () => REMOTE_STREAM_URL,
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
resolveMediaSource: async () => ({
|
||||
path: REMOTE_STREAM_URL,
|
||||
inputOptions: { reconnect: true },
|
||||
}),
|
||||
acquireMediaWindow: options.windowStub.acquireMediaWindow,
|
||||
generateWaveform: async (waveformOptions) => {
|
||||
options.waveformCalls.push(waveformOptions);
|
||||
return [0.1, 0.8, 0.2];
|
||||
},
|
||||
createPreviewSession: () => {
|
||||
let mediaPath = '';
|
||||
return {
|
||||
start: async (startOptions) => {
|
||||
mediaPath = startOptions.mediaPath;
|
||||
options.previewStarts.push(startOptions);
|
||||
},
|
||||
play: async (startTime, endTime) => {
|
||||
options.previewPlays.push([mediaPath, startTime, endTime]);
|
||||
},
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => {
|
||||
options.disposed.push(mediaPath);
|
||||
},
|
||||
};
|
||||
},
|
||||
openModal: async (payload) => {
|
||||
await options.openModal(runtime, payload);
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
|
||||
test('media timing review downloads one window of a remote stream for the waveform and preview', async () => {
|
||||
const windowStub = createWindowStub();
|
||||
const waveformCalls: SpeechWaveformOptions[] = [];
|
||||
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
|
||||
const previewPlays: Array<[string, number, number]> = [];
|
||||
const disposed: string[] = [];
|
||||
const runtime = createRemoteReviewRuntime({
|
||||
windowStub,
|
||||
waveformCalls,
|
||||
previewStarts,
|
||||
previewPlays,
|
||||
disposed,
|
||||
openModal: async (active, payload) => {
|
||||
const waveform = await active.getWaveform({
|
||||
reviewId: payload.reviewId,
|
||||
startTime: payload.timelineStartTime,
|
||||
endTime: payload.timelineEndTime,
|
||||
});
|
||||
assert.deepEqual(waveform, { ok: true, peaks: [0.1, 0.8, 0.2] });
|
||||
assert.deepEqual(
|
||||
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 }),
|
||||
{ ok: true },
|
||||
);
|
||||
active.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'confirm', startTime: 9.5, endTime: 12.5 },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const decision = await runtime.requestReview({
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.deepEqual(decision, { action: 'confirm', startTime: 9.5, endTime: 12.5 });
|
||||
assert.deepEqual(windowStub.calls, [
|
||||
{
|
||||
source: { path: REMOTE_STREAM_URL, inputOptions: { reconnect: true }, audioStreamIndex: 2 },
|
||||
range: { startTime: 7.5, endTime: 14.5 },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(waveformCalls, [
|
||||
{
|
||||
mediaPath: {
|
||||
path: '/tmp/window-7.5-14.5.mkv',
|
||||
source: 'remote-window',
|
||||
singleResolvedStream: true,
|
||||
absoluteTimestamps: true,
|
||||
},
|
||||
startTime: 7.5,
|
||||
endTime: 14.5,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(previewStarts, [
|
||||
{
|
||||
mediaPath: '/tmp/window-7.5-14.5.mkv',
|
||||
executablePath: 'mpv',
|
||||
volume: 60,
|
||||
absoluteTimestamps: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(previewPlays, [['/tmp/window-7.5-14.5.mkv', 9.5, 12.5]]);
|
||||
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv']);
|
||||
});
|
||||
|
||||
test('media timing review restarts the preview on a wider window when the timeline grows', async () => {
|
||||
const windowStub = createWindowStub();
|
||||
const waveformCalls: SpeechWaveformOptions[] = [];
|
||||
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
|
||||
const previewPlays: Array<[string, number, number]> = [];
|
||||
const disposed: string[] = [];
|
||||
const runtime = createRemoteReviewRuntime({
|
||||
windowStub,
|
||||
waveformCalls,
|
||||
previewStarts,
|
||||
previewPlays,
|
||||
disposed,
|
||||
openModal: async (active, payload) => {
|
||||
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 });
|
||||
// The user revealed two more seconds before the clip.
|
||||
await active.getWaveform({ reviewId: payload.reviewId, startTime: 5.5, endTime: 14.5 });
|
||||
await active.previewRange({ reviewId: payload.reviewId, startTime: 6, endTime: 12.5 });
|
||||
active.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
windowStub.calls.map((call) => call.range),
|
||||
[
|
||||
{ startTime: 7.5, endTime: 14.5 },
|
||||
{ startTime: 5.5, endTime: 14.5 },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
previewStarts.map((start) => start.mediaPath),
|
||||
['/tmp/window-7.5-14.5.mkv', '/tmp/window-5.5-14.5.mkv'],
|
||||
);
|
||||
assert.deepEqual(previewPlays, [
|
||||
['/tmp/window-7.5-14.5.mkv', 9.5, 12.5],
|
||||
['/tmp/window-5.5-14.5.mkv', 6, 12.5],
|
||||
]);
|
||||
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv', '/tmp/window-5.5-14.5.mkv']);
|
||||
assert.equal(waveformCalls[0]?.startTime, 5.5);
|
||||
});
|
||||
|
||||
test('media timing review falls back to the remote stream after one failed window download', async () => {
|
||||
const windowStub = createWindowStub({ fail: true });
|
||||
const waveformCalls: SpeechWaveformOptions[] = [];
|
||||
const previewStarts: Array<Parameters<MediaTimingPreviewSessionLike['start']>[0]> = [];
|
||||
const previewPlays: Array<[string, number, number]> = [];
|
||||
const disposed: string[] = [];
|
||||
const runtime = createRemoteReviewRuntime({
|
||||
windowStub,
|
||||
waveformCalls,
|
||||
previewStarts,
|
||||
previewPlays,
|
||||
disposed,
|
||||
openModal: async (active, payload) => {
|
||||
await active.getWaveform({
|
||||
reviewId: payload.reviewId,
|
||||
startTime: payload.timelineStartTime,
|
||||
endTime: payload.timelineEndTime,
|
||||
});
|
||||
await active.previewRange({ reviewId: payload.reviewId, startTime: 9.5, endTime: 12.5 });
|
||||
active.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.requestReview({
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.equal(windowStub.calls.length, 1);
|
||||
assert.deepEqual(waveformCalls, [
|
||||
{
|
||||
mediaPath: { path: REMOTE_STREAM_URL, inputOptions: { reconnect: true } },
|
||||
startTime: 7.5,
|
||||
endTime: 14.5,
|
||||
audioStreamIndex: 2,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(previewStarts, [
|
||||
{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 },
|
||||
]);
|
||||
assert.deepEqual(previewPlays, [[REMOTE_STREAM_URL, 9.5, 12.5]]);
|
||||
});
|
||||
|
||||
test('media timing review never downloads windows for local media', async () => {
|
||||
const windowStub = createWindowStub();
|
||||
let runtime!: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
|
||||
send: () => undefined,
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
resolveMediaSource: async () => ({ path: '/video/show.mkv' }),
|
||||
acquireMediaWindow: windowStub.acquireMediaWindow,
|
||||
generateWaveform: async () => [0.1, 0.8, 0.2],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 7.5, endTime: 14.5 });
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
await runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.equal(windowStub.calls.length, 0);
|
||||
});
|
||||
|
||||
test('media timing review rejects stale and out-of-range actions before allowing discard', async () => {
|
||||
const { runtime, payload, pendingDecision, previewCalls } = await startActiveMediaTimingReview({
|
||||
maxMediaDuration: 3,
|
||||
@@ -246,14 +552,14 @@ test('media timing review rejects stale and out-of-range actions before allowing
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.previewRange({ reviewId: 'stale-review', startTime: 10, endTime: 12 }),
|
||||
{ ok: false, message: 'This timing review is no longer active.' },
|
||||
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtime.resolveReview({
|
||||
reviewId: 'stale-review',
|
||||
decision: { action: 'confirm', startTime: 10, endTime: 12 },
|
||||
}),
|
||||
{ ok: false, message: 'This timing review is no longer active.' },
|
||||
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtime.resolveReview({
|
||||
@@ -346,6 +652,7 @@ test('media timing review does not resume playback when the prior state is unava
|
||||
},
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
@@ -384,11 +691,17 @@ test('media timing review restores playback when setup fails after pausing', asy
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => {
|
||||
getMpvExecutablePath: () => {
|
||||
throw new Error('preview setup failed');
|
||||
},
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async () => true,
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
@@ -426,6 +739,7 @@ test('disposing an open review settles it with original timing and restores play
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async () => true,
|
||||
@@ -449,3 +763,97 @@ test('disposing an open review settles it with original timing and restores play
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
|
||||
const endedReviewIds: string[] = [];
|
||||
const playback: { ended?: () => void } = {};
|
||||
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
|
||||
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
|
||||
publishPayload = resolve;
|
||||
});
|
||||
const runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'duration' ? 100 : null),
|
||||
send: () => undefined,
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
generateWaveform: async () => [],
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
onPlaybackEnded: (listener) => {
|
||||
playback.ended = listener;
|
||||
},
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
publishPayload(payload);
|
||||
return true;
|
||||
},
|
||||
onPreviewEnded: (reviewId) => {
|
||||
endedReviewIds.push(reviewId);
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
const pendingDecision = runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
const payload = await openedPayload;
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.previewRange({ reviewId: payload.reviewId, startTime: 10, endTime: 12 }),
|
||||
{
|
||||
ok: true,
|
||||
},
|
||||
);
|
||||
assert.ok(playback.ended);
|
||||
playback.ended();
|
||||
assert.deepEqual(endedReviewIds, [payload.reviewId]);
|
||||
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
|
||||
await pendingDecision;
|
||||
playback.ended();
|
||||
assert.deepEqual(endedReviewIds, [payload.reviewId]);
|
||||
});
|
||||
|
||||
test('preview reports a stale review when the review ends during playback', async () => {
|
||||
let endReview: (() => Promise<void>) | null = null;
|
||||
const { runtime, payload, pendingDecision } = await startActiveMediaTimingReview({
|
||||
play: async () => {
|
||||
await endReview?.();
|
||||
},
|
||||
});
|
||||
endReview = () => runtime.dispose();
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.previewRange({ reviewId: payload.reviewId, startTime: 10, endTime: 12 }),
|
||||
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
|
||||
);
|
||||
await pendingDecision;
|
||||
});
|
||||
|
||||
test('waveform reports a stale review when the review ends during analysis', async () => {
|
||||
let endReview: (() => Promise<void>) | null = null;
|
||||
const { runtime, payload, pendingDecision } = await startActiveMediaTimingReview({
|
||||
generateWaveform: async () => {
|
||||
await endReview?.();
|
||||
return [0.1, 0.9, 0.2];
|
||||
},
|
||||
});
|
||||
endReview = () => runtime.dispose();
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 8, endTime: 14 }),
|
||||
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
|
||||
);
|
||||
await pendingDecision;
|
||||
});
|
||||
|
||||
@@ -11,6 +11,13 @@ import type {
|
||||
MediaTimingReviewWaveformResult,
|
||||
} from '../../types/anki';
|
||||
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
|
||||
import {
|
||||
isRemoteMediaWindowSourcePath,
|
||||
type RemoteMediaWindow,
|
||||
type RemoteMediaWindowRange,
|
||||
type RemoteMediaWindowSource,
|
||||
} from '../../core/services/remote-media-window-cache';
|
||||
import type { MediaInput, MediaInputOptions } from '../../media-input';
|
||||
|
||||
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
|
||||
const REVIEW_DECISION_TIMEOUT_MS = 5 * 60_000;
|
||||
@@ -31,19 +38,38 @@ interface PreviewSession {
|
||||
executablePath?: string;
|
||||
audioTrackId?: number;
|
||||
volume?: number;
|
||||
absoluteTimestamps?: boolean;
|
||||
}): Promise<void>;
|
||||
play(startTime: number, endTime: number): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
/** Fires when the player reaches the end of the clip started by play(). */
|
||||
onPlaybackEnded(listener: () => void): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface ReviewMediaSource {
|
||||
path: string;
|
||||
inputOptions?: MediaInputOptions;
|
||||
singleResolvedStream?: boolean;
|
||||
}
|
||||
|
||||
interface ActiveReview {
|
||||
payload: MediaTimingReviewOpenPayload;
|
||||
/** What the hidden mpv preview plays when no cached window is available. */
|
||||
mediaPath: string;
|
||||
/** What the waveform reads when no cached window is available. */
|
||||
waveformMedia: MediaInput;
|
||||
audioStreamIndex?: number;
|
||||
/** Remote source to download windows of; null for local media or without a cache. */
|
||||
windowSource: RemoteMediaWindowSource | null;
|
||||
/** Latest window returned for this review; reused while it still covers the request. */
|
||||
window: RemoteMediaWindow | null;
|
||||
windowRequest: (RemoteMediaWindowRange & { promise: Promise<RemoteMediaWindow | null> }) | null;
|
||||
windowFailed: boolean;
|
||||
previewOptions: { executablePath?: string; audioTrackId?: number; volume?: number };
|
||||
preview: { path: string; session: Promise<PreviewSession> } | null;
|
||||
mpvClient: ReviewMpvClient;
|
||||
restorePlayback: boolean;
|
||||
preview: Promise<PreviewSession>;
|
||||
resolve: (decision: MediaTimingReviewDecision) => void;
|
||||
}
|
||||
|
||||
@@ -53,12 +79,21 @@ export interface MediaTimingReviewRuntimeDeps {
|
||||
getMpvExecutablePath: () => string;
|
||||
createPreviewSession: () => PreviewSession;
|
||||
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
|
||||
/** Resolves the FFmpeg-readable stream URL and headers behind the current media path. */
|
||||
resolveMediaSource?: () => Promise<ReviewMediaSource | null>;
|
||||
/** Downloads (or reuses) a local window of a remote source covering the range. */
|
||||
acquireMediaWindow?: (
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
) => Promise<RemoteMediaWindow>;
|
||||
getSubtitleContextLines?: (range: { startTime: number; endTime: number }) => {
|
||||
previous: MediaTimingReviewContextLine[];
|
||||
next: MediaTimingReviewContextLine[];
|
||||
};
|
||||
decisionTimeoutMs?: number;
|
||||
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
||||
/** Tells the modal that the hidden player finished the previewed clip. */
|
||||
onPreviewEnded?: (reviewId: string) => void;
|
||||
showStatus: (message: string) => void;
|
||||
}
|
||||
|
||||
@@ -124,6 +159,15 @@ export function collectMediaTimingContextLines(options: {
|
||||
return { previous, next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Result for requests that name a review main has already resolved or disposed (decision
|
||||
* watchdog, overlay teardown, duplicate modal). The renderer closes on it instead of
|
||||
* leaving the user with controls that can never succeed.
|
||||
*/
|
||||
function staleReviewResult(): MediaTimingReviewActionResult {
|
||||
return { ok: false, stale: true, message: 'This timing review is no longer active.' };
|
||||
}
|
||||
|
||||
function isValidMediaTimingRange(
|
||||
payload: MediaTimingReviewOpenPayload,
|
||||
startTime: number,
|
||||
@@ -199,6 +243,89 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWindow(
|
||||
review: ActiveReview,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<RemoteMediaWindow | null> {
|
||||
const { windowSource } = review;
|
||||
if (!windowSource || review.windowFailed || !deps.acquireMediaWindow) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const coversRange = (candidate: RemoteMediaWindowRange): boolean =>
|
||||
candidate.startTime <= range.startTime && candidate.endTime >= range.endTime;
|
||||
if (review.window && coversRange(review.window)) return Promise.resolve(review.window);
|
||||
const inFlight = review.windowRequest;
|
||||
if (inFlight && coversRange(inFlight)) return inFlight.promise;
|
||||
|
||||
const request = {
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
promise: Promise.resolve<RemoteMediaWindow | null>(null),
|
||||
};
|
||||
request.promise = deps
|
||||
.acquireMediaWindow(windowSource, { startTime: range.startTime, endTime: range.endTime })
|
||||
.then((window) => {
|
||||
review.window = window;
|
||||
return window;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to the remote source for the rest of this review instead of retrying.
|
||||
review.windowFailed = true;
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
if (review.windowRequest === request) review.windowRequest = null;
|
||||
});
|
||||
review.windowRequest = request;
|
||||
return request.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preview player for the range, restarting it when the range needs a
|
||||
* different file (the first cached window, or a wider one after the timeline grew).
|
||||
*/
|
||||
async function previewFor(
|
||||
review: ActiveReview,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<PreviewSession> {
|
||||
const window = await ensureWindow(review, range);
|
||||
if (active !== review) {
|
||||
// The review ended during the download; do not start a player nobody will dispose.
|
||||
throw new Error('This timing review is no longer active.');
|
||||
}
|
||||
const mediaPath = window?.path ?? review.mediaPath;
|
||||
if (review.preview?.path === mediaPath) return review.preview.session;
|
||||
|
||||
const previous = review.preview;
|
||||
const session = deps.createPreviewSession();
|
||||
session.onPlaybackEnded(() => {
|
||||
if (active === review && review.preview?.session === started) {
|
||||
deps.onPreviewEnded?.(review.payload.reviewId);
|
||||
}
|
||||
});
|
||||
const { audioTrackId, ...previewOptions } = review.previewOptions;
|
||||
const started = session
|
||||
.start({
|
||||
mediaPath,
|
||||
...previewOptions,
|
||||
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
|
||||
...(window
|
||||
? { absoluteTimestamps: true }
|
||||
: audioTrackId !== undefined
|
||||
? { audioTrackId }
|
||||
: {}),
|
||||
})
|
||||
.then(() => session)
|
||||
.catch((error) => {
|
||||
session.dispose();
|
||||
throw error;
|
||||
});
|
||||
review.preview = { path: mediaPath, session: started };
|
||||
void started.catch(() => {});
|
||||
if (previous) void previous.session.then((old) => old.dispose()).catch(() => {});
|
||||
return started;
|
||||
}
|
||||
|
||||
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
const mediaPath =
|
||||
@@ -208,11 +335,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
|
||||
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw] = await Promise.all([
|
||||
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource] = await Promise.all([
|
||||
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
|
||||
deps.resolveMediaSource?.().catch(() => null) ?? null,
|
||||
]);
|
||||
const pauseState = booleanProperty(pauseRaw);
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
|
||||
@@ -232,38 +360,51 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
mediaDuration: finiteNumber(durationRaw) ?? undefined,
|
||||
...(contextLines ? { contextLines } : {}),
|
||||
});
|
||||
const previewSession = deps.createPreviewSession();
|
||||
const preview = previewSession
|
||||
.start({
|
||||
mediaPath,
|
||||
executablePath: deps.getMpvExecutablePath(),
|
||||
audioTrackId: finiteNumber(audioTrackRaw) ?? undefined,
|
||||
volume: finiteNumber(volumeRaw) ?? undefined,
|
||||
})
|
||||
.then(() => previewSession)
|
||||
.catch((error) => {
|
||||
previewSession.dispose();
|
||||
throw error;
|
||||
});
|
||||
void preview.catch(() => {});
|
||||
const sourcePath = resolvedSource?.path.trim() || mediaPath;
|
||||
const inputOptions = resolvedSource?.inputOptions;
|
||||
const audioStreamIndex =
|
||||
resolvedSource?.singleResolvedStream || mpvClient.currentAudioStreamIndex == null
|
||||
? undefined
|
||||
: mpvClient.currentAudioStreamIndex;
|
||||
const windowSource: RemoteMediaWindowSource | null =
|
||||
deps.acquireMediaWindow && isRemoteMediaWindowSourcePath(sourcePath)
|
||||
? {
|
||||
path: sourcePath,
|
||||
...(inputOptions ? { inputOptions } : {}),
|
||||
audioStreamIndex: audioStreamIndex ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
let resolveDecision!: (decision: MediaTimingReviewDecision) => void;
|
||||
const decisionPromise = new Promise<MediaTimingReviewDecision>((resolve) => {
|
||||
resolveDecision = resolve;
|
||||
});
|
||||
active = {
|
||||
const review: ActiveReview = {
|
||||
payload,
|
||||
mediaPath,
|
||||
...(mpvClient.currentAudioStreamIndex !== null &&
|
||||
mpvClient.currentAudioStreamIndex !== undefined
|
||||
? { audioStreamIndex: mpvClient.currentAudioStreamIndex }
|
||||
: {}),
|
||||
waveformMedia: inputOptions ? { path: sourcePath, inputOptions } : sourcePath,
|
||||
...(audioStreamIndex !== undefined ? { audioStreamIndex } : {}),
|
||||
windowSource,
|
||||
window: null,
|
||||
windowRequest: null,
|
||||
windowFailed: false,
|
||||
previewOptions: {
|
||||
executablePath: deps.getMpvExecutablePath(),
|
||||
audioTrackId: finiteNumber(audioTrackRaw) ?? undefined,
|
||||
volume: finiteNumber(volumeRaw) ?? undefined,
|
||||
},
|
||||
preview: null,
|
||||
mpvClient,
|
||||
restorePlayback: pendingPauseRestore === mpvClient,
|
||||
preview,
|
||||
resolve: resolveDecision,
|
||||
};
|
||||
active = review;
|
||||
pendingPauseRestore = null;
|
||||
// Download the visible timeline once now; the waveform and preview both wait on it.
|
||||
void previewFor(review, {
|
||||
startTime: payload.timelineStartTime,
|
||||
endTime: payload.timelineEndTime,
|
||||
}).catch(() => {});
|
||||
|
||||
const opened = await deps.openModal(payload).catch(() => false);
|
||||
if (!opened) {
|
||||
@@ -311,16 +452,27 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
): Promise<MediaTimingReviewActionResult> {
|
||||
const current = active;
|
||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||
return { ok: false, message: 'This timing review is no longer active.' };
|
||||
return staleReviewResult();
|
||||
}
|
||||
if (!isValidMediaTimingRange(current.payload, request.startTime, request.endTime)) {
|
||||
return { ok: false, message: 'The selected preview range is invalid.' };
|
||||
}
|
||||
try {
|
||||
const previewSession = await current.preview;
|
||||
const previewSession = await previewFor(current, request);
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
await previewSession.play(request.startTime, request.endTime);
|
||||
// Playback spans the whole clip, so the review can end (watchdog, teardown) while
|
||||
// it runs; reporting success would leave the modal open on a dead review.
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: `Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -333,7 +485,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
): Promise<MediaTimingReviewWaveformResult> {
|
||||
const current = active;
|
||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||
return { ok: false, message: 'This timing review is no longer active.' };
|
||||
return staleReviewResult();
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(request.startTime) ||
|
||||
@@ -347,19 +499,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
}
|
||||
|
||||
try {
|
||||
const window = await ensureWindow(current, request);
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
const peaks = await deps.generateWaveform({
|
||||
mediaPath: current.mediaPath,
|
||||
mediaPath: window?.media ?? current.waveformMedia,
|
||||
startTime: request.startTime,
|
||||
endTime: request.endTime,
|
||||
...(current.audioStreamIndex !== undefined
|
||||
...(!window && current.audioStreamIndex !== undefined
|
||||
? { audioStreamIndex: current.audioStreamIndex }
|
||||
: {}),
|
||||
});
|
||||
if (active !== current || peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
|
||||
// ffmpeg decoding runs long enough for the review to end underneath it.
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
if (peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
|
||||
return { ok: false, message: 'Timing waveform is unavailable.' };
|
||||
}
|
||||
return { ok: true, peaks };
|
||||
} catch {
|
||||
if (active !== current) {
|
||||
return staleReviewResult();
|
||||
}
|
||||
return { ok: false, message: 'Timing waveform is unavailable.' };
|
||||
}
|
||||
}
|
||||
@@ -367,11 +530,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
|
||||
const current = active;
|
||||
if (!current || reviewId !== current.payload.reviewId) {
|
||||
return { ok: false, message: 'This timing review is no longer active.' };
|
||||
return staleReviewResult();
|
||||
}
|
||||
try {
|
||||
const previewSession = await current.preview;
|
||||
await previewSession.stop();
|
||||
const previewSession = current.preview ? await current.preview.session : null;
|
||||
await previewSession?.stop();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -384,7 +547,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
function resolveReview(request: MediaTimingReviewResolveRequest): MediaTimingReviewActionResult {
|
||||
const current = active;
|
||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||
return { ok: false, message: 'This timing review is no longer active.' };
|
||||
return staleReviewResult();
|
||||
}
|
||||
if (request.decision.action === 'confirm') {
|
||||
const { startTime, endTime, text } = request.decision;
|
||||
@@ -403,7 +566,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
const current = active;
|
||||
active = null;
|
||||
if (!current) return;
|
||||
void current.preview.then((session) => session.dispose()).catch(() => {});
|
||||
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
|
||||
if (current.restorePlayback && current.mpvClient.connected) {
|
||||
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import {
|
||||
resolveCanonicalPrimarySubtitle,
|
||||
resolvePrimarySubtitle,
|
||||
resolvePrimarySubtitleText,
|
||||
stripCanonicalFragmentLines,
|
||||
} from './primary-subtitle-text';
|
||||
@@ -702,3 +703,37 @@ test('resolvePrimarySubtitleText publishes a wrapped caption sentence as one cue
|
||||
'(東)≪好きだと\n自覚してしまったものの➡',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitle drops a finished caption row lingering beside a fresh line', () => {
|
||||
// Broadcast captions give each row its own event, and a row of the previous line can
|
||||
// outlive its siblings by a frame. mpv's sub-text still lists it, so the mined line
|
||||
// must come from the parsed cue that is actually running, with that cue's timings.
|
||||
const ass = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 540',
|
||||
'',
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:14:30.00,0:14:33.00,Default,,0,0,0,,{\\pos(232,437)\\fscx50}({\\fscx100}東{\\fscx50}){\\fscx100}ずっと 言えなかっ',
|
||||
'Dialogue: 0,0:14:30.00,0:14:33.02,Default,,0,0,0,,{\\pos(232,497)}たが',
|
||||
'Dialogue: 0,0:14:33.00,0:14:36.00,Default,,0,0,0,,{\\pos(232,437)}⸨もし お互い',
|
||||
'Dialogue: 0,0:14:33.00,0:14:36.00,Default,,0,0,0,,{\\pos(232,497)}本命 受かったら 大学 近いし➡',
|
||||
].join('\n');
|
||||
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e09.ass');
|
||||
|
||||
const resolved = resolvePrimarySubtitle({
|
||||
liveText: 'たが\n⸨もし お互い\n本命 受かったら 大学 近いし➡',
|
||||
currentTimeSec: 14 * 60 + 33.05,
|
||||
cues,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
{ ...resolved, cues: resolved?.cues.map((cue) => cue.text) },
|
||||
{
|
||||
text: '⸨もし お互い\n本命 受かったら 大学 近いし➡',
|
||||
startTime: 14 * 60 + 33,
|
||||
endTime: 14 * 60 + 36,
|
||||
cues: ['⸨もし お互い\n本命 受かったら 大学 近いし➡'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -319,6 +319,26 @@ export function resolveRecordedPrimarySubtitleText(options: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed view of the live text with its cue timings: a canonical animation when one
|
||||
* explains the live lines, otherwise the active parsed cues. Null when the parsed cues
|
||||
* cannot account for every live line, in which case callers keep the raw mpv text.
|
||||
*/
|
||||
export function resolvePrimarySubtitle(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): ResolvedPrimarySubtitle | null {
|
||||
const liveText = decodedLiveText(options.liveText, options.cues);
|
||||
if (!liveText.trim()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
resolveCanonicalPrimarySubtitle({ ...options, liveText }) ??
|
||||
resolveActiveParsedPrimarySubtitle({ ...options, liveText })
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePrimarySubtitleText(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
@@ -328,13 +348,5 @@ export function resolvePrimarySubtitleText(options: {
|
||||
if (!liveText.trim()) {
|
||||
return liveText;
|
||||
}
|
||||
return (
|
||||
resolveCanonicalPrimarySubtitle({
|
||||
liveText,
|
||||
currentTimeSec: options.currentTimeSec,
|
||||
cues: options.cues,
|
||||
})?.text ??
|
||||
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
|
||||
removeLiveGlyphFragmentLines(liveText)
|
||||
);
|
||||
return resolvePrimarySubtitle(options)?.text ?? removeLiveGlyphFragmentLines(liveText);
|
||||
}
|
||||
|
||||
+141
-31
@@ -10,6 +10,9 @@ import {
|
||||
MediaGenerator,
|
||||
type MediaGeneratorOptions,
|
||||
} from './media-generator';
|
||||
import { RemoteMediaWindowCache } from './core/services/remote-media-window-cache';
|
||||
|
||||
const REMOTE_STREAM_URL = 'https://jellyfin.example/Videos/abc/stream?static=true';
|
||||
|
||||
async function withStubbedFfmpeg(
|
||||
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
|
||||
@@ -21,6 +24,7 @@ async function withStubbedFfmpeg(
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-'));
|
||||
const binDir = path.join(root, 'bin');
|
||||
const tempDir = path.join(root, 'media');
|
||||
const windowsDir = path.join(root, 'windows');
|
||||
const argsPath = path.join(root, 'ffmpeg-args.txt');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const ffmpegStubPath = path.join(binDir, 'ffmpeg-stub.cjs');
|
||||
@@ -34,7 +38,7 @@ async function withStubbedFfmpeg(
|
||||
" console.log(' V..... libaom-av1');",
|
||||
' process.exit(0);',
|
||||
'}',
|
||||
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, JSON.stringify(args), 'utf8');",
|
||||
"fs.appendFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, JSON.stringify(args) + '\\n', 'utf8');",
|
||||
'const outputPath = args.at(-1);',
|
||||
"if (process.env.SUBMINER_TEST_FFMPEG_SKIP_OUTPUT !== '1') {",
|
||||
" fs.writeFileSync(outputPath, 'avif', 'utf8');",
|
||||
@@ -61,12 +65,18 @@ async function withStubbedFfmpeg(
|
||||
} else {
|
||||
delete process.env.SUBMINER_TEST_FFMPEG_SKIP_OUTPUT;
|
||||
}
|
||||
const generator = new MediaGenerator(tempDir, options);
|
||||
// Each test gets its own window cache so remote inputs never leak windows between tests.
|
||||
const remoteMediaWindows = new RemoteMediaWindowCache({ tempDir: windowsDir, idleTtlMs: 0 });
|
||||
const generator = new MediaGenerator(tempDir, {
|
||||
remoteMediaWindows,
|
||||
...options,
|
||||
});
|
||||
|
||||
try {
|
||||
await run(generator, argsPath);
|
||||
} finally {
|
||||
generator.cleanup();
|
||||
remoteMediaWindows.cleanup();
|
||||
process.env.PATH = originalPath;
|
||||
if (originalArgsPath === undefined) {
|
||||
delete process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
||||
@@ -82,8 +92,17 @@ async function withStubbedFfmpeg(
|
||||
}
|
||||
}
|
||||
|
||||
function readAllFfmpegArgs(argsPath: string): string[][] {
|
||||
return fs
|
||||
.readFileSync(argsPath, 'utf8')
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line) as string[]);
|
||||
}
|
||||
|
||||
/** Arguments of the most recent ffmpeg invocation. */
|
||||
function readFfmpegArgs(argsPath: string): string[] {
|
||||
return JSON.parse(fs.readFileSync(argsPath, 'utf8')) as string[];
|
||||
return readAllFfmpegArgs(argsPath).at(-1) ?? [];
|
||||
}
|
||||
|
||||
test('buildAnimatedImageVideoFilter holds lead-in until the next frame after the audio boundary', () => {
|
||||
@@ -272,41 +291,131 @@ test('generateAudio recreates missing temp directory before invoking ffmpeg', as
|
||||
});
|
||||
|
||||
test('generateAudio adds remote input options before the ffmpeg input', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio(
|
||||
{
|
||||
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm',
|
||||
inputOptions: {
|
||||
reconnect: true,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
headers: {
|
||||
Referer: 'https://www.youtube.com/',
|
||||
Origin: 'https://www.youtube.com',
|
||||
await withStubbedFfmpeg(
|
||||
async (generator, argsPath) => {
|
||||
await generator.generateAudio(
|
||||
{
|
||||
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm',
|
||||
inputOptions: {
|
||||
reconnect: true,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
headers: {
|
||||
Referer: 'https://www.youtube.com/',
|
||||
Origin: 'https://www.youtube.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
10,
|
||||
12,
|
||||
);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
const inputIndex = args.indexOf('-i');
|
||||
assert.ok(inputIndex > 0);
|
||||
assert.ok(args.indexOf('-reconnect') > -1);
|
||||
assert.ok(args.indexOf('-reconnect') < inputIndex);
|
||||
assert.equal(args[args.indexOf('-reconnect') + 1], '1');
|
||||
assert.equal(args[args.indexOf('-reconnect_streamed') + 1], '1');
|
||||
assert.equal(args[args.indexOf('-reconnect_on_network_error') + 1], '1');
|
||||
assert.equal(args[args.indexOf('-reconnect_on_http_error') + 1], '403,5xx');
|
||||
assert.equal(args[args.indexOf('-reconnect_delay_max') + 1], '5');
|
||||
assert.equal(args[args.indexOf('-user_agent') + 1], 'Mozilla/5.0');
|
||||
assert.equal(
|
||||
args[args.indexOf('-headers') + 1],
|
||||
'Referer: https://www.youtube.com/\r\nOrigin: https://www.youtube.com\r\n',
|
||||
);
|
||||
},
|
||||
{ remoteMediaWindows: null },
|
||||
);
|
||||
});
|
||||
|
||||
test('generateAudio downloads a remote window once and extracts from it with absolute seeks', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio(
|
||||
{ path: REMOTE_STREAM_URL, inputOptions: { reconnect: true } },
|
||||
10,
|
||||
12,
|
||||
0.5,
|
||||
2,
|
||||
);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
const inputIndex = args.indexOf('-i');
|
||||
assert.ok(inputIndex > 0);
|
||||
assert.ok(args.indexOf('-reconnect') > -1);
|
||||
assert.ok(args.indexOf('-reconnect') < inputIndex);
|
||||
assert.equal(args[args.indexOf('-reconnect') + 1], '1');
|
||||
assert.equal(args[args.indexOf('-reconnect_streamed') + 1], '1');
|
||||
assert.equal(args[args.indexOf('-reconnect_on_network_error') + 1], '1');
|
||||
assert.equal(args[args.indexOf('-reconnect_on_http_error') + 1], '403,5xx');
|
||||
assert.equal(args[args.indexOf('-reconnect_delay_max') + 1], '5');
|
||||
assert.equal(args[args.indexOf('-user_agent') + 1], 'Mozilla/5.0');
|
||||
assert.equal(
|
||||
args[args.indexOf('-headers') + 1],
|
||||
'Referer: https://www.youtube.com/\r\nOrigin: https://www.youtube.com\r\n',
|
||||
);
|
||||
const calls = readAllFfmpegArgs(argsPath);
|
||||
assert.equal(calls.length, 2);
|
||||
const [fetchArgs, audioArgs] = calls as [string[], string[]];
|
||||
assert.equal(fetchArgs[fetchArgs.indexOf('-i') + 1], REMOTE_STREAM_URL);
|
||||
assert.ok(fetchArgs.indexOf('-reconnect') < fetchArgs.indexOf('-i'));
|
||||
assert.equal(fetchArgs[fetchArgs.indexOf('-ss') + 1], '9.25');
|
||||
assert.equal(fetchArgs[fetchArgs.lastIndexOf('-map') + 1], '0:2');
|
||||
assert.ok(fetchArgs.includes('-copyts'));
|
||||
|
||||
const windowPath = audioArgs[audioArgs.indexOf('-i') + 1];
|
||||
assert.ok(windowPath?.endsWith('.mkv'));
|
||||
assert.notEqual(windowPath, REMOTE_STREAM_URL);
|
||||
assert.equal(audioArgs[audioArgs.indexOf('-ss') + 1], '9.5');
|
||||
assert.equal(audioArgs[audioArgs.indexOf('-seek_timestamp') + 1], '1');
|
||||
assert.ok(audioArgs.indexOf('-seek_timestamp') < audioArgs.indexOf('-i'));
|
||||
assert.equal(audioArgs.includes('-reconnect'), false);
|
||||
assert.equal(audioArgs.includes('-map'), false);
|
||||
assert.equal(audioArgs.includes('-probesize'), false);
|
||||
assert.ok(audioArgs.includes('loudnorm=I=-23:TP=-2:LRA=11'));
|
||||
});
|
||||
});
|
||||
|
||||
test('generateScreenshot reuses a downloaded window but never downloads one itself', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateScreenshot(REMOTE_STREAM_URL, 11, { format: 'jpg' });
|
||||
let calls = readAllFfmpegArgs(argsPath);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]![calls[0]!.indexOf('-i') + 1], REMOTE_STREAM_URL);
|
||||
|
||||
await generator.generateAudio(REMOTE_STREAM_URL, 10, 12);
|
||||
await generator.generateScreenshot(REMOTE_STREAM_URL, 11, { format: 'jpg' });
|
||||
await generator.generateScreenshot(REMOTE_STREAM_URL, 40, { format: 'jpg' });
|
||||
|
||||
calls = readAllFfmpegArgs(argsPath);
|
||||
assert.equal(calls.length, 5);
|
||||
const insideWindow = calls[3]!;
|
||||
assert.ok(insideWindow[insideWindow.indexOf('-i') + 1]?.endsWith('.mkv'));
|
||||
assert.equal(insideWindow[insideWindow.indexOf('-seek_timestamp') + 1], '1');
|
||||
const outsideWindow = calls[4]!;
|
||||
assert.equal(outsideWindow[outsideWindow.indexOf('-i') + 1], REMOTE_STREAM_URL);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAnimatedImage downloads the clip window before encoding', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAnimatedImage(REMOTE_STREAM_URL, 10, 12, 0, { fps: 10 });
|
||||
|
||||
const calls = readAllFfmpegArgs(argsPath).filter(
|
||||
(args) => args[0] !== '-hide_banner' || args[1] !== '-encoders',
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0]![calls[0]!.indexOf('-i') + 1], REMOTE_STREAM_URL);
|
||||
assert.ok(calls[1]![calls[1]!.indexOf('-i') + 1]?.endsWith('.mkv'));
|
||||
assert.equal(calls[1]![calls[1]!.indexOf('-seek_timestamp') + 1], '1');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio reads the remote source directly when the window download fails', async () => {
|
||||
await withStubbedFfmpeg(
|
||||
async (generator, argsPath) => {
|
||||
await generator.generateAudio(REMOTE_STREAM_URL, 10, 12);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-i') + 1], REMOTE_STREAM_URL);
|
||||
assert.equal(args.includes('-seek_timestamp'), false);
|
||||
},
|
||||
{
|
||||
remoteMediaWindows: new RemoteMediaWindowCache({
|
||||
execFile: (_file, _args, _options, callback) =>
|
||||
queueMicrotask(() => callback(Object.assign(new Error('offline'), { code: 1 }))),
|
||||
idleTtlMs: 0,
|
||||
logDebug: () => undefined,
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('generateAudio skips stale audio stream maps for single resolved streams', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio(
|
||||
@@ -320,8 +429,9 @@ test('generateAudio skips stale audio stream maps for single resolved streams',
|
||||
22,
|
||||
);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args.includes('-map'), false);
|
||||
const [fetchArgs, audioArgs] = readAllFfmpegArgs(argsPath) as [string[], string[]];
|
||||
assert.equal(fetchArgs[fetchArgs.lastIndexOf('-map') + 1], '0:a');
|
||||
assert.equal(audioArgs.includes('-map'), false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+84
-3
@@ -22,6 +22,12 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from './logger';
|
||||
import { normalizeMediaInput, type MediaInput } from './media-input';
|
||||
import {
|
||||
getSharedRemoteMediaWindowCache,
|
||||
isRemoteMediaWindowSourcePath,
|
||||
type RemoteMediaWindowCache,
|
||||
type RemoteMediaWindowRange,
|
||||
} from './core/services/remote-media-window-cache';
|
||||
|
||||
const log = createLogger('media');
|
||||
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11';
|
||||
@@ -86,6 +92,11 @@ export interface MediaGeneratorOptions {
|
||||
logDebug?: (message: string) => void;
|
||||
now?: () => number;
|
||||
execFile?: MediaGeneratorExecFile;
|
||||
/**
|
||||
* Local window cache for http(s) sources. Defaults to the process-wide cache shared
|
||||
* with the timing review; pass `null` to always read remote sources directly.
|
||||
*/
|
||||
remoteMediaWindows?: RemoteMediaWindowCache | null;
|
||||
}
|
||||
|
||||
function sanitizeDebugToken(value: string, fallback: string): string {
|
||||
@@ -232,6 +243,54 @@ export class MediaGenerator {
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps an http(s) input for the locally cached window that covers `range`, so the
|
||||
* clip is downloaded once instead of per FFmpeg run. `acquire` downloads on a miss;
|
||||
* `lookup` only reuses a window that another step already fetched. Any failure falls
|
||||
* back to reading the remote source directly.
|
||||
*/
|
||||
private async resolveRemoteWindowInput(
|
||||
input: MediaInput,
|
||||
range: RemoteMediaWindowRange,
|
||||
audioStreamIndex: number | null | undefined,
|
||||
mode: 'acquire' | 'lookup',
|
||||
): Promise<MediaInput> {
|
||||
const cache =
|
||||
this.options.remoteMediaWindows === undefined
|
||||
? getSharedRemoteMediaWindowCache()
|
||||
: this.options.remoteMediaWindows;
|
||||
const sourcePath = typeof input === 'string' ? input : input.path;
|
||||
if (!cache || !isRemoteMediaWindowSourcePath(sourcePath)) {
|
||||
return input;
|
||||
}
|
||||
const source = {
|
||||
path: sourcePath,
|
||||
...(typeof input === 'object' && input.inputOptions
|
||||
? { inputOptions: input.inputOptions }
|
||||
: {}),
|
||||
audioStreamIndex:
|
||||
typeof input === 'object' && input.singleResolvedStream ? null : (audioStreamIndex ?? null),
|
||||
};
|
||||
const description = describeMediaInputForDebugLog(input);
|
||||
try {
|
||||
const window =
|
||||
mode === 'acquire' ? await cache.acquire(source, range) : await cache.lookup(source, range);
|
||||
if (!window) {
|
||||
this.logMediaDebug(`window miss ${description} mode=${mode}`);
|
||||
return input;
|
||||
}
|
||||
this.logMediaDebug(
|
||||
`window hit ${description} mode=${mode} start=${window.startTime} end=${window.endTime}`,
|
||||
);
|
||||
return window.media;
|
||||
} catch (error) {
|
||||
this.logMediaDebug(
|
||||
`window failed ${description} mode=${mode} reason=${sanitizeDebugToken((error as Error).message, 'error')}`,
|
||||
);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
private ffmpegError(label: string, error: ExecFileException): Error {
|
||||
if (error.code === 'ENOENT') {
|
||||
return new Error('FFmpeg not found. Install FFmpeg to enable media generation.');
|
||||
@@ -281,7 +340,13 @@ export class MediaGenerator {
|
||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||
const start = Math.max(0, startTime - safePadding);
|
||||
const duration = endTime - start + safePadding;
|
||||
const mediaInput = normalizeMediaInput(videoPath);
|
||||
const sourceInput = await this.resolveRemoteWindowInput(
|
||||
videoPath,
|
||||
{ startTime: start, endTime: start + duration },
|
||||
audioStreamIndex,
|
||||
'acquire',
|
||||
);
|
||||
const mediaInput = normalizeMediaInput(sourceInput);
|
||||
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||
const hasSelectedAudioStream =
|
||||
!mediaInput.singleResolvedStream &&
|
||||
@@ -385,7 +450,15 @@ export class MediaGenerator {
|
||||
png: 'png',
|
||||
webp: 'webp',
|
||||
};
|
||||
const mediaInput = normalizeMediaInput(videoPath);
|
||||
// A single frame is cheap to fetch remotely, so only reuse a window another step downloaded.
|
||||
const mediaInput = normalizeMediaInput(
|
||||
await this.resolveRemoteWindowInput(
|
||||
videoPath,
|
||||
{ startTime: timestamp, endTime: timestamp },
|
||||
null,
|
||||
'lookup',
|
||||
),
|
||||
);
|
||||
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||
|
||||
const args: string[] = [
|
||||
@@ -533,9 +606,17 @@ export class MediaGenerator {
|
||||
);
|
||||
}
|
||||
|
||||
const mediaInput = normalizeMediaInput(
|
||||
await this.resolveRemoteWindowInput(
|
||||
videoPath,
|
||||
{ startTime: start, endTime: start + duration },
|
||||
null,
|
||||
'acquire',
|
||||
),
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = this.createTempOutputPath('animation', 'avif');
|
||||
const mediaInput = normalizeMediaInput(videoPath);
|
||||
const startedAt = this.nowMs();
|
||||
|
||||
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
||||
|
||||
@@ -11,6 +11,12 @@ export type MediaInput =
|
||||
source?: string;
|
||||
inputOptions?: MediaInputOptions;
|
||||
singleResolvedStream?: boolean;
|
||||
/**
|
||||
* The file keeps the original media timestamps instead of starting at zero (a
|
||||
* stream-copied window of a longer source). Seek with `-ss` against those
|
||||
* absolute timestamps rather than relative to the file's own start time.
|
||||
*/
|
||||
absoluteTimestamps?: boolean;
|
||||
};
|
||||
|
||||
export type NormalizedMediaInput = {
|
||||
@@ -89,6 +95,10 @@ export function normalizeMediaInput(input: MediaInput): NormalizedMediaInput {
|
||||
inputArgs.push('-headers', headers);
|
||||
}
|
||||
|
||||
if (input.absoluteTimestamps) {
|
||||
inputArgs.push('-seek_timestamp', '1');
|
||||
}
|
||||
|
||||
return {
|
||||
path: input.path,
|
||||
inputArgs,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { build } from 'esbuild';
|
||||
|
||||
test('sidebar clipboard bridge writes exact text without renderer focus and rejects non-text input', async () => {
|
||||
const result = await build({
|
||||
entryPoints: ['src/preload.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
external: ['electron'],
|
||||
write: false,
|
||||
});
|
||||
const output = result.outputFiles[0];
|
||||
assert.ok(output);
|
||||
const writes: string[] = [];
|
||||
let exposed: unknown;
|
||||
runInNewContext(output.text, {
|
||||
process: { argv: [] },
|
||||
require: (name: string) => {
|
||||
assert.equal(name, 'electron');
|
||||
return {
|
||||
ipcRenderer: { on: () => {} },
|
||||
clipboard: { writeText: (text: string) => writes.push(text) },
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (_name: string, api: unknown) => {
|
||||
exposed = api;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.ok(
|
||||
typeof exposed === 'object' && exposed !== null && 'copySubtitleSidebarSelection' in exposed,
|
||||
);
|
||||
const copy = exposed.copySubtitleSidebarSelection;
|
||||
if (typeof copy !== 'function') throw new Error('Missing clipboard bridge');
|
||||
const text = '最初の台詞\n二行目\n\n同じ台詞';
|
||||
await copy(text);
|
||||
assert.deepEqual(writes, [text]);
|
||||
for (const value of [null, undefined, 42, { text }, ['台詞']]) {
|
||||
await assert.rejects(async () => copy(value), /Subtitle selection must be text/);
|
||||
}
|
||||
assert.deepEqual(writes, [text]);
|
||||
});
|
||||
+10
-1
@@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
|
||||
import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
|
||||
import { resolveOverlayLayerFromArgv } from './preload-args';
|
||||
import type {
|
||||
SubtitleData,
|
||||
@@ -191,6 +191,10 @@ const onOpenMediaTimingReviewEvent =
|
||||
IPC_CHANNELS.event.mediaTimingReviewOpen,
|
||||
(payload) => payload as MediaTimingReviewOpenPayload,
|
||||
);
|
||||
const onMediaTimingReviewPreviewEndedEvent = createQueuedIpcListenerWithPayload<string>(
|
||||
IPC_CHANNELS.event.mediaTimingReviewPreviewEnded,
|
||||
(payload) => (typeof payload === 'string' ? payload : ''),
|
||||
);
|
||||
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
|
||||
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.youtubePickerCancel,
|
||||
@@ -297,6 +301,10 @@ const electronAPI: ElectronAPI = {
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
|
||||
getSubtitleSidebarSnapshot: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
|
||||
copySubtitleSidebarSelection: async (text: unknown) => {
|
||||
if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.');
|
||||
clipboard.writeText(text);
|
||||
},
|
||||
getPlaybackPaused: (): Promise<boolean | null> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
|
||||
onSubtitleAss: (callback: (assText: string) => void) => {
|
||||
@@ -469,6 +477,7 @@ const electronAPI: ElectronAPI = {
|
||||
onOpenTsukihime: onOpenTsukihimeEvent,
|
||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
|
||||
onMediaTimingReviewPreviewEnded: onMediaTimingReviewPreviewEndedEvent,
|
||||
previewMediaTimingReview: (
|
||||
request: MediaTimingReviewPreviewRequest,
|
||||
): Promise<MediaTimingReviewActionResult> =>
|
||||
|
||||
@@ -1863,6 +1863,29 @@ test('keyboard mode: popup hidden after mode off clears stale selected token hig
|
||||
}
|
||||
});
|
||||
|
||||
test('Yomitan popup dismissal and subtitle updates preserve selection outside the overlay subtitle', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
let cleared = false;
|
||||
try {
|
||||
Object.defineProperty(window, 'getSelection', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
anchorNode: {},
|
||||
removeAllRanges: () => {
|
||||
cleared = true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
Object.assign(ctx.dom.subtitleRoot, { contains: () => false });
|
||||
await handlers.setupMpvInputForwarding();
|
||||
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
handlers.syncKeyboardTokenSelection();
|
||||
assert.equal(cleared, false);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -436,7 +436,10 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function clearNativeSubtitleSelection(): void {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.anchorNode || ctx.dom.subtitleRoot.contains(selection.anchorNode)) {
|
||||
selection?.removeAllRanges();
|
||||
}
|
||||
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
||||
}
|
||||
|
||||
|
||||
@@ -731,6 +731,7 @@
|
||||
<div id="subtitleSidebarContent" class="modal-content subtitle-sidebar-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Subtitle Sidebar</div>
|
||||
<button id="subtitleSidebarCopy" class="modal-close" type="button" hidden>Copy</button>
|
||||
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body subtitle-sidebar-body">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
formatMediaTimingTimestamp,
|
||||
mediaTimingTimeFromPointer,
|
||||
slideMediaTimingSelection,
|
||||
trimMediaTimingSelectionEnd,
|
||||
} from './media-timing-review';
|
||||
|
||||
test('waveform path mirrors normalized peaks around its center line', () => {
|
||||
@@ -122,3 +123,35 @@ test('preview request guard blocks overlap and invalidates stale responses', ()
|
||||
guard.finish(second!);
|
||||
assert.equal(guard.isInFlight(), false);
|
||||
});
|
||||
|
||||
test('trailing silence trim follows the last speech slice and keeps cut-off or silent lines', () => {
|
||||
// 10 slices over a 10 s timeline: one slice per second, speech in seconds 2-4 only.
|
||||
const peaks = [0, 0, 0.9, 0.8, 0.7, 0.1, 0.2, 0, 0, 0];
|
||||
const base = { peaks, timelineStart: 0, timelineEnd: 10, lineStart: 2, lineEnd: 8 };
|
||||
|
||||
const trimmed = trimMediaTimingSelectionEnd({ ...base, selectionEnd: 8, endPadSeconds: 0 });
|
||||
assert.ok(trimmed !== null && Math.abs(trimmed - 5.15) < 1e-9);
|
||||
|
||||
const padded = trimMediaTimingSelectionEnd({ ...base, selectionEnd: 8.5, endPadSeconds: 0.5 });
|
||||
assert.ok(padded !== null && Math.abs(padded - 5.65) < 1e-9);
|
||||
|
||||
// Speech running through the line end means the subtitle cuts the audio off; keep it.
|
||||
assert.equal(
|
||||
trimMediaTimingSelectionEnd({ ...base, lineEnd: 5, selectionEnd: 5, endPadSeconds: 0 }),
|
||||
null,
|
||||
);
|
||||
// No speech inside the line at all: keep the subtitle timing rather than guess.
|
||||
assert.equal(
|
||||
trimMediaTimingSelectionEnd({ ...base, lineStart: 6, selectionEnd: 8, endPadSeconds: 0 }),
|
||||
null,
|
||||
);
|
||||
// A saving below the minimum trim is not worth moving the handle for.
|
||||
assert.equal(
|
||||
trimMediaTimingSelectionEnd({ ...base, lineEnd: 5.2, selectionEnd: 5.2, endPadSeconds: 0 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
trimMediaTimingSelectionEnd({ ...base, peaks: [1], selectionEnd: 8, endPadSeconds: 0 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,16 @@ const FINE_ADJUST_SECONDS = 0.1;
|
||||
const COARSE_ADJUST_SECONDS = 0.5;
|
||||
const TIMELINE_EXPANSION_SECONDS = 2;
|
||||
const LINE_REVEAL_MARGIN_SECONDS = 1;
|
||||
/**
|
||||
* Subtitles usually linger past the dialogue for readability, so an untouched clip end
|
||||
* follows the last speech-weighted waveform slice above this level (0..1, relative to the
|
||||
* clip's own noise floor) plus a short tail, when that saves at least the minimum trim.
|
||||
*/
|
||||
const SPEECH_LEVEL_THRESHOLD = 0.3;
|
||||
const SPEECH_TAIL_SECONDS = 0.15;
|
||||
const MINIMUM_TRAILING_TRIM_SECONDS = 0.1;
|
||||
/** Slack past the clip length before the UI gives up waiting for mpv's end-of-clip signal. */
|
||||
const PREVIEW_END_GRACE_MS = 2_500;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
@@ -105,6 +115,48 @@ export function buildMediaTimingWaveformPath(peaks: number[]): string {
|
||||
return `M ${upper.join(' L ')} L ${lower.join(' L ')} Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an untouched clip should end once the waveform is known: just after the line's
|
||||
* last speech slice, plus the configured end padding. Null keeps the subtitle timing when
|
||||
* no speech shows inside the line, when speech runs through the line end (the subtitle is
|
||||
* cutting the audio off, not lingering), or when the saving is too small to matter.
|
||||
*/
|
||||
export function trimMediaTimingSelectionEnd(options: {
|
||||
peaks: readonly number[];
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
selectionEnd: number;
|
||||
endPadSeconds: number;
|
||||
}): number | null {
|
||||
const pointCount = options.peaks.length;
|
||||
const span = options.timelineEnd - options.timelineStart;
|
||||
if (pointCount < 2 || span <= 0 || options.lineEnd <= options.lineStart) return null;
|
||||
// Point i covers [i, i + 1) / pointCount of the timeline (see computeWaveformPeaks).
|
||||
const sliceEnd = (index: number): number =>
|
||||
options.timelineStart + ((index + 1) / pointCount) * span;
|
||||
const firstIndex = Math.max(
|
||||
0,
|
||||
Math.floor(((options.lineStart - options.timelineStart) / span) * pointCount),
|
||||
);
|
||||
const lastIndex = Math.min(
|
||||
pointCount - 1,
|
||||
Math.ceil(((options.lineEnd - options.timelineStart) / span) * pointCount) - 1,
|
||||
);
|
||||
let lastSpeechIndex = -1;
|
||||
for (let index = lastIndex; index >= firstIndex; index -= 1) {
|
||||
if ((options.peaks[index] ?? 0) >= SPEECH_LEVEL_THRESHOLD) {
|
||||
lastSpeechIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastSpeechIndex === -1 || lastSpeechIndex >= lastIndex) return null;
|
||||
const trimmedEnd = sliceEnd(lastSpeechIndex) + SPEECH_TAIL_SECONDS + options.endPadSeconds;
|
||||
if (options.selectionEnd - trimmedEnd < MINIMUM_TRAILING_TRIM_SECONDS) return null;
|
||||
return trimmedEnd;
|
||||
}
|
||||
|
||||
export function constrainMediaTimingSelection(options: {
|
||||
nextStart: number;
|
||||
nextEnd: number;
|
||||
@@ -174,6 +226,8 @@ export function createMediaTimingReviewModal(
|
||||
let nextCount = 0;
|
||||
let startPadSeconds = 0;
|
||||
let endPadSeconds = 0;
|
||||
/** True until the user moves the clip; the first waveform then trims trailing silence. */
|
||||
let trailingTrimPending = false;
|
||||
let resolveInFlight = false;
|
||||
let previewPlaying = false;
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -209,7 +263,11 @@ export function createMediaTimingReviewModal(
|
||||
previewTimer = null;
|
||||
}
|
||||
|
||||
/** Drives the play button label plus the playhead sweep that mirrors the hidden audio player. */
|
||||
/**
|
||||
* Drives the play button label plus the playhead sweep that mirrors the hidden audio player.
|
||||
* mpv reports when the clip actually finishes (see handlePreviewEnded), which accounts for
|
||||
* output latency such as Bluetooth headphones; the timer only covers a player that never does.
|
||||
*/
|
||||
function setPreviewPlaying(playing: boolean): void {
|
||||
previewPlaying = playing;
|
||||
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
|
||||
@@ -222,7 +280,14 @@ export function createMediaTimingReviewModal(
|
||||
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
|
||||
void track.offsetWidth;
|
||||
track.classList.add('is-previewing');
|
||||
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000);
|
||||
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000 + PREVIEW_END_GRACE_MS);
|
||||
}
|
||||
|
||||
/** The hidden player reached the end of the clip and paused itself. */
|
||||
function handlePreviewEnded(reviewId: string): void {
|
||||
if (!payload || payload.reviewId !== reviewId || !previewPlaying) return;
|
||||
setPreviewPlaying(false);
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
/** Callers that need to report a failure set their own status after stopping the preview. */
|
||||
@@ -322,6 +387,10 @@ export function createMediaTimingReviewModal(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!result.ok && result.stale) {
|
||||
closeResolvedReview();
|
||||
return;
|
||||
}
|
||||
const path = result.ok ? buildMediaTimingWaveformPath(result.peaks ?? []) : '';
|
||||
if (!path) {
|
||||
setWaveformState('unavailable');
|
||||
@@ -329,6 +398,7 @@ export function createMediaTimingReviewModal(
|
||||
}
|
||||
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', path);
|
||||
setWaveformState('ready');
|
||||
trimTrailingSilence(result.peaks ?? []);
|
||||
} catch {
|
||||
if (sequence === waveformSequence && payload?.reviewId === reviewId) {
|
||||
setWaveformState('unavailable');
|
||||
@@ -336,6 +406,29 @@ export function createMediaTimingReviewModal(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an untouched clip end back to where the line's dialogue ends. The Line end
|
||||
* rail keeps marking the subtitle timing, and Reset restores it.
|
||||
*/
|
||||
function trimTrailingSilence(peaks: readonly number[]): void {
|
||||
if (!payload || !trailingTrimPending || previewPlaying || previewRequest.isInFlight()) {
|
||||
return;
|
||||
}
|
||||
const lineRange = currentLineSelection();
|
||||
const trimmedEnd = trimMediaTimingSelectionEnd({
|
||||
peaks,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
lineStart: lineRange.rangeStart,
|
||||
lineEnd: lineRange.rangeEnd,
|
||||
selectionEnd,
|
||||
endPadSeconds,
|
||||
});
|
||||
if (trimmedEnd === null) return;
|
||||
updateSelection(selectionStart, trimmedEnd);
|
||||
setStatus('Clip end moved to where the dialogue ends. Reset restores the subtitle timing.');
|
||||
}
|
||||
|
||||
function queueWaveformLoad(delayMs = 0): void {
|
||||
if (waveformTimer !== null) clearTimeout(waveformTimer);
|
||||
waveformSequence += 1;
|
||||
@@ -443,6 +536,7 @@ export function createMediaTimingReviewModal(
|
||||
});
|
||||
selectionStart = nextSelection.start;
|
||||
selectionEnd = nextSelection.end;
|
||||
trailingTrimPending = false;
|
||||
if (previewPlaying || previewRequest.isInFlight()) stopPreview();
|
||||
setStatus('');
|
||||
renderSelection();
|
||||
@@ -617,11 +711,13 @@ export function createMediaTimingReviewModal(
|
||||
reviewId: payload.reviewId,
|
||||
decision,
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (!result.ok && !result.stale) {
|
||||
setStatus(result.message ?? 'The timing review could not be resolved.', true);
|
||||
showEditor();
|
||||
return;
|
||||
}
|
||||
// A stale review was already settled by main; keeping the modal up would leave
|
||||
// controls that can never succeed over a live mpv window.
|
||||
closeResolvedReview();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||
@@ -669,6 +765,10 @@ export function createMediaTimingReviewModal(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!result.ok && result.stale) {
|
||||
closeResolvedReview();
|
||||
return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
setStatus(result.message ?? 'Audio preview is unavailable.', true);
|
||||
setPreviewPlaying(false);
|
||||
@@ -704,6 +804,7 @@ export function createMediaTimingReviewModal(
|
||||
nextCount = 0;
|
||||
startPadSeconds = Math.max(0, nextPayload.originalStartTime - nextPayload.selectionStartTime);
|
||||
endPadSeconds = Math.max(0, nextPayload.selectionEndTime - nextPayload.originalEndTime);
|
||||
trailingTrimPending = true;
|
||||
resolveInFlight = false;
|
||||
setPreviewPlaying(false);
|
||||
ctx.dom.mediaTimingReviewKind.textContent =
|
||||
@@ -851,6 +952,7 @@ export function createMediaTimingReviewModal(
|
||||
|
||||
return {
|
||||
openMediaTimingReviewModal,
|
||||
handlePreviewEnded,
|
||||
requestCancel,
|
||||
handleMediaTimingReviewKeydown,
|
||||
wireDomEvents,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
|
||||
import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subtitle';
|
||||
import { createRendererState } from '../state';
|
||||
import { resolveRendererDom } from '../utils/dom';
|
||||
import { resolvePlatformInfo } from '../utils/platform';
|
||||
import { createSubtitleSidebarModal } from './subtitle-sidebar';
|
||||
import {
|
||||
getSubtitleSidebarSelection,
|
||||
wireSubtitleSidebarSelection,
|
||||
} from './subtitle-sidebar-selection';
|
||||
|
||||
export async function setup() {
|
||||
const commands: unknown[] = [];
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'episode-1:track-1',
|
||||
cues: [
|
||||
{ text: '最初の台詞', startTime: 0, endTime: 1 },
|
||||
{ text: '同じ台詞\n二行目', startTime: 1, endTime: 2 },
|
||||
{ text: '同じ台詞', startTime: 2, endTime: 3 },
|
||||
...Array.from({ length: 30 }, (_, i) => ({
|
||||
text: `後の台詞${i}`,
|
||||
startTime: i + 3,
|
||||
endTime: i + 4,
|
||||
})),
|
||||
],
|
||||
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
|
||||
currentTimeSec: 0,
|
||||
config: {
|
||||
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
||||
enabled: true,
|
||||
layout: 'overlay',
|
||||
pauseVideoOnHover: false,
|
||||
autoScroll: true,
|
||||
css: {},
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
value: {
|
||||
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||
copySubtitleSidebarSelection: async (text) => {
|
||||
if (!('copyTestSelection' in window) || typeof window.copyTestSelection !== 'function')
|
||||
throw new Error('Missing test clipboard bridge');
|
||||
window.copyTestSelection(text);
|
||||
},
|
||||
getOverlayLayer: () => 'visible',
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
},
|
||||
setIgnoreMouseEvents: () => {},
|
||||
} satisfies Pick<
|
||||
ElectronAPI,
|
||||
| 'getSubtitleSidebarSnapshot'
|
||||
| 'copySubtitleSidebarSelection'
|
||||
| 'getOverlayLayer'
|
||||
| 'sendMpvCommand'
|
||||
| 'setIgnoreMouseEvents'
|
||||
>,
|
||||
});
|
||||
const ctx = {
|
||||
dom: resolveRendererDom(),
|
||||
state: createRendererState(),
|
||||
platform: resolvePlatformInfo(),
|
||||
};
|
||||
const modal = createSubtitleSidebarModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
wireSubtitleSidebarSelection(ctx);
|
||||
await modal.openSubtitleSidebarModal();
|
||||
const list = ctx.dom.subtitleSidebarList;
|
||||
list.style.height = '180px';
|
||||
list.style.overflowY = 'auto';
|
||||
const selection = window.getSelection();
|
||||
if (!selection) throw new Error('Native selection unavailable');
|
||||
const textNode = (index: number) => {
|
||||
const node = list.children[index]?.querySelector('.subtitle-sidebar-text')?.firstChild;
|
||||
if (!node) throw new Error(`Missing cue ${index}`);
|
||||
return node;
|
||||
};
|
||||
const select = (backward = false) => {
|
||||
const start = textNode(0);
|
||||
const end = textNode(2);
|
||||
selection.setBaseAndExtent(backward ? end : start, 2, backward ? start : end, 2);
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
return getSubtitleSidebarSelection(list);
|
||||
};
|
||||
// This is the same competing action as the renderer's current-subtitle shortcut.
|
||||
let fallbackCopies = 0;
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') fallbackCopies += 1;
|
||||
});
|
||||
return {
|
||||
select,
|
||||
selected: () => getSubtitleSidebarSelection(list),
|
||||
buttonVisible: () => !ctx.dom.subtitleSidebarCopy.hidden,
|
||||
fallbackCopies: () => fallbackCopies,
|
||||
dragPoints: () =>
|
||||
[0, 2].map((index) => {
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode(index), 2);
|
||||
range.collapse(true);
|
||||
const rect = range.getBoundingClientRect();
|
||||
return { x: Math.round(rect.x), y: Math.round(rect.y + rect.height / 2) };
|
||||
}),
|
||||
clickCopy: () => ctx.dom.subtitleSidebarCopy.click(),
|
||||
clickCue: () => {
|
||||
const before = commands.length;
|
||||
list.children[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
return commands.length - before;
|
||||
},
|
||||
updatePlayback: async () => {
|
||||
list.scrollTop = 0;
|
||||
snapshot.currentTimeSec = 25;
|
||||
snapshot.currentSubtitle = { text: '後の台詞22', startTime: 25, endTime: 26 };
|
||||
await modal.refreshSubtitleSidebarSnapshot();
|
||||
return list.scrollTop;
|
||||
},
|
||||
changeSource: async () => {
|
||||
snapshot.sourceKey = 'episode-2:track-1';
|
||||
await modal.refreshSubtitleSidebarSnapshot();
|
||||
return getSubtitleSidebarSelection(list);
|
||||
},
|
||||
clear: () => selection.removeAllRanges(),
|
||||
close: () => modal.closeSubtitleSidebarModal(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import test from 'node:test';
|
||||
import { build } from 'esbuild';
|
||||
|
||||
// This check opens Electron and uses the clipboard. Keep it out of normal code-only lanes.
|
||||
const electronTest =
|
||||
process.env.SUBMINER_ELECTRON_TESTS === '1' &&
|
||||
(process.platform !== 'linux' || process.env.DISPLAY)
|
||||
? test
|
||||
: test.skip;
|
||||
|
||||
electronTest(
|
||||
'sidebar selection copies clean chronological text without seeking or losing context',
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'subminer-sidebar-selection-'));
|
||||
await build({
|
||||
entryPoints: [resolve('src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts')],
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
format: 'iife',
|
||||
globalName: 'sidebarTest',
|
||||
outfile: join(dir, 'fixture.js'),
|
||||
});
|
||||
const html = (await readFile('src/renderer/index.html', 'utf8'))
|
||||
.replace(
|
||||
'<script type="module" src="renderer.js"></script>',
|
||||
'<script src="fixture.js"></script>',
|
||||
)
|
||||
.replace(
|
||||
'href="style.css"',
|
||||
`href="${new URL(`file://${resolve('src/renderer/style.css')}`).href}"`,
|
||||
);
|
||||
await writeFile(join(dir, 'index.html'), html);
|
||||
await writeFile(
|
||||
join(dir, 'clipboard.cjs'),
|
||||
'const { clipboard, contextBridge } = require("electron"); contextBridge.exposeInMainWorld("copyTestSelection", text => clipboard.writeText(text));',
|
||||
);
|
||||
await build({
|
||||
entryPoints: [resolve('src/core/services/overlay-window-input.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
outfile: join(dir, 'input.cjs'),
|
||||
});
|
||||
await writeFile(
|
||||
join(dir, 'run.cjs'),
|
||||
`
|
||||
const { app, BrowserWindow, clipboard } = require('electron');
|
||||
const assert = require('node:assert/strict');
|
||||
const { handleOverlayWindowBeforeInputEvent } = require('./input.cjs');
|
||||
app.setPath('userData', ${JSON.stringify(join(dir, 'user-data'))});
|
||||
app.whenReady().then(async () => {
|
||||
const window = new BrowserWindow({ width: 900, height: 700, show: false, webPreferences: { preload: ${JSON.stringify(join(dir, 'clipboard.cjs'))}, sandbox: false } });
|
||||
window.webContents.on('console-message', (_event, details) => {
|
||||
if (details.level === 'error') console.error(details.message);
|
||||
});
|
||||
let intercepted = 0;
|
||||
window.webContents.on('before-input-event', (event, input) => handleOverlayWindowBeforeInputEvent({
|
||||
kind: 'visible', windowVisible: true, input,
|
||||
preventDefault: () => event.preventDefault(),
|
||||
sendKeyboardModeToggleRequested() {}, sendLookupWindowToggleRequested() {}, forwardTabToMpv() {},
|
||||
tryHandleOverlayShortcutLocalFallback() { intercepted++; return true; },
|
||||
}));
|
||||
await window.loadFile(${JSON.stringify(join(dir, 'index.html'))});
|
||||
const run = (code) => window.webContents.executeJavaScript(code, true);
|
||||
await run('sidebarTest.setup().then(checks => { window.checks = checks; })');
|
||||
const expected = { text: 'の台詞\\n\\n同じ台詞\\n二行目\\n\\n同じ', cueCount: 3 };
|
||||
assert.deepEqual(await run('checks.select()'), expected);
|
||||
assert.deepEqual(await run('checks.select(true)'), expected);
|
||||
assert.equal(await run('checks.buttonVisible()'), true);
|
||||
assert.equal(await run('checks.clickCue()'), 0);
|
||||
assert.equal(await run('checks.updatePlayback()'), 0);
|
||||
assert.deepEqual(await run('checks.selected()'), expected);
|
||||
const previousClipboard = clipboard.readText();
|
||||
try {
|
||||
clipboard.writeText('sentinel');
|
||||
window.show(); app.focus({ steal: true }); window.focus(); window.webContents.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await run('checks.clear()');
|
||||
const [start, end] = await run('checks.dragPoints()');
|
||||
window.webContents.sendInputEvent({ type: 'mouseDown', ...start, button: 'left', clickCount: 1 });
|
||||
window.webContents.sendInputEvent({ type: 'mouseMove', ...end, button: 'left' });
|
||||
window.webContents.sendInputEvent({ type: 'mouseUp', ...end, button: 'left', clickCount: 1 });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
assert.deepEqual(await run('checks.selected()'), expected);
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'C', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'C', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
assert.equal(intercepted, 0);
|
||||
assert.equal(await run('checks.fallbackCopies()'), 0);
|
||||
assert.equal(clipboard.readText() === expected.text, true, 'Keyboard copies the selected excerpt');
|
||||
clipboard.writeText('sentinel');
|
||||
await run('checks.clickCopy()');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
assert.equal(clipboard.readText() === expected.text, true, 'Button copies the selected excerpt');
|
||||
} finally { clipboard.writeText(previousClipboard); }
|
||||
await run('document.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape", bubbles:true}))');
|
||||
assert.equal(await run('checks.selected()'), null);
|
||||
assert.equal(await run('checks.buttonVisible()'), false);
|
||||
assert.equal(await run('checks.clickCue()'), 1);
|
||||
await run('document.dispatchEvent(new KeyboardEvent("keydown", {key:"c", ctrlKey:true, bubbles:true}))');
|
||||
assert.equal(await run('checks.fallbackCopies()'), 1);
|
||||
await run('checks.select()');
|
||||
assert.equal(await run('checks.changeSource()'), null);
|
||||
await run('checks.select(); checks.close()');
|
||||
assert.equal(await run('checks.selected()'), null);
|
||||
window.destroy();
|
||||
console.log('SIDEBAR_SELECTION_OK');
|
||||
app.quit();
|
||||
}).catch(error => { console.error(error); app.exit(1); });
|
||||
`,
|
||||
);
|
||||
const env = { ...process.env };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
const { stdout } = await promisify(execFile)(
|
||||
resolve('node_modules/.bin/electron'),
|
||||
[join(dir, 'run.cjs')],
|
||||
{ env, timeout: 25_000 },
|
||||
);
|
||||
assert.match(stdout, /SIDEBAR_SELECTION_OK/);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
|
||||
|
||||
function isEditingText(target: EventTarget | null): boolean {
|
||||
return (
|
||||
target instanceof HTMLElement &&
|
||||
(target.isContentEditable ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement)
|
||||
);
|
||||
}
|
||||
|
||||
function getSelectionRange(list: HTMLElement): Range | null {
|
||||
const selection = list.ownerDocument?.defaultView?.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
return list.contains(range.startContainer) && list.contains(range.endContainer) ? range : null;
|
||||
}
|
||||
|
||||
export function hasSubtitleSidebarSelection(list: HTMLElement): boolean {
|
||||
return getSelectionRange(list) !== null;
|
||||
}
|
||||
|
||||
// Read only dialogue nodes, preserving partial first/last lines and DOM cue order.
|
||||
export function getSubtitleSidebarSelection(list: HTMLElement): {
|
||||
text: string;
|
||||
cueCount: number;
|
||||
} | null {
|
||||
const range = getSelectionRange(list);
|
||||
if (!range) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const text of list.querySelectorAll<HTMLElement>('.subtitle-sidebar-text')) {
|
||||
if (!range.intersectsNode(text)) continue;
|
||||
const part = list.ownerDocument.createRange();
|
||||
part.selectNodeContents(text);
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, part) > 0) {
|
||||
part.setStart(range.startContainer, range.startOffset);
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, part) < 0) {
|
||||
part.setEnd(range.endContainer, range.endOffset);
|
||||
}
|
||||
const selectedText = part.toString();
|
||||
if (selectedText.trim()) parts.push(selectedText);
|
||||
}
|
||||
return parts.length > 0 ? { text: parts.join('\n\n'), cueCount: parts.length } : null;
|
||||
}
|
||||
|
||||
export function clearSubtitleSidebarSelection(list: HTMLElement): void {
|
||||
const selection = list.ownerDocument?.defaultView?.getSelection();
|
||||
if (selection?.anchorNode && list.contains(selection.anchorNode)) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
export function wireSubtitleSidebarSelection(ctx: RendererContext): () => void {
|
||||
const list = ctx.dom.subtitleSidebarList;
|
||||
const button = ctx.dom.subtitleSidebarCopy;
|
||||
const doc = list.ownerDocument;
|
||||
const abort = new AbortController();
|
||||
const { signal } = abort;
|
||||
|
||||
const updateButton = () => {
|
||||
const selected = getSubtitleSidebarSelection(list);
|
||||
button.hidden = !selected;
|
||||
button.textContent = selected
|
||||
? `Copy ${selected.cueCount} ${selected.cueCount === 1 ? 'line' : 'lines'}`
|
||||
: 'Copy';
|
||||
};
|
||||
const copied = () => {
|
||||
ctx.dom.subtitleSidebarStatus.textContent = 'Selection copied.';
|
||||
};
|
||||
const copySelection = async () => {
|
||||
const selected = getSubtitleSidebarSelection(list);
|
||||
if (!selected) return;
|
||||
try {
|
||||
await window.electronAPI.copySubtitleSidebarSelection(selected.text);
|
||||
copied();
|
||||
} catch {
|
||||
ctx.dom.subtitleSidebarStatus.textContent = 'Could not copy selection. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
doc.addEventListener('selectionchange', updateButton, { signal });
|
||||
doc.addEventListener(
|
||||
'copy',
|
||||
(event) => {
|
||||
if (!ctx.state.subtitleSidebarModalOpen || isEditingText(event.target)) return;
|
||||
const selected = getSubtitleSidebarSelection(list);
|
||||
if (!selected || !event.clipboardData) return;
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData('text/plain', selected.text);
|
||||
copied();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
// Capture before modal Escape handling and the current-subtitle copy shortcut.
|
||||
doc.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (
|
||||
!ctx.state.subtitleSidebarModalOpen ||
|
||||
isEditingText(event.target) ||
|
||||
!getSubtitleSidebarSelection(list)
|
||||
)
|
||||
return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
clearSubtitleSidebarSelection(list);
|
||||
updateButton();
|
||||
} else if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === 'c'
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
void copySelection();
|
||||
}
|
||||
},
|
||||
{ capture: true, signal },
|
||||
);
|
||||
button.addEventListener('mousedown', (event) => event.preventDefault(), { signal });
|
||||
button.addEventListener('click', copySelection, { signal });
|
||||
list.addEventListener(
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
if (event.button === 0) {
|
||||
list.dataset.selecting = 'true';
|
||||
list.scrollTo({ top: list.scrollTop, behavior: 'instant' });
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
const stopDragging = () => {
|
||||
delete list.dataset.selecting;
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
};
|
||||
doc.addEventListener('pointerup', stopDragging, { signal });
|
||||
doc.addEventListener('pointercancel', stopDragging, { signal });
|
||||
doc.defaultView?.addEventListener('blur', stopDragging, { signal });
|
||||
updateButton();
|
||||
return () => {
|
||||
abort.abort();
|
||||
stopDragging();
|
||||
};
|
||||
}
|
||||
@@ -239,6 +239,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
|
||||
const modalNotifications: string[] = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 3.4, text: 'first' },
|
||||
{ startTime: 3, endTime: 4, text: 'second' },
|
||||
@@ -381,6 +382,7 @@ test('subtitle sidebar rows support keyboard activation', async () => {
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 2, text: 'first' },
|
||||
{ startTime: 3, endTime: 4, text: 'second' },
|
||||
@@ -487,6 +489,7 @@ test('subtitle sidebar renders hour-long cue timestamps as HH:MM:SS', async () =
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 3665, endTime: 3670, text: 'long cue' }],
|
||||
currentSubtitle: {
|
||||
text: 'long cue',
|
||||
@@ -580,6 +583,7 @@ test('subtitle sidebar does not open when the feature is disabled', async () =>
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [],
|
||||
currentSubtitle: {
|
||||
text: '',
|
||||
@@ -676,6 +680,7 @@ test('subtitle sidebar auto-open on startup only opens when enabled and configur
|
||||
const previousDocument = globals.document;
|
||||
|
||||
let snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -786,6 +791,7 @@ test('subtitle sidebar auto-open restores previously open sidebar after renderer
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -884,6 +890,7 @@ test('subtitle sidebar refresh closes and clears state when config becomes disab
|
||||
const previousDocument = globals.document;
|
||||
const bodyClassList = createClassList();
|
||||
let snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -1008,6 +1015,7 @@ test('subtitle sidebar keeps nearby repeated cue when subtitle update lacks timi
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 2, text: 'same' },
|
||||
{ startTime: 3, endTime: 4, text: 'other' },
|
||||
@@ -1125,6 +1133,7 @@ test('subtitle sidebar does not regress to previous cue on text-only transition
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 2, text: 'first' },
|
||||
{ startTime: 3, endTime: 4, text: 'second' },
|
||||
@@ -1233,6 +1242,7 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a
|
||||
const previousDocument = globals.document;
|
||||
|
||||
let snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: Array.from({ length: 12 }, (_, index) => ({
|
||||
startTime: index * 2,
|
||||
endTime: index * 2 + 1.5,
|
||||
@@ -1403,6 +1413,7 @@ test('subtitle sidebar polling schedules serialized timeouts instead of interval
|
||||
});
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -1515,6 +1526,7 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
|
||||
const contentListeners = new Map<string, Array<() => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -1633,6 +1645,7 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
|
||||
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -1752,6 +1765,7 @@ test('subtitle sidebar keeps hover pause while a Yomitan lookup popup remains op
|
||||
const windowListeners = new Map<string, Array<() => Promise<void> | void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -1881,6 +1895,7 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin',
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2040,6 +2055,7 @@ test('subtitle sidebar embedded layout measures reserved width after embedded cl
|
||||
const contentClassList = createClassList();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2161,6 +2177,7 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
|
||||
const contentListeners = new Map<string, Array<() => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2290,6 +2307,7 @@ test('subtitle sidebar overlay layout restores macOS and Windows passthrough out
|
||||
const contentListeners = new Map<string, Array<() => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2417,6 +2435,7 @@ test('subtitle sidebar overlay layout only stays interactive while focus remains
|
||||
const contentListeners = new Map<string, Array<(event?: FocusEvent) => void>>();
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2532,6 +2551,7 @@ test('closing embedded subtitle sidebar recomputes passthrough from remaining su
|
||||
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
@@ -2640,6 +2660,7 @@ test('subtitle sidebar resets embedded mpv margin on startup while closed', asyn
|
||||
const mpvCommands: Array<Array<string | number>> = [];
|
||||
|
||||
const snapshot: SubtitleSidebarSnapshot = {
|
||||
sourceKey: 'test-subtitles',
|
||||
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: {
|
||||
text: 'first',
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
import {
|
||||
clearSubtitleSidebarSelection,
|
||||
hasSubtitleSidebarSelection,
|
||||
} from './subtitle-sidebar-selection.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
@@ -209,6 +213,7 @@ export function createSubtitleSidebarModal(
|
||||
let subtitleSidebarYomitanPopupVisible = false;
|
||||
let subtitleSidebarPauseHeldByYomitanPopup = false;
|
||||
let lastSubtitleSidebarLookupCueIndex = -1;
|
||||
let subtitleSourceKey: string | null = null;
|
||||
|
||||
function restoreEmbeddedSidebarPassthrough(): void {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
@@ -469,6 +474,8 @@ export function createSubtitleSidebarModal(
|
||||
): void {
|
||||
if (
|
||||
!ctx.state.subtitleSidebarAutoScroll ||
|
||||
ctx.dom.subtitleSidebarList.dataset?.selecting === 'true' ||
|
||||
hasSubtitleSidebarSelection(ctx.dom.subtitleSidebarList) ||
|
||||
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
|
||||
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
|
||||
nowForUiTiming() < ctx.state.subtitleSidebarManualScrollUntilMs
|
||||
@@ -565,8 +572,14 @@ export function createSubtitleSidebarModal(
|
||||
|
||||
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
|
||||
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
||||
if (snapshot.sourceKey !== subtitleSourceKey) {
|
||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||
lastSubtitleSidebarLookupCueIndex = -1;
|
||||
subtitleSourceKey = snapshot.sourceKey;
|
||||
}
|
||||
applyConfig(snapshot);
|
||||
if (!snapshot.config.enabled) {
|
||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
clearSidebarInteractionState();
|
||||
ctx.state.subtitleSidebarCues = [];
|
||||
@@ -586,6 +599,7 @@ export function createSubtitleSidebarModal(
|
||||
|
||||
const cuesChanged = !subtitleCueListsEqual(ctx.state.subtitleSidebarCues, snapshot.cues);
|
||||
if (cuesChanged) {
|
||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||
ctx.state.subtitleSidebarCues = snapshot.cues;
|
||||
if (ctx.state.subtitleSidebarModalOpen) {
|
||||
renderCueList();
|
||||
@@ -670,6 +684,7 @@ export function createSubtitleSidebarModal(
|
||||
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||
return;
|
||||
}
|
||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||
resumeSubtitleSidebarHoverPause();
|
||||
clearSidebarInteractionState();
|
||||
ctx.state.subtitleSidebarModalOpen = false;
|
||||
@@ -710,6 +725,7 @@ export function createSubtitleSidebarModal(
|
||||
closeSubtitleSidebarModal();
|
||||
});
|
||||
ctx.dom.subtitleSidebarList.addEventListener('click', (event) => {
|
||||
if (hasSubtitleSidebarSelection(ctx.dom.subtitleSidebarList)) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
|
||||
@@ -40,13 +40,19 @@ function createElementStub() {
|
||||
}
|
||||
|
||||
function createListStub() {
|
||||
return {
|
||||
innerHTML: '',
|
||||
const list = {
|
||||
children: [] as unknown[],
|
||||
appendChild(child: unknown) {
|
||||
this.children.push(child);
|
||||
list.children.push(child);
|
||||
},
|
||||
};
|
||||
// The modal clears lists through innerHTML before re-rendering.
|
||||
return Object.defineProperty(list, 'innerHTML', {
|
||||
get: () => '',
|
||||
set: () => {
|
||||
list.children.length = 0;
|
||||
},
|
||||
}) as typeof list & { innerHTML: string };
|
||||
}
|
||||
|
||||
function createTabStub(active: boolean) {
|
||||
@@ -118,6 +124,7 @@ function createModalHarness(
|
||||
files: TsukihimeSubtitleFile[],
|
||||
options: {
|
||||
secondaryLanguages?: string[];
|
||||
secondaryLanguagesGate?: Promise<void>;
|
||||
downloadFile?: (query: unknown) => Promise<unknown>;
|
||||
listFiles?: (entryId: number) => Promise<unknown>;
|
||||
searchEntries?: (query: unknown) => Promise<unknown>;
|
||||
@@ -138,7 +145,10 @@ function createModalHarness(
|
||||
if (options.downloadFile) return options.downloadFile(query);
|
||||
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
|
||||
},
|
||||
tsukihimeGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
|
||||
tsukihimeGetSecondaryLanguages: async () => {
|
||||
await options.secondaryLanguagesGate;
|
||||
return options.secondaryLanguages ?? ['en', 'eng'];
|
||||
},
|
||||
tsukihimeListFiles: async ({ entryId }: { entryId: number }) =>
|
||||
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
|
||||
tsukihimeSearchEntries: async (query: unknown) =>
|
||||
@@ -613,3 +623,202 @@ test('renderFiles omits the size detail when the API does not report one', () =>
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
const ENGLISH_ONLY_ENTRY = {
|
||||
id: 606713,
|
||||
title: 'english only release',
|
||||
timestamp: null,
|
||||
totalSize: null,
|
||||
numFiles: 1,
|
||||
sublangs: ['en'],
|
||||
};
|
||||
|
||||
const MULTI_SUB_ENTRY = {
|
||||
id: 12255,
|
||||
title: 'multi-sub release',
|
||||
timestamp: null,
|
||||
totalSize: null,
|
||||
numFiles: 1,
|
||||
sublangs: ['en-US', 'ja'],
|
||||
};
|
||||
|
||||
const UNLABELED_ENTRY = {
|
||||
id: 12256,
|
||||
title: 'release without langs',
|
||||
timestamp: null,
|
||||
totalSize: null,
|
||||
numFiles: 1,
|
||||
sublangs: [],
|
||||
};
|
||||
|
||||
function visibleEntryTitles(harness: ModalHarness): string[] {
|
||||
return (harness.entriesList.children as Array<{ textContent: string }>).map(
|
||||
(li) => li.textContent,
|
||||
);
|
||||
}
|
||||
|
||||
test('Japanese tab lists only releases that carry Japanese subtitles', async () => {
|
||||
const SECOND_JAPANESE_TRACK: TsukihimeSubtitleFile = {
|
||||
...JAPANESE_TRACK,
|
||||
attachmentId: 1955401,
|
||||
filename: 'episode01.jpn.sdh.ass',
|
||||
};
|
||||
const harness = createModalHarness([], {
|
||||
// Two tracks so the modal does not auto-download a lone match.
|
||||
listFiles: async () => ({ ok: true, data: [JAPANESE_TRACK, SECOND_JAPANESE_TRACK] }),
|
||||
});
|
||||
try {
|
||||
harness.state.currentTsukihimeEntryId = null;
|
||||
harness.state.tsukihimeEntries = [ENGLISH_ONLY_ENTRY, MULTI_SUB_ENTRY, UNLABELED_ENTRY];
|
||||
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.deepEqual(visibleEntryTitles(harness), ['multi-sub release']);
|
||||
|
||||
// Enter addresses the visible list, so it must pick the multi-sub release
|
||||
// rather than the hidden first search result.
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
assert.equal(harness.state.currentTsukihimeEntryId, MULTI_SUB_ENTRY.id);
|
||||
assert.equal(harness.status.textContent, 'Select a subtitle track.');
|
||||
|
||||
pressKey(harness, 'ArrowLeft');
|
||||
assert.deepEqual(visibleEntryTitles(harness), [
|
||||
'english only release',
|
||||
'multi-sub release',
|
||||
'release without langs',
|
||||
]);
|
||||
assert.equal(harness.state.currentTsukihimeEntryId, MULTI_SUB_ENTRY.id);
|
||||
assert.equal(harness.state.selectedTsukihimeEntryIndex, 1);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('Japanese tab reports when no release carries Japanese subtitles', () => {
|
||||
const harness = createModalHarness([]);
|
||||
try {
|
||||
harness.state.currentTsukihimeEntryId = null;
|
||||
harness.state.tsukihimeEntries = [ENGLISH_ONLY_ENTRY, UNLABELED_ENTRY];
|
||||
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.deepEqual(visibleEntryTitles(harness), []);
|
||||
assert.equal(
|
||||
harness.status.textContent,
|
||||
'No releases with Japanese subtitles. Switch to the English tab.',
|
||||
);
|
||||
|
||||
pressKey(harness, 'ArrowLeft');
|
||||
assert.deepEqual(visibleEntryTitles(harness), [
|
||||
'english only release',
|
||||
'release without langs',
|
||||
]);
|
||||
assert.equal(harness.status.textContent, 'Select a release.');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('search reports when no release carries the secondary language', async () => {
|
||||
const harness = createModalHarness([], {
|
||||
searchEntries: async () => ({
|
||||
ok: true,
|
||||
data: [{ ...MULTI_SUB_ENTRY, sublangs: ['ja'] }],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.state.currentTsukihimeEntryId = null;
|
||||
harness.titleInput.value = 'Futsutsuka na Akujo';
|
||||
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
assert.deepEqual(visibleEntryTitles(harness), []);
|
||||
assert.equal(
|
||||
harness.status.textContent,
|
||||
'No releases with English subtitles. Switch to the Japanese tab.',
|
||||
);
|
||||
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.deepEqual(visibleEntryTitles(harness), ['multi-sub release']);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('switching to a tab that hides the selected release clears its tracks', () => {
|
||||
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
||||
try {
|
||||
harness.state.tsukihimeEntries = [ENGLISH_ONLY_ENTRY, MULTI_SUB_ENTRY];
|
||||
|
||||
pressKey(harness, 'ArrowRight');
|
||||
assert.equal(harness.state.currentTsukihimeEntryId, null);
|
||||
assert.deepEqual(harness.state.tsukihimeFiles, []);
|
||||
assert.deepEqual(visibleEntryTitles(harness), ['multi-sub release']);
|
||||
assert.equal(harness.status.textContent, 'Select a release.');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a search waits for the configured secondary languages before filtering', async () => {
|
||||
let openGate!: () => void;
|
||||
const harness = createModalHarness([], {
|
||||
secondaryLanguages: ['de'],
|
||||
secondaryLanguagesGate: new Promise<void>((resolve) => {
|
||||
openGate = resolve;
|
||||
}),
|
||||
searchEntries: async () => ({
|
||||
ok: true,
|
||||
data: [{ ...MULTI_SUB_ENTRY, title: 'german release', sublangs: ['de'] }],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.state.tsukihimeModalOpen = false;
|
||||
harness.modal.openTsukihimeModal();
|
||||
harness.titleInput.value = 'Futsutsuka na Akujo';
|
||||
|
||||
// Searching before the config arrives must not filter against the English
|
||||
// fallback, which would hide this German-only release.
|
||||
pressKey(harness, 'Enter');
|
||||
await flushAsyncWork();
|
||||
assert.deepEqual(visibleEntryTitles(harness), []);
|
||||
|
||||
openGate();
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(visibleEntryTitles(harness), ['german release']);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a search from a prior modal session cannot repopulate a reopened modal', async () => {
|
||||
let openGate!: () => void;
|
||||
const harness = createModalHarness([], {
|
||||
secondaryLanguagesGate: new Promise<void>((resolve) => {
|
||||
openGate = resolve;
|
||||
}),
|
||||
searchEntries: async () => ({ ok: true, data: [MULTI_SUB_ENTRY] }),
|
||||
});
|
||||
try {
|
||||
harness.state.tsukihimeModalOpen = false;
|
||||
harness.modal.openTsukihimeModal();
|
||||
harness.titleInput.value = 'Futsutsuka na Akujo';
|
||||
|
||||
// The search parks on the language config, then the user closes and
|
||||
// reopens the modal before it resolves.
|
||||
pressKey(harness, 'Enter');
|
||||
harness.modal.closeTsukihimeModal();
|
||||
harness.modal.openTsukihimeModal();
|
||||
await flushAsyncWork();
|
||||
harness.status.textContent = 'Fresh modal session';
|
||||
|
||||
openGate();
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.deepEqual(harness.state.tsukihimeEntries, []);
|
||||
assert.deepEqual(visibleEntryTitles(harness), []);
|
||||
assert.equal(harness.status.textContent, 'Fresh modal session');
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,7 +41,13 @@ export function createTsukihimeModal(
|
||||
|
||||
// Defaults to English until the configured secondary languages arrive.
|
||||
let secondaryLanguages: string[] = ['en'];
|
||||
// Both tab filters read the configured languages, so a search must wait for
|
||||
// them rather than filtering against the English fallback.
|
||||
let secondaryLanguagesReady: Promise<void> = Promise.resolve();
|
||||
let activeDownloadToken = 0;
|
||||
// Bumped by every new search and by closing the modal, so results that
|
||||
// arrive late cannot repopulate a reopened modal or a newer search.
|
||||
let activeSearchToken = 0;
|
||||
|
||||
function secondaryTabLabel(): string {
|
||||
return describeTsukihimeTabLanguages(secondaryLanguages);
|
||||
@@ -61,6 +67,47 @@ export function createTsukihimeModal(
|
||||
);
|
||||
}
|
||||
|
||||
// Releases are filtered by the languages the search index reports for
|
||||
// them. Most releases carry no Japanese track, so the primary tab hides
|
||||
// them outright. A release with no language data cannot be classified and
|
||||
// stays on the secondary tab, mirroring how unlabeled tracks are handled.
|
||||
function entryMatchesTab(entry: TsukihimeEntry, tab: 'secondary' | 'primary'): boolean {
|
||||
if (tab === 'primary') {
|
||||
return entry.sublangs.some((lang) => normalizeTsukihimeLangCode(lang) === 'ja');
|
||||
}
|
||||
if (entry.sublangs.length === 0) return true;
|
||||
return entry.sublangs.some(
|
||||
(lang) =>
|
||||
normalizeTsukihimeLangCode(lang) !== 'ja' &&
|
||||
tsukihimeTrackMatchesLanguages(lang, secondaryLanguages),
|
||||
);
|
||||
}
|
||||
|
||||
function getVisibleEntries(): TsukihimeEntry[] {
|
||||
return ctx.state.tsukihimeEntries.filter((entry) =>
|
||||
entryMatchesTab(entry, ctx.state.tsukihimeActiveTab),
|
||||
);
|
||||
}
|
||||
|
||||
function describeEmptyReleases(): string {
|
||||
const otherTab = ctx.state.tsukihimeActiveTab === 'primary' ? 'secondary' : 'primary';
|
||||
const otherTabHasReleases = ctx.state.tsukihimeEntries.some((entry) =>
|
||||
entryMatchesTab(entry, otherTab),
|
||||
);
|
||||
const language = ctx.state.tsukihimeActiveTab === 'primary' ? 'Japanese' : secondaryTabLabel();
|
||||
const otherLabel = otherTab === 'primary' ? 'Japanese' : secondaryTabLabel();
|
||||
return otherTabHasReleases
|
||||
? `No releases with ${language} subtitles. Switch to the ${otherLabel} tab.`
|
||||
: `No releases with ${language} subtitles.`;
|
||||
}
|
||||
|
||||
function clearFiles(): void {
|
||||
ctx.state.tsukihimeFiles = [];
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
ctx.dom.tsukihimeFilesList.innerHTML = '';
|
||||
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
function renderTabs(): void {
|
||||
const primaryActive = ctx.state.tsukihimeActiveTab === 'primary';
|
||||
ctx.dom.tsukihimeTabSecondaryButton.setAttribute(
|
||||
@@ -98,12 +145,37 @@ export function createTsukihimeModal(
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
renderTabs();
|
||||
|
||||
if (ctx.state.tsukihimeFiles.length === 0) return;
|
||||
renderFiles();
|
||||
if (getVisibleFiles().length === 0) {
|
||||
setTsukihimeStatus(describeEmptyTab());
|
||||
} else {
|
||||
setTsukihimeStatus('Select a subtitle track.');
|
||||
const currentEntry = ctx.state.tsukihimeEntries.find(
|
||||
(entry) => entry.id === ctx.state.currentTsukihimeEntryId,
|
||||
);
|
||||
if (currentEntry && !entryMatchesTab(currentEntry, tab)) {
|
||||
// The selected release is hidden on this tab; drop its tracks so the
|
||||
// list matches what the tab claims to show.
|
||||
ctx.state.currentTsukihimeEntryId = null;
|
||||
ctx.state.selectedTsukihimeEntryIndex = 0;
|
||||
clearFiles();
|
||||
renderEntries();
|
||||
setTsukihimeStatus(
|
||||
getVisibleEntries().length === 0 ? describeEmptyReleases() : 'Select a release.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleEntries = getVisibleEntries();
|
||||
ctx.state.selectedTsukihimeEntryIndex = currentEntry ? visibleEntries.indexOf(currentEntry) : 0;
|
||||
renderEntries();
|
||||
|
||||
if (ctx.state.tsukihimeFiles.length > 0) {
|
||||
renderFiles();
|
||||
setTsukihimeStatus(
|
||||
getVisibleFiles().length === 0 ? describeEmptyTab() : 'Select a subtitle track.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!currentEntry && ctx.state.tsukihimeEntries.length > 0) {
|
||||
setTsukihimeStatus(
|
||||
visibleEntries.length === 0 ? describeEmptyReleases() : 'Select a release.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,13 +193,14 @@ export function createTsukihimeModal(
|
||||
|
||||
function renderEntries(): void {
|
||||
ctx.dom.tsukihimeEntriesList.innerHTML = '';
|
||||
if (ctx.state.tsukihimeEntries.length === 0) {
|
||||
const visibleEntries = getVisibleEntries();
|
||||
if (visibleEntries.length === 0) {
|
||||
ctx.dom.tsukihimeEntriesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.tsukihimeEntriesSection.classList.remove('hidden');
|
||||
ctx.state.tsukihimeEntries.forEach((entry, index) => {
|
||||
visibleEntries.forEach((entry, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = entry.title;
|
||||
|
||||
@@ -210,11 +283,15 @@ export function createTsukihimeModal(
|
||||
return;
|
||||
}
|
||||
|
||||
const searchToken = ++activeSearchToken;
|
||||
resetTsukihimeLists();
|
||||
setTsukihimeStatus('Searching TsukiHime...');
|
||||
await secondaryLanguagesReady;
|
||||
if (searchToken !== activeSearchToken) return;
|
||||
|
||||
const response: TsukihimeApiResponse<TsukihimeEntry[]> =
|
||||
await window.electronAPI.tsukihimeSearchEntries({ query });
|
||||
if (searchToken !== activeSearchToken) return;
|
||||
if (!response.ok) {
|
||||
setTsukihimeStatus(response.error.error, true);
|
||||
return;
|
||||
@@ -228,20 +305,22 @@ export function createTsukihimeModal(
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleEntries = getVisibleEntries();
|
||||
if (visibleEntries.length === 0) {
|
||||
setTsukihimeStatus(describeEmptyReleases());
|
||||
return;
|
||||
}
|
||||
|
||||
setTsukihimeStatus('Select a release.');
|
||||
renderEntries();
|
||||
if (ctx.state.tsukihimeEntries.length === 1) {
|
||||
if (visibleEntries.length === 1) {
|
||||
selectEntry(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles(entryId: number): Promise<void> {
|
||||
setTsukihimeStatus('Loading subtitle tracks...');
|
||||
ctx.state.tsukihimeFiles = [];
|
||||
ctx.state.selectedTsukihimeFileIndex = 0;
|
||||
|
||||
ctx.dom.tsukihimeFilesList.innerHTML = '';
|
||||
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
|
||||
clearFiles();
|
||||
|
||||
const response: TsukihimeApiResponse<TsukihimeSubtitleFile[]> =
|
||||
await window.electronAPI.tsukihimeListFiles({ entryId });
|
||||
@@ -279,11 +358,14 @@ export function createTsukihimeModal(
|
||||
}
|
||||
}
|
||||
|
||||
// `index` addresses the entries visible on the active tab, not the full
|
||||
// search result list.
|
||||
function selectEntry(index: number): void {
|
||||
if (index < 0 || index >= ctx.state.tsukihimeEntries.length) return;
|
||||
const visibleEntries = getVisibleEntries();
|
||||
if (index < 0 || index >= visibleEntries.length) return;
|
||||
|
||||
ctx.state.selectedTsukihimeEntryIndex = index;
|
||||
ctx.state.currentTsukihimeEntryId = ctx.state.tsukihimeEntries[index]!.id;
|
||||
ctx.state.currentTsukihimeEntryId = visibleEntries[index]!.id;
|
||||
renderEntries();
|
||||
|
||||
if (ctx.state.currentTsukihimeEntryId !== null) {
|
||||
@@ -363,16 +445,15 @@ export function createTsukihimeModal(
|
||||
resetTsukihimeLists();
|
||||
renderTabs();
|
||||
|
||||
const secondaryLanguagesReady = loadSecondaryLanguages();
|
||||
secondaryLanguagesReady = loadSecondaryLanguages();
|
||||
|
||||
window.electronAPI
|
||||
.getJimakuMediaInfo()
|
||||
.then(async (info: JimakuMediaInfo) => {
|
||||
.then((info: JimakuMediaInfo) => {
|
||||
ctx.dom.tsukihimeTitleInput.value = info.title || '';
|
||||
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
|
||||
|
||||
if (info.confidence === 'high' && info.title && info.episode) {
|
||||
await secondaryLanguagesReady;
|
||||
void performTsukihimeSearch();
|
||||
} else if (info.title) {
|
||||
setTsukihimeStatus('Check title/episode and press Search.');
|
||||
@@ -389,6 +470,7 @@ export function createTsukihimeModal(
|
||||
if (!ctx.state.tsukihimeModalOpen) return;
|
||||
|
||||
activeDownloadToken += 1;
|
||||
activeSearchToken += 1;
|
||||
ctx.state.tsukihimeModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.tsukihimeModal.classList.add('hidden');
|
||||
@@ -438,9 +520,9 @@ export function createTsukihimeModal(
|
||||
ctx.state.selectedTsukihimeFileIndex + 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
} else if (getVisibleEntries().length > 0) {
|
||||
ctx.state.selectedTsukihimeEntryIndex = Math.min(
|
||||
ctx.state.tsukihimeEntries.length - 1,
|
||||
getVisibleEntries().length - 1,
|
||||
ctx.state.selectedTsukihimeEntryIndex + 1,
|
||||
);
|
||||
renderEntries();
|
||||
@@ -456,7 +538,7 @@ export function createTsukihimeModal(
|
||||
ctx.state.selectedTsukihimeFileIndex - 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
} else if (getVisibleEntries().length > 0) {
|
||||
ctx.state.selectedTsukihimeEntryIndex = Math.max(
|
||||
0,
|
||||
ctx.state.selectedTsukihimeEntryIndex - 1,
|
||||
@@ -470,7 +552,7 @@ export function createTsukihimeModal(
|
||||
e.preventDefault();
|
||||
if (getVisibleFiles().length > 0) {
|
||||
void selectFile(ctx.state.selectedTsukihimeFileIndex);
|
||||
} else if (ctx.state.tsukihimeEntries.length > 0) {
|
||||
} else if (getVisibleEntries().length > 0) {
|
||||
selectEntry(ctx.state.selectedTsukihimeEntryIndex);
|
||||
} else {
|
||||
void performTsukihimeSearch();
|
||||
|
||||
@@ -27,7 +27,9 @@ function isYomitanPopupInteractionActive(state: RendererState): boolean {
|
||||
|
||||
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||
const shouldKeepWindowInteractive =
|
||||
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
|
||||
ctx.dom.subtitleSidebarList?.dataset?.selecting === 'true' ||
|
||||
isYomitanPopupInteractionActive(ctx.state) ||
|
||||
isBlockingOverlayModalOpen(ctx.state);
|
||||
const shouldStayInteractive =
|
||||
ctx.state.isOverSubtitle ||
|
||||
ctx.state.isOverSubtitleSidebar ||
|
||||
|
||||
@@ -40,6 +40,7 @@ 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 { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selection.js';
|
||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
@@ -239,6 +240,8 @@ const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
||||
measurementReporter.emitNow();
|
||||
},
|
||||
});
|
||||
const disposeSubtitleSidebarSelection = wireSubtitleSidebarSelection(ctx);
|
||||
window.addEventListener('beforeunload', disposeSubtitleSidebarSelection, { once: true });
|
||||
const kikuModal = createKikuModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -591,6 +594,11 @@ function registerModalOpenHandlers(): void {
|
||||
mediaTimingReviewModal.openMediaTimingReviewModal(payload);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onMediaTimingReviewPreviewEnded((reviewId) => {
|
||||
runGuarded('media-timing-review:preview-ended', () => {
|
||||
mediaTimingReviewModal.handlePreviewEnded(reviewId);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenPlaylistBrowser(() => {
|
||||
runGuardedAsync('playlist-browser:open', async () => {
|
||||
await playlistBrowserModal.openPlaylistBrowserModal();
|
||||
|
||||
@@ -3690,6 +3690,7 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
}
|
||||
|
||||
.subtitle-sidebar-timestamp {
|
||||
user-select: none;
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -3712,6 +3713,8 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
}
|
||||
|
||||
.subtitle-sidebar-text {
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
font-size: 1em;
|
||||
|
||||
@@ -157,6 +157,7 @@ export type RendererDom = {
|
||||
subtitleSidebarModal: HTMLDivElement;
|
||||
subtitleSidebarContent: HTMLDivElement;
|
||||
subtitleSidebarClose: HTMLButtonElement;
|
||||
subtitleSidebarCopy: HTMLButtonElement;
|
||||
subtitleSidebarStatus: HTMLDivElement;
|
||||
subtitleSidebarList: HTMLUListElement;
|
||||
|
||||
@@ -405,6 +406,7 @@ export function resolveRendererDom(): RendererDom {
|
||||
subtitleSidebarModal: getRequiredElement<HTMLDivElement>('subtitleSidebarModal'),
|
||||
subtitleSidebarContent: getRequiredElement<HTMLDivElement>('subtitleSidebarContent'),
|
||||
subtitleSidebarClose: getRequiredElement<HTMLButtonElement>('subtitleSidebarClose'),
|
||||
subtitleSidebarCopy: getRequiredElement<HTMLButtonElement>('subtitleSidebarCopy'),
|
||||
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
|
||||
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ export const IPC_CHANNELS = {
|
||||
tsukihimeOpen: 'tsukihime:open',
|
||||
youtubePickerOpen: 'youtube:picker-open',
|
||||
mediaTimingReviewOpen: 'media-timing-review:open',
|
||||
mediaTimingReviewPreviewEnded: 'media-timing-review:preview-ended',
|
||||
youtubePickerCancel: 'youtube:picker-cancel',
|
||||
playlistBrowserOpen: 'playlist-browser:open',
|
||||
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface MediaTimingReviewResolveRequest {
|
||||
export interface MediaTimingReviewActionResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
/** The review this request targeted has already ended; the renderer should close. */
|
||||
stale?: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationOptions {
|
||||
|
||||
@@ -448,6 +448,7 @@ export interface ElectronAPI {
|
||||
getCurrentSubtitleRaw: () => Promise<string>;
|
||||
getCurrentSubtitleAss: () => Promise<string>;
|
||||
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
|
||||
copySubtitleSidebarSelection: (text: string) => Promise<void>;
|
||||
getSubtitleSidebarOpen: () => Promise<boolean>;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
onSubtitleAss: (callback: (assText: string) => void) => void;
|
||||
@@ -523,6 +524,7 @@ export interface ElectronAPI {
|
||||
onOpenTsukihime: (callback: () => void) => void;
|
||||
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||
onOpenMediaTimingReview: (callback: (payload: MediaTimingReviewOpenPayload) => void) => void;
|
||||
onMediaTimingReviewPreviewEnded: (callback: (reviewId: string) => void) => void;
|
||||
previewMediaTimingReview: (
|
||||
request: MediaTimingReviewPreviewRequest,
|
||||
) => Promise<MediaTimingReviewActionResult>;
|
||||
|
||||
@@ -227,6 +227,7 @@ export interface SubtitleData {
|
||||
}
|
||||
|
||||
export interface SubtitleSidebarSnapshot {
|
||||
sourceKey: string | null;
|
||||
cues: SubtitleCue[];
|
||||
currentTimeSec?: number | null;
|
||||
currentSubtitle: {
|
||||
|
||||
Reference in New Issue
Block a user