mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 01:01:34 -07:00
Compare commits
4 Commits
c09d009a3e
...
b3b45521b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
b3b45521b6
|
|||
|
131b23efa9
|
|||
| e2afceb492 | |||
| 7be1843c41 |
@@ -1,7 +1,7 @@
|
||||
name: Bug Report
|
||||
description: Report something that is broken or behaving incorrectly
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
title: '[Bug]: '
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or an improvement to an existing one
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## v0.16.0 (2026-06-10)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Notification Mode `both`**: `notificationType: "both"` now routes to overlay + system notifications. Users who previously used `"both"` for mpv OSD + system notifications should set `notificationType` to `"osd-system"` in `config.jsonc`. The `osd` and `osd-system` values remain valid as config-file entries but no longer appear in Settings.
|
||||
|
||||
### Added
|
||||
|
||||
- **Overlay Notifications**: New overlay notification stack (Catppuccin Macchiato theme) with configurable screen position (`notifications.overlayPosition`: top-left, top-center, top-right), 3-second transient dismissals, and persistent cards for long-running jobs like character dictionary sync.
|
||||
- **Notification History Panel**: `Ctrl/Cmd+N` (configurable via `shortcuts.toggleNotificationHistory`) opens a session log of all notifications. Works whether the overlay or mpv has focus; slides in from the notification edge; entries can be individually removed or cleared.
|
||||
- **Mined-Card Notification Actions**: Mined-card overlay notifications show generated card thumbnails and include an Open in Anki button in both live cards and history entries.
|
||||
- **Update Notification Action**: Update-available overlay notifications include an Update button to start the app update flow directly from the notification.
|
||||
- **Stats Search**: New Search tab in Stats for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AniSkip**: Moved intro detection from the mpv plugin to the SubMiner app. Lookups now cover every file loaded during a session including playlist advances, and `mpv.aniskipEnabled`/`mpv.aniskipButtonKey` hot-reload without restarting playback. The bundled plugin no longer makes network calls. Note: AniSkip now requires the SubMiner app to be connected; plugin-only mpv sessions will not fetch skip windows.
|
||||
- **Stats Library**: Entries are now split by detected season (season folder first, filename parsing as fallback). Existing combined-series rows are automatically migrated to per-season entries on startup. Cover art and anime details refresh immediately after a manual AniList entry change.
|
||||
- **Stats Vocabulary**: Remembers Hide Known/Hide Kana filters across sessions, applies Hide Kana filtering cross-title, collapses duplicate token variants in exclusions, and matches Related Seen Words by shared readings or kanji.
|
||||
- **Stats Trends**: Reorganized into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections; disambiguated per-period vs. cumulative charts; added Words/Min and Cards/Hour efficiency charts.
|
||||
- **Stats Mining**: Sentence cards are created before slow media generation finishes; stored/requested secondary subtitles are preserved before falling back to sidecar or alass-retimed English subtitles; empty `ankiConnect.deck` falls back to Yomitan's mining deck; partial media failures are surfaced.
|
||||
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
|
||||
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
|
||||
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
|
||||
- **Update Notification Default**: New installs default `notificationType` to `both` so update alerts appear in both overlay and system notifications.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **AniList Completion**: Entries are now marked completed when a post-watch update reaches the final known episode of the season.
|
||||
- **AniSkip Markers**: Fixed intro markers disappearing after same-media mpv reloads; fixed metadata detection for intros that start at 0 seconds and common release-group filenames.
|
||||
- **Jellyfin Session**: Remote session now restarts after setup login so the websocket reconnects with fresh credentials, and stops cleanly on logout.
|
||||
- **Sentence Card Audio**: Mining a sentence card no longer fills the expression audio field; generated audio goes only to the configured sentence audio field.
|
||||
- **Stats Mining Fields**: Sentence clips update `SentenceAudio` correctly; word audio uses configured Yomitan sources; English subtitle text is not written to word cards; secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
|
||||
- **Overlay Hover Readiness**: Subtitle bars are hoverable and clickable from the first subtitle line on visible overlay startup or resume, without waiting for the next subtitle event.
|
||||
- **Startup Autoplay**: Playback is released after tokenization and overlay content are ready even when playback begins before the first subtitle line appears.
|
||||
- **Overlay Startup Feedback**: Restored mpv OSD loading spinner that starts on connect, media open, or overlay request, and clears once the overlay is content-ready and visible.
|
||||
- **Linux Overlay Input**: Notification close and action buttons remain clickable above subtitle bars on Linux.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
|
||||
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
|
||||
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.15.2 (2026-06-02)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: release
|
||||
|
||||
- Release notes now credit contributors with a `What's Changed` list (`by @author in #pr`) and a `New Contributors` section for first-time authors, resolved from changelog fragments via git and the GitHub API.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- Marked AniList entries completed when a post-watch update reaches the final known episode of the season.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: changed
|
||||
area: playback
|
||||
|
||||
- AniSkip intro detection now runs in the SubMiner app instead of the mpv plugin: lookups cover every local file loaded during an mpv session (including playlist advances), and the plugin no longer performs any network calls.
|
||||
- `mpv.aniskipEnabled` and `mpv.aniskipButtonKey` now hot-reload without restarting playback.
|
||||
- AniSkip now requires the SubMiner app to be connected to mpv; plugin-only mpv sessions without the app no longer fetch skip windows.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: playback
|
||||
|
||||
- Fixed AniSkip intro markers disappearing after same-media mpv reloads.
|
||||
- Fixed AniSkip metadata detection for intros that start at `0` seconds and common release-group filenames without `guessit`.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Changed PR changelog guidance to preserve multiple fragments for genuinely separate outcomes while directing contributors to update, remove, or merge same-PR fragment notes before adding follow-up churn.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Restarted the Jellyfin remote session after successful setup login so websocket reconnects use the freshly saved credentials.
|
||||
- Stopped the Jellyfin remote session on setup logout.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: release
|
||||
|
||||
- Kept the GitHub release `What's Changed` and `New Contributors` attribution sections when CI regenerates release notes from the committed changelog.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: changed
|
||||
area: stats
|
||||
|
||||
- Split local and Jellyfin library entries by detected season, using season folders first and filename parsing as fallback.
|
||||
- Repaired older combined-series stats rows by moving parsed episodes into season-specific library entries, rebuilding summaries, and deleting now-empty legacy rows.
|
||||
- Refresh anime detail and library cover art immediately after manually changing an AniList entry.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: build
|
||||
|
||||
- Updated `make deps` so a fresh source checkout initializes submodules before installing root, stats, and texthooker-ui dependencies.
|
||||
@@ -1,9 +0,0 @@
|
||||
type: changed
|
||||
area: stats
|
||||
|
||||
- Added the Stats Search tab for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
|
||||
- Improved Stats mining from Search and vocabulary examples: empty `ankiConnect.deck` can use Yomitan's mining deck, sentence cards are created before slow media generation finishes, stored/requested secondary subtitles are preserved before falling back to sidecar files or temporary alass-retimed English sidecars for sentence Selection Text, invalid stored timings are blocked before FFmpeg runs, future out-of-order subtitle timing pairs are skipped until valid timings arrive, and partial media failures are shown.
|
||||
- Fixed Stats mining field/audio behavior so sentence clips update `SentenceAudio`, word audio uses the configured Yomitan sources, English subtitle text is not written onto word cards, and secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
|
||||
- Improved vocabulary review with remembered Hide Known/Hide Kana filters, cross-title Hide Kana filtering, duplicate-collapsed exclusions across token variants, and Related Seen Words matching based on shared readings or kanji.
|
||||
- Reorganized the Stats Trends tab into clearer Activity, Cumulative Totals, Efficiency, Patterns, and Library sections, disambiguated per-period vs cumulative charts, and added Words/Min and Cards/Hour efficiency charts.
|
||||
- Improved Stats browsing reliability by remembering library card size, retrying stored cover art without extra AniList lookups, preserving PNG/WebP cover MIME types, honoring custom AnkiConnect URLs for Browse, showing progress during session deletes, and making session deletes refresh faster.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: updates
|
||||
|
||||
- New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||
+13
-3
@@ -172,10 +172,19 @@
|
||||
"updates": {
|
||||
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
||||
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
|
||||
"notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
|
||||
}, // Automatic update check behavior.
|
||||
|
||||
// ==========================================
|
||||
// Notifications
|
||||
// Overlay notification display behavior.
|
||||
// Hot-reload: position changes apply to the next overlay notification.
|
||||
// ==========================================
|
||||
"notifications": {
|
||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||
}, // Overlay notification display behavior.
|
||||
|
||||
// ==========================================
|
||||
// Keyboard Shortcuts
|
||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
@@ -199,7 +208,8 @@
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
|
||||
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
|
||||
"toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
@@ -539,7 +549,7 @@
|
||||
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
|
||||
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
|
||||
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
|
||||
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
||||
"notificationType": "overlay", // Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||
}, // Behavior setting.
|
||||
"nPlusOne": {
|
||||
|
||||
@@ -216,11 +216,15 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
|
||||
"overwriteImage": true, // replace existing image, or append
|
||||
"mediaInsertMode": "append", // "append" or "prepend" to field content
|
||||
"autoUpdateNewCards": true, // auto-update when new card detected
|
||||
"notificationType": "osd" // "osd", "system", "both", or "none"
|
||||
"notificationType": "overlay" // "overlay", "system", "both", or "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`both` now means overlay + system notification. `osd` and `osd-system` are legacy config-file-only values; set `notificationType` to `"osd-system"` in `config.jsonc` if you previously used `both` and want to keep mpv OSD + system notifications. The Settings window shows `osd` or `osd-system` when already configured, but only offers `overlay`, `system`, `both`, and `none` as normal choices.
|
||||
|
||||
When media is available, mined-card overlay and system notifications include the same current-frame thumbnail.
|
||||
|
||||
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged.
|
||||
|
||||
## AI Translation
|
||||
@@ -351,7 +355,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"overwriteImage": true,
|
||||
"mediaInsertMode": "append",
|
||||
"autoUpdateNewCards": true,
|
||||
"notificationType": "osd",
|
||||
"notificationType": "overlay",
|
||||
},
|
||||
"ai": {
|
||||
"enabled": false,
|
||||
|
||||
+57
-4
@@ -1,6 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## v0.15.2 (2026-06-02)
|
||||
## v0.16.0 (2026-06-10)
|
||||
|
||||
**Breaking Changes**
|
||||
|
||||
- **Notification Mode `both`**: `notificationType: "both"` now routes to overlay + system notifications. Users who previously used `"both"` for mpv OSD + system notifications should set `notificationType` to `"osd-system"` in `config.jsonc`. The `osd` and `osd-system` values remain valid as config-file entries but no longer appear in Settings.
|
||||
|
||||
**Added**
|
||||
|
||||
- **Overlay Notifications**: New overlay notification stack (Catppuccin Macchiato theme) with configurable screen position (`notifications.overlayPosition`: top-left, top-center, top-right), 3-second transient dismissals, and persistent cards for long-running jobs like character dictionary sync.
|
||||
- **Notification History Panel**: `Ctrl/Cmd+N` (configurable via `shortcuts.toggleNotificationHistory`) opens a session log of all notifications. Works whether the overlay or mpv has focus; slides in from the notification edge; entries can be individually removed or cleared.
|
||||
- **Mined-Card Notification Actions**: Mined-card overlay notifications show generated card thumbnails and include an Open in Anki button in both live cards and history entries.
|
||||
- **Update Notification Action**: Update-available overlay notifications include an Update button to start the app update flow directly from the notification.
|
||||
- **Stats Search**: New Search tab in Stats for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
|
||||
|
||||
**Changed**
|
||||
|
||||
- **AniSkip**: Moved intro detection from the mpv plugin to the SubMiner app. Lookups now cover every file loaded during a session including playlist advances, and `mpv.aniskipEnabled`/`mpv.aniskipButtonKey` hot-reload without restarting playback. The bundled plugin no longer makes network calls. Note: AniSkip now requires the SubMiner app to be connected; plugin-only mpv sessions will not fetch skip windows.
|
||||
- **Stats Library**: Entries are now split by detected season (season folder first, filename parsing as fallback). Existing combined-series rows are automatically migrated to per-season entries on startup. Cover art and anime details refresh immediately after a manual AniList entry change.
|
||||
- **Stats Vocabulary**: Remembers Hide Known/Hide Kana filters across sessions, applies Hide Kana filtering cross-title, collapses duplicate token variants in exclusions, and matches Related Seen Words by shared readings or kanji.
|
||||
- **Stats Trends**: Reorganized into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections; disambiguated per-period vs. cumulative charts; added Words/Min and Cards/Hour efficiency charts.
|
||||
- **Stats Mining**: Sentence cards are created before slow media generation finishes; stored/requested secondary subtitles are preserved before falling back to sidecar or alass-retimed English subtitles; empty `ankiConnect.deck` falls back to Yomitan's mining deck; partial media failures are surfaced.
|
||||
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
|
||||
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
|
||||
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
|
||||
- **Update Notification Default**: New installs default `notificationType` to `overlay`, while `both` remains available for overlay + system notifications.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **AniList Completion**: Entries are now marked completed when a post-watch update reaches the final known episode of the season.
|
||||
- **AniSkip Markers**: Fixed intro markers disappearing after same-media mpv reloads; fixed metadata detection for intros that start at 0 seconds and common release-group filenames.
|
||||
- **Jellyfin Session**: Remote session now restarts after setup login so the websocket reconnects with fresh credentials, and stops cleanly on logout.
|
||||
- **Sentence Card Audio**: Mining a sentence card no longer fills the expression audio field; generated audio goes only to the configured sentence audio field.
|
||||
- **Stats Mining Fields**: Sentence clips update `SentenceAudio` correctly; word audio uses configured Yomitan sources; English subtitle text is not written to word cards; secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
|
||||
- **Overlay Hover Readiness**: Subtitle bars are hoverable and clickable from the first subtitle line on visible overlay startup or resume, without waiting for the next subtitle event.
|
||||
- **Startup Autoplay**: Playback is released after tokenization and overlay content are ready even when playback begins before the first subtitle line appears.
|
||||
- **Overlay Startup Feedback**: Restored mpv OSD loading spinner that starts on connect, media open, or overlay request, and clears once the overlay is content-ready and visible.
|
||||
- **Linux Overlay Input**: Notification close and action buttons remain clickable above subtitle bars on Linux.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
|
||||
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
|
||||
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
|
||||
<details>
|
||||
<summary>v0.15.x</summary>
|
||||
|
||||
<h2>v0.15.2 (2026-06-02)</h2>
|
||||
|
||||
**Changed**
|
||||
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
|
||||
@@ -11,7 +64,7 @@
|
||||
- Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click.
|
||||
- Overlay (macOS): Fixed overlay, subtitles, and subtitle sidebar staying hidden after a modal closes until the user clicked the mpv window; focus is now restored to mpv when the last modal closes, so playback shortcuts and the overlay reappear correctly - including in native fullscreen.
|
||||
|
||||
## v0.15.1 (2026-05-31)
|
||||
<h2>v0.15.1 (2026-05-31)</h2>
|
||||
|
||||
**Fixed**
|
||||
|
||||
@@ -27,7 +80,7 @@
|
||||
|
||||
- **Troubleshooting**: Updated Hyprland overlay docs with current Lua (`hl.window_rule`) and legacy config syntax; added troubleshooting for KDE/Wayland and other non-Hyprland/Sway Wayland sessions; added a Character Dictionary troubleshooting section; added a "See Also" index linking each feature's troubleshooting page.
|
||||
|
||||
## v0.15.0 (2026-05-29)
|
||||
<h2>v0.15.0 (2026-05-29)</h2>
|
||||
|
||||
**Breaking Changes**
|
||||
|
||||
@@ -179,7 +232,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.14.x</summary>
|
||||
|
||||
@@ -158,6 +158,8 @@ The three collapsible sections can be configured to start open or closed:
|
||||
|
||||
When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes.
|
||||
|
||||
These phases are emitted through the configured notification surface. Some phases are skipped when unnecessary: `generating` only appears on a cache miss, `building` only appears when the merged ZIP must be rebuilt, and `importing` only appears when Yomitan needs a new dictionary import.
|
||||
|
||||
**Phases:**
|
||||
|
||||
1. **checking** - Is there already a cached snapshot for this media ID?
|
||||
|
||||
+122
-91
@@ -158,6 +158,7 @@ The configuration file includes several main sections:
|
||||
- [**MPV Launcher**](#mpv-launcher) - mpv executable path, profile, and window launch mode
|
||||
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
|
||||
- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
|
||||
- [**Notifications**](#notifications) - Overlay notification placement
|
||||
|
||||
## Core Settings
|
||||
|
||||
@@ -196,18 +197,46 @@ Configure automatic update checks and update notifications:
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"checkIntervalHours": 24,
|
||||
"notificationType": "system",
|
||||
"notificationType": "overlay",
|
||||
"channel": "stable"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
|
||||
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
|
||||
| `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. |
|
||||
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
|
||||
| Option | Values | Description |
|
||||
| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
|
||||
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
|
||||
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"overlay"`. `"both"` means overlay + system. |
|
||||
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
|
||||
|
||||
When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow.
|
||||
|
||||
`osd` and `osd-system` are legacy config-file-only notification values. The Settings window offers `overlay`, `system`, `both`, and `none`; if your config already contains `osd` or `osd-system`, it is shown as the selected value but not offered as a normal choice. If you previously used `both` for mpv OSD + system notifications, set `notificationType` to `"osd-system"` in `config.jsonc` to keep that behavior.
|
||||
|
||||
### Notifications
|
||||
|
||||
Configure where overlay notification cards appear:
|
||||
|
||||
```json
|
||||
{
|
||||
"notifications": {
|
||||
"overlayPosition": "top-right"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------------- | ---------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `overlayPosition` | `"top-left"` \| `"top"` \| `"top-right"` | Position for in-overlay notification cards. Default `"top-right"`. |
|
||||
|
||||
#### Notification history panel
|
||||
|
||||
Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use — left when `overlayPosition` is `"top-left"`, and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts.
|
||||
|
||||
Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`; AniSkip prompts and skip result messages are playback feedback and still route to overlay notifications when configured.
|
||||
|
||||
The equivalent direct CLI command is `--playback-feedback <text>` (`playbackFeedback` internally). It sends that one non-empty feedback string through the same route controlled by `ankiConnect.behavior.notificationType`; it does not change the saved config.
|
||||
|
||||
### Auto-Start Overlay
|
||||
|
||||
@@ -223,7 +252,7 @@ Control whether the overlay automatically becomes visible when it connects to mp
|
||||
| -------------------- | --------------- | ----------------------------------------------------- |
|
||||
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) |
|
||||
|
||||
When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
||||
When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness. On visible-overlay startup, SubMiner brings up the tray and visible overlay shell before tokenization and annotation warmups finish, then releases playback only after autoplay readiness.
|
||||
|
||||
On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`.
|
||||
|
||||
@@ -620,31 +649,33 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
"openControllerDebug": "Alt+Shift+C",
|
||||
"openJimaku": "Ctrl+Shift+J",
|
||||
"toggleSubtitleSidebar": "Backslash",
|
||||
"toggleNotificationHistory": "CommandOrControl+N",
|
||||
"multiCopyTimeoutMs": 3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
||||
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
|
||||
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
||||
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
||||
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
||||
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
|
||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
|
||||
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
||||
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
|
||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
||||
| Option | Values | Description |
|
||||
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
||||
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
|
||||
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
||||
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
||||
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
||||
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
|
||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
|
||||
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
||||
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
|
||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
||||
| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). |
|
||||
|
||||
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||
|
||||
@@ -943,57 +974,57 @@ This example is intentionally compact. The option table below documents availabl
|
||||
|
||||
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
|
||||
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
||||
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
||||
@@ -1458,18 +1489,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
|
||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
|
||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
||||
| Option | Values | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
|
||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
|
||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
||||
|
||||
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||
|
||||
|
||||
+14
-14
@@ -1,6 +1,6 @@
|
||||
# MPV Plugin
|
||||
|
||||
**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs *inside* mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window.
|
||||
**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs _inside_ mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window.
|
||||
|
||||
**Who needs this page:** Most users never touch the plugin directly - SubMiner-managed launches (the app, the `subminer` launcher, or the Windows shortcut) inject the bundled plugin automatically for that session, so there is nothing to install into mpv's global `scripts` directory. Read on if you launch mpv from another tool and want SubMiner's in-player controls, or you want to script mpv against SubMiner.
|
||||
|
||||
@@ -29,18 +29,18 @@ input-ipc-server=\\.\pipe\subminer-socket
|
||||
|
||||
Most plugin actions use a `y` chord prefix - press `y`, then the second key (a "chord"):
|
||||
|
||||
| Chord | Action |
|
||||
| ---------------- | -------------------------------------- |
|
||||
| `y-y` | Open menu |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `y-o` | Open settings window |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check status |
|
||||
| `y-h` | Open session help / keybinding modal |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `TAB` (default) | Skip intro (AniSkip) |
|
||||
| Chord | Action |
|
||||
| --------------- | -------------------------------------- |
|
||||
| `y-y` | Open menu |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `y-o` | Open settings window |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check status |
|
||||
| `y-h` | Open session help / keybinding modal |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `TAB` (default) | Skip intro (AniSkip) |
|
||||
|
||||
The AniSkip key is **not** a `y` chord and is not bound by the plugin: the SubMiner app binds it over the mpv IPC socket while it is connected. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it. See [AniSkip Integration](/aniskip-integration) for setup and details.
|
||||
|
||||
@@ -151,7 +151,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
||||
For how the plugin's auto-start fits into the full launch sequence - including when the launcher starts the overlay instead of the plugin - see [Playback Startup Flow](./architecture#playback-startup-flow).
|
||||
|
||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay.
|
||||
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
|
||||
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused. On cold managed background startup, SubMiner opens the tray and visible overlay shell before tokenization warmups finish, then the plugin resumes playback after SubMiner reports tokenization-ready (with a 30-second timeout fallback).
|
||||
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
|
||||
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
||||
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
|
||||
|
||||
@@ -172,10 +172,19 @@
|
||||
"updates": {
|
||||
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
||||
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
|
||||
"notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
|
||||
}, // Automatic update check behavior.
|
||||
|
||||
// ==========================================
|
||||
// Notifications
|
||||
// Overlay notification display behavior.
|
||||
// Hot-reload: position changes apply to the next overlay notification.
|
||||
// ==========================================
|
||||
"notifications": {
|
||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||
}, // Overlay notification display behavior.
|
||||
|
||||
// ==========================================
|
||||
// Keyboard Shortcuts
|
||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
@@ -199,7 +208,8 @@
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
|
||||
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
|
||||
"toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
@@ -539,7 +549,7 @@
|
||||
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
|
||||
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
|
||||
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
|
||||
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
||||
"notificationType": "overlay", // Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||
}, // Behavior setting.
|
||||
"nPlusOne": {
|
||||
|
||||
@@ -82,6 +82,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
||||
|
||||
@@ -126,7 +126,7 @@ The detected launcher is installed in a protected path such as `/usr/local/bin/s
|
||||
|
||||
**OSD update notification did not appear**
|
||||
|
||||
`updates.notificationType: "osd"` uses the existing mpv/overlay notification path. If mpv is disconnected, SubMiner logs the update and does not force-start the overlay. Use `"system"` or `"both"` if you want OS notifications outside playback.
|
||||
`updates.notificationType: "osd"` uses the legacy mpv OSD path. If mpv is disconnected, SubMiner logs the update and does not force-start the overlay. Use `"system"` for OS notifications, `"both"` for overlay + OS notifications, or `"osd-system"` in `config.jsonc` if you want the legacy OSD + OS combination.
|
||||
|
||||
## AnkiConnect
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle
|
||||
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||
SubMiner.AppImage --start --debug # Alias for --dev
|
||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||
SubMiner.AppImage --playback-feedback "your feedback" # Route playback feedback through the configured feedback surface
|
||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||
SubMiner.AppImage --settings # Open SubMiner settings window
|
||||
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
|
||||
@@ -163,6 +164,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
|
||||
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
||||
Override with e.g. `--password-store=basic_text`.
|
||||
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug` (or `SubMiner.exe --start --dev --log-level debug` on Windows).
|
||||
- `--playback-feedback <text>` (also `--playback-feedback=<text>`) sends a non-empty text string through the playback-feedback route used for recording/playback prompts. For example: `SubMiner.AppImage --playback-feedback "your feedback"`.
|
||||
|
||||
### Windows mpv Shortcut
|
||||
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ Notes:
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## What’s Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
||||
|
||||
@@ -64,6 +64,25 @@ prefetch work and re-centers prefetch around the live playback time.
|
||||
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||
written.
|
||||
|
||||
## Startup Ready Release
|
||||
|
||||
- `mpv.pauseUntilOverlayReady` waits for tokenization warmup plus visible-overlay readiness before
|
||||
releasing the mpv startup gate.
|
||||
- Visible-overlay startup creates the tray and visible overlay shell before tokenization and
|
||||
annotation warmups continue. Cold `--start --background --managed-playback` launches still handle
|
||||
initial args before the deferred Yomitan wait.
|
||||
- Overlay-routed startup notifications are queued in the main process until an overlay window has
|
||||
finished loading. Progress notifications with the same id are upserted so spinner ticks do not
|
||||
flood a cold-start overlay, while events with distinct history ids are retained for phase-level
|
||||
history such as character dictionary checking/building/importing.
|
||||
- The mpv plugin has a 30-second fallback for cold starts; app-side retry/release budgets match that
|
||||
window so readiness can still arrive before fallback resumes playback.
|
||||
- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
|
||||
waits for a fresh measured subtitle rectangle before signaling readiness.
|
||||
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
|
||||
tokenization warmup and visible overlay content-ready. This releases playback without waiting for
|
||||
a later subtitle event that cannot happen while mpv is paused.
|
||||
|
||||
## Linux/X11 Window Shape
|
||||
|
||||
- `restoreLinuxOverlayWindowShape()` reads `BrowserWindow.getBounds()` and calls `setShape()` with
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<!-- read_when: changing managed mpv startup, pause-until-ready, or visible overlay boot ordering -->
|
||||
|
||||
# Early Managed Overlay Startup Design
|
||||
|
||||
Status: approved
|
||||
Date: 2026-06-06
|
||||
|
||||
## Problem
|
||||
|
||||
Managed mpv startup can pause playback immediately, then leave SubMiner's tray and visible overlay
|
||||
unavailable until Yomitan/tokenization warmups finish. Startup notifications therefore miss the
|
||||
overlay surface and fall back to non-overlay status paths.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
For cold `--start --background --managed-playback` launches, handle initial args before waiting for
|
||||
the deferred overlay warmup. That lets the tray and visible overlay shell initialize immediately
|
||||
while the existing tokenization warmups continue in the background.
|
||||
|
||||
The mpv plugin pause gate stays armed. Playback release still waits for SubMiner's autoplay-ready
|
||||
signal, which is emitted only after tokenization warmup and visible-overlay readiness. Existing
|
||||
second-instance attach behavior remains unchanged: when the launcher finds an already-running
|
||||
background app, it sends the same control command to that process and reuses its warmups/tokenizer.
|
||||
|
||||
## Checks
|
||||
|
||||
- Add a startup ordering regression test for managed background playback.
|
||||
- Keep the existing deferred startup ordering for non-managed launches.
|
||||
- Run the startup/runtime test slice plus SubMiner verification lane.
|
||||
@@ -0,0 +1,27 @@
|
||||
<!-- read_when: changing overlay notification hover, macOS mouse passthrough, or notification actions -->
|
||||
|
||||
# macOS Notification Hover Stability Design
|
||||
|
||||
Status: approved
|
||||
Date: 2026-06-09
|
||||
|
||||
## Problem
|
||||
|
||||
On macOS, hovering a character dictionary build notification can make the card flicker and slide as
|
||||
if it is hiding, then snap back. The likely trigger is the notification stack changing the overlay
|
||||
window's mouse-passthrough state for a progress card that has no user action.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Keep non-action overlay notifications visually stable and click-through on hover. Only notifications
|
||||
with explicit actions should request interactive overlay input. The notification history panel keeps
|
||||
its existing interactive behavior.
|
||||
|
||||
This avoids a macOS mouseenter/mouseleave passthrough loop for passive progress cards while
|
||||
preserving clickable notification actions.
|
||||
|
||||
## Checks
|
||||
|
||||
- Add a renderer regression test for passive notification hover.
|
||||
- Keep action-bearing notification cards interactive.
|
||||
- Run the targeted overlay notification and mouse-ignore tests.
|
||||
@@ -45,6 +45,7 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
},
|
||||
appPath: '/tmp/subminer.app',
|
||||
|
||||
@@ -82,6 +82,7 @@ function createContext(): LauncherCommandContext {
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
},
|
||||
appPath: '/tmp/SubMiner.AppImage',
|
||||
@@ -207,6 +208,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
};
|
||||
const appPath = context.appPath ?? '';
|
||||
@@ -268,6 +270,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
@@ -335,10 +338,12 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
};
|
||||
let availabilityConfigDir: string | undefined;
|
||||
let overlayConfigDir: string | undefined;
|
||||
let overlayLoadingOsd: boolean | undefined;
|
||||
|
||||
try {
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
@@ -349,7 +354,19 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
overlayLoadingOsd = (
|
||||
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
|
||||
)?.overlayLoadingOsd;
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
|
||||
overlayConfigDir = configDir;
|
||||
@@ -366,6 +383,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
||||
|
||||
assert.equal(availabilityConfigDir, expectedConfigDir);
|
||||
assert.equal(overlayConfigDir, expectedConfigDir);
|
||||
assert.equal(overlayLoadingOsd, true);
|
||||
} finally {
|
||||
if (originalXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
@@ -395,6 +413,7 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -232,6 +232,14 @@ export async function runPlaybackCommandWithDeps(
|
||||
? { ...pluginRuntimeConfig, autoStart: false }
|
||||
: pluginRuntimeConfig;
|
||||
|
||||
const shouldShowOverlayLoadingOsd =
|
||||
!isAppOwnedYoutubeFlow &&
|
||||
(pluginRuntimeConfig.autoStartVisibleOverlay || args.startOverlay || args.autoStartOverlay) &&
|
||||
(pluginRuntimeConfig.autoStart ||
|
||||
args.startOverlay ||
|
||||
args.autoStartOverlay ||
|
||||
shouldLauncherAttachRunningApp);
|
||||
|
||||
const shouldPauseUntilOverlayReady =
|
||||
pluginRuntimeConfig.autoStart &&
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
||||
@@ -266,6 +274,7 @@ export async function runPlaybackCommandWithDeps(
|
||||
}
|
||||
: {}),
|
||||
backend: args.backend,
|
||||
overlayLoadingOsd: shouldShowOverlayLoadingOsd,
|
||||
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -125,6 +125,11 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
||||
test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => {
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig({
|
||||
auto_start_overlay: false,
|
||||
ankiConnect: {
|
||||
behavior: {
|
||||
notificationType: 'osd-system',
|
||||
},
|
||||
},
|
||||
texthooker: {
|
||||
launchAtStartup: false,
|
||||
},
|
||||
@@ -142,16 +147,30 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
|
||||
assert.equal(parsed.autoStart, true);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
assert.equal(parsed.osdMessages, true);
|
||||
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(parsed.texthookerEnabled, false);
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigFromMainConfig disables plugin osd messages for overlay notification routing', () => {
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig({
|
||||
ankiConnect: {
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.osdMessages, false);
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig(null);
|
||||
|
||||
assert.equal(parsed.autoStart, true);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
assert.equal(parsed.osdMessages, false);
|
||||
assert.equal(parsed.texthookerEnabled, false);
|
||||
});
|
||||
|
||||
@@ -165,6 +184,7 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: true,
|
||||
texthookerEnabled: false,
|
||||
},
|
||||
'/fallback/SubMiner.AppImage',
|
||||
@@ -175,7 +195,10 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
|
||||
'subminer-backend=x11',
|
||||
'subminer-auto_start=yes',
|
||||
'subminer-auto_start_visible_overlay=no',
|
||||
'subminer-overlay_loading_osd=no',
|
||||
'subminer-auto_start_pause_until_ready=yes',
|
||||
'subminer-auto_start_pause_until_ready_timeout_seconds=30',
|
||||
'subminer-osd_messages=yes',
|
||||
'subminer-texthooker_enabled=no',
|
||||
],
|
||||
);
|
||||
@@ -191,6 +214,7 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
},
|
||||
'/fallback/SubMiner.AppImage',
|
||||
@@ -201,7 +225,10 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
||||
'subminer-backend=x11',
|
||||
'subminer-auto_start=yes',
|
||||
'subminer-auto_start_visible_overlay=no',
|
||||
'subminer-overlay_loading_osd=no',
|
||||
'subminer-auto_start_pause_until_ready=yes',
|
||||
'subminer-auto_start_pause_until_ready_timeout_seconds=30',
|
||||
'subminer-osd_messages=no',
|
||||
'subminer-texthooker_enabled=no',
|
||||
],
|
||||
);
|
||||
|
||||
@@ -16,10 +16,9 @@ function booleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function nonEmptyStringOrDefault(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : fallback;
|
||||
function pluginOsdMessagesFromNotificationType(root: Record<string, unknown> | null): boolean {
|
||||
const notificationType = rootObject(rootObject(root, 'ankiConnect'), 'behavior').notificationType;
|
||||
return notificationType === 'osd' || notificationType === 'osd-system';
|
||||
}
|
||||
|
||||
function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
|
||||
@@ -53,6 +52,7 @@ export function parsePluginRuntimeConfigFromMainConfig(
|
||||
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
|
||||
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
||||
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
||||
osdMessages: pluginOsdMessagesFromNotificationType(root),
|
||||
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
|
||||
};
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}`,
|
||||
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, osd_messages=${parsed.osdMessages}, texthooker_enabled=${parsed.texthookerEnabled}`,
|
||||
);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -385,6 +385,7 @@ test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -207,6 +207,8 @@ export interface PluginRuntimeConfig {
|
||||
autoStart: boolean;
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
overlayLoadingOsd?: boolean;
|
||||
osdMessages: boolean;
|
||||
texthookerEnabled: boolean;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -104,6 +104,14 @@ function M.create(ctx)
|
||||
return options_helper.coerce_bool(raw_visible_overlay, false)
|
||||
end
|
||||
|
||||
local function resolve_overlay_loading_osd_enabled()
|
||||
local raw_overlay_loading_osd = opts.overlay_loading_osd
|
||||
if raw_overlay_loading_osd == nil then
|
||||
raw_overlay_loading_osd = opts["overlay-loading-osd"]
|
||||
end
|
||||
return options_helper.coerce_bool(raw_overlay_loading_osd, false)
|
||||
end
|
||||
|
||||
local function next_auto_start_retry_generation()
|
||||
state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1
|
||||
return state.auto_start_retry_generation
|
||||
@@ -143,6 +151,14 @@ function M.create(ctx)
|
||||
and not (state.overlay_running and state.auto_play_ready_signal_seen == true)
|
||||
end
|
||||
|
||||
local function should_show_overlay_loading_osd()
|
||||
return (
|
||||
resolve_overlay_loading_osd_enabled()
|
||||
or (resolve_auto_start_enabled() and resolve_auto_start_visible_overlay_enabled())
|
||||
)
|
||||
and not state.suppress_ready_overlay_restore
|
||||
end
|
||||
|
||||
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
|
||||
if generation ~= state.auto_start_retry_generation then
|
||||
return
|
||||
@@ -169,6 +185,7 @@ function M.create(ctx)
|
||||
.. process.describe_mpv_ipc_socket_match(opts.socket_path)
|
||||
.. ")"
|
||||
)
|
||||
process.stop_overlay_loading_osd()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -180,6 +197,9 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
local function on_start_file()
|
||||
if should_show_overlay_loading_osd() then
|
||||
process.start_overlay_loading_osd()
|
||||
end
|
||||
if state.pending_reload_media_identity ~= nil then
|
||||
local media_identity = resolve_media_identity()
|
||||
if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then
|
||||
@@ -233,6 +253,7 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
if same_media_reload then
|
||||
process.stop_overlay_loading_osd()
|
||||
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
|
||||
if state.app_managed_playback_active then
|
||||
return
|
||||
@@ -260,6 +281,7 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
if state.app_managed_playback_active then
|
||||
process.stop_overlay_loading_osd()
|
||||
subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload")
|
||||
return
|
||||
end
|
||||
@@ -276,6 +298,7 @@ function M.create(ctx)
|
||||
next_auto_start_retry_generation()
|
||||
hover.clear_hover_overlay()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
process.stop_overlay_loading_osd()
|
||||
clear_pending_visible_overlay_hide()
|
||||
state.auto_play_ready_signal_seen = false
|
||||
state.current_media_identity = nil
|
||||
@@ -295,6 +318,7 @@ function M.create(ctx)
|
||||
hover.clear_hover_overlay()
|
||||
end)
|
||||
mp.register_event("end-file", function(event)
|
||||
process.stop_overlay_loading_osd()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
hover.clear_hover_overlay()
|
||||
local reason = type(event) == "table" and event.reason or nil
|
||||
|
||||
@@ -43,8 +43,8 @@ function M.create(ctx)
|
||||
end
|
||||
end
|
||||
|
||||
local function show_osd(message)
|
||||
if opts.osd_messages then
|
||||
local function show_osd(message, options)
|
||||
if opts.osd_messages or (options and options.force == true) then
|
||||
local payload = "SubMiner: " .. message
|
||||
local sent = false
|
||||
if type(mp.osd_message) == "function" then
|
||||
|
||||
@@ -2,6 +2,7 @@ local M = {}
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local opts = ctx.opts
|
||||
local process = ctx.process
|
||||
local hover = ctx.hover
|
||||
local ui = ctx.ui
|
||||
@@ -42,6 +43,9 @@ function M.create(ctx)
|
||||
mp.register_script_message("subminer-autoplay-ready", function()
|
||||
process.notify_auto_play_ready()
|
||||
end)
|
||||
mp.register_script_message("subminer-overlay-loading-ready", function()
|
||||
process.stop_overlay_loading_osd()
|
||||
end)
|
||||
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
|
||||
hover.handle_hover_message(payload_json)
|
||||
end)
|
||||
@@ -49,7 +53,9 @@ function M.create(ctx)
|
||||
hover.handle_hover_message(payload_json)
|
||||
end)
|
||||
mp.register_script_message("subminer-stats-toggle", function()
|
||||
mp.osd_message("Stats: press ` (backtick) in overlay", 3)
|
||||
if opts.osd_messages then
|
||||
mp.osd_message("Stats: press ` (backtick) in overlay", 3)
|
||||
end
|
||||
end)
|
||||
mp.register_script_message("subminer-reload-session-bindings", function()
|
||||
ctx.session_bindings.reload_bindings()
|
||||
|
||||
@@ -31,9 +31,10 @@ function M.load(options_lib, default_socket_path)
|
||||
backend = "auto",
|
||||
auto_start = false,
|
||||
auto_start_visible_overlay = false,
|
||||
overlay_loading_osd = false,
|
||||
auto_start_pause_until_ready = true,
|
||||
auto_start_pause_until_ready_owns_initial_pause = false,
|
||||
auto_start_pause_until_ready_timeout_seconds = 15,
|
||||
auto_start_pause_until_ready_timeout_seconds = 30,
|
||||
osd_messages = true,
|
||||
log_level = "info",
|
||||
}
|
||||
|
||||
+116
-12
@@ -4,9 +4,12 @@ local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||
local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2
|
||||
local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
|
||||
local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading "
|
||||
local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" }
|
||||
local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18
|
||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30
|
||||
local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
|
||||
|
||||
function M.create(ctx)
|
||||
@@ -53,6 +56,14 @@ function M.create(ctx)
|
||||
return options_helper.coerce_bool(raw_pause_until_ready, false)
|
||||
end
|
||||
|
||||
local function resolve_osd_messages_enabled()
|
||||
local raw_osd_messages = opts.osd_messages
|
||||
if raw_osd_messages == nil then
|
||||
raw_osd_messages = opts["osd-messages"]
|
||||
end
|
||||
return options_helper.coerce_bool(raw_osd_messages, false)
|
||||
end
|
||||
|
||||
local function resolve_pause_until_ready_owns_initial_pause()
|
||||
local raw_owns_initial_pause = opts.auto_start_pause_until_ready_owns_initial_pause
|
||||
if raw_owns_initial_pause == nil then
|
||||
@@ -246,6 +257,42 @@ function M.create(ctx)
|
||||
state.auto_play_ready_osd_timer = nil
|
||||
end
|
||||
|
||||
local function clear_overlay_loading_osd_timer()
|
||||
local timer = state.overlay_loading_osd_timer
|
||||
if timer and timer.kill then
|
||||
timer:kill()
|
||||
end
|
||||
state.overlay_loading_osd_timer = nil
|
||||
end
|
||||
|
||||
local function stop_overlay_loading_osd()
|
||||
state.overlay_loading_osd_active = false
|
||||
state.overlay_loading_osd_frame = 1
|
||||
clear_overlay_loading_osd_timer()
|
||||
end
|
||||
|
||||
local function start_overlay_loading_osd()
|
||||
if state.overlay_loading_osd_active then
|
||||
return
|
||||
end
|
||||
state.overlay_loading_osd_active = true
|
||||
state.overlay_loading_osd_frame = 1
|
||||
local function show_next_overlay_loading_frame()
|
||||
local frame_index = state.overlay_loading_osd_frame or 1
|
||||
local frame = OVERLAY_LOADING_OSD_FRAMES[frame_index] or OVERLAY_LOADING_OSD_FRAMES[1]
|
||||
show_osd(OVERLAY_LOADING_OSD_PREFIX .. frame, { force = true })
|
||||
state.overlay_loading_osd_frame = (frame_index % #OVERLAY_LOADING_OSD_FRAMES) + 1
|
||||
end
|
||||
show_next_overlay_loading_frame()
|
||||
if type(mp.add_periodic_timer) == "function" then
|
||||
state.overlay_loading_osd_timer = mp.add_periodic_timer(OVERLAY_LOADING_OSD_REFRESH_SECONDS, function()
|
||||
if state.overlay_loading_osd_active then
|
||||
show_next_overlay_loading_frame()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
local function disarm_auto_play_ready_gate(options)
|
||||
local should_resume = options == nil or options.resume_playback ~= false
|
||||
local was_armed = state.auto_play_ready_gate_armed
|
||||
@@ -264,8 +311,11 @@ function M.create(ctx)
|
||||
return false
|
||||
end
|
||||
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
|
||||
if resolve_osd_messages_enabled() then
|
||||
stop_overlay_loading_osd()
|
||||
show_osd(AUTO_PLAY_READY_READY_OSD)
|
||||
end
|
||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||
show_osd(AUTO_PLAY_READY_READY_OSD)
|
||||
if should_resume_playback then
|
||||
mp.set_property_native("pause", false)
|
||||
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
|
||||
@@ -287,8 +337,11 @@ function M.create(ctx)
|
||||
end
|
||||
state.auto_play_ready_gate_armed = true
|
||||
mp.set_property_native("pause", true)
|
||||
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
||||
if type(mp.add_periodic_timer) == "function" then
|
||||
if resolve_osd_messages_enabled() then
|
||||
stop_overlay_loading_osd()
|
||||
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
||||
end
|
||||
if resolve_osd_messages_enabled() and type(mp.add_periodic_timer) == "function" then
|
||||
state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
|
||||
if state.auto_play_ready_gate_armed then
|
||||
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
||||
@@ -375,6 +428,9 @@ function M.create(ctx)
|
||||
table.insert(args, "--texthooker")
|
||||
end
|
||||
end
|
||||
if action == "playback-feedback" and type(overrides.message) == "string" and overrides.message ~= "" then
|
||||
table.insert(args, overrides.message)
|
||||
end
|
||||
|
||||
return args
|
||||
end
|
||||
@@ -462,6 +518,27 @@ function M.create(ctx)
|
||||
end)
|
||||
end
|
||||
|
||||
local function notify_playback_feedback(message, fallback)
|
||||
if type(message) ~= "string" or message == "" then
|
||||
return
|
||||
end
|
||||
if resolve_osd_messages_enabled() then
|
||||
show_osd(message)
|
||||
return
|
||||
end
|
||||
if not binary.ensure_binary_available() then
|
||||
if fallback then
|
||||
fallback()
|
||||
end
|
||||
return
|
||||
end
|
||||
run_control_command_async("playback-feedback", { message = message }, function(ok)
|
||||
if not ok and fallback then
|
||||
fallback()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt)
|
||||
attempt = attempt or 1
|
||||
run_control_command_async("app-ping", nil, function(_ok, result)
|
||||
@@ -543,6 +620,7 @@ function M.create(ctx)
|
||||
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
stop_overlay_loading_osd()
|
||||
show_osd("Error: binary not found")
|
||||
return
|
||||
end
|
||||
@@ -627,6 +705,7 @@ function M.create(ctx)
|
||||
state.overlay_running = false
|
||||
state.auto_play_ready_signal_seen = false
|
||||
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
|
||||
stop_overlay_loading_osd()
|
||||
show_osd("Overlay start failed")
|
||||
release_auto_play_ready_gate("overlay-start-failed")
|
||||
return
|
||||
@@ -679,6 +758,7 @@ function M.create(ctx)
|
||||
state.overlay_running = false
|
||||
state.texthooker_running = false
|
||||
state.auto_play_ready_signal_seen = false
|
||||
stop_overlay_loading_osd()
|
||||
disarm_auto_play_ready_gate()
|
||||
show_osd("Stopped")
|
||||
end
|
||||
@@ -690,6 +770,7 @@ function M.create(ctx)
|
||||
return
|
||||
end
|
||||
state.suppress_ready_overlay_restore = true
|
||||
stop_overlay_loading_osd()
|
||||
|
||||
run_control_command_async("hide-visible-overlay", nil, function(ok, result)
|
||||
if ok then
|
||||
@@ -794,14 +875,22 @@ function M.create(ctx)
|
||||
return
|
||||
end
|
||||
|
||||
local function show_restart_feedback(message)
|
||||
notify_playback_feedback(message, function()
|
||||
show_osd(message)
|
||||
end)
|
||||
end
|
||||
|
||||
start_overlay_loading_osd()
|
||||
subminer_log("info", "process", "Restarting overlay...")
|
||||
show_osd("Restarting...")
|
||||
show_restart_feedback("Restarting...")
|
||||
|
||||
run_control_command_async("stop", nil, function(ok, result)
|
||||
if not ok then
|
||||
local reason = result and result.stderr or "unknown error"
|
||||
subminer_log("warn", "process", "Restart stop command failed: " .. reason)
|
||||
show_osd("Restart failed")
|
||||
stop_overlay_loading_osd()
|
||||
show_restart_feedback("Restart failed")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -836,14 +925,25 @@ function M.create(ctx)
|
||||
"process",
|
||||
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
|
||||
)
|
||||
show_osd("Restart failed")
|
||||
stop_overlay_loading_osd()
|
||||
show_restart_feedback("Restart failed")
|
||||
else
|
||||
wait_for_app_ping_state(true, "own the single-instance lock", function()
|
||||
run_control_command_async("show-visible-overlay")
|
||||
show_osd("Restarted successfully")
|
||||
run_control_command_async("show-visible-overlay", nil, function(ok)
|
||||
if ok then
|
||||
show_restart_feedback("Restarted successfully")
|
||||
else
|
||||
show_restart_feedback("Restart failed")
|
||||
end
|
||||
end)
|
||||
end, function()
|
||||
run_control_command_async("show-visible-overlay")
|
||||
show_osd("Restarted successfully")
|
||||
run_control_command_async("show-visible-overlay", nil, function(ok)
|
||||
if ok then
|
||||
show_restart_feedback("Restarted successfully")
|
||||
else
|
||||
show_restart_feedback("Restart failed")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end)
|
||||
@@ -852,7 +952,8 @@ function M.create(ctx)
|
||||
ensure_texthooker_running(function() end)
|
||||
end
|
||||
end, function()
|
||||
show_osd("Restart failed")
|
||||
stop_overlay_loading_osd()
|
||||
show_restart_feedback("Restart failed")
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -877,6 +978,7 @@ function M.create(ctx)
|
||||
describe_mpv_ipc_socket_match = describe_mpv_ipc_socket_match,
|
||||
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
||||
run_control_command_async = run_control_command_async,
|
||||
notify_playback_feedback = notify_playback_feedback,
|
||||
record_visible_overlay_visibility = record_visible_overlay_visibility,
|
||||
run_binary_command_async = run_binary_command_async,
|
||||
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
||||
@@ -893,6 +995,8 @@ function M.create(ctx)
|
||||
check_binary_available = check_binary_available,
|
||||
notify_auto_play_ready = notify_auto_play_ready,
|
||||
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
|
||||
start_overlay_loading_osd = start_overlay_loading_osd,
|
||||
stop_overlay_loading_osd = stop_overlay_loading_osd,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -244,6 +244,8 @@ function M.create(ctx)
|
||||
return { "--toggle-secondary-sub" }
|
||||
elseif action_id == "toggleSubtitleSidebar" then
|
||||
return { "--toggle-subtitle-sidebar" }
|
||||
elseif action_id == "toggleNotificationHistory" then
|
||||
return { "--session-action", '{"actionId":"toggleNotificationHistory"}' }
|
||||
elseif action_id == "markAudioCard" then
|
||||
return { "--mark-audio-card" }
|
||||
elseif action_id == "markWatched" then
|
||||
|
||||
@@ -24,6 +24,9 @@ function M.new()
|
||||
auto_play_ready_osd_timer = nil,
|
||||
auto_play_ready_signal_seen = false,
|
||||
auto_play_ready_initial_pause_ownership_consumed = false,
|
||||
overlay_loading_osd_active = false,
|
||||
overlay_loading_osd_timer = nil,
|
||||
overlay_loading_osd_frame = 1,
|
||||
pending_visible_overlay_hide_timer = nil,
|
||||
pending_visible_overlay_hide_generation = 0,
|
||||
suppress_ready_overlay_restore = false,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
## Highlights
|
||||
### Breaking Changes
|
||||
- **Notification Type `both`**: This setting now routes to overlay + system notifications instead of mpv OSD + system.
|
||||
- Set `notificationType` to `osd-system` in `config.jsonc` to keep the previous OSD + system behavior.
|
||||
- `osd` and `osd-system` remain valid config-file values but no longer appear as options in the Settings UI.
|
||||
|
||||
### Added
|
||||
- **Overlay Notifications**: A new in-app notification stack replaces bare OSD text for most alerts, using Catppuccin Macchiato styling with 3-second auto-dismiss.
|
||||
- Position via `notifications.overlayPosition` (top-left, top-center, or top-right; default top-right). Startup, mining, sync, and error alerts queue for the overlay instead of falling back to raw OSD.
|
||||
- Mined-card notifications include card thumbnails and an **Open in Anki** button; update-available notifications include a one-click **Update** button.
|
||||
|
||||
- **Notification History Panel**: A slide-in panel logging every notification from the current session, toggled with `Ctrl/Cmd+N` (configurable via `shortcuts.toggleNotificationHistory`).
|
||||
- Works whether the overlay or mpv has focus; slides in from the same edge as the notification stack.
|
||||
- Entries retain thumbnails and action buttons (Open in Anki, etc.) and can be removed individually or cleared all at once.
|
||||
|
||||
- **Stats Search**: A new Search tab for real-time subtitle sentence search across your library.
|
||||
- Matches by headword with media context; mine directly to sentence cards, word cards, or audio cards.
|
||||
- Sentence cards are queued before slow media generation finishes, so the card lands in Anki quickly with audio filled in later.
|
||||
|
||||
### Changed
|
||||
- **AniSkip**: Intro detection now runs in the SubMiner app rather than the mpv plugin.
|
||||
- Covers all files in the mpv session including playlist advances; the plugin no longer makes any network calls.
|
||||
- `mpv.aniskipEnabled` and `mpv.aniskipButtonKey` hot-reload without restarting playback. Requires SubMiner to be connected to mpv — plugin-only sessions no longer fetch skip windows.
|
||||
|
||||
- **Library**: Local and Jellyfin entries are now split by season using folder structure first, filename parsing as fallback.
|
||||
- Existing combined-series stats rows are automatically migrated to season-specific entries on startup.
|
||||
- Anime detail and cover art refresh immediately after manually changing an AniList entry.
|
||||
|
||||
- **Stats — Vocabulary Review**: Hide Known/Hide Kana filters are remembered across sessions; Related Seen Words now matches on shared readings or kanji; duplicate-collapsed exclusions cover all token variants.
|
||||
|
||||
- **Stats — Trends**: Reorganized into Activity, Cumulative Totals, Efficiency, Patterns, and Library sections; disambiguated per-period vs. cumulative charts; added Words/Min and Cards/Hour efficiency charts.
|
||||
|
||||
- **Stats — Library Browsing**: Remembers card size between sessions; retries stored cover art preserving PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; session deletes show progress and refresh faster.
|
||||
|
||||
- **Stats Mining**: Several reliability improvements when mining from Search and vocabulary examples.
|
||||
- Empty `ankiConnect.deck` falls back to Yomitan's configured mining deck; secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
|
||||
- Invalid stored timings and out-of-order subtitle pairs are skipped before FFmpeg runs; partial media failures are shown inline rather than silently dropped.
|
||||
|
||||
### Fixed
|
||||
- **AniList**: Entries are now marked completed when a post-watch sync reaches the final known episode of the season.
|
||||
- **AniSkip**: Fixed intro markers disappearing after same-media mpv reloads; fixed detection for intros starting at 0 seconds and common release-group filenames.
|
||||
- **Jellyfin**: Session restarts after setup login so the websocket reconnects with fresh credentials; session stops on logout.
|
||||
- **Anki — Sentence Cards**: Generated audio is written only to the configured sentence audio field and no longer also fills the expression audio field.
|
||||
- **Stats Mining**: Word audio uses configured Yomitan sources; English subtitle text is no longer written to word cards; sentence clips correctly update the SentenceAudio field.
|
||||
- **Overlay Startup**: Subtitle bars are hoverable and clickable as soon as the first subtitle line appears; Linux overlay input is primed from the first measured surface so first-line subtitles and startup notifications are immediately clickable; an OSD spinner now shows from mpv connect through to content-ready.
|
||||
- **Startup Autoplay**: SubMiner now releases playback after tokenization and overlay content are ready even when playback begins before the first subtitle line appears.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Release notes now credit contributors with a What's Changed list and a New Contributors section, resolved from changelog fragments via git and the GitHub API.
|
||||
- Updated `make deps` so a fresh source checkout initializes submodules before installing root, stats, and texthooker-ui dependencies.
|
||||
- Changed PR changelog guidance to preserve multiple fragments for genuinely separate outcomes and direct contributors to consolidate same-PR churn before merging.
|
||||
|
||||
</details>
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(notifications): add overlay notifications with position config by @ksyasuda in #110
|
||||
- feat(stats): speed up session maintenance and improve stats UI by @ksyasuda in #111
|
||||
- [codex] Restart Jellyfin remote session after setup login by @bee-san in #112
|
||||
- docs(changelog): require reconciled fragments, not just new ones by @ksyasuda in #113
|
||||
- feat(release): add contributor attribution to release notes by @ksyasuda in #114
|
||||
- fix(anilist): mark entry completed when final episode is reached by @ksyasuda in #115
|
||||
- feat(aniskip): move intro detection from mpv plugin to app runtime by @ksyasuda in #117
|
||||
- fix(anki): write sentence card audio only to sentence audio field by @ksyasuda in #118
|
||||
|
||||
## 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`.
|
||||
@@ -1122,13 +1122,22 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(releaseNotes, /## What’s Changed\n\n/);
|
||||
assert.match(releaseNotes, /## What's Changed\n\n/);
|
||||
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
|
||||
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
|
||||
"What's Changed should follow Highlights",
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
|
||||
'contributor attribution should appear before Installation',
|
||||
);
|
||||
assert.doesNotMatch(releaseNotes, /## What’s Changed/);
|
||||
assert.doesNotMatch(
|
||||
releaseNotes,
|
||||
/ksyasuda made their first contribution/,
|
||||
@@ -1137,13 +1146,96 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
||||
|
||||
// Attribution is a release-notes concern only; the CHANGELOG stays clean.
|
||||
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
|
||||
assert.doesNotMatch(changelog, /What’s Changed/);
|
||||
assert.doesNotMatch(changelog, /What's Changed|What’s Changed/);
|
||||
assert.doesNotMatch(changelog, /New Contributors/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeReleaseNotesForVersion preserves committed contributor attribution before installation', async () => {
|
||||
const { writeReleaseNotesForVersion } = await loadModule();
|
||||
const workspace = createWorkspace('release-notes-preserve-attribution');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
const existingChangelog = [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## v0.8.0 (2026-04-17)',
|
||||
'### Added',
|
||||
'- Polished: released feature.',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Internal changes</summary>',
|
||||
'',
|
||||
'### Internal',
|
||||
'- Polished: internal release note.',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
].join('\n');
|
||||
const committedReleaseNotes = [
|
||||
'## Highlights',
|
||||
'### Added',
|
||||
'- Old generated body.',
|
||||
'',
|
||||
'## Installation',
|
||||
'',
|
||||
'See the README and docs/installation guide for full setup steps.',
|
||||
'',
|
||||
'## Assets',
|
||||
'',
|
||||
'- Linux: `SubMiner.AppImage`',
|
||||
'',
|
||||
'## What’s Changed',
|
||||
'',
|
||||
'- feat(release): add contributor attribution by @ksyasuda in #114',
|
||||
'',
|
||||
'## New Contributors',
|
||||
'',
|
||||
'- @bee-san made their first contribution in #112',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
committedReleaseNotes,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const outputPath = writeReleaseNotesForVersion({
|
||||
cwd: projectRoot,
|
||||
version: '0.8.0',
|
||||
});
|
||||
const releaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||
|
||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: released feature\./);
|
||||
assert.doesNotMatch(releaseNotes, /<details>/);
|
||||
assert.doesNotMatch(releaseNotes, /### Internal/);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## What's Changed\n\n- feat\(release\): add contributor attribution by @ksyasuda in #114/,
|
||||
);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
|
||||
"What's Changed should follow Highlights",
|
||||
);
|
||||
assert.ok(
|
||||
releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
|
||||
'New Contributors should appear before Installation',
|
||||
);
|
||||
assert.doesNotMatch(releaseNotes, /## What’s Changed/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('reuse-existing-section');
|
||||
|
||||
@@ -433,12 +433,45 @@ function resolveContributionsForFragments(
|
||||
);
|
||||
}
|
||||
|
||||
function isWhatsChangedHeading(line: string): boolean {
|
||||
return line === "## What's Changed" || line === '## What’s Changed';
|
||||
}
|
||||
|
||||
function extractContributorSections(releaseNotes: string): string[] {
|
||||
const lines = releaseNotes.split(/\r?\n/);
|
||||
const start = lines.findIndex(isWhatsChangedHeading);
|
||||
if (start === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let end = lines.length;
|
||||
for (let index = start + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index]!;
|
||||
if (line.startsWith('## ') && !isWhatsChangedHeading(line) && line !== '## New Contributors') {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const block = lines.slice(start, end);
|
||||
while (block.length > 0 && block[block.length - 1] === '') {
|
||||
block.pop();
|
||||
}
|
||||
if (block.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
block[0] = "## What's Changed";
|
||||
block.push('');
|
||||
return block;
|
||||
}
|
||||
|
||||
function renderContributorsSections(contributions: Contribution[]): string[] {
|
||||
if (contributions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines: string[] = ['## What’s Changed', ''];
|
||||
const lines: string[] = ["## What's Changed", ''];
|
||||
for (const contribution of contributions) {
|
||||
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
|
||||
}
|
||||
@@ -635,14 +668,18 @@ function renderReleaseNotes(
|
||||
options?: {
|
||||
disclaimer?: string;
|
||||
contributions?: Contribution[];
|
||||
contributorSections?: string[];
|
||||
},
|
||||
): string {
|
||||
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
||||
const contributorSections =
|
||||
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
|
||||
return [
|
||||
...prefix,
|
||||
'## Highlights',
|
||||
changes,
|
||||
'',
|
||||
...contributorSections,
|
||||
'## Installation',
|
||||
'',
|
||||
'See the README and docs/installation guide for full setup steps.',
|
||||
@@ -656,7 +693,6 @@ function renderReleaseNotes(
|
||||
'',
|
||||
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
|
||||
'',
|
||||
...renderContributorsSections(options?.contributions ?? []),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -668,6 +704,7 @@ function writeReleaseNotesFile(
|
||||
disclaimer?: string;
|
||||
outputPath?: string;
|
||||
contributions?: Contribution[];
|
||||
contributorSections?: string[];
|
||||
},
|
||||
): string {
|
||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||
@@ -960,6 +997,7 @@ export function generateDocsChangelog(options?: Pick<ChangelogOptions, 'cwd' | '
|
||||
|
||||
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
|
||||
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
|
||||
const version = resolveVersion(options ?? {});
|
||||
const changelogPath = path.join(cwd, 'CHANGELOG.md');
|
||||
@@ -970,7 +1008,14 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
|
||||
throw new Error(`Missing CHANGELOG section for v${version}.`);
|
||||
}
|
||||
|
||||
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps);
|
||||
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
|
||||
const contributorSections = existsSync(releaseNotesPath)
|
||||
? extractContributorSections(readFileSync(releaseNotesPath, 'utf8'))
|
||||
: [];
|
||||
|
||||
return writeReleaseNotesFile(cwd, stripDetailsBlocks(changes), options?.deps, {
|
||||
contributorSections,
|
||||
});
|
||||
}
|
||||
|
||||
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package.path = "plugin/subminer/?.lua;" .. package.path
|
||||
|
||||
local process_module = require("process")
|
||||
local options_helper = require("options")
|
||||
|
||||
local function assert_true(condition, message)
|
||||
if condition then
|
||||
return
|
||||
end
|
||||
error(message or "assert_true failed")
|
||||
end
|
||||
|
||||
local function has_arg(args, target)
|
||||
for _, value in ipairs(args or {}) do
|
||||
if value == target then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function count_feedback(recorded, target)
|
||||
local count = 0
|
||||
for _, message in ipairs(recorded.feedback) do
|
||||
if message == target then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
local function create_restart_runtime(config)
|
||||
config = config or {}
|
||||
local recorded = {
|
||||
async_calls = {},
|
||||
feedback = {},
|
||||
osd = {},
|
||||
periodic_timers = {},
|
||||
}
|
||||
local app_ping_index = 0
|
||||
local opts = {
|
||||
binary_path = "/tmp/SubMiner",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
backend = "x11",
|
||||
osd_messages = config.osd_messages == true,
|
||||
texthooker_enabled = false,
|
||||
log_level = "info",
|
||||
}
|
||||
local state = {
|
||||
binary_path = opts.binary_path,
|
||||
overlay_running = true,
|
||||
texthooker_running = false,
|
||||
}
|
||||
|
||||
local mp = {}
|
||||
|
||||
function mp.command_native_async(command, callback)
|
||||
recorded.async_calls[#recorded.async_calls + 1] = command
|
||||
local args = command.args or {}
|
||||
if has_arg(args, "--playback-feedback") then
|
||||
recorded.feedback[#recorded.feedback + 1] = args[#args]
|
||||
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
if has_arg(args, "--app-ping") then
|
||||
app_ping_index = app_ping_index + 1
|
||||
local statuses = config.app_ping_statuses or { 1, 0 }
|
||||
local status = statuses[app_ping_index] or statuses[#statuses]
|
||||
callback(status == 0, { status = status, stdout = "", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
if has_arg(args, "--show-visible-overlay") and not has_arg(args, "--start") then
|
||||
local status = config.show_visible_overlay_status or 0
|
||||
callback(status == 0, { status = status, stdout = "", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
||||
end
|
||||
|
||||
function mp.add_timeout(_, callback)
|
||||
if config.run_timeouts_immediately and callback then
|
||||
callback()
|
||||
end
|
||||
return {
|
||||
killed = false,
|
||||
kill = function(self)
|
||||
self.killed = true
|
||||
end,
|
||||
callback = callback,
|
||||
}
|
||||
end
|
||||
|
||||
function mp.add_periodic_timer()
|
||||
local timer = {
|
||||
killed = false,
|
||||
kill = function(self)
|
||||
self.killed = true
|
||||
end,
|
||||
}
|
||||
recorded.periodic_timers[#recorded.periodic_timers + 1] = timer
|
||||
return timer
|
||||
end
|
||||
|
||||
function mp.get_property(name)
|
||||
if name == "input-ipc-server" then
|
||||
return opts.socket_path
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
function mp.get_time()
|
||||
return 1
|
||||
end
|
||||
|
||||
function mp.set_property_native() end
|
||||
|
||||
local process = process_module.create({
|
||||
mp = mp,
|
||||
utils = {},
|
||||
opts = opts,
|
||||
state = state,
|
||||
binary = {
|
||||
ensure_binary_available = function()
|
||||
return true
|
||||
end,
|
||||
},
|
||||
environment = {
|
||||
is_linux = function()
|
||||
return false
|
||||
end,
|
||||
detect_backend = function()
|
||||
return "x11"
|
||||
end,
|
||||
resolve_subminer_config_dir = function()
|
||||
return "/tmp"
|
||||
end,
|
||||
join_path = function(...)
|
||||
return table.concat({ ... }, "/")
|
||||
end,
|
||||
},
|
||||
options_helper = options_helper,
|
||||
log = {
|
||||
normalize_log_level = function(level)
|
||||
return level or "info"
|
||||
end,
|
||||
subminer_log = function() end,
|
||||
show_osd = function(message, options)
|
||||
if opts.osd_messages or (options and options.force == true) then
|
||||
recorded.osd[#recorded.osd + 1] = message
|
||||
end
|
||||
end,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
process = process,
|
||||
recorded = recorded,
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
local runtime = create_restart_runtime({ osd_messages = false })
|
||||
|
||||
runtime.process.restart_overlay()
|
||||
|
||||
assert_true(
|
||||
runtime.recorded.osd[1] == "Overlay loading |",
|
||||
"restart should show the forced overlay loading OSD while the overlay reloads"
|
||||
)
|
||||
assert_true(
|
||||
#runtime.recorded.periodic_timers == 1,
|
||||
"restart should refresh the forced overlay loading OSD while the overlay reloads"
|
||||
)
|
||||
assert_true(
|
||||
runtime.recorded.feedback[1] == "Restarting...",
|
||||
"restart should route progress through playback feedback"
|
||||
)
|
||||
assert_true(
|
||||
runtime.recorded.feedback[#runtime.recorded.feedback] == "Restarted successfully",
|
||||
"restart should route success through playback feedback"
|
||||
)
|
||||
assert_true(
|
||||
runtime.recorded.periodic_timers[1].killed ~= true,
|
||||
"restart should keep the loading OSD alive until the overlay reports ready"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local runtime = create_restart_runtime({
|
||||
osd_messages = false,
|
||||
show_visible_overlay_status = 1,
|
||||
})
|
||||
|
||||
runtime.process.restart_overlay()
|
||||
|
||||
assert_true(
|
||||
count_feedback(runtime.recorded, "Restarted successfully") == 0,
|
||||
"restart should not show success feedback when show-visible-overlay fails after ready ping"
|
||||
)
|
||||
assert_true(
|
||||
runtime.recorded.feedback[#runtime.recorded.feedback] == "Restart failed",
|
||||
"restart should show failure feedback when show-visible-overlay fails after ready ping"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local statuses = { 1 }
|
||||
for _ = 1, 20 do
|
||||
statuses[#statuses + 1] = 1
|
||||
end
|
||||
local runtime = create_restart_runtime({
|
||||
app_ping_statuses = statuses,
|
||||
osd_messages = false,
|
||||
run_timeouts_immediately = true,
|
||||
show_visible_overlay_status = 1,
|
||||
})
|
||||
|
||||
runtime.process.restart_overlay()
|
||||
|
||||
assert_true(
|
||||
count_feedback(runtime.recorded, "Restarted successfully") == 0,
|
||||
"restart should not show success feedback when fallback show-visible-overlay fails after ping timeout"
|
||||
)
|
||||
assert_true(
|
||||
runtime.recorded.feedback[#runtime.recorded.feedback] == "Restart failed",
|
||||
"restart should show failure feedback when fallback show-visible-overlay fails after ping timeout"
|
||||
)
|
||||
end
|
||||
|
||||
print("plugin restart feedback tests: OK")
|
||||
@@ -900,6 +900,31 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
auto_start_visible_overlay = "yes",
|
||||
overlay_loading_osd = "yes",
|
||||
osd_messages = false,
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for explicit early overlay loading OSD scenario: " .. tostring(err))
|
||||
fire_event(recorded, "start-file")
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
|
||||
"explicit overlay loading OSD option should show spinner even when plugin auto-start is disabled"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -1539,6 +1564,91 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
osd_messages = false,
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for early overlay loading OSD scenario: " .. tostring(err))
|
||||
fire_event(recorded, "start-file")
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
|
||||
"auto-start visible overlay should force overlay loading OSD spinner on start-file"
|
||||
)
|
||||
assert_true(
|
||||
#recorded.periodic_timers == 1,
|
||||
"auto-start visible overlay should refresh the early overlay loading OSD"
|
||||
)
|
||||
local overlay_loading_timer = recorded.periodic_timers[1]
|
||||
recorded.periodic_timers[1].callback()
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Overlay loading /"),
|
||||
"auto-start visible overlay should advance the early overlay loading OSD spinner"
|
||||
)
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
overlay_loading_timer.killed ~= true,
|
||||
"autoplay gate should keep forced overlay loading OSD alive while normal plugin OSD messages are disabled"
|
||||
)
|
||||
assert_true(
|
||||
#recorded.periodic_timers == 1,
|
||||
"autoplay gate should not replace forced overlay loading OSD with a suppressed tokenization OSD timer"
|
||||
)
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
overlay_loading_timer.killed ~= true,
|
||||
"autoplay readiness should not stop forced overlay loading OSD before overlay content is ready"
|
||||
)
|
||||
overlay_loading_timer.callback()
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Overlay loading -"),
|
||||
"forced overlay loading OSD should keep spinning during the overlay startup gap"
|
||||
)
|
||||
assert_true(
|
||||
recorded.script_messages["subminer-overlay-loading-ready"] ~= nil,
|
||||
"overlay loading ready script message should be registered"
|
||||
)
|
||||
recorded.script_messages["subminer-overlay-loading-ready"]()
|
||||
assert_true(
|
||||
recorded.periodic_timers[1].killed == true,
|
||||
"overlay loading ready should stop the early overlay loading OSD refresher"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "no",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for hidden overlay loading OSD scenario: " .. tostring(err))
|
||||
fire_event(recorded, "start-file")
|
||||
assert_true(
|
||||
not has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
|
||||
"auto-start hidden visible overlay should not show early overlay loading OSD"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -1759,6 +1869,32 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
auto_start_pause_until_ready_owns_initial_pause = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
paused = true,
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for default pause timeout scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
has_timeout(recorded.timeouts, 30),
|
||||
"pause-until-ready default timeout should give cold app startup 30 seconds"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
|
||||
@@ -87,6 +87,25 @@ test('AnkiConnectClient lists decks and note type fields', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient opens a note in the Anki browser', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
client as unknown as { openNoteInBrowser: (noteId: number) => Promise<void> }
|
||||
).openNoteInBrowser(12345);
|
||||
|
||||
assert.deepEqual(calls, [{ action: 'guiBrowse', params: { query: 'nid:12345' } }]);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient derives field names from sampled notes in a deck', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
|
||||
@@ -247,6 +247,13 @@ export class AnkiConnectClient {
|
||||
return (result as Record<string, unknown>[]) || [];
|
||||
}
|
||||
|
||||
async openNoteInBrowser(noteId: number): Promise<void> {
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
throw new Error('Invalid Anki note id');
|
||||
}
|
||||
await this.invoke('guiBrowse', { query: `nid:${noteId}` });
|
||||
}
|
||||
|
||||
async updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void> {
|
||||
await this.invoke('updateNoteFields', {
|
||||
note: {
|
||||
|
||||
@@ -7,6 +7,14 @@ import { AnkiIntegration } from './anki-integration';
|
||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||
import { AnkiConnectConfig } from './types';
|
||||
|
||||
type TestOverlayNotificationPayload = {
|
||||
title: string;
|
||||
body?: string;
|
||||
image?: string;
|
||||
variant?: string;
|
||||
actions?: Array<{ id: string; label: string; noteId?: number }>;
|
||||
};
|
||||
|
||||
interface IntegrationTestContext {
|
||||
integration: AnkiIntegration;
|
||||
calls: {
|
||||
@@ -406,6 +414,188 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
|
||||
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
||||
});
|
||||
|
||||
test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
const generatedFrom: Array<{ videoPath: string; timestamp: number }> = [];
|
||||
const cleanupPaths: string[] = [];
|
||||
const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png');
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: '/tmp/show.mkv',
|
||||
currentTimePos: 123.45,
|
||||
} as never,
|
||||
undefined,
|
||||
(title, options) => {
|
||||
desktopNotifications.push({ title, body: options.body, icon: options.icon });
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
integration as unknown as {
|
||||
mediaGenerator: {
|
||||
generateNotificationIcon: (videoPath: string, timestamp: number) => Promise<Buffer>;
|
||||
writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string;
|
||||
scheduleNotificationIconCleanup: (filePath: string) => void;
|
||||
};
|
||||
}
|
||||
).mediaGenerator = {
|
||||
generateNotificationIcon: async (videoPath, timestamp) => {
|
||||
generatedFrom.push({ videoPath, timestamp });
|
||||
return Buffer.from('png');
|
||||
},
|
||||
writeNotificationIconToFile: (iconBuffer, noteId) => {
|
||||
assert.equal(iconBuffer.toString(), 'png');
|
||||
assert.equal(noteId, 42);
|
||||
return notificationIconPath;
|
||||
},
|
||||
scheduleNotificationIconCleanup: (filePath) => {
|
||||
cleanupPaths.push(filePath);
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
integration as unknown as {
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
}
|
||||
).showNotification(42, '食べる');
|
||||
|
||||
assert.deepEqual(generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]);
|
||||
assert.equal(overlayNotifications.length, 1);
|
||||
assert.equal(overlayNotifications[0]?.title, 'Anki Card Updated');
|
||||
assert.equal(overlayNotifications[0]?.body, 'Updated card: 食べる');
|
||||
assert.equal(
|
||||
overlayNotifications[0]?.image,
|
||||
`data:image/png;base64,${Buffer.from('png').toString('base64')}`,
|
||||
);
|
||||
assert.deepEqual(overlayNotifications[0]?.actions, [
|
||||
{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 },
|
||||
]);
|
||||
assert.deepEqual(desktopNotifications, [
|
||||
{
|
||||
title: 'Anki Card Updated',
|
||||
body: 'Updated card: 食べる',
|
||||
icon: notificationIconPath,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(cleanupPaths, [notificationIconPath]);
|
||||
});
|
||||
|
||||
test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: '/tmp/show.mkv',
|
||||
currentTimePos: 123.45,
|
||||
} as never,
|
||||
undefined,
|
||||
(title, options) => {
|
||||
desktopNotifications.push({ title, body: options.body, icon: options.icon });
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
integration as unknown as {
|
||||
mediaGenerator: {
|
||||
generateNotificationIcon: () => Promise<Buffer>;
|
||||
writeNotificationIconToFile: () => string;
|
||||
scheduleNotificationIconCleanup: (filePath: string) => void;
|
||||
};
|
||||
}
|
||||
).mediaGenerator = {
|
||||
generateNotificationIcon: async () => Buffer.from('png'),
|
||||
writeNotificationIconToFile: () => {
|
||||
throw new Error('disk full');
|
||||
},
|
||||
scheduleNotificationIconCleanup: (filePath) => {
|
||||
cleanupPaths.push(filePath);
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
integration as unknown as {
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
}
|
||||
).showNotification(42, '食べる');
|
||||
|
||||
assert.equal(
|
||||
overlayNotifications[0]?.image,
|
||||
`data:image/png;base64,${Buffer.from('png').toString('base64')}`,
|
||||
);
|
||||
assert.deepEqual(desktopNotifications, [
|
||||
{
|
||||
title: 'Anki Card Updated',
|
||||
body: 'Updated card: 食べる',
|
||||
icon: undefined,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(cleanupPaths, []);
|
||||
});
|
||||
|
||||
test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
const desktopMessages: string[] = [];
|
||||
const overlayMessages: string[] = [];
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
(text) => {
|
||||
osdMessages.push(text);
|
||||
},
|
||||
(title, options) => {
|
||||
desktopMessages.push(`${title}:${options.body ?? ''}`);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`);
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(await integration.createSentenceCard('食べる', 0, 1), false);
|
||||
|
||||
assert.deepEqual(osdMessages, []);
|
||||
assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']);
|
||||
assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']);
|
||||
});
|
||||
|
||||
test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
|
||||
const collaborator = createFieldGroupingMergeCollaborator();
|
||||
|
||||
|
||||
+145
-36
@@ -29,6 +29,8 @@ import {
|
||||
} from './types/anki';
|
||||
import { AiConfig } from './types/integrations';
|
||||
import { MpvClient } from './types/runtime';
|
||||
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
|
||||
import type { NotificationType, OverlayNotificationPayload } from './types/notification';
|
||||
import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle';
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
|
||||
import {
|
||||
@@ -119,6 +121,15 @@ function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): b
|
||||
);
|
||||
}
|
||||
|
||||
function toOverlayNotificationImageSource(iconBuffer: Buffer): string {
|
||||
return `data:image/png;base64,${iconBuffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
interface NotificationIcon {
|
||||
filePath?: string;
|
||||
overlayImageSource: string;
|
||||
}
|
||||
|
||||
export class AnkiIntegration {
|
||||
private client: AnkiConnectClient;
|
||||
private mediaGenerator: MediaGenerator;
|
||||
@@ -130,6 +141,8 @@ export class AnkiIntegration {
|
||||
private osdCallback: ((text: string) => void) | null = null;
|
||||
private notificationCallback: ((title: string, options: NotificationOptions) => void) | null =
|
||||
null;
|
||||
private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null =
|
||||
null;
|
||||
private updateInProgress = false;
|
||||
private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
|
||||
private parseWarningKeys = new Set<string>();
|
||||
@@ -166,6 +179,7 @@ export class AnkiIntegration {
|
||||
knownWordCacheStatePath?: string,
|
||||
aiConfig: AiConfig = {},
|
||||
recordCardsMined?: (count: number, noteIds?: number[]) => void,
|
||||
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
|
||||
) {
|
||||
this.config = normalizeAnkiIntegrationConfig(config);
|
||||
this.aiConfig = { ...aiConfig };
|
||||
@@ -175,6 +189,7 @@ export class AnkiIntegration {
|
||||
this.mpvClient = mpvClient;
|
||||
this.osdCallback = osdCallback || null;
|
||||
this.notificationCallback = notificationCallback || null;
|
||||
this.overlayNotificationCallback = overlayNotificationCallback || null;
|
||||
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
||||
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||
@@ -335,7 +350,7 @@ export class AnkiIntegration {
|
||||
options,
|
||||
),
|
||||
},
|
||||
showOsdNotification: (text: string) => this.showOsdNotification(text),
|
||||
showOsdNotification: (text: string) => this.showStatusNotification(text),
|
||||
showUpdateResult: (message: string, success: boolean) =>
|
||||
this.showUpdateResult(message, success),
|
||||
showStatusNotification: (message: string) => this.showStatusNotification(message),
|
||||
@@ -387,7 +402,7 @@ export class AnkiIntegration {
|
||||
getDeck: () => this.config.deck,
|
||||
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) =>
|
||||
this.withUpdateProgress(initialMessage, action),
|
||||
showOsdNotification: (text: string) => this.showOsdNotification(text),
|
||||
showOsdNotification: (text: string) => this.showStatusNotification(text),
|
||||
findNotes: async (query, options) =>
|
||||
(await this.client.findNotes(query, options)) as number[],
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[],
|
||||
@@ -463,7 +478,7 @@ export class AnkiIntegration {
|
||||
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
|
||||
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
showOsdNotification: (message) => this.showOsdNotification(message),
|
||||
showOsdNotification: (message) => this.showStatusNotification(message),
|
||||
beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage),
|
||||
endUpdateProgress: () => this.endUpdateProgress(),
|
||||
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
||||
@@ -510,7 +525,7 @@ export class AnkiIntegration {
|
||||
},
|
||||
showStatusNotification: (message) => this.showStatusNotification(message),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
showOsdNotification: (message) => this.showOsdNotification(message),
|
||||
showOsdNotification: (message) => this.showStatusNotification(message),
|
||||
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
||||
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
||||
truncateSentence: (sentence) => this.truncateSentence(sentence),
|
||||
@@ -525,6 +540,10 @@ export class AnkiIntegration {
|
||||
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
|
||||
}
|
||||
|
||||
async openNoteInAnki(noteId: number): Promise<void> {
|
||||
await this.client.openNoteInBrowser(noteId);
|
||||
}
|
||||
|
||||
private isKnownWordCacheEnabled(): boolean {
|
||||
return (
|
||||
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true
|
||||
@@ -860,10 +879,13 @@ export class AnkiIntegration {
|
||||
|
||||
private showStatusNotification(message: string): void {
|
||||
showStatusNotification(message, {
|
||||
getNotificationType: () => this.config.behavior?.notificationType,
|
||||
getNotificationType: () => this.getNotificationType(),
|
||||
showOsd: (text: string) => {
|
||||
this.showOsdNotification(text);
|
||||
},
|
||||
showOverlayNotification: (payload) => {
|
||||
this.overlayNotificationCallback?.(payload);
|
||||
},
|
||||
showSystemNotification: (title: string, options: NotificationOptions) => {
|
||||
if (this.notificationCallback) {
|
||||
this.notificationCallback(title, options);
|
||||
@@ -872,19 +894,51 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
private getNotificationType(): NotificationType {
|
||||
return this.config.behavior?.notificationType ?? 'osd';
|
||||
}
|
||||
|
||||
private shouldUseOsdNotifications(): boolean {
|
||||
const type = this.getNotificationType();
|
||||
return type === 'osd' || type === 'osd-system';
|
||||
}
|
||||
|
||||
private shouldUseOverlayNotifications(): boolean {
|
||||
const type = this.getNotificationType();
|
||||
return type === 'overlay' || type === 'both';
|
||||
}
|
||||
|
||||
private beginUpdateProgress(initialMessage: string): void {
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
if (this.shouldUseOverlayNotifications()) {
|
||||
this.overlayNotificationCallback?.({
|
||||
id: 'anki-update-progress',
|
||||
title: 'Anki update',
|
||||
body: initialMessage,
|
||||
variant: 'progress',
|
||||
persistent: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => {
|
||||
this.showOsdNotification(text);
|
||||
});
|
||||
}
|
||||
|
||||
private endUpdateProgress(): void {
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
return;
|
||||
}
|
||||
endUpdateProgress(this.uiFeedbackState, (timer) => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
}
|
||||
|
||||
private clearUpdateProgress(): void {
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
return;
|
||||
}
|
||||
clearUpdateProgress(this.uiFeedbackState, (timer) => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
@@ -894,6 +948,23 @@ export class AnkiIntegration {
|
||||
initialMessage: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
this.updateInProgress = true;
|
||||
if (this.shouldUseOverlayNotifications()) {
|
||||
this.overlayNotificationCallback?.({
|
||||
id: 'anki-update-progress',
|
||||
title: 'Anki update',
|
||||
body: initialMessage,
|
||||
variant: 'progress',
|
||||
persistent: false,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
this.updateInProgress = false;
|
||||
}
|
||||
}
|
||||
return withUpdateProgress(
|
||||
this.uiFeedbackState,
|
||||
{
|
||||
@@ -1017,51 +1088,89 @@ export class AnkiIntegration {
|
||||
? `Updated card: ${label} (${errorSuffix})`
|
||||
: `Updated card: ${label}`;
|
||||
|
||||
const type = this.config.behavior?.notificationType || 'osd';
|
||||
const type = this.getNotificationType();
|
||||
|
||||
if (type === 'osd' || type === 'both') {
|
||||
if (type === 'osd' || type === 'osd-system') {
|
||||
this.showUpdateResult(message, errorSuffix === undefined);
|
||||
} else {
|
||||
this.clearUpdateProgress();
|
||||
}
|
||||
|
||||
if ((type === 'system' || type === 'both') && this.notificationCallback) {
|
||||
let notificationIconPath: string | undefined;
|
||||
const shouldShowOverlayNotification =
|
||||
(type === 'overlay' || type === 'both') && this.overlayNotificationCallback !== null;
|
||||
const shouldShowSystemNotification =
|
||||
(type === 'system' || type === 'both' || type === 'osd-system') &&
|
||||
this.notificationCallback !== null;
|
||||
const notificationIcon =
|
||||
shouldShowOverlayNotification || shouldShowSystemNotification
|
||||
? await this.generateNotificationIcon(noteId, shouldShowSystemNotification)
|
||||
: undefined;
|
||||
|
||||
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
||||
try {
|
||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||
const notificationIconSource = await resolveMediaGenerationInputPath(
|
||||
this.mpvClient,
|
||||
'video',
|
||||
);
|
||||
if (!notificationIconSource) {
|
||||
throw new Error('No media source available for notification icon');
|
||||
}
|
||||
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
|
||||
notificationIconSource,
|
||||
timestamp,
|
||||
);
|
||||
if (iconBuffer && iconBuffer.length > 0) {
|
||||
notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
|
||||
if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
|
||||
this.overlayNotificationCallback({
|
||||
id: 'anki-update-progress',
|
||||
title: 'Anki Card Updated',
|
||||
body: message,
|
||||
...(notificationIcon ? { image: notificationIcon.overlayImageSource } : {}),
|
||||
variant: errorSuffix === undefined ? 'success' : 'error',
|
||||
persistent: false,
|
||||
actions: [{ id: OPEN_ANKI_CARD_ACTION_ID, label: 'Open in Anki', noteId }],
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldShowSystemNotification && this.notificationCallback) {
|
||||
this.notificationCallback('Anki Card Updated', {
|
||||
body: message,
|
||||
icon: notificationIcon?.filePath,
|
||||
});
|
||||
}
|
||||
|
||||
if (notificationIcon) {
|
||||
if (notificationIcon.filePath) {
|
||||
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIcon.filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async generateNotificationIcon(
|
||||
noteId: number,
|
||||
shouldWriteToFile: boolean,
|
||||
): Promise<NotificationIcon | undefined> {
|
||||
if (!this.mpvClient?.currentVideoPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||
const notificationIconSource = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
|
||||
if (!notificationIconSource) {
|
||||
throw new Error('No media source available for notification icon');
|
||||
}
|
||||
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
|
||||
notificationIconSource,
|
||||
timestamp,
|
||||
);
|
||||
if (iconBuffer && iconBuffer.length > 0) {
|
||||
const notificationIcon: NotificationIcon = {
|
||||
overlayImageSource: toOverlayNotificationImageSource(iconBuffer),
|
||||
};
|
||||
if (shouldWriteToFile) {
|
||||
try {
|
||||
notificationIcon.filePath = this.mediaGenerator.writeNotificationIconToFile(
|
||||
iconBuffer,
|
||||
noteId,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn('Failed to write notification icon:', (err as Error).message);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to generate notification icon:', (err as Error).message);
|
||||
}
|
||||
return notificationIcon;
|
||||
}
|
||||
|
||||
this.notificationCallback('Anki Card Updated', {
|
||||
body: message,
|
||||
icon: notificationIconPath,
|
||||
});
|
||||
|
||||
if (notificationIconPath) {
|
||||
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to generate notification icon:', (err as Error).message);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private showUpdateResult(message: string, success: boolean): void {
|
||||
|
||||
@@ -271,3 +271,28 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||
assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/);
|
||||
});
|
||||
|
||||
test('createSentenceCard relies on Anki progress notification without standalone status toast', async () => {
|
||||
const statusMessages: string[] = [];
|
||||
const progressMessages: string[] = [];
|
||||
const { service } = createManualUpdateService({
|
||||
showOsdNotification: (message) => {
|
||||
statusMessages.push(message);
|
||||
},
|
||||
withUpdateProgress: async (message, action) => {
|
||||
progressMessages.push(message);
|
||||
return await action();
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
||||
assert.deepEqual(statusMessages, []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
|
||||
test('sentence card writes generated audio only to sentence audio field', async () => {
|
||||
const addedFields: Record<string, string>[] = [];
|
||||
const updatedFields: Record<string, string>[] = [];
|
||||
const storedMedia: string[] = [];
|
||||
|
||||
const deps: CardCreationDeps = {
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'ExpressionAudio',
|
||||
translation: 'SelectionText',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 12,
|
||||
currentSubEnd: 14,
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async (_deck, _modelName, fields) => {
|
||||
addedFields.push(fields);
|
||||
return 42;
|
||||
},
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '字幕' },
|
||||
Sentence: { value: '字幕' },
|
||||
SelectionText: { value: 'Subtitle' },
|
||||
ExpressionAudio: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
||||
for (const preferredName of preferredNames) {
|
||||
if (preferredName && preferredName in noteInfo.fields) return preferredName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) =>
|
||||
preferredName && preferredName in noteInfo.fields ? preferredName : null,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
};
|
||||
|
||||
const created = await new CardCreationService(deps).createSentenceCard(
|
||||
'字幕',
|
||||
12,
|
||||
14,
|
||||
'Subtitle',
|
||||
);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(addedFields[0], {
|
||||
Sentence: '字幕',
|
||||
SelectionText: 'Subtitle',
|
||||
IsSentenceCard: 'x',
|
||||
Expression: '字幕',
|
||||
});
|
||||
assert.equal(storedMedia.length, 1);
|
||||
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||
});
|
||||
@@ -511,7 +511,6 @@ export class CardCreationService {
|
||||
endTime = startTime + maxMediaDuration;
|
||||
}
|
||||
|
||||
this.deps.showOsdNotification('Creating sentence card...');
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
|
||||
@@ -529,7 +528,6 @@ export class CardCreationService {
|
||||
const translationField = this.deps.getConfig().fields?.translation || 'SelectionText';
|
||||
let resolvedMiscInfoField: string | null = null;
|
||||
let resolvedSentenceAudioField: string = audioFieldName;
|
||||
let resolvedExpressionAudioField: string | null = null;
|
||||
|
||||
fields[sentenceField] = sentence;
|
||||
|
||||
@@ -627,10 +625,6 @@ export class CardCreationService {
|
||||
this.deps.appendKnownWordsFromNoteInfo(createdNoteInfo);
|
||||
resolvedSentenceAudioField =
|
||||
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName;
|
||||
resolvedExpressionAudioField = this.deps.resolveConfiguredFieldName(
|
||||
createdNoteInfo,
|
||||
this.deps.getConfig().fields?.audio || 'ExpressionAudio',
|
||||
);
|
||||
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
|
||||
createdNoteInfo,
|
||||
this.deps.getConfig().fields?.miscInfo,
|
||||
@@ -663,12 +657,6 @@ export class CardCreationService {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
mediaFields[resolvedSentenceAudioField] = audioValue;
|
||||
if (
|
||||
resolvedExpressionAudioField &&
|
||||
resolvedExpressionAudioField !== resolvedSentenceAudioField
|
||||
) {
|
||||
mediaFields[resolvedExpressionAudioField] = audioValue;
|
||||
}
|
||||
miscInfoFilename = audioFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
beginUpdateProgress,
|
||||
createUiFeedbackState,
|
||||
showProgressTick,
|
||||
showStatusNotification,
|
||||
showUpdateResult,
|
||||
} from './ui-feedback';
|
||||
|
||||
@@ -65,3 +66,57 @@ test('showUpdateResult renders failed updates with an x marker', () => {
|
||||
'x Sentence card failed: deck missing',
|
||||
]);
|
||||
});
|
||||
|
||||
test('showStatusNotification falls back to system when overlay delivery is unavailable', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
showStatusNotification('Waiting for card update', {
|
||||
getNotificationType: () => 'overlay',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showSystemNotification: (title, options) => {
|
||||
calls.push(`system:${title}:${options.body}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['system:SubMiner:Waiting for card update']);
|
||||
});
|
||||
|
||||
test('showStatusNotification defaults to mpv osd when notification type is unset', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
showStatusNotification('Card updated', {
|
||||
getNotificationType: () => undefined,
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) => {
|
||||
calls.push(`overlay:${payload.body}`);
|
||||
},
|
||||
showSystemNotification: (title, options) => {
|
||||
calls.push(`system:${title}:${options.body}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:Card updated']);
|
||||
});
|
||||
|
||||
test('showStatusNotification does not duplicate system notifications for both', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
showStatusNotification('Card updated', {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) => {
|
||||
calls.push(`overlay:${payload.body}`);
|
||||
},
|
||||
showSystemNotification: (title, options) => {
|
||||
calls.push(`system:${title}:${options.body}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['overlay:Card updated', 'system:SubMiner:Card updated']);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NotificationOptions } from '../types/anki';
|
||||
import type { NotificationOptions } from '../types/anki';
|
||||
import type { NotificationType, OverlayNotificationPayload } from '../types/notification';
|
||||
|
||||
export interface UiFeedbackState {
|
||||
progressDepth: number;
|
||||
@@ -13,8 +14,9 @@ export interface UiFeedbackResult {
|
||||
}
|
||||
|
||||
export interface UiFeedbackNotificationContext {
|
||||
getNotificationType: () => string | undefined;
|
||||
getNotificationType: () => NotificationType | undefined;
|
||||
showOsd: (text: string) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showSystemNotification: (title: string, options: NotificationOptions) => void;
|
||||
}
|
||||
|
||||
@@ -36,13 +38,29 @@ export function showStatusNotification(
|
||||
message: string,
|
||||
context: UiFeedbackNotificationContext,
|
||||
): void {
|
||||
const type = context.getNotificationType() || 'osd';
|
||||
const type = context.getNotificationType() ?? 'osd';
|
||||
|
||||
if (type === 'osd' || type === 'both') {
|
||||
if (type === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'overlay' || type === 'both') {
|
||||
if (context.showOverlayNotification) {
|
||||
context.showOverlayNotification({
|
||||
title: 'SubMiner',
|
||||
body: message,
|
||||
variant: 'info',
|
||||
});
|
||||
} else if (type === 'overlay') {
|
||||
context.showSystemNotification('SubMiner', { body: message });
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'osd' || type === 'osd-system') {
|
||||
context.showOsd(message);
|
||||
}
|
||||
|
||||
if (type === 'system' || type === 'both') {
|
||||
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||
context.showSystemNotification('SubMiner', { body: message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,15 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs captures internal playback feedback command', () => {
|
||||
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
|
||||
|
||||
assert.equal(args.playbackFeedback, 'You can skip by pressing TAB');
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
assert.equal(commandNeedsOverlayRuntime(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs ignores non-positive numeric session action counts', () => {
|
||||
const args = parseArgs(['--copy-subtitle-count=0', '--mine-sentence-count', '-1']);
|
||||
|
||||
|
||||
+14
-1
@@ -43,6 +43,7 @@ export interface CliArgs {
|
||||
playNextSubtitle: boolean;
|
||||
shiftSubDelayPrevLine: boolean;
|
||||
shiftSubDelayNextLine: boolean;
|
||||
playbackFeedback?: string;
|
||||
cycleRuntimeOptionId?: string;
|
||||
cycleRuntimeOptionDirection?: 1 | -1;
|
||||
sessionAction?: SessionActionDispatchRequest;
|
||||
@@ -150,6 +151,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
playbackFeedback: undefined,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
@@ -296,7 +298,13 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
|
||||
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
|
||||
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
|
||||
else if (arg.startsWith('--cycle-runtime-option=')) {
|
||||
else if (arg.startsWith('--playback-feedback=')) {
|
||||
const value = arg.slice('--playback-feedback='.length).trim();
|
||||
if (value) args.playbackFeedback = value;
|
||||
} else if (arg === '--playback-feedback') {
|
||||
const value = readValue(argv[i + 1])?.trim();
|
||||
if (value) args.playbackFeedback = value;
|
||||
} else if (arg.startsWith('--cycle-runtime-option=')) {
|
||||
const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]);
|
||||
if (parsed) {
|
||||
args.cycleRuntimeOptionId = parsed.id;
|
||||
@@ -556,6 +564,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
@@ -631,6 +640,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
@@ -697,6 +707,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
@@ -757,6 +768,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
@@ -822,6 +834,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
|
||||
@@ -98,6 +98,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
|
||||
assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
|
||||
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
||||
assert.equal(config.shortcuts.toggleNotificationHistory, 'CommandOrControl+N');
|
||||
assert.equal(config.discordPresence.enabled, true);
|
||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||
assert.equal(config.subtitleStyle.backgroundColor, 'transparent');
|
||||
@@ -152,7 +153,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.stats.autoOpenBrowser, false);
|
||||
assert.equal(config.updates.enabled, true);
|
||||
assert.equal(config.updates.checkIntervalHours, 24);
|
||||
assert.equal(config.updates.notificationType, 'system');
|
||||
assert.equal(config.updates.notificationType, 'overlay');
|
||||
assert.equal(config.updates.channel, 'stable');
|
||||
assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||
assert.equal(config.mpv.backend, 'auto');
|
||||
@@ -172,7 +173,7 @@ test('parses updates config and warns on invalid values', () => {
|
||||
"updates": {
|
||||
"enabled": false,
|
||||
"checkIntervalHours": 6,
|
||||
"notificationType": "both",
|
||||
"notificationType": "osd-system",
|
||||
"channel": "prerelease"
|
||||
}
|
||||
}`,
|
||||
@@ -182,7 +183,7 @@ test('parses updates config and warns on invalid values', () => {
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().updates.enabled, false);
|
||||
assert.equal(validService.getConfig().updates.checkIntervalHours, 6);
|
||||
assert.equal(validService.getConfig().updates.notificationType, 'both');
|
||||
assert.equal(validService.getConfig().updates.notificationType, 'osd-system');
|
||||
assert.equal(validService.getConfig().updates.channel, 'prerelease');
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
@@ -212,6 +213,69 @@ test('parses updates config and warns on invalid values', () => {
|
||||
assert.ok(warnings.some((warning) => warning.path === 'updates.channel'));
|
||||
});
|
||||
|
||||
test('accepts overlay notification config values', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"updates": {
|
||||
"notificationType": "overlay"
|
||||
},
|
||||
"ankiConnect": {
|
||||
"behavior": {
|
||||
"notificationType": "osd-system"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
|
||||
assert.equal(service.getConfig().updates.notificationType, 'overlay');
|
||||
assert.equal(service.getConfig().ankiConnect.behavior.notificationType, 'osd-system');
|
||||
assert.deepEqual(service.getWarnings(), []);
|
||||
});
|
||||
|
||||
test('parses overlay notification position config and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"notifications": {
|
||||
"overlayPosition": "top-left"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().notifications.overlayPosition, 'top-left');
|
||||
assert.deepEqual(validService.getWarnings(), []);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"notifications": {
|
||||
"overlayPosition": "bottom-right"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().notifications.overlayPosition,
|
||||
DEFAULT_CONFIG.notifications.overlayPosition,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'notifications.overlayPosition'),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws actionable startup parse error for malformed config at construction time', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
@@ -2750,7 +2814,7 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/,
|
||||
/"notificationType": "overlay",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
subsync,
|
||||
startupWarmups,
|
||||
updates,
|
||||
notifications,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
@@ -57,6 +58,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subsync,
|
||||
startupWarmups,
|
||||
updates,
|
||||
notifications,
|
||||
subtitleStyle,
|
||||
subtitleSidebar,
|
||||
auto_start_overlay,
|
||||
|
||||
@@ -15,6 +15,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'subsync'
|
||||
| 'startupWarmups'
|
||||
| 'updates'
|
||||
| 'notifications'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
@@ -101,6 +102,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: 'Backslash',
|
||||
toggleNotificationHistory: 'CommandOrControl+N',
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: [],
|
||||
@@ -126,8 +128,11 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
updates: {
|
||||
enabled: true,
|
||||
checkIntervalHours: 24,
|
||||
notificationType: 'system',
|
||||
notificationType: 'overlay',
|
||||
channel: 'stable',
|
||||
},
|
||||
notifications: {
|
||||
overlayPosition: 'top-right',
|
||||
},
|
||||
auto_start_overlay: true,
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
overwriteImage: true,
|
||||
mediaInsertMode: 'append',
|
||||
highlightWord: true,
|
||||
notificationType: 'osd',
|
||||
notificationType: 'overlay',
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
nPlusOne: {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import {
|
||||
NOTIFICATION_TYPE_VALUES,
|
||||
OVERLAY_NOTIFICATION_POSITION_VALUES,
|
||||
SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
} from '../../types/notification';
|
||||
import { ConfigOptionRegistryEntry } from './shared';
|
||||
|
||||
export function buildCoreConfigOptionRegistry(
|
||||
@@ -484,9 +489,11 @@ export function buildCoreConfigOptionRegistry(
|
||||
{
|
||||
path: 'updates.notificationType',
|
||||
kind: 'enum',
|
||||
enumValues: ['system', 'osd', 'both', 'none'],
|
||||
enumValues: NOTIFICATION_TYPE_VALUES,
|
||||
settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
defaultValue: defaultConfig.updates.notificationType,
|
||||
description: 'How SubMiner announces available updates.',
|
||||
description:
|
||||
'How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values.',
|
||||
},
|
||||
{
|
||||
path: 'updates.channel',
|
||||
@@ -495,6 +502,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.updates.channel,
|
||||
description: 'Release channel used for update checks.',
|
||||
},
|
||||
{
|
||||
path: 'notifications.overlayPosition',
|
||||
kind: 'enum',
|
||||
enumValues: OVERLAY_NOTIFICATION_POSITION_VALUES,
|
||||
defaultValue: defaultConfig.notifications.overlayPosition,
|
||||
description: 'Position for in-overlay notification cards.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.multiCopyTimeoutMs',
|
||||
kind: 'number',
|
||||
@@ -608,5 +622,11 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar,
|
||||
description: 'Accelerator that toggles the subtitle sidebar visibility.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleNotificationHistory',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleNotificationHistory,
|
||||
description: 'Accelerator that toggles the overlay notification history panel.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
||||
import {
|
||||
NOTIFICATION_TYPE_VALUES,
|
||||
SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
} from '../../types/notification';
|
||||
import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
|
||||
|
||||
export function buildIntegrationConfigOptionRegistry(
|
||||
@@ -158,9 +162,11 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
{
|
||||
path: 'ankiConnect.behavior.notificationType',
|
||||
kind: 'enum',
|
||||
enumValues: ['osd', 'system', 'both', 'none'],
|
||||
enumValues: NOTIFICATION_TYPE_VALUES,
|
||||
settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.notificationType,
|
||||
description: 'Notification surface used to announce mining and update outcomes.',
|
||||
description:
|
||||
'Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.syncAnimatedImageToWordAudio',
|
||||
|
||||
@@ -27,7 +27,17 @@ export interface ConfigOptionRegistryEntry {
|
||||
kind: ConfigValueKind;
|
||||
defaultValue: unknown;
|
||||
description: string;
|
||||
/**
|
||||
* Complete runtime-valid enum options, including legacy file-config values such as
|
||||
* `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES.
|
||||
*/
|
||||
enumValues?: readonly string[];
|
||||
/**
|
||||
* Optional settings UI subset when legacy/runtime-valid enum options should remain
|
||||
* editable in config files but hidden from new UI choices, for example
|
||||
* SETTINGS_NOTIFICATION_TYPE_VALUES.
|
||||
*/
|
||||
settingsEnumValues?: readonly string[];
|
||||
runtime?: RuntimeOptionRegistryEntry;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'updates',
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description: ['Overlay notification display behavior.'],
|
||||
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
||||
key: 'notifications',
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { DEFAULT_CONFIG } from '../definitions';
|
||||
import type { ResolveContext } from './context';
|
||||
import { isNotificationType, type NotificationType } from '../../types/notification';
|
||||
import { asBoolean, asColor, asNumber, asString, isObject } from './shared';
|
||||
|
||||
function asNotificationType(value: unknown): NotificationType | undefined {
|
||||
return isNotificationType(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
if (!isObject(context.src.ankiConnect)) {
|
||||
return;
|
||||
@@ -42,6 +47,8 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
'notificationType',
|
||||
'autoUpdateNewCards',
|
||||
]);
|
||||
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(obj, key);
|
||||
|
||||
const {
|
||||
knownWords: _knownWordsConfigFromAnkiConnect,
|
||||
@@ -99,6 +106,22 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
},
|
||||
};
|
||||
|
||||
if (hasOwn(behavior, 'notificationType')) {
|
||||
const parsed = asNotificationType(behavior.notificationType);
|
||||
if (parsed === undefined) {
|
||||
context.resolved.ankiConnect.behavior.notificationType =
|
||||
DEFAULT_CONFIG.ankiConnect.behavior.notificationType;
|
||||
context.warn(
|
||||
'ankiConnect.behavior.notificationType',
|
||||
behavior.notificationType,
|
||||
context.resolved.ankiConnect.behavior.notificationType,
|
||||
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
||||
);
|
||||
} else {
|
||||
context.resolved.ankiConnect.behavior.notificationType = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(ac.isLapis)) {
|
||||
const lapisEnabled = asBoolean(ac.isLapis.enabled);
|
||||
if (lapisEnabled !== undefined) {
|
||||
@@ -289,8 +312,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const legacy = ac as Record<string, unknown>;
|
||||
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(obj, key);
|
||||
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
|
||||
const parsed = asNumber(value);
|
||||
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
@@ -328,11 +349,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
|
||||
return value === 'append' || value === 'prepend' ? value : undefined;
|
||||
};
|
||||
const asNotificationType = (value: unknown): 'osd' | 'system' | 'both' | 'none' | undefined => {
|
||||
return value === 'osd' || value === 'system' || value === 'both' || value === 'none'
|
||||
? value
|
||||
: undefined;
|
||||
};
|
||||
const mapLegacy = <T>(
|
||||
key: string,
|
||||
parse: (value: unknown) => T | undefined,
|
||||
@@ -633,7 +649,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
context.resolved.ankiConnect.behavior.notificationType = value;
|
||||
},
|
||||
context.resolved.ankiConnect.behavior.notificationType,
|
||||
"Expected 'osd', 'system', 'both', or 'none'.",
|
||||
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
||||
);
|
||||
}
|
||||
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ResolveContext } from './context';
|
||||
import { applyControllerConfig } from './controller';
|
||||
import { isNotificationType, isOverlayNotificationPosition } from '../../types/notification';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
@@ -194,19 +195,14 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const notificationType = asString(src.updates.notificationType);
|
||||
if (
|
||||
notificationType === 'system' ||
|
||||
notificationType === 'osd' ||
|
||||
notificationType === 'both' ||
|
||||
notificationType === 'none'
|
||||
) {
|
||||
if (isNotificationType(notificationType)) {
|
||||
resolved.updates.notificationType = notificationType;
|
||||
} else if (src.updates.notificationType !== undefined) {
|
||||
warn(
|
||||
'updates.notificationType',
|
||||
src.updates.notificationType,
|
||||
resolved.updates.notificationType,
|
||||
'Expected system, osd, both, or none.',
|
||||
'Expected overlay, system, both, none, osd, or osd-system.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'toggleNotificationHistory',
|
||||
] as const;
|
||||
|
||||
for (const key of shortcutKeys) {
|
||||
@@ -323,4 +320,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
resolved.subtitlePosition.yPercent = y;
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.notifications)) {
|
||||
const overlayPosition = asString(src.notifications.overlayPosition);
|
||||
if (isOverlayNotificationPosition(overlayPosition)) {
|
||||
resolved.notifications.overlayPosition = overlayPosition;
|
||||
} else if (src.notifications.overlayPosition !== undefined) {
|
||||
warn(
|
||||
'notifications.overlayPosition',
|
||||
src.notifications.overlayPosition,
|
||||
resolved.notifications.overlayPosition,
|
||||
'Expected top-left, top, or top-right.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Startup warmups',
|
||||
'Logging',
|
||||
'Updates',
|
||||
'Notifications',
|
||||
'Immersion tracking',
|
||||
].map((section, index) => [section, index]),
|
||||
);
|
||||
@@ -411,6 +412,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
) {
|
||||
return { category: 'behavior', section: 'Playback Behavior' };
|
||||
}
|
||||
if (path.startsWith('notifications.')) {
|
||||
return { category: 'behavior', section: 'Notifications' };
|
||||
}
|
||||
if (path === 'mpv.aniskipButtonKey') {
|
||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||
}
|
||||
@@ -478,6 +482,7 @@ function topSection(path: string): string {
|
||||
mpv: 'mpv Playback',
|
||||
stats: 'Stats dashboard',
|
||||
startupWarmups: 'Startup warmups',
|
||||
notifications: 'Notifications',
|
||||
subsync: 'Subtitle Sync',
|
||||
texthooker: 'Texthooker',
|
||||
updates: 'Updates',
|
||||
@@ -577,6 +582,7 @@ function subsectionForPath(path: string): string | undefined {
|
||||
if (
|
||||
leaf === 'toggleVisibleOverlayGlobal' ||
|
||||
leaf === 'toggleSubtitleSidebar' ||
|
||||
leaf === 'toggleNotificationHistory' ||
|
||||
leaf === 'toggleSecondarySub' ||
|
||||
leaf === 'toggleStatsOverlay' ||
|
||||
leaf === 'markWatched'
|
||||
@@ -687,6 +693,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'logging.level' ||
|
||||
path === 'logging.rotation' ||
|
||||
pathStartsWith(path, 'logging.files') ||
|
||||
pathStartsWith(path, 'notifications') ||
|
||||
path === 'youtube.primarySubLanguages' ||
|
||||
pathStartsWith(path, 'jimaku') ||
|
||||
pathStartsWith(path, 'subsync')
|
||||
@@ -710,7 +717,9 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||
...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}),
|
||||
control: controlForPath(leaf.path, leaf.value),
|
||||
defaultValue: leaf.value,
|
||||
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
|
||||
...(option?.settingsEnumValues || option?.enumValues
|
||||
? { enumValues: option.settingsEnumValues ?? option.enumValues }
|
||||
: {}),
|
||||
restartBehavior: restartBehaviorForPath(leaf.path),
|
||||
advanced:
|
||||
leaf.path.startsWith('controller.') ||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
JimakuMediaInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
OverlayNotificationPayload,
|
||||
} from '../../types';
|
||||
import { sortJimakuFiles } from '../../jimaku/utils';
|
||||
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
|
||||
@@ -40,6 +41,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -103,6 +105,8 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
options.createFieldGroupingCallback(),
|
||||
options.getKnownWordCacheStatePath(),
|
||||
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
|
||||
undefined,
|
||||
options.showOverlayNotification,
|
||||
);
|
||||
integration.start();
|
||||
options.setAnkiIntegration(integration);
|
||||
|
||||
@@ -2,6 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
|
||||
|
||||
function waitTurn(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
@@ -277,20 +281,80 @@ test('runAppReadyRuntime does not await background warmups', async () => {
|
||||
releaseWarmup();
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
|
||||
test('runAppReadyRuntime handles managed background initial args before deferred Yomitan wait', async () => {
|
||||
const calls: string[] = [];
|
||||
const { deps } = makeDeps({
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||
createMpvClient: () => calls.push('createMpvClient'),
|
||||
let releaseYomitan!: () => void;
|
||||
const yomitanGate = new Promise<void>((resolve) => {
|
||||
releaseYomitan = resolve;
|
||||
});
|
||||
const { deps } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension:start');
|
||||
await yomitanGate;
|
||||
calls.push('loadYomitanExtension:done');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('handleFirstRunSetup');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
} as Partial<AppReadyRuntimeDeps>);
|
||||
|
||||
const readyPromise = runAppReadyRuntime(deps);
|
||||
await waitTurn();
|
||||
|
||||
try {
|
||||
assert.ok(calls.includes('handleFirstRunSetup'));
|
||||
assert.ok(calls.includes('handleInitialArgs'));
|
||||
assert.equal(calls.includes('loadYomitanExtension:done'), false);
|
||||
} finally {
|
||||
releaseYomitan();
|
||||
await readyPromise;
|
||||
}
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomitan readiness', async () => {
|
||||
const calls: string[] = [];
|
||||
let releaseYomitan!: () => void;
|
||||
const yomitanGate = new Promise<void>((resolve) => {
|
||||
releaseYomitan = resolve;
|
||||
});
|
||||
const { deps } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => false,
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension:start');
|
||||
await yomitanGate;
|
||||
calls.push('loadYomitanExtension:done');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
} as Partial<AppReadyRuntimeDeps>);
|
||||
|
||||
const readyPromise = runAppReadyRuntime(deps);
|
||||
await waitTurn();
|
||||
|
||||
assert.equal(calls.includes('handleInitialArgs'), false);
|
||||
|
||||
releaseYomitan();
|
||||
await readyPromise;
|
||||
|
||||
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts background warmups after overlay startup', async () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient'));
|
||||
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
playbackFeedback: undefined,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
@@ -252,6 +253,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
showPlaybackFeedback: (text) => {
|
||||
calls.push(`feedback:${text}`);
|
||||
},
|
||||
log: (message) => {
|
||||
calls.push(`log:${message}`);
|
||||
},
|
||||
@@ -493,6 +497,15 @@ test('handleCliCommand reports async mine errors to OSD', async () => {
|
||||
assert.ok(osd.some((value) => value.includes('Mine sentence failed: boom')));
|
||||
});
|
||||
|
||||
test('handleCliCommand routes playback feedback through configured feedback surface', () => {
|
||||
const { deps, calls, osd } = createDeps();
|
||||
|
||||
handleCliCommand(makeArgs({ playbackFeedback: 'You can skip by pressing TAB' }), 'initial', deps);
|
||||
|
||||
assert.deepEqual(calls, ['initializeOverlayRuntime', 'feedback:You can skip by pressing TAB']);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleCliCommand applies socket path and connects on start', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
log: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
@@ -128,6 +129,7 @@ interface MpvCliRuntime {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getClient: () => MpvClientLike | null;
|
||||
showOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
}
|
||||
|
||||
interface TexthookerCliRuntime {
|
||||
@@ -295,6 +297,7 @@ export function createCliCommandDepsRuntime(
|
||||
hasMainWindow: options.app.hasMainWindow,
|
||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||
showMpvOsd: options.mpv.showOsd,
|
||||
showPlaybackFeedback: options.mpv.showPlaybackFeedback,
|
||||
log: options.log,
|
||||
logDebug: options.logDebug,
|
||||
warn: options.warn,
|
||||
@@ -546,6 +549,9 @@ export function handleCliCommand(
|
||||
'shiftSubDelayNextLine',
|
||||
'Shift subtitle delay failed',
|
||||
);
|
||||
} else if (args.playbackFeedback) {
|
||||
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
|
||||
showFeedback(args.playbackFeedback);
|
||||
} else if (args.cycleRuntimeOptionId !== undefined) {
|
||||
dispatchCliSessionAction(
|
||||
{
|
||||
|
||||
@@ -845,10 +845,22 @@ test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
|
||||
];
|
||||
|
||||
assert.deepEqual([...new Set(titles)], ['Jellyfin Video']);
|
||||
assert.equal(titles.some((title) => title.includes('api_key=')), false);
|
||||
assert.equal(titles.some((title) => title.includes('api key')), false);
|
||||
assert.equal(titles.some((title) => title.includes('secret-token')), false);
|
||||
assert.equal(titles.some((title) => title.includes('stream?')), false);
|
||||
assert.equal(
|
||||
titles.some((title) => title.includes('api_key=')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
titles.some((title) => title.includes('api key')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
titles.some((title) => title.includes('secret-token')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
titles.some((title) => title.includes('stream?')),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
|
||||
@@ -6,6 +6,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
const calls: string[] = [];
|
||||
const sentCommands: (string | number)[][] = [];
|
||||
const osd: string[] = [];
|
||||
const playbackFeedback: string[] = [];
|
||||
const options: Parameters<typeof handleMpvCommandFromIpc>[1] = {
|
||||
specialCommands: {
|
||||
SUBSYNC_TRIGGER: '__subsync-trigger',
|
||||
@@ -38,6 +39,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
showPlaybackFeedback: (text) => {
|
||||
playbackFeedback.push(text);
|
||||
},
|
||||
mpvReplaySubtitle: () => {
|
||||
calls.push('replay');
|
||||
},
|
||||
@@ -55,7 +59,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
...overrides,
|
||||
};
|
||||
return { options, calls, sentCommands, osd };
|
||||
return { options, calls, sentCommands, osd, playbackFeedback };
|
||||
}
|
||||
|
||||
test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
|
||||
@@ -65,41 +69,55 @@ test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for subtitle position keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
test('handleMpvCommandFromIpc routes show-text through playback feedback', () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||
handleMpvCommandFromIpc(['show-text', 'Primary subtitle: hover', '1500'], options);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Primary subtitle: hover']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits feedback for subtitle position keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||
handleMpvCommandFromIpc(['add', 'sub-pos', 1], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['add', 'sub-pos', 1]]);
|
||||
assert.deepEqual(osd, ['Subtitle position: ${sub-pos}']);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Subtitle position: ${sub-pos}']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits resolved osd for primary subtitle track keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
test('handleMpvCommandFromIpc emits resolved feedback for primary subtitle track keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions({
|
||||
resolveProxyCommandOsd: async () => 'Subtitle track: Internal #3 - Japanese (active)',
|
||||
});
|
||||
handleMpvCommandFromIpc(['cycle', 'sid'], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['cycle', 'sid']]);
|
||||
assert.deepEqual(osd, ['Subtitle track: Internal #3 - Japanese (active)']);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Subtitle track: Internal #3 - Japanese (active)']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits resolved osd for secondary subtitle track keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
test('handleMpvCommandFromIpc emits resolved feedback for secondary subtitle track keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions({
|
||||
resolveProxyCommandOsd: async () =>
|
||||
'Secondary subtitle track: External #8 - English Commentary',
|
||||
});
|
||||
handleMpvCommandFromIpc(['set_property', 'secondary-sid', 'auto'], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['set_property', 'secondary-sid', 'auto']]);
|
||||
assert.deepEqual(osd, ['Secondary subtitle track: External #8 - English Commentary']);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, [
|
||||
'Secondary subtitle track: External #8 - English Commentary',
|
||||
]);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for subtitle delay keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
test('handleMpvCommandFromIpc emits feedback for subtitle delay keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Subtitle delay: ${sub-delay}']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
mpvReplaySubtitle: () => void;
|
||||
mpvPlayNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
@@ -68,13 +69,14 @@ function showResolvedProxyCommandOsd(
|
||||
): void {
|
||||
const template = resolveProxyCommandOsdTemplate(command);
|
||||
if (!template) return;
|
||||
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd;
|
||||
|
||||
const emit = async () => {
|
||||
try {
|
||||
const resolved = await options.resolveProxyCommandOsd?.(command);
|
||||
options.showMpvOsd(resolved || template);
|
||||
showFeedback(resolved || template);
|
||||
} catch {
|
||||
options.showMpvOsd(template);
|
||||
showFeedback(template);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,6 +144,15 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (first === 'show-text') {
|
||||
const message = (typeof command[1] === 'string' ? command[1] : String(command[1] ?? '')).trim();
|
||||
if (message) {
|
||||
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd;
|
||||
showFeedback(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.isMpvConnected()) {
|
||||
if (first === options.specialCommands.REPLAY_SUBTITLE) {
|
||||
options.mpvReplaySubtitle();
|
||||
|
||||
@@ -137,6 +137,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async () => {},
|
||||
@@ -242,6 +243,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
@@ -552,6 +554,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
@@ -977,6 +980,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: (update) => {
|
||||
@@ -1058,6 +1062,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async (update) => {
|
||||
@@ -1262,6 +1267,44 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () =>
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers forwards valid overlay notification actions', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const actions: Array<{ notificationId: string; actionId: string; noteId?: number }> = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
handleOverlayNotificationAction: ((
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
noteId?: number,
|
||||
) => {
|
||||
actions.push({ notificationId, actionId, noteId });
|
||||
}) as IpcServiceDeps['handleOverlayNotificationAction'],
|
||||
} as Partial<IpcServiceDeps>),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const actionHandler = handlers.on.get(IPC_CHANNELS.command.overlayNotificationAction);
|
||||
assert.ok(actionHandler);
|
||||
|
||||
actionHandler({}, null);
|
||||
actionHandler({}, { notificationId: '', actionId: 'install-update' });
|
||||
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 42 });
|
||||
actionHandler(
|
||||
{},
|
||||
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: -1 },
|
||||
);
|
||||
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 'install-update' });
|
||||
actionHandler(
|
||||
{},
|
||||
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 },
|
||||
);
|
||||
|
||||
assert.deepEqual(actions, [
|
||||
{ notificationId: 'subminer-update-available', actionId: 'install-update', noteId: undefined },
|
||||
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
registerIpcHandlers(
|
||||
@@ -1289,6 +1332,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async () => {},
|
||||
|
||||
@@ -53,6 +53,11 @@ export interface IpcServiceDeps {
|
||||
interactive: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
handleOverlayNotificationAction?: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
noteId?: number,
|
||||
) => void | Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
@@ -80,6 +85,7 @@ export interface IpcServiceDeps {
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
getMarkWatchedKey: () => string;
|
||||
getOverlayNotificationPosition: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
@@ -223,6 +229,25 @@ function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | n
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOverlayNotificationActionPayload(
|
||||
payload: unknown,
|
||||
): { notificationId: string; actionId: string; noteId?: number } | null {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const notificationId = record.notificationId;
|
||||
const actionId = record.actionId;
|
||||
const noteId = record.noteId;
|
||||
if (typeof notificationId !== 'string' || notificationId.trim().length === 0) return null;
|
||||
if (typeof actionId !== 'string' || actionId.trim().length === 0) return null;
|
||||
if (
|
||||
noteId !== undefined &&
|
||||
(typeof noteId !== 'number' || !Number.isInteger(noteId) || noteId <= 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) };
|
||||
}
|
||||
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
@@ -242,6 +267,11 @@ export interface IpcDepsRuntimeOptions {
|
||||
interactive: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
handleOverlayNotificationAction?: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
noteId?: number,
|
||||
) => void | Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
@@ -262,6 +292,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
getMarkWatchedKey: () => string;
|
||||
getOverlayNotificationPosition: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
@@ -312,6 +343,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
|
||||
onOverlayInteractiveHint: options.onOverlayInteractiveHint,
|
||||
handleOverlayNotificationAction: options.handleOverlayNotificationAction,
|
||||
openYomitanSettings: options.openYomitanSettings,
|
||||
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
|
||||
quitApp: options.quitApp,
|
||||
@@ -349,6 +381,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
|
||||
getStatsToggleKey: options.getStatsToggleKey,
|
||||
getMarkWatchedKey: options.getMarkWatchedKey,
|
||||
getOverlayNotificationPosition: options.getOverlayNotificationPosition,
|
||||
getControllerConfig: options.getControllerConfig,
|
||||
saveControllerConfig: options.saveControllerConfig,
|
||||
saveControllerPreference: options.saveControllerPreference,
|
||||
@@ -473,6 +506,22 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||
deps.onOverlayModalOpened(parsedModal, senderWindow);
|
||||
});
|
||||
ipc.on(IPC_CHANNELS.command.overlayNotificationAction, (_event: unknown, payload: unknown) => {
|
||||
const parsedPayload = parseOverlayNotificationActionPayload(payload);
|
||||
if (!parsedPayload) return;
|
||||
void Promise.resolve(
|
||||
deps.handleOverlayNotificationAction?.(
|
||||
parsedPayload.notificationId,
|
||||
parsedPayload.actionId,
|
||||
parsedPayload.noteId,
|
||||
),
|
||||
).catch((error) => {
|
||||
console.warn(
|
||||
'Failed to handle overlay notification action:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.youtubePickerResolve,
|
||||
@@ -641,6 +690,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getMarkWatchedKey();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getOverlayNotificationPosition, () => {
|
||||
return deps.getOverlayNotificationPosition();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
||||
return deps.getControllerConfig();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AnkiConnectConfig,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
OverlayNotificationPayload,
|
||||
WindowGeometry,
|
||||
} from '../../types';
|
||||
|
||||
@@ -19,6 +20,7 @@ type CreateAnkiIntegrationArgs = {
|
||||
subtitleTimingTracker: unknown;
|
||||
mpvClient: { send?: (payload: { command: string[] }) => void };
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -61,6 +63,8 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
||||
args.createFieldGroupingCallback(),
|
||||
args.knownWordCacheStatePath,
|
||||
args.aiConfig,
|
||||
undefined,
|
||||
args.showOverlayNotification,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,6 +127,7 @@ export function initializeOverlayRuntime(
|
||||
getAnkiIntegration?: () => unknown | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -156,6 +161,7 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
getAnkiIntegration?: () => unknown | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -191,6 +197,7 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
subtitleTimingTracker,
|
||||
mpvClient,
|
||||
showDesktopNotification: options.showDesktopNotification,
|
||||
showOverlayNotification: options.showOverlayNotification,
|
||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,7 +154,127 @@ test('macOS keeps visible overlay hidden while tracker is not ready and emits on
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay stays hidden while tracker is not ready', () => {
|
||||
test('macOS dismisses overlay loading OSD when tracker recovers', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
const osdMessages: string[] = [];
|
||||
const dismissedOsds: string[] = [];
|
||||
let tracking = false;
|
||||
let geometry: WindowTrackerStub['getGeometry'] extends () => infer T ? T : never = null;
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => tracking,
|
||||
getGeometry: () => geometry,
|
||||
isTargetWindowFocused: () => tracking,
|
||||
};
|
||||
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
dismissOverlayLoadingOsd: () => {
|
||||
dismissedOsds.push('dismiss');
|
||||
},
|
||||
} as never);
|
||||
|
||||
run();
|
||||
tracking = true;
|
||||
geometry = { x: 0, y: 0, width: 1280, height: 720 };
|
||||
run();
|
||||
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...']);
|
||||
assert.deepEqual(dismissedOsds, ['dismiss']);
|
||||
assert.equal(trackerWarning, false);
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
});
|
||||
|
||||
test('tracked non-native overlay shows loading OSD until renderer content is visible', () => {
|
||||
const { window, calls, setContentReady } = createMainWindowRecorder();
|
||||
let loadingShown = false;
|
||||
const osdMessages: string[] = [];
|
||||
const dismissedOsds: string[] = [];
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => true,
|
||||
};
|
||||
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: loadingShown,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
loadingShown = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
dismissOverlayLoadingOsd: () => {
|
||||
dismissedOsds.push('dismiss');
|
||||
},
|
||||
} as never);
|
||||
|
||||
setContentReady(false);
|
||||
run();
|
||||
run();
|
||||
|
||||
assert.equal(loadingShown, true);
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...']);
|
||||
assert.deepEqual(dismissedOsds, []);
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('show-inactive'));
|
||||
|
||||
setContentReady(true);
|
||||
run();
|
||||
|
||||
assert.equal(loadingShown, false);
|
||||
assert.deepEqual(dismissedOsds, ['dismiss']);
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay stays hidden and emits loading OSD while tracker is not ready', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
const tracker: WindowTrackerStub = {
|
||||
@@ -197,7 +317,7 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
assert.ok(!calls.includes('osd'));
|
||||
assert.ok(calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
|
||||
|
||||
@@ -88,6 +88,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
isMacOSPlatform?: boolean;
|
||||
isWindowsPlatform?: boolean;
|
||||
showOverlayLoadingOsd?: (message: string) => void;
|
||||
dismissOverlayLoadingOsd?: () => void;
|
||||
shouldShowOverlayLoadingOsd?: () => boolean;
|
||||
markOverlayLoadingOsdShown?: () => void;
|
||||
resetOverlayLoadingOsdSuppression?: () => void;
|
||||
@@ -310,8 +311,18 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
!args.isWindowsPlatform &&
|
||||
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
|
||||
|
||||
const isWaitingForOverlayContentReady = (): boolean => {
|
||||
const hasWebContents =
|
||||
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
|
||||
return (
|
||||
!mainWindow.isVisible() &&
|
||||
hasWebContents &&
|
||||
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
|
||||
);
|
||||
};
|
||||
|
||||
const maybeShowOverlayLoadingOsd = (): void => {
|
||||
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
||||
if (!args.showOverlayLoadingOsd) {
|
||||
return;
|
||||
}
|
||||
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
|
||||
@@ -320,6 +331,9 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.showOverlayLoadingOsd('Overlay loading...');
|
||||
args.markOverlayLoadingOsdShown?.();
|
||||
};
|
||||
const maybeDismissOverlayLoadingOsd = (): void => {
|
||||
args.dismissOverlayLoadingOsd?.();
|
||||
};
|
||||
|
||||
const refreshNonNativeOverlayBoundsAfterFirstShow = (geometry: WindowGeometry | null): void => {
|
||||
if (
|
||||
@@ -350,6 +364,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
if (!args.visibleOverlayVisible) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.resetOverlayLoadingOsdSuppression?.();
|
||||
maybeDismissOverlayLoadingOsd();
|
||||
if (args.isWindowsPlatform) {
|
||||
clearPendingWindowsOverlayReveal(mainWindow);
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
@@ -371,7 +386,15 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
if (isWaitingForOverlayContentReady()) {
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
} else {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
maybeDismissOverlayLoadingOsd();
|
||||
}
|
||||
const geometry = args.windowTracker.getGeometry();
|
||||
if (geometry) {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
@@ -432,6 +455,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
(mainWindow.isVisible() || hasRetainedTrackedGeometry)
|
||||
) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
maybeDismissOverlayLoadingOsd();
|
||||
const geometry = args.windowTracker.getGeometry();
|
||||
if (geometry) {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
|
||||
@@ -116,6 +116,7 @@ export function createOverlayWindow(
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowDidFinishLoad?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void;
|
||||
yomitanSession?: Session | null;
|
||||
@@ -139,6 +140,7 @@ export function createOverlayWindow(
|
||||
window.webContents.on('did-finish-load', () => {
|
||||
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||
options.onRuntimeOptionsChanged();
|
||||
options.onWindowDidFinishLoad?.();
|
||||
});
|
||||
|
||||
window.webContents.on('page-title-updated', (event) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
mineSentenceCount: (count) => calls.push(`mine:${count}`),
|
||||
toggleSecondarySub: () => calls.push('secondary'),
|
||||
toggleSubtitleSidebar: () => calls.push('sidebar'),
|
||||
toggleNotificationHistory: () => calls.push('notification-history'),
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('audio');
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SessionActionExecutorDeps {
|
||||
mineSentenceCount: (count: number) => void;
|
||||
toggleSecondarySub: () => void;
|
||||
toggleSubtitleSidebar: () => void;
|
||||
toggleNotificationHistory: () => void;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
markActiveVideoWatched: () => Promise<boolean>;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -79,6 +80,9 @@ export async function dispatchSessionAction(
|
||||
case 'toggleSubtitleSidebar':
|
||||
deps.toggleSubtitleSidebar();
|
||||
return;
|
||||
case 'toggleNotificationHistory':
|
||||
deps.toggleNotificationHistory();
|
||||
return;
|
||||
case 'markAudioCard':
|
||||
await deps.markLastCardAsAudioCard();
|
||||
return;
|
||||
|
||||
@@ -26,6 +26,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -195,7 +196,10 @@ test('compileSessionBindings keeps mouse buttons scoped to keybindings', () => {
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
|
||||
assert.deepEqual(
|
||||
result.bindings.map((binding) => binding.sourcePath),
|
||||
['keybindings[0].key'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
|
||||
['unsupported:shortcuts.openJimaku'],
|
||||
|
||||
@@ -59,6 +59,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
|
||||
{ key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' },
|
||||
{ key: 'toggleNotificationHistory', actionId: 'toggleNotificationHistory' },
|
||||
];
|
||||
|
||||
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
|
||||
|
||||
@@ -269,7 +269,7 @@ test('runAppReadyRuntime loads Yomitan before headless overlay fallback initiali
|
||||
]);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
|
||||
test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
@@ -354,9 +354,10 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.ok(calls.indexOf('load-yomitan') !== -1);
|
||||
assert.ok(calls.indexOf('init-overlay') !== -1);
|
||||
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
|
||||
assert.ok(calls.indexOf('warmups') !== -1);
|
||||
assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups'));
|
||||
assert.equal(calls.includes('load-yomitan'), false);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
|
||||
|
||||
@@ -158,6 +158,7 @@ export interface AppReadyRuntimeDeps {
|
||||
shouldRunHeadlessInitialCommand?: () => boolean;
|
||||
shouldUseMinimalStartup?: () => boolean;
|
||||
shouldSkipHeavyStartup?: () => boolean;
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: () => boolean;
|
||||
}
|
||||
|
||||
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
|
||||
@@ -229,6 +230,31 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
const startupStartedAtMs = now();
|
||||
const ensureYomitanExtensionReady =
|
||||
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
|
||||
let firstRunSetupHandled = false;
|
||||
let initialArgsHandled = false;
|
||||
let backgroundWarmupsHandled = false;
|
||||
const handleFirstRunSetupOnce = async (): Promise<void> => {
|
||||
if (firstRunSetupHandled) {
|
||||
return;
|
||||
}
|
||||
firstRunSetupHandled = true;
|
||||
await deps.handleFirstRunSetup();
|
||||
};
|
||||
const handleInitialArgsOnce = (): void => {
|
||||
if (initialArgsHandled) {
|
||||
return;
|
||||
}
|
||||
initialArgsHandled = true;
|
||||
deps.handleInitialArgs();
|
||||
};
|
||||
const startBackgroundWarmupsOnce = (): void => {
|
||||
if (backgroundWarmupsHandled) {
|
||||
return;
|
||||
}
|
||||
backgroundWarmupsHandled = true;
|
||||
deps.startBackgroundWarmups();
|
||||
};
|
||||
|
||||
deps.ensureDefaultConfigBootstrap();
|
||||
if (deps.shouldRunHeadlessInitialCommand?.()) {
|
||||
deps.reloadConfig();
|
||||
@@ -247,7 +273,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
if (deps.shouldUseMinimalStartup?.()) {
|
||||
deps.reloadConfig();
|
||||
deps.handleInitialArgs();
|
||||
handleInitialArgsOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,8 +282,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.reloadConfig();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
return;
|
||||
}
|
||||
@@ -279,8 +305,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
for (const warning of deps.getConfigWarnings()) {
|
||||
deps.logConfigWarning(warning);
|
||||
}
|
||||
deps.startBackgroundWarmups();
|
||||
|
||||
deps.loadSubtitlePosition();
|
||||
deps.resolveKeybindings();
|
||||
deps.createMpvClient();
|
||||
@@ -326,16 +350,24 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
if (deps.texthookerOnlyMode) {
|
||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||
startBackgroundWarmupsOnce();
|
||||
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
deps.initializeOverlayRuntime();
|
||||
startBackgroundWarmupsOnce();
|
||||
} else {
|
||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||
await ensureYomitanExtensionReady();
|
||||
if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) {
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
startBackgroundWarmupsOnce();
|
||||
} else {
|
||||
startBackgroundWarmupsOnce();
|
||||
await ensureYomitanExtensionReady();
|
||||
}
|
||||
}
|
||||
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ConfiguredShortcuts {
|
||||
openControllerSelect: string | null | undefined;
|
||||
openControllerDebug: string | null | undefined;
|
||||
toggleSubtitleSidebar: string | null | undefined;
|
||||
toggleNotificationHistory: string | null | undefined;
|
||||
}
|
||||
|
||||
export function resolveConfiguredShortcuts(
|
||||
@@ -67,5 +68,6 @@ export function resolveConfiguredShortcuts(
|
||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
|
||||
toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')),
|
||||
toggleNotificationHistory: normalizeShortcut(shortcutValue('toggleNotificationHistory')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ export interface ConfiguredWindowsMpvLaunch {
|
||||
}
|
||||
|
||||
export function buildWindowsMpvPluginRuntimeConfig(
|
||||
config: Pick<ResolvedConfig, 'auto_start_overlay' | 'logging' | 'mpv' | 'texthooker'>,
|
||||
config: Pick<
|
||||
ResolvedConfig,
|
||||
'ankiConnect' | 'auto_start_overlay' | 'logging' | 'mpv' | 'texthooker'
|
||||
>,
|
||||
): SubminerPluginRuntimeScriptOptConfig {
|
||||
return {
|
||||
socketPath: config.mpv.socketPath,
|
||||
@@ -27,6 +30,9 @@ export function buildWindowsMpvPluginRuntimeConfig(
|
||||
autoStart: config.mpv.autoStartSubMiner,
|
||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||
osdMessages:
|
||||
config.ankiConnect.behavior.notificationType === 'osd' ||
|
||||
config.ankiConnect.behavior.notificationType === 'osd-system',
|
||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@ test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script
|
||||
autoStart: DEFAULT_CONFIG.mpv.autoStartSubMiner,
|
||||
autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay,
|
||||
autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup,
|
||||
});
|
||||
} finally {
|
||||
@@ -377,6 +378,7 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
|
||||
autoStart: false,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
});
|
||||
} finally {
|
||||
|
||||
+428
-50
@@ -64,6 +64,7 @@ import {
|
||||
type ForegroundSuppressionGraceState,
|
||||
mapOverlayMeasurementForPointerInteraction,
|
||||
resolveForegroundSuppressionWithGrace,
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement,
|
||||
tickLinuxOverlayPointerInteraction,
|
||||
} from './main/runtime/linux-overlay-pointer-interaction';
|
||||
import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point';
|
||||
@@ -140,9 +141,13 @@ import type {
|
||||
SubtitleData,
|
||||
SubtitleMiningContext,
|
||||
SubtitlePosition,
|
||||
OverlayNotificationPayload,
|
||||
OverlayNotificationEventPayload,
|
||||
NotificationType,
|
||||
UpdateChannel,
|
||||
WindowGeometry,
|
||||
} from './types';
|
||||
import { OPEN_ANKI_CARD_ACTION_ID } from './types';
|
||||
import { AnkiIntegration } from './anki-integration';
|
||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { RuntimeOptionsManager } from './runtime-options';
|
||||
@@ -189,6 +194,7 @@ import {
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
import {
|
||||
getStartupModeFlags,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||
shouldRefreshAnilistOnConfigReload,
|
||||
shouldStartAutomaticUpdateChecks,
|
||||
} from './main/runtime/startup-mode-flags';
|
||||
@@ -601,7 +607,21 @@ import {
|
||||
} from './main/runtime/update/release-assets';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy';
|
||||
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
|
||||
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
||||
import {
|
||||
INSTALL_UPDATE_ACTION_ID,
|
||||
notifyUpdateAvailable,
|
||||
UPDATE_AVAILABLE_NOTIFICATION_ID,
|
||||
} from './main/runtime/update/update-notifications';
|
||||
import { createOverlayLoadingOsdController } from './main/runtime/overlay-loading-osd';
|
||||
import { createMaybeStartOverlayLoadingOsdHandler } from './main/runtime/overlay-loading-osd-start';
|
||||
import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position';
|
||||
import { createOverlayNotificationDelivery } from './main/runtime/overlay-notification-delivery';
|
||||
import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
notifyConfiguredStatus,
|
||||
type ConfiguredStatusNotificationOptions,
|
||||
} from './main/runtime/configured-status-notification';
|
||||
import { resolveOverlayReadinessNotificationType } from './main/runtime/notification-routing';
|
||||
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
|
||||
import {
|
||||
runUpdateCliCommand,
|
||||
@@ -1234,7 +1254,7 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||
mainWindow.webContents.focus();
|
||||
}
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showYoutubeFlowStatusNotification(text),
|
||||
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
|
||||
notifyPrimarySubtitleLoaded: () =>
|
||||
youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(),
|
||||
@@ -1297,7 +1317,6 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
|
||||
},
|
||||
isSignalTargetReady: (signal) =>
|
||||
isTokenizationWarmupReady() &&
|
||||
isVisibleOverlayAutoplayTargetReady(
|
||||
{
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
@@ -1469,6 +1488,9 @@ function getMpvPluginRuntimeConfig() {
|
||||
autoStart: config.mpv.autoStartSubMiner,
|
||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||
osdMessages:
|
||||
config.ankiConnect.behavior.notificationType === 'osd' ||
|
||||
config.ankiConnect.behavior.notificationType === 'osd-system',
|
||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||
};
|
||||
}
|
||||
@@ -1714,7 +1736,7 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
|
||||
setSubsyncInProgress: (inProgress) => {
|
||||
appState.subsyncInProgress = inProgress;
|
||||
},
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showSubsyncStatusNotification(text),
|
||||
openManualPicker: (payload) => {
|
||||
openOverlayHostedModalWithOsd(
|
||||
(deps) => openSubsyncManualModalRuntime(deps, payload),
|
||||
@@ -1736,7 +1758,10 @@ const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntim
|
||||
const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler());
|
||||
const currentMediaTokenizationGate = createCurrentMediaTokenizationGate();
|
||||
const startupOsdSequencer = createStartupOsdSequencer({
|
||||
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||
showOsd: (message) => showMpvOsd(message),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
});
|
||||
const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
|
||||
@@ -1767,11 +1792,21 @@ function isYoutubePlaybackActiveNow(): boolean {
|
||||
}
|
||||
|
||||
function reportYoutubeSubtitleFailure(message: string): void {
|
||||
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||
if (type === 'osd' || type === 'both') {
|
||||
const type = getConfiguredStatusNotificationType();
|
||||
if (type === 'none') {
|
||||
return;
|
||||
}
|
||||
if (type === 'overlay' || type === 'both') {
|
||||
showOverlayNotification({
|
||||
title: 'SubMiner',
|
||||
body: message,
|
||||
variant: 'warning',
|
||||
});
|
||||
}
|
||||
if (type === 'osd' || type === 'osd-system') {
|
||||
showMpvOsd(message);
|
||||
}
|
||||
if (type === 'system' || type === 'both') {
|
||||
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||
try {
|
||||
showDesktopNotification('SubMiner', { body: message });
|
||||
} catch {
|
||||
@@ -1782,13 +1817,22 @@ function reportYoutubeSubtitleFailure(message: string): void {
|
||||
|
||||
async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
|
||||
if (youtubeFlowRuntime.hasActiveSession()) {
|
||||
showMpvOsd('YouTube subtitle flow already in progress.');
|
||||
showConfiguredStatusNotification('YouTube subtitle flow already in progress.', {
|
||||
title: 'YouTube subtitles',
|
||||
variant: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const currentMediaPath =
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
|
||||
if (!isYoutubePlaybackActiveNow() || !currentMediaPath) {
|
||||
showMpvOsd('YouTube subtitle picker is only available during YouTube playback.');
|
||||
showConfiguredStatusNotification(
|
||||
'YouTube subtitle picker is only available during YouTube playback.',
|
||||
{
|
||||
title: 'YouTube subtitles',
|
||||
variant: 'warning',
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
await youtubeFlowRuntime.openManualPicker({
|
||||
@@ -1861,10 +1905,16 @@ async function resolveSentenceSearchHeadwords(term: string): Promise<string[]> {
|
||||
function signalCurrentSubtitleAutoplayReady(): void {
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
const payload = getCurrentAutoplaySubtitlePayload();
|
||||
if (!payload) {
|
||||
if (payload) {
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
|
||||
return;
|
||||
}
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
|
||||
if (!appState.currentSubText.trim()) {
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(
|
||||
{ text: '__warm__', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
const buildSubtitleProcessingControllerMainDepsHandler =
|
||||
createBuildSubtitleProcessingControllerMainDepsHandler({
|
||||
@@ -1897,6 +1947,8 @@ let subtitleSidebarRequestedOpen = false;
|
||||
const SEEK_THRESHOLD_SECONDS = 3;
|
||||
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
||||
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
||||
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
|
||||
|
||||
function getCurrentAutoplayMediaPath(): string | null {
|
||||
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
|
||||
@@ -1971,6 +2023,7 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
},
|
||||
deferUncachedRefresh: true,
|
||||
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||
setCurrentSecondarySubText: (text) => {
|
||||
if (appState.mpvClient) {
|
||||
@@ -1986,6 +2039,38 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||
if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer);
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||
}
|
||||
|
||||
function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||
if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||
return;
|
||||
}
|
||||
if (!overlayManager.getVisibleOverlayVisible() || !appState.currentSubText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => {
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
const text = appState.currentSubText;
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
subtitlePrefetchService?.pause();
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
|
||||
}
|
||||
|
||||
async function primeAutoplaySubtitleFromParsedCues(
|
||||
mediaPath: string,
|
||||
cues: SubtitleCue[],
|
||||
@@ -2134,7 +2219,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||
|
||||
return windowTracker.isTargetWindowFocused();
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
||||
openRuntimeOptionsPalette: () => {
|
||||
openRuntimeOptionsPalette();
|
||||
},
|
||||
@@ -2177,7 +2262,9 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => {
|
||||
|
||||
const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler(
|
||||
{
|
||||
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||
showMpvOsd: (message) => showMpvOsd(message),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
},
|
||||
);
|
||||
@@ -2536,8 +2623,9 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
logWarn: (message) => logger.warn(message),
|
||||
onSyncStatus: (event) => {
|
||||
notifyCharacterDictionaryAutoSyncStatus(event, {
|
||||
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||
showOsd: (message) => showMpvOsd(message),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
startupOsdSequencer,
|
||||
});
|
||||
@@ -2614,7 +2702,10 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
isMacOSPlatform: () => process.platform === 'darwin',
|
||||
isWindowsPlatform: () => process.platform === 'win32',
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
showMpvOsd(message);
|
||||
showOverlayLoadingStatusNotification(message);
|
||||
},
|
||||
dismissOverlayLoadingOsd: () => {
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
},
|
||||
hideNonNativeOverlayWhenTargetUnfocused: () =>
|
||||
shouldRunLinuxOverlayZOrderKeepAlive() &&
|
||||
@@ -2643,6 +2734,7 @@ const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
|
||||
// subtitle pointer interaction. Right after playback starts the overlay can briefly become the
|
||||
// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s).
|
||||
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500;
|
||||
const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500;
|
||||
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
|
||||
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
@@ -2657,6 +2749,8 @@ const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState =
|
||||
};
|
||||
let visibleOverlayInteractionActive = false;
|
||||
let linuxOverlayInputShapeActive = false;
|
||||
let linuxVisibleOverlayStartupInputPrimed = false;
|
||||
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
|
||||
// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
|
||||
// moves off measured subtitle/sidebar rects onto the popup.
|
||||
@@ -2679,6 +2773,7 @@ const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHa
|
||||
function resetVisibleOverlayInputState(): void {
|
||||
visibleOverlayInteractionActive = false;
|
||||
linuxOverlayInputShapeActive = false;
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
linuxOverlayInteractiveHint = false;
|
||||
overlayContentMeasurementStore.clear('visible');
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
@@ -3151,6 +3246,23 @@ function shouldUseLinuxOverlayInputShape(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasLinuxVisibleOverlayStartupInputGrace(): boolean {
|
||||
return (
|
||||
process.platform === 'linux' &&
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs > 0 &&
|
||||
Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs
|
||||
);
|
||||
}
|
||||
|
||||
function clearLinuxVisibleOverlayStartupInputGrace(): void {
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||
}
|
||||
|
||||
function resetLinuxVisibleOverlayStartupInputPrimer(): void {
|
||||
linuxVisibleOverlayStartupInputPrimed = false;
|
||||
clearLinuxVisibleOverlayStartupInputGrace();
|
||||
}
|
||||
|
||||
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
|
||||
if (!shouldUseLinuxOverlayInputShape()) {
|
||||
linuxOverlayInputShapeActive = false;
|
||||
@@ -3189,6 +3301,28 @@ function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}
|
||||
|
||||
function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
if (linuxVisibleOverlayStartupInputPrimed) return;
|
||||
if (shouldUseLinuxOverlayInputShape()) return;
|
||||
if (
|
||||
!shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
linuxVisibleOverlayStartupInputPrimed = true;
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs =
|
||||
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
|
||||
updateLinuxOverlayPointerInteractionActive(true);
|
||||
}
|
||||
|
||||
const linuxOverlayZOrderKeepAliveDeps = {
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
@@ -3249,7 +3383,8 @@ const linuxOverlayPointerInteractionDeps = {
|
||||
getCursorScreenPoint: () =>
|
||||
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
|
||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||
getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
|
||||
getRendererInteractiveHint: () =>
|
||||
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
|
||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
|
||||
@@ -3296,6 +3431,177 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void {
|
||||
overlayManager.broadcastToOverlayWindows(channel, ...args);
|
||||
}
|
||||
|
||||
function isVisibleOverlayContentReady(): boolean {
|
||||
const overlayWindow = overlayManager.getMainWindow();
|
||||
return Boolean(
|
||||
overlayManager.getVisibleOverlayVisible() &&
|
||||
overlayWindow &&
|
||||
isOverlayWindowReadyForNotification(overlayWindow),
|
||||
);
|
||||
}
|
||||
|
||||
function getConfiguredStatusNotificationType(): NotificationType {
|
||||
const configuredType = getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
|
||||
}
|
||||
|
||||
function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
|
||||
if (window.isDestroyed() || !isOverlayWindowContentReady(window)) {
|
||||
return false;
|
||||
}
|
||||
if (window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const currentURL = window.webContents.getURL();
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
}
|
||||
|
||||
const overlayNotificationDelivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => isVisibleOverlayContentReady(),
|
||||
send: (payload) => {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
|
||||
},
|
||||
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
});
|
||||
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null = null;
|
||||
|
||||
function flushQueuedOverlayNotifications(): void {
|
||||
overlayNotificationDelivery.flush();
|
||||
}
|
||||
|
||||
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
|
||||
overlayNotificationDelivery.send(payload);
|
||||
}
|
||||
|
||||
function showOverlayNotification(payload: OverlayNotificationPayload): void {
|
||||
sendOverlayNotificationEvent(
|
||||
withConfiguredOverlayNotificationPosition(payload, getResolvedConfig()),
|
||||
);
|
||||
}
|
||||
|
||||
function dismissOverlayNotification(id: string): void {
|
||||
sendOverlayNotificationEvent({ id, dismiss: true });
|
||||
}
|
||||
|
||||
async function openAnkiCardFromNotification(noteId: number): Promise<void> {
|
||||
const activeIntegrationOpen = appState.ankiIntegration?.openNoteInAnki(noteId);
|
||||
if (activeIntegrationOpen) {
|
||||
await activeIntegrationOpen;
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedConfig = getResolvedConfig();
|
||||
const effectiveAnkiConfig =
|
||||
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
|
||||
resolvedConfig.ankiConnect;
|
||||
const fallbackClient = new AnkiConnectClient(
|
||||
effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url,
|
||||
);
|
||||
await fallbackClient.openNoteInBrowser(noteId);
|
||||
}
|
||||
|
||||
function toggleNotificationHistoryPanel(): void {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
|
||||
}
|
||||
|
||||
function showConfiguredStatusNotification(
|
||||
message: string,
|
||||
options: ConfiguredStatusNotificationOptions = {},
|
||||
): void {
|
||||
notifyConfiguredStatus(
|
||||
message,
|
||||
{
|
||||
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||
isOverlayReady: () => isVisibleOverlayContentReady(),
|
||||
showOsd: (text) => showMpvOsd(text),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, notificationOptions) =>
|
||||
showDesktopNotification(title, notificationOptions),
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
function showConfiguredPlaybackFeedback(
|
||||
message: string,
|
||||
options: ConfiguredStatusNotificationOptions = {},
|
||||
): void {
|
||||
showConfiguredStatusNotification(message, {
|
||||
...getPlaybackFeedbackNotificationOptions(message),
|
||||
...options,
|
||||
delivery: 'feedback',
|
||||
});
|
||||
}
|
||||
|
||||
function showSubsyncStatusNotification(message: string): void {
|
||||
const syncing = message.startsWith('Subsync: syncing');
|
||||
const failed = message.toLowerCase().includes('failed');
|
||||
showConfiguredStatusNotification(message, {
|
||||
id: 'subsync-status',
|
||||
title: 'Subsync',
|
||||
variant: failed ? 'error' : syncing ? 'progress' : 'info',
|
||||
persistent: syncing,
|
||||
desktop: !syncing,
|
||||
});
|
||||
}
|
||||
|
||||
function showYoutubeFlowStatusNotification(message: string): void {
|
||||
const progress =
|
||||
message.startsWith('Downloading subtitles') ||
|
||||
message.startsWith('Loading subtitles') ||
|
||||
message.startsWith('Getting subtitles') ||
|
||||
message === 'Opening YouTube video';
|
||||
showConfiguredStatusNotification(message, {
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
variant: progress ? 'progress' : 'info',
|
||||
persistent: progress,
|
||||
desktop: !progress,
|
||||
});
|
||||
}
|
||||
|
||||
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
|
||||
if (!overlayLoadingOsdController) {
|
||||
overlayLoadingOsdController = createOverlayLoadingOsdController({
|
||||
showOsd: (message) => {
|
||||
showMpvOsd(message);
|
||||
},
|
||||
clearOsd: () => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['show-text', '', '1']);
|
||||
},
|
||||
setInterval: (callback, delayMs) => {
|
||||
const timer = setInterval(callback, delayMs);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
},
|
||||
clearInterval: (timer) => {
|
||||
clearInterval(timer as ReturnType<typeof setInterval>);
|
||||
},
|
||||
});
|
||||
}
|
||||
return overlayLoadingOsdController;
|
||||
}
|
||||
|
||||
function showOverlayLoadingStatusNotification(message: string): void {
|
||||
void message;
|
||||
getOverlayLoadingOsdController().start();
|
||||
}
|
||||
|
||||
function dismissOverlayLoadingStatusNotification(): void {
|
||||
getOverlayLoadingOsdController().stop();
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-overlay-loading-ready']);
|
||||
dismissOverlayNotification('overlay-loading-status');
|
||||
}
|
||||
|
||||
const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({
|
||||
getVisibleOverlayRequested: () => overlayManager.getVisibleOverlayVisible(),
|
||||
isOverlayContentReady: () => isVisibleOverlayContentReady(),
|
||||
startOverlayLoadingOsd: () => {
|
||||
showOverlayLoadingStatusNotification('Overlay loading...');
|
||||
},
|
||||
});
|
||||
|
||||
const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
|
||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
@@ -3386,12 +3692,12 @@ function openOverlayHostedModalWithOsd(
|
||||
void openModal(createOverlayHostedModalOpenDeps())
|
||||
.then((opened) => {
|
||||
if (!opened) {
|
||||
showMpvOsd(unavailableMessage);
|
||||
showConfiguredStatusNotification(unavailableMessage, { variant: 'warning' });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(failureLogMessage, error);
|
||||
showMpvOsd(unavailableMessage);
|
||||
showConfiguredStatusNotification(unavailableMessage, { variant: 'error' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3422,7 +3728,7 @@ function openSessionHelpOverlay(): void {
|
||||
function openCharacterDictionaryManagerOverlay(): void {
|
||||
openCharacterDictionaryManagerWithConfigGate({
|
||||
isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||
openManager: () => {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openCharacterDictionaryManagerModalRuntime,
|
||||
@@ -3431,6 +3737,7 @@ function openCharacterDictionaryManagerOverlay(): void {
|
||||
);
|
||||
},
|
||||
showOsd: (message) => showMpvOsd(message),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
@@ -3454,7 +3761,10 @@ function openControllerDebugOverlay(): void {
|
||||
|
||||
function openPlaylistBrowser(): void {
|
||||
if (!appState.mpvClient?.connected) {
|
||||
showMpvOsd('Playlist browser requires active playback.');
|
||||
showConfiguredStatusNotification('Playlist browser requires active playback.', {
|
||||
title: 'Playlist browser',
|
||||
variant: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
openOverlayHostedModalWithOsd(
|
||||
@@ -3636,7 +3946,7 @@ const {
|
||||
void appState.jellyfinRemoteSession?.reportPlaying(payload);
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
showMpvOsd(text);
|
||||
showConfiguredStatusNotification(text, { title: 'Jellyfin' });
|
||||
},
|
||||
updateCurrentMediaTitle: (title) => {
|
||||
mediaRuntime.updateCurrentMediaTitle(title);
|
||||
@@ -3770,7 +4080,7 @@ const {
|
||||
}),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logError: (message, error) => logger.error(message, error),
|
||||
showMpvOsd: (message) => showMpvOsd(message),
|
||||
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'Jellyfin' }),
|
||||
clearSetupWindow: () => {
|
||||
appState.jellyfinSetupWindow = null;
|
||||
},
|
||||
@@ -3938,8 +4248,10 @@ const {
|
||||
registerSubminerProtocolClient,
|
||||
} = composeAnilistSetupHandlers({
|
||||
notifyDeps: {
|
||||
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||
hasMpvClient: () => Boolean(appState.mpvClient),
|
||||
showMpvOsd: (message) => showMpvOsd(message),
|
||||
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
logInfo: (message) => logger.info(message),
|
||||
},
|
||||
@@ -4266,7 +4578,7 @@ const {
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
rememberAnilistAttemptedUpdate(key);
|
||||
},
|
||||
showMpvOsd: (message) => showMpvOsd(message),
|
||||
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS,
|
||||
@@ -4939,6 +5251,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
shouldUseMinimalStartup: () =>
|
||||
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () =>
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs),
|
||||
createImmersionTracker: () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
},
|
||||
@@ -5017,7 +5331,7 @@ let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined)
|
||||
const {
|
||||
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
|
||||
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
|
||||
tokenizeSubtitle,
|
||||
tokenizeSubtitle: tokenizeSubtitleRuntime,
|
||||
createMecabTokenizerAndCheck,
|
||||
prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups,
|
||||
@@ -5040,6 +5354,7 @@ const {
|
||||
void reportJellyfinRemoteStopped();
|
||||
},
|
||||
onMpvConnected: () => {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
if (appState.sessionBindingsInitialized) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
@@ -5077,6 +5392,7 @@ const {
|
||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
|
||||
updateCurrentMediaPath: (path) => {
|
||||
const normalizedPath = path.trim();
|
||||
maybeStartOverlayLoadingOsd(normalizedPath);
|
||||
const previousPath = appState.currentMediaPath?.trim() || null;
|
||||
const preserveParsedSubtitleCues = isSameYoutubeMediaPath(
|
||||
normalizedPath,
|
||||
@@ -5332,13 +5648,13 @@ const {
|
||||
ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
|
||||
ensureFrequencyDictionaryLookup: () =>
|
||||
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
|
||||
showMpvOsd: (message: string) => showMpvOsd(message),
|
||||
showMpvOsd: (message: string) => showConfiguredStatusNotification(message),
|
||||
showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message),
|
||||
showLoadedOsd: (message: string) =>
|
||||
startupOsdSequencer.markAnnotationLoadingComplete(message),
|
||||
shouldShowOsdNotification: () => {
|
||||
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||
return type === 'osd' || type === 'both';
|
||||
const type = getConfiguredStatusNotificationType();
|
||||
return type === 'osd' || type === 'osd-system';
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -5391,6 +5707,14 @@ const {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
|
||||
if (!isTokenizationWarmupReady()) {
|
||||
startupOsdSequencer.showTokenizationLoading('Loading subtitle tokenization...');
|
||||
}
|
||||
return await tokenizeSubtitleRuntime(text);
|
||||
}
|
||||
|
||||
signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => isTokenizationWarmupReady(),
|
||||
startTokenizationWarmups: async () => {
|
||||
@@ -5424,6 +5748,7 @@ const aniSkipRuntime = createAniSkipRuntime({
|
||||
showMpvOsd: (text, durationMs) => {
|
||||
appState.mpvClient?.send({ command: ['show-text', text, durationMs] });
|
||||
},
|
||||
showPlaybackFeedback: (text) => showConfiguredPlaybackFeedback(text),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
@@ -5891,8 +6216,7 @@ function openYomitanSettings(): boolean {
|
||||
logger.warn(
|
||||
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
|
||||
);
|
||||
showDesktopNotification('SubMiner', { body: message });
|
||||
showMpvOsd(message);
|
||||
showConfiguredStatusNotification(message, { variant: 'warning' });
|
||||
return false;
|
||||
}
|
||||
openYomitanSettingsHandler();
|
||||
@@ -5979,7 +6303,7 @@ const {
|
||||
},
|
||||
numericShortcutRuntimeMainDeps: {
|
||||
globalShortcut,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
||||
clearTimer: (timer) => clearTimeout(timer),
|
||||
},
|
||||
@@ -6214,6 +6538,7 @@ function getUpdateService() {
|
||||
{ notificationType: getResolvedConfig().updates.notificationType, version },
|
||||
{
|
||||
showSystemNotification: (title, body) => showDesktopNotification(title, { body }),
|
||||
showOverlayNotification,
|
||||
showOsdNotification: (message) => {
|
||||
showMpvOsd(message);
|
||||
},
|
||||
@@ -6238,7 +6563,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
|
||||
broadcastToOverlayWindows: (channel, mode) => {
|
||||
broadcastToOverlayWindows(channel, mode);
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
},
|
||||
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
|
||||
});
|
||||
@@ -6275,7 +6600,7 @@ const buildUpdateLastCardFromClipboardMainDepsHandler =
|
||||
createBuildUpdateLastCardFromClipboardMainDepsHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
readClipboardText: () => clipboard.readText(),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
updateLastCardFromClipboardCore,
|
||||
});
|
||||
const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler();
|
||||
@@ -6294,7 +6619,7 @@ const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler(
|
||||
|
||||
const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
triggerFieldGroupingCore,
|
||||
});
|
||||
const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler();
|
||||
@@ -6303,7 +6628,7 @@ const triggerFieldGroupingHandler = createTriggerFieldGroupingHandler(triggerFie
|
||||
const buildMarkLastCardAsAudioCardMainDepsHandler =
|
||||
createBuildMarkLastCardAsAudioCardMainDepsHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
markLastCardAsAudioCardCore,
|
||||
});
|
||||
const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler();
|
||||
@@ -6314,7 +6639,7 @@ const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler(
|
||||
const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
mineSentenceCardCore,
|
||||
recordCardsMined: (count, noteIds) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
@@ -6328,7 +6653,7 @@ const mineSentenceCardHandler = createMineSentenceCardHandler(
|
||||
const buildHandleMultiCopyDigitMainDepsHandler = createBuildHandleMultiCopyDigitMainDepsHandler({
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
writeClipboardText: (text) => clipboard.writeText(text),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||
handleMultiCopyDigitCore,
|
||||
});
|
||||
const handleMultiCopyDigitMainDeps = buildHandleMultiCopyDigitMainDepsHandler();
|
||||
@@ -6337,7 +6662,7 @@ const handleMultiCopyDigitHandler = createHandleMultiCopyDigitHandler(handleMult
|
||||
const buildCopyCurrentSubtitleMainDepsHandler = createBuildCopyCurrentSubtitleMainDepsHandler({
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
writeClipboardText: (text) => clipboard.writeText(text),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
copyCurrentSubtitleCore,
|
||||
});
|
||||
const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler();
|
||||
@@ -6348,7 +6673,7 @@ const buildHandleMineSentenceDigitMainDepsHandler =
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
logError: (message, err) => {
|
||||
logger.error(message, err);
|
||||
},
|
||||
@@ -6391,7 +6716,7 @@ const buildAppendClipboardVideoToQueueMainDepsHandler =
|
||||
appendClipboardVideoToQueueRuntime,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
readClipboardText: () => clipboard.readText(),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
sendMpvCommand: (command) => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
},
|
||||
@@ -6530,7 +6855,7 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
||||
logger.warn('Failed to save Jellyfin subtitle delay.');
|
||||
}
|
||||
},
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||
});
|
||||
|
||||
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
||||
@@ -6556,6 +6881,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
mineSentenceCount: (count) => handleMineSentenceDigit(count),
|
||||
toggleSecondarySub: () => handleCycleSecondarySubMode(),
|
||||
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
|
||||
toggleNotificationHistory: () => toggleNotificationHistoryPanel(),
|
||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
||||
markActiveVideoWatched: async () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
@@ -6587,12 +6913,12 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
}
|
||||
return applyRuntimeOptionResultRuntime(
|
||||
appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||
(text) => showMpvOsd(text),
|
||||
(text) => showConfiguredPlaybackFeedback(text),
|
||||
);
|
||||
},
|
||||
playNextPlaylistItem: () =>
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6614,10 +6940,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
return applyRuntimeOptionResultRuntime(
|
||||
appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||
(text) => showMpvOsd(text),
|
||||
(text) => showConfiguredPlaybackFeedback(text),
|
||||
);
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
||||
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
@@ -6633,7 +6960,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
registration: {
|
||||
runtimeOptions: {
|
||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
},
|
||||
mainDeps: {
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
@@ -6703,6 +7030,30 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
linuxOverlayInteractiveHint = interactive;
|
||||
applyLinuxOverlayInputShapeFromLatestMeasurement();
|
||||
},
|
||||
handleOverlayNotificationAction: (notificationId, actionId, noteId) => {
|
||||
if (
|
||||
notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID &&
|
||||
actionId === INSTALL_UPDATE_ACTION_ID
|
||||
) {
|
||||
void getUpdateService()
|
||||
.checkForUpdates({
|
||||
source: 'manual',
|
||||
installWhenAvailable: true,
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to install update from overlay notification action:', error);
|
||||
});
|
||||
}
|
||||
if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) {
|
||||
void openAnkiCardFromNotification(noteId).catch((error) => {
|
||||
logger.warn('Failed to open Anki card from overlay notification action:', error);
|
||||
showConfiguredStatusNotification('Failed to open Anki card in Anki.', {
|
||||
id: 'open-anki-card-failed',
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context),
|
||||
@@ -6714,9 +7065,14 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
currentSubText: appState.currentSubText,
|
||||
currentSubtitleData: appState.currentSubtitleData,
|
||||
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
|
||||
tokenizeUncached: false,
|
||||
tokenizeSubtitle: tokenizeSubtitleForCurrent
|
||||
? (text) => tokenizeSubtitleForCurrent(text)
|
||||
: undefined,
|
||||
onResolvedSubtitle: (payload) => {
|
||||
appState.currentSubtitleData = payload;
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
|
||||
},
|
||||
});
|
||||
},
|
||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||
@@ -6840,6 +7196,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
|
||||
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
|
||||
getOverlayNotificationPosition: () => getResolvedConfig().notifications.overlayPosition,
|
||||
getControllerConfig: () => getResolvedConfig().controller,
|
||||
saveControllerConfig: (update) => {
|
||||
const currentRawConfig = configService.getRawConfig();
|
||||
@@ -6862,7 +7219,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
if (overlayContentMeasurementStore.report(payload)) {
|
||||
tickLinuxOverlayPointerInteractionNow();
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
}
|
||||
},
|
||||
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
|
||||
@@ -6970,6 +7329,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
},
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
showDesktopNotification,
|
||||
showOverlayNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
||||
getFieldGroupingResolver: () => getFieldGroupingResolver(),
|
||||
@@ -7006,7 +7366,8 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
openExternal: (url: string) => shell.openExternal(url),
|
||||
logBrowserOpenError: (url: string, error: unknown) =>
|
||||
logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
||||
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
|
||||
@@ -7231,11 +7592,14 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
linuxVisibleOverlayWindowMode === 'fullscreen-override',
|
||||
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
||||
onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(),
|
||||
onWindowDidFinishLoad: () => {
|
||||
flushQueuedOverlayNotifications();
|
||||
},
|
||||
onWindowContentReady: () => {
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
flushQueuedOverlayNotifications();
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
if (appState.currentSubText.trim()) {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
}
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
},
|
||||
onWindowClosed: (windowKind, window) => {
|
||||
@@ -7274,7 +7638,8 @@ function getJellyfinTrayDiscoveryDeps() {
|
||||
startRemoteSession: (options: { explicit: true }) => startJellyfinRemoteSession(options),
|
||||
refreshTrayMenu: () => refreshTrayMenuIfPresent(),
|
||||
logger,
|
||||
showMpvOsd: (message: string) => showMpvOsd(message),
|
||||
showMpvOsd: (message: string) =>
|
||||
showConfiguredStatusNotification(message, { title: 'Jellyfin' }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7421,6 +7786,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
getOverlayWindows: () => getOverlayWindows(),
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
showDesktopNotification,
|
||||
showOverlayNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
shouldStartAnkiIntegration: () =>
|
||||
@@ -7512,11 +7878,15 @@ function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void {
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
resetVisibleOverlayInputState();
|
||||
}
|
||||
if (visible) {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void primeCurrentSubtitleForVisibleOverlay();
|
||||
@@ -7530,10 +7900,14 @@ function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
||||
if (!nextVisible) {
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
resetVisibleOverlayInputState();
|
||||
} else {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void primeCurrentSubtitleForVisibleOverlay();
|
||||
@@ -7544,11 +7918,15 @@ function toggleVisibleOverlay(): void {
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
if (!visible) {
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
resetVisibleOverlayInputState();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void primeCurrentSubtitleForVisibleOverlay();
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
shouldRunHeadlessInitialCommand?: AppReadyRuntimeDeps['shouldRunHeadlessInitialCommand'];
|
||||
shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup'];
|
||||
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: AppReadyRuntimeDeps['shouldHandleInitialArgsBeforeDeferredOverlayWarmup'];
|
||||
}
|
||||
|
||||
export function createAppLifecycleRuntimeDeps(
|
||||
@@ -133,6 +134,8 @@ export function createAppReadyRuntimeDeps(
|
||||
shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand,
|
||||
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup:
|
||||
params.shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getClient: CliCommandRuntimeServiceDepsParams['mpv']['getClient'];
|
||||
showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd'];
|
||||
showPlaybackFeedback?: CliCommandRuntimeServiceDepsParams['mpv']['showPlaybackFeedback'];
|
||||
getTexthookerPort: () => number;
|
||||
setTexthookerPort: (port: number) => void;
|
||||
getTexthookerWebsocketUrl: () => string | undefined;
|
||||
@@ -74,6 +75,7 @@ function createCliCommandDepsFromContext(
|
||||
setSocketPath: context.setSocketPath,
|
||||
getClient: context.getClient,
|
||||
showOsd: context.showOsd,
|
||||
showPlaybackFeedback: context.showPlaybackFeedback,
|
||||
},
|
||||
texthooker: {
|
||||
service: context.texthookerService,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RuntimeOptionId, RuntimeOptionValue, SubsyncManualPayload } from '../types';
|
||||
import type { OverlayNotificationPayload } from '../types/notification';
|
||||
import { SubsyncResolvedConfig } from '../subsync/utils';
|
||||
import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner';
|
||||
import type { IpcDepsRuntimeOptions } from '../core/services/ipc';
|
||||
@@ -59,6 +60,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
|
||||
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
|
||||
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
|
||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
@@ -82,6 +84,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
|
||||
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
|
||||
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
|
||||
getOverlayNotificationPosition: IpcDepsRuntimeOptions['getOverlayNotificationPosition'];
|
||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
|
||||
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
||||
@@ -124,6 +127,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
||||
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
|
||||
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
|
||||
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
|
||||
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
|
||||
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
|
||||
@@ -145,6 +149,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
setSocketPath: CliCommandDepsRuntimeOptions['mpv']['setSocketPath'];
|
||||
getClient: CliCommandDepsRuntimeOptions['mpv']['getClient'];
|
||||
showOsd: CliCommandDepsRuntimeOptions['mpv']['showOsd'];
|
||||
showPlaybackFeedback?: CliCommandDepsRuntimeOptions['mpv']['showPlaybackFeedback'];
|
||||
};
|
||||
texthooker: {
|
||||
service: CliCommandDepsRuntimeOptions['texthooker']['service'];
|
||||
@@ -221,6 +226,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
|
||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
|
||||
@@ -240,6 +246,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
|
||||
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
|
||||
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
|
||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
@@ -261,6 +268,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
dispatchSessionAction: params.dispatchSessionAction,
|
||||
getStatsToggleKey: params.getStatsToggleKey,
|
||||
getMarkWatchedKey: params.getMarkWatchedKey,
|
||||
getOverlayNotificationPosition: params.getOverlayNotificationPosition,
|
||||
getControllerConfig: params.getControllerConfig,
|
||||
saveControllerConfig: params.saveControllerConfig,
|
||||
saveControllerPreference: params.saveControllerPreference,
|
||||
@@ -309,6 +317,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
||||
setAnkiIntegration: params.setAnkiIntegration,
|
||||
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
|
||||
showDesktopNotification: params.showDesktopNotification,
|
||||
showOverlayNotification: params.showOverlayNotification,
|
||||
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
||||
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
|
||||
getFieldGroupingResolver: params.getFieldGroupingResolver,
|
||||
@@ -334,6 +343,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
setSocketPath: params.mpv.setSocketPath,
|
||||
getClient: params.mpv.getClient,
|
||||
showOsd: params.mpv.showOsd,
|
||||
showPlaybackFeedback: params.mpv.showPlaybackFeedback,
|
||||
},
|
||||
texthooker: {
|
||||
service: params.texthooker.service,
|
||||
@@ -414,6 +424,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
||||
openPlaylistBrowser: params.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||
showMpvOsd: params.showMpvOsd,
|
||||
showPlaybackFeedback: params.showPlaybackFeedback,
|
||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
replayCurrentSubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
@@ -41,6 +42,7 @@ export function handleMpvCommandFromIpcRuntime(
|
||||
openPlaylistBrowser: deps.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
showPlaybackFeedback: deps.showPlaybackFeedback,
|
||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||
mpvPlayNextSubtitle: deps.playNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user