Compare commits

..

11 Commits

Author SHA1 Message Date
sudacode 5a98397efe chore: prepare 0.17.0-beta.1 prerelease 2026-06-14 17:34:05 -07:00
sudacode a117c5759c fix(linux): auto-install managed plugin copy; include in asset updates (#127) 2026-06-14 17:25:28 -07:00
sudacode ae7e6f82a8 fix(overlay): restore macOS Yomitan popup focus without breaking click-away (#125) 2026-06-14 16:46:13 -07:00
sudacode 1158be5b39 fix(overlay): preserve visible state across playlist item transitions (#124) 2026-06-12 23:38:54 -07:00
sudacode 33e767458f refactor(main): split main.ts into focused runtime modules (#123) 2026-06-12 17:35:46 -07:00
sudacode 94a65416ae fix(stats): strip Season N suffix from AniList title searches (#121) 2026-06-12 01:07:11 -07:00
sudacode 0a384a22c9 Replace subtitle delay actions with native mpv keybindings (#120) 2026-06-12 00:03:06 -07:00
sudacode b3b45521b6 fix(release): preserve attribution placement; default update notifs to o
- Move What's Changed/New Contributors before Installation in release notes
- Preserve committed attribution when regenerating via writeReleaseNotesForVersion
- Change notificationType default from 'both' to 'overlay' for new installs
2026-06-10 23:53:31 -07:00
sudacode 131b23efa9 chore(release): prepare v0.16.0 2026-06-10 23:05:37 -07:00
sudacode e2afceb492 fix(anki): write sentence card audio only to sentence audio field (#118) 2026-06-10 22:51:19 -07:00
sudacode 7be1843c41 feat(notifications): add overlay notifications with position config (#110) 2026-06-10 22:46:52 -07:00
277 changed files with 15215 additions and 3094 deletions
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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:
+48
View File
@@ -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
View File
@@ -1 +0,0 @@
AGENTS.md
@@ -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.
-6
View File
@@ -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.
-5
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: notifications
- Restored the SubMiner app icon on system notifications that do not provide a custom notification image.
@@ -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: docs
area: updates
- Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets if either one is missing.
+6
View File
@@ -0,0 +1,6 @@
type: fixed
area: updates
- Fixed Linux updates so the managed support-asset install now creates and refreshes both the launcher runtime plugin copy and the rofi theme alongside AppImage and launcher updates.
- Fixed Linux support-asset refreshes so unrelated SubMiner data directories are left alone and plugin copies are staged before replacing the live runtime plugin.
- Fixed first Linux launcher playback on fresh installs by auto-installing the managed runtime plugin copy and rofi theme from the bundled app before mpv starts when either support asset is missing.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: overlay
- Fixed macOS Yomitan popup focus after card mining or popup reload while still allowing click-away to close the popup without a hide/reappear cycle.
- Fixed macOS Yomitan popups staying open when clicking transparent overlay space; click-away is captured for popup close, then passthrough returns to mpv.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed auto-paused Linux visible overlay startup so the overlay stays interactive during the first measurement gap, startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv `sub-text` refreshes parsed cues before synthetic warm readiness can resume playback.
@@ -0,0 +1,4 @@
type: changed
area: overlay
- Updated default overlay subtitle delay/step bindings to match mpv: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show subtitle delay on the OSD. Removed the old SubMiner-only adjacent-cue delay action.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept the visible overlay active while mpv advances to the next playlist item, even when the next episode loads after the warm transition delay.
@@ -0,0 +1,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.
-6
View File
@@ -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.
-4
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
type: internal
area: runtime
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
- Hardened split runtime helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Fixed manual AniList linking from the stats anime page so automatic searches drop the generated `Season N` suffix and search only the anime title.
-9
View File
@@ -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.
+43 -7
View File
@@ -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.
// ==========================================
@@ -280,15 +290,41 @@
] // Command setting.
},
{
"key": "Shift+BracketRight", // Key setting.
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
"command": [
"__sub-delay-next-line"
"sub-step",
-1
] // Command setting.
},
{
"key": "Shift+BracketLeft", // Key setting.
"key": "Ctrl+Shift+ArrowRight", // Key setting.
"command": [
"__sub-delay-prev-line"
"sub-step",
1
] // Command setting.
},
{
"key": "KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
-0.1
] // Command setting.
},
{
"key": "Shift+KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting.
},
{
"key": "KeyX", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting.
},
{
@@ -539,7 +575,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": {
+6 -2
View File
@@ -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
View File
@@ -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>
+2
View File
@@ -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?
+48 -14
View File
@@ -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,19 +197,47 @@ 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"`. |
| `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
Control whether the overlay automatically becomes visible when it connects to mpv:
@@ -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`.
@@ -543,7 +572,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
**Default keybindings:**
| Key | Command | Description |
| -------------------- | ----------------------------- | --------------------------------------- |
| ----------------------- | ----------------------------- | --------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
@@ -556,8 +585,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
| `Ctrl+Shift+ArrowLeft` | `["sub-step", -1]` | Shift subtitle delay to previous cue |
| `Ctrl+Shift+ArrowRight` | `["sub-step", 1]` | Shift subtitle delay to next cue |
| `KeyZ` | `["add", "sub-delay", -0.1]` | Shift subtitles 100 ms earlier |
| `Shift+KeyZ` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
| `KeyX` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
| `KeyQ` | `["quit"]` | Quit mpv |
@@ -587,11 +619,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "Space", "command": null }
```
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) and subtitle delay commands (`sub-delay`), SubMiner also shows an mpv OSD notification after the command runs.
Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notification after the command runs. Subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) show playback feedback through the configured notification surface.
**See `config.example.jsonc`** for more keybinding examples and configuration options.
@@ -620,13 +652,14 @@ 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"`) |
@@ -645,6 +678,7 @@ See `config.example.jsonc` for detailed configuration options.
| `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.
@@ -944,7 +978,7 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
@@ -989,7 +1023,7 @@ This example is intentionally compact. The option table below documents availabl
| `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.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`. |
@@ -1459,14 +1493,14 @@ 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`) |
| `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"`) |
+13 -8
View File
@@ -13,7 +13,7 @@ Three steps to get started:
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
| Dependency | Status | What it does |
| -------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
@@ -300,11 +300,11 @@ subminer -u
subminer --update
```
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer` plus the rofi theme at `SubMiner/themes/subminer.rasi`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes the managed support assets from `subminer-assets.tar.gz`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
`subminer -u` also performs the AppImage update directly from the launcher process, which is useful when SubMiner is not currently running.
`subminer -u` also performs the AppImage, launcher, and managed support-asset updates directly from the launcher process, which is useful when SubMiner is not currently running.
## How It All Fits Together
@@ -312,13 +312,14 @@ SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IP
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher now checks for its managed runtime plugin copy and rofi theme before every mpv-managed launch and installs those support assets from the bundled app automatically if either one is missing. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
## Platform Notes
### macOS
**MeCab paths (Homebrew):**
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
- Intel: `/usr/local/bin/mecab`
@@ -361,17 +362,21 @@ sudo chmod +x /usr/local/bin/subminer
## Optional Extras
### Rofi Theme (Linux Only)
### Linux Support Assets
SubMiner ships a custom rofi theme in the release assets:
SubMiner ships the Linux rofi theme plus the launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
mkdir -p ~/.local/share/SubMiner/plugin
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
```
Override with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs the managed runtime plugin copy and rofi theme from the bundled app if either support asset is missing, so manual extraction is mainly useful for pre-seeding or custom setups.
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
Next: [Usage](/usage) - learn about the `subminer` wrapper, keybindings, and YouTube playback.
+6 -2
View File
@@ -34,15 +34,19 @@ subminer -R -r -d ~/Anime # rofi picker, recursive
subminer -R /directory # rofi picker, directory shortcut
```
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships a custom rofi theme bundled in the release assets tarball:
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme plus the Linux launcher-managed runtime plugin copy in the release assets tarball:
```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
mkdir -p ~/.local/share/SubMiner/plugin
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
```
Once the `SubMiner` data dir exists, `subminer -u` refreshes both assets automatically. Normal Linux launcher playback also checks for the managed runtime plugin copy and rofi theme before mpv launch and installs them from the bundled app automatically if either one is missing.
The theme is auto-detected from these paths (first match wins):
- `$SUBMINER_ROFI_THEME` environment variable (absolute path)
@@ -113,7 +117,7 @@ Use `subminer <subcommand> -h` for command-specific help.
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, and rofi theme even when SubMiner is already running in the tray.
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, runtime plugin copy, and rofi theme even when SubMiner is already running in the tray.
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
+5 -3
View File
@@ -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.
@@ -10,6 +10,8 @@ The plugin ships as a modular Lua package under `plugin/subminer/` (entry point
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
On Linux, the launcher-managed runtime plugin copy lives under the SubMiner data dir (`$XDG_DATA_HOME/SubMiner/plugin/subminer` by default, plus `/usr/local/share/SubMiner` or `/usr/share/SubMiner` for system installs). `subminer -u` and the tray updater keep that managed copy current. This is separate from mpv's global `scripts/` directory.
If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available.
mpv must have IPC enabled for SubMiner to connect:
@@ -30,7 +32,7 @@ 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 |
@@ -151,7 +153,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.
+43 -7
View File
@@ -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.
// ==========================================
@@ -280,15 +290,41 @@
] // Command setting.
},
{
"key": "Shift+BracketRight", // Key setting.
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
"command": [
"__sub-delay-next-line"
"sub-step",
-1
] // Command setting.
},
{
"key": "Shift+BracketLeft", // Key setting.
"key": "Ctrl+Shift+ArrowRight", // Key setting.
"command": [
"__sub-delay-prev-line"
"sub-step",
1
] // Command setting.
},
{
"key": "KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
-0.1
] // Command setting.
},
{
"key": "Shift+KeyZ", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting.
},
{
"key": "KeyX", // Key setting.
"command": [
"add",
"sub-delay",
0.1
] // Command setting.
},
{
@@ -539,7 +575,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": {
+10 -6
View File
@@ -44,7 +44,7 @@ The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcu
These control playback and subtitle display. They require overlay window focus.
| Shortcut | Action |
| -------------------- | --------------------------------------------------- |
| -------------------- | ---------------------------------------------------------- |
| `Space` | Toggle mpv pause |
| `F` | Toggle fullscreen |
| `V` | Cycle primary subtitle bar mode (hidden → visible → hover) |
@@ -57,8 +57,11 @@ These control playback and subtitle display. They require overlay window focus.
| `ArrowDown` | Seek backward 60 seconds |
| `Shift+H` | Jump to previous subtitle |
| `Shift+L` | Jump to next subtitle |
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
| `Shift+]` | Shift subtitle delay to next subtitle cue |
| `Ctrl+Shift+Left` | Shift subtitle delay to previous subtitle cue |
| `Ctrl+Shift+Right` | Shift subtitle delay to next subtitle cue |
| `z` | Shift subtitles 100 ms earlier |
| `Shift+Z` | Delay subtitles by 100 ms |
| `x` | Delay subtitles by 100 ms |
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
| `Q` | Quit mpv |
@@ -67,7 +70,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
@@ -76,12 +79,13 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ----------------------------------------------- |
| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `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` |
@@ -108,7 +112,7 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key within 1 second.
| Chord | Action |
| ----- | -------------------------------------- |
| ----- | ---------------------------------------------------------- |
| `y-y` | Open SubMiner menu (OSD) |
| `y-s` | Start overlay |
| `y-S` | Stop overlay |
+1 -1
View File
@@ -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
+3 -1
View File
@@ -54,7 +54,7 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
## Live Config Reload
@@ -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
View File
@@ -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 `## Whats Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
@@ -61,7 +61,7 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
#### Subtitle File Parsing
A new cue parser that extracts both timing and text content from subtitle files. The existing `parseSrtOrVttStartTimes` in `subtitle-delay-shift.ts` only extracts timing; this needs a companion that also extracts the dialogue text.
A cue parser extracts both timing and text content from subtitle files for prefetching.
**Parsed cue structure:**
```typescript
+39 -1
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming
Status: active
Last verified: 2026-06-01
Last verified: 2026-06-14
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
@@ -64,6 +64,35 @@ 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 the startup subtitle has no cached annotations yet, autoplay priming emits a raw first-paint
subtitle payload before background tokenization. The tokenized payload replaces it when ready, but
the visible overlay can paint and measure the line before the mpv startup gate resumes playback.
- If startup `sub-text` is temporarily empty, autoplay priming refreshes the active subtitle source
and then awaits cue-based priming before synthetic warm readiness can proceed. A parsed current or
imminent cue is treated as the startup subtitle so the visible overlay can paint and measure it
before playback resumes.
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
tokenization warmup and visible overlay content-ready. This releases playback without waiting for
a later subtitle event that cannot happen while mpv is paused.
- After a synthetic warm readiness release, SubMiner briefly polls/refreshes the current subtitle
again. This covers Linux/mpv startup cases where `sub-text` is still empty while paused but becomes
available right after playback resumes, without waiting for the next subtitle property change.
## Linux/X11 Window Shape
- `restoreLinuxOverlayWindowShape()` reads `BrowserWindow.getBounds()` and calls `setShape()` with
@@ -76,6 +105,15 @@ prefetch work and re-centers prefetch around the live playback time.
overlay window remained mapped above mpv.
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
the Linux cursor-poll fallback, not bounding-shape clipping.
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
interaction state is already inactive. The next cursor-poll tick must still reapply
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
full-window input capture and block both mpv and overlay controls before the first subtitle
measurement.
- Visible-overlay show also starts a short Linux input grace before the first content measurement.
Native Wayland surfaces can become inert while `setIgnoreMouseEvents(true)` is active; keeping the
overlay interactive during this startup gap lets notifications and overlay mouse bindings work
until subtitle/sidebar/notification rectangles are reported.
## Config And Migration
@@ -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.
+31 -2
View File
@@ -7,7 +7,7 @@ import { runConfigCommand } from './config-command.js';
import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js';
import { runLogsCommand } from './logs-command.js';
import { runMpvPreAppCommand } from './mpv-command.js';
import { runMpvPostAppCommand, runMpvPreAppCommand } from './mpv-command.js';
import { runAppPassthroughCommand } from './app-command.js';
import { runStatsCommand } from './stats-command.js';
import { runUpdateCommand } from './update-command.js';
@@ -45,6 +45,7 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
},
appPath: '/tmp/subminer.app',
@@ -261,7 +262,9 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
await assert.rejects(
async () => {
await runMpvPreAppCommand(context, {
ensureRuntimePluginReady: async () => {},
waitForUnixSocketReady: async () => false,
resolveRuntimePluginPath: () => null,
launchMpvIdleDetached: async () => {},
});
},
@@ -269,6 +272,32 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
);
});
test('mpv idle command ensures Linux runtime plugin before detached launch', async () => {
const context = createContext();
context.args.mpvIdle = true;
const calls: string[] = [];
const handled = await runMpvPostAppCommand(context, {
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
waitForUnixSocketReady: async () => {
calls.push('wait');
return true;
},
launchMpvIdleDetached: async () => {
calls.push('launch');
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
});
assert.equal(handled, true);
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait']);
});
test('dictionary command forwards --dictionary and target path to app binary', () => {
const context = createContext();
context.args.dictionary = true;
@@ -360,7 +389,7 @@ test('update command runs direct Linux release update without launching Electron
'direct:/tmp/subminer.app:/tmp/subminer:stable',
'info:AppImage update: not-found',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
'info:Support assets update: skipped',
]);
});
+17 -1
View File
@@ -5,9 +5,12 @@ import {
resolveLauncherRuntimePluginPath,
} from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
interface MpvCommandDeps {
ensureRuntimePluginReady(context: LauncherCommandContext): Promise<void>;
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
resolveRuntimePluginPath(context: LauncherCommandContext): string | null;
launchMpvIdleDetached(
socketPath: string,
appPath: string,
@@ -18,7 +21,19 @@ interface MpvCommandDeps {
}
const defaultDeps: MpvCommandDeps = {
ensureRuntimePluginReady: async (context) => {
await ensureLinuxRuntimePluginAvailable({
appPath: context.appPath ?? undefined,
scriptPath: context.scriptPath,
logLevel: context.args.logLevel,
});
},
waitForUnixSocketReady,
resolveRuntimePluginPath: (context) =>
resolveLauncherRuntimePluginPath({
appPath: context.appPath ?? '',
scriptPath: context.scriptPath,
}),
launchMpvIdleDetached,
};
@@ -58,11 +73,12 @@ export async function runMpvPostAppCommand(
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
await deps.ensureRuntimePluginReady(context);
await deps.launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
deps.resolveRuntimePluginPath(context),
{
...pluginRuntimeConfig,
backend: args.backend,
+57 -1
View File
@@ -82,6 +82,7 @@ function createContext(): LauncherCommandContext {
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
},
appPath: '/tmp/SubMiner.AppImage',
@@ -111,6 +112,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -160,6 +162,7 @@ test('youtube app-owned playback disables mpv plugin auto-start', async () => {
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -207,6 +210,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 ?? '';
@@ -225,6 +229,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
try {
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -268,6 +273,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[] = [];
@@ -275,6 +281,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -335,10 +342,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;
@@ -346,10 +355,23 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
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 +388,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,12 +418,14 @@ 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[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
@@ -422,3 +447,34 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
});
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
const calls: string[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
assert.deepEqual(calls, ['plugin', 'startMpv']);
});
+20
View File
@@ -19,6 +19,7 @@ import type { Args } from '../types.js';
import { nowMs } from '../time.js';
import type { LauncherCommandContext } from './context.js';
import { ensureLauncherSetupReady } from '../setup-gate.js';
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
import {
getDefaultConfigDir,
getSetupStatePath,
@@ -144,6 +145,13 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
return runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady,
ensureRuntimePluginReady: async (commandContext) => {
await ensureLinuxRuntimePluginAvailable({
appPath: commandContext.appPath ?? undefined,
scriptPath: commandContext.scriptPath,
logLevel: commandContext.args.logLevel,
});
},
chooseTarget,
checkDependencies,
registerCleanup,
@@ -160,6 +168,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
type PlaybackCommandDeps = {
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
ensureRuntimePluginReady: (context: LauncherCommandContext) => Promise<void>;
chooseTarget: (
args: Args,
scriptPath: string,
@@ -232,6 +241,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 &&
@@ -245,6 +262,8 @@ export async function runPlaybackCommandWithDeps(
);
}
await deps.ensureRuntimePluginReady(context);
await deps.startMpv(
selectedTarget.target,
selectedTarget.kind,
@@ -266,6 +285,7 @@ export async function runPlaybackCommandWithDeps(
}
: {}),
backend: args.backend,
overlayLoadingOsd: shouldShowOverlayLoadingOsd,
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
},
},
+7 -3
View File
@@ -34,7 +34,10 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
return {
appImage: { status: 'updated' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
supportAssets: [
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
],
};
},
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
@@ -48,7 +51,8 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
'info:AppImage update: updated',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
'info:Support assets (theme) update: updated - Installed theme.',
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
]);
});
@@ -101,7 +105,7 @@ test('runUpdateCommand skips Linux asset replacement when release is not newer',
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
'info:AppImage update: up to date',
'info:Launcher update: up to date',
'info:Rofi theme update: up to date',
'info:Support assets update: up to date',
]);
} finally {
globalThis.fetch = originalFetch;
+21 -7
View File
@@ -39,7 +39,12 @@ type DirectReleaseUpdateRequest = {
type DirectReleaseUpdateResult = {
appImage: { status: string; command?: string; message?: string };
launcher: { status: string; command?: string; message?: string };
supportAssets: Array<{ status: string; command?: string; message?: string }>;
supportAssets: Array<{
status: string;
component?: 'theme' | 'plugin';
command?: string;
message?: string;
}>;
};
type UpdateCommandDeps = {
@@ -124,20 +129,29 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
function logUpdateResult(
label: string,
result: { status: string; command?: string; message?: string },
result: {
status: string;
component?: 'theme' | 'plugin';
command?: string;
message?: string;
},
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
deps: Pick<UpdateCommandDeps, 'log'>,
): void {
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`);
const componentLabel = result.component ? ` (${result.component})` : '';
const detailSuffix = result.message ? ` - ${result.message}` : '';
deps.log(
'info',
configuredLogLevel,
`${label}${componentLabel} update: ${displayStatus}${detailSuffix}`,
);
if (result.command) {
deps.log(
'warn',
configuredLogLevel,
`${label} update requires manual command: ${result.command}`,
`${label}${componentLabel} update requires manual command: ${result.command}`,
);
} else if (result.message) {
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
}
}
@@ -187,7 +201,7 @@ export async function runUpdateCommand(
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
for (const supportResult of result.supportAssets) {
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
logUpdateResult('Support assets', supportResult, logLevel, resolvedDeps);
}
return true;
}
+27
View File
@@ -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',
],
);
+5 -5
View File
@@ -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;
}
+134
View File
@@ -0,0 +1,134 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Args } from './types.js';
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
function createArgs(): Args {
return {
backend: 'auto',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubeMode: 'download',
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 0,
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: '',
youtubeSubgenKeepTemp: false,
youtubeFixWithAi: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: '',
aiConfig: {},
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
target: '',
targetKind: '',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'ja',
jimakuMaxEntryResults: 20,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
settings: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: 'https://jellyfin.example.test',
jellyfinUsername: '',
jellyfinPassword: '',
launchMode: 'normal',
};
}
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
const calls: string[] = [];
try {
await assert.rejects(
() =>
runJellyfinPlayMenuWithDeps(
'/tmp/SubMiner.AppImage',
createArgs(),
'/tmp/subminer',
'/tmp/subminer.sock',
{
loadLauncherJellyfinConfig: () => ({}),
findRofiTheme: () => null,
resolveJellyfinSelection: async () => 'item-123',
resolveJellyfinSelectionViaApp: async () => {
throw new Error('unexpected app-based selection');
},
hasStoredJellyfinSession: () => true,
requestJellyfinPreviewAuthFromApp: async () => null,
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
pathExists: () => false,
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
waitForUnixSocketReady: async () => {
calls.push('wait');
return true;
},
launchMpvIdleDetached: async () => {
calls.push('launch');
},
resolveLauncherRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
runAppCommandWithInheritLogged: () => {
calls.push('handoff');
throw new Error('stop after handoff');
},
log: () => {},
},
),
/stop after handoff/,
);
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
} finally {
if (originalAccessToken === undefined) {
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
} else {
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
}
if (originalUserId === undefined) {
delete process.env.SUBMINER_JELLYFIN_USER_ID;
} else {
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
}
}
});
+87 -19
View File
@@ -30,9 +30,54 @@ import {
resolveLauncherRuntimePluginPath,
waitForUnixSocketReady,
} from './mpv.js';
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
type JellyfinPlayMenuDeps = {
loadLauncherJellyfinConfig: typeof loadLauncherJellyfinConfig;
findRofiTheme: typeof findRofiTheme;
resolveJellyfinSelection: typeof resolveJellyfinSelection;
hasStoredJellyfinSession: typeof hasStoredJellyfinSession;
requestJellyfinPreviewAuthFromApp: typeof requestJellyfinPreviewAuthFromApp;
resolveLauncherMainConfigPath: typeof resolveLauncherMainConfigPath;
resolveJellyfinSelectionViaApp: typeof resolveJellyfinSelectionViaApp;
pathExists: (candidate: string) => boolean;
ensureRuntimePluginReady: (options: {
appPath: string;
scriptPath: string;
logLevel: Args['logLevel'];
}) => Promise<void>;
waitForUnixSocketReady: typeof waitForUnixSocketReady;
launchMpvIdleDetached: typeof launchMpvIdleDetached;
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
log: typeof log;
};
const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
loadLauncherJellyfinConfig,
findRofiTheme,
resolveJellyfinSelection,
hasStoredJellyfinSession,
requestJellyfinPreviewAuthFromApp,
resolveLauncherMainConfigPath,
resolveJellyfinSelectionViaApp,
pathExists: (candidate) => fs.existsSync(candidate),
ensureRuntimePluginReady: async ({ appPath, scriptPath, logLevel }) => {
await ensureLinuxRuntimePluginAvailable({
appPath,
scriptPath,
logLevel,
});
},
waitForUnixSocketReady,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
runAppCommandWithInheritLogged,
log,
};
export function sanitizeServerUrl(value: string): string {
return value.trim().replace(/\/+$/, '');
}
@@ -974,7 +1019,17 @@ export async function runJellyfinPlayMenu(
scriptPath: string,
mpvSocketPath: string,
): Promise<never> {
const config = loadLauncherJellyfinConfig();
return runJellyfinPlayMenuWithDeps(appPath, args, scriptPath, mpvSocketPath);
}
export async function runJellyfinPlayMenuWithDeps(
appPath: string,
args: Args,
scriptPath: string,
mpvSocketPath: string,
deps: JellyfinPlayMenuDeps = defaultJellyfinPlayMenuDeps,
): Promise<never> {
const config = deps.loadLauncherJellyfinConfig();
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
const session: JellyfinSessionConfig = {
@@ -986,58 +1041,71 @@ export async function runJellyfinPlayMenu(
iconCacheDir: config.iconCacheDir || '',
};
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
const rofiTheme = args.useRofi ? deps.findRofiTheme(scriptPath) : null;
if (args.useRofi && !rofiTheme) {
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
deps.log(
'warn',
args.logLevel,
'Rofi theme not found for Jellyfin picker; using rofi defaults.',
);
}
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
let itemId = '';
if (hasDirectSession) {
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
itemId = await deps.resolveJellyfinSelection(args, session, rofiTheme);
} else {
const configPath = resolveLauncherMainConfigPath();
if (!hasStoredJellyfinSession(configPath)) {
const configPath = deps.resolveLauncherMainConfigPath();
if (!deps.hasStoredJellyfinSession(configPath)) {
fail(
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
);
}
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args);
const previewAuth = await deps.requestJellyfinPreviewAuthFromApp(appPath, args);
if (previewAuth) {
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
session.accessToken = previewAuth.accessToken;
session.userId = previewAuth.userId || session.userId;
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.');
deps.log(
'debug',
args.logLevel,
'Jellyfin preview auth bridge ready for picker image previews.',
);
} else {
log(
deps.log(
'debug',
args.logLevel,
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
);
}
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
itemId = await deps.resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
}
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
deps.log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
deps.log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
let mpvReady = false;
if (fs.existsSync(mpvSocketPath)) {
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
if (deps.pathExists(mpvSocketPath)) {
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 250);
}
if (!mpvReady) {
await launchMpvIdleDetached(
await deps.ensureRuntimePluginReady({ appPath, scriptPath, logLevel: args.logLevel });
await deps.launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
deps.resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
);
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
}
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
deps.log(
'debug',
args.logLevel,
`MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`,
);
if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
}
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
}
+1
View File
@@ -385,6 +385,7 @@ test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
},
}),
+259
View File
@@ -0,0 +1,259 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {
ensureLinuxRuntimePluginAvailable,
installManagedPluginAssetsViaApp,
} from './runtime-plugin-preflight';
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'darwin',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: () => {
calls.push('log');
},
});
assert.deepEqual(calls, []);
});
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => {
calls.push('detect');
return true;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
isManagedThemeAvailable: () => {
calls.push('theme');
return true;
},
log: () => {},
});
assert.deepEqual(calls, ['detect', 'theme']);
});
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
isManagedThemeAvailable: () => {
calls.push('theme');
return true;
},
log: () => {},
});
assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
isManagedThemeAvailable: () => {
calls.push('theme');
return false;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.deepEqual(calls, [
'detect',
'resolve',
'theme',
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
'resolve',
]);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
const calls: string[] = [];
let resolveCount = 0;
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => {
resolveCount += 1;
calls.push(`resolve:${resolveCount}`);
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.deepEqual(calls, [
'resolve:1',
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
'resolve:2',
]);
});
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: false,
status: 'failed',
error: 'copy failed',
}),
log: () => {},
}),
/copy failed/,
);
});
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
log: () => {},
}),
/managed runtime plugin assets could not be installed/i,
);
});
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
let waited = false;
const result = await installManagedPluginAssetsViaApp(
{
appPath: '/opt/SubMiner/subminer',
},
{
runAppCommandCaptureOutput: () => ({
status: 1,
stdout: '',
stderr: '',
error: new Error('spawn failed'),
}),
waitForInstallResponse: async () => {
waited = true;
return null;
},
},
);
assert.deepEqual(result, {
ok: false,
status: 'failed',
error: 'spawn failed',
});
assert.equal(waited, false);
});
test('installManagedPluginAssetsViaApp does not let temp cleanup errors mask install result', async () => {
const originalRmSync = fs.rmSync;
fs.rmSync = ((targetPath, options) => {
if (String(targetPath).includes('subminer-runtime-plugin-')) {
throw new Error('cleanup failed');
}
return originalRmSync(targetPath, options);
}) as typeof fs.rmSync;
try {
const result = await installManagedPluginAssetsViaApp(
{
appPath: '/opt/SubMiner/subminer',
},
{
runAppCommandCaptureOutput: () => ({
status: 0,
stdout: '',
stderr: '',
}),
waitForInstallResponse: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
},
);
assert.deepEqual(result, {
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
});
} finally {
fs.rmSync = originalRmSync;
}
});
+223
View File
@@ -0,0 +1,223 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { log as launcherLog } from './log.js';
import { runAppCommandCaptureOutput, resolveLauncherRuntimePluginPath } from './mpv.js';
import { nowMs } from './time.js';
import { sleep } from './util.js';
import { detectInstalledMpvPlugin } from '../src/main/runtime/first-run-setup-plugin.js';
import {
resolveManagedLinuxRuntimePluginPaths,
type EnsureLinuxRuntimePluginAssetsResult,
} from '../src/main/runtime/linux-runtime-plugin-assets.js';
const RESPONSE_TIMEOUT_MS = 30_000;
type PreflightLog = (
level: 'debug' | 'info' | 'warn' | 'error',
configured: 'debug' | 'info' | 'warn' | 'error',
message: string,
) => void;
type EnsureLinuxRuntimePluginAvailableOptions = {
appPath?: string;
scriptPath?: string;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
platform?: NodeJS.Platform;
homeDir?: string;
xdgConfigHome?: string;
xdgDataHome?: string;
appDataDir?: string;
detectInstalledPlugin?: () => boolean;
resolveRuntimePluginPath?: () => string | null;
isManagedThemeAvailable?: () => boolean;
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
log?: PreflightLog;
};
type RuntimePluginPreflightResponse = {
ok: boolean;
status: 'installed' | 'already-present' | 'failed';
path?: string;
error?: string;
};
function resolveConfiguredLogLevel(
logLevel: EnsureLinuxRuntimePluginAvailableOptions['logLevel'],
): 'debug' | 'info' | 'warn' | 'error' {
return logLevel ?? 'warn';
}
async function waitForInstallResponse(
responsePath: string,
): Promise<RuntimePluginPreflightResponse | null> {
const deadline = nowMs() + RESPONSE_TIMEOUT_MS;
while (nowMs() < deadline) {
try {
if (fs.existsSync(responsePath)) {
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as RuntimePluginPreflightResponse;
}
} catch {
// retry until timeout
}
await sleep(100);
}
return null;
}
type InstallManagedPluginAssetsViaAppDeps = {
runAppCommandCaptureOutput?: typeof runAppCommandCaptureOutput;
waitForInstallResponse?: typeof waitForInstallResponse;
};
export async function installManagedPluginAssetsViaApp(
options: {
appPath: string;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
},
deps: InstallManagedPluginAssetsViaAppDeps = {},
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-runtime-plugin-'));
const responsePath = path.join(tempDir, 'response.json');
const runAppCommand = deps.runAppCommandCaptureOutput ?? runAppCommandCaptureOutput;
const waitForResponse = deps.waitForInstallResponse ?? waitForInstallResponse;
try {
const appArgs = [
'--ensure-linux-runtime-plugin-assets',
'--ensure-linux-runtime-plugin-assets-response-path',
responsePath,
];
const result = runAppCommand(options.appPath, appArgs);
if (result.error) {
return {
ok: false,
status: 'failed',
error: result.error.message,
};
}
if (result.status !== 0) {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
return {
ok: false,
status: 'failed',
error:
stderr ||
stdout ||
`Linux runtime plugin asset install command exited with status ${result.status}.`,
};
}
const response = await waitForResponse(responsePath);
if (response) {
return response;
}
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
return {
ok: false,
status: 'failed',
error:
stderr ||
stdout ||
`Timed out waiting for Linux runtime plugin asset response after app exit status ${result.status}.`,
};
} finally {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Avoid hiding the install failure or success result behind temp cleanup errors.
}
}
}
export async function ensureLinuxRuntimePluginAvailable(
options: EnsureLinuxRuntimePluginAvailableOptions,
): Promise<void> {
const platform = options.platform ?? process.platform;
if (platform !== 'linux') {
return;
}
const configuredLogLevel = resolveConfiguredLogLevel(options.logLevel);
const log = options.log ?? launcherLog;
const homeDir = options.homeDir ?? os.homedir();
const detectInstalledPlugin =
options.detectInstalledPlugin ??
(() =>
detectInstalledMpvPlugin({
platform,
homeDir,
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
appDataDir: options.appDataDir ?? process.env.APPDATA,
}).installed);
const installedPluginAvailable = detectInstalledPlugin();
const managedPaths = resolveManagedLinuxRuntimePluginPaths({
homeDir,
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
});
const resolveRuntimePluginPath =
options.resolveRuntimePluginPath ??
(() => {
if (!options.appPath) return null;
return resolveLauncherRuntimePluginPath({
appPath: options.appPath,
scriptPath: options.scriptPath,
platform,
homeDir,
env: process.env,
});
});
const isManagedThemeAvailable =
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
if (runtimePluginAvailable && isManagedThemeAvailable()) {
return;
}
log(
'info',
configuredLogLevel,
'Linux runtime support assets missing; installing managed plugin/theme assets.',
);
const installManagedPluginAssets =
options.installManagedPluginAssets ??
(() => {
if (!options.appPath) {
throw new Error(
'Linux managed runtime plugin assets could not be installed. Launch aborted before starting mpv.',
);
}
return installManagedPluginAssetsViaApp({
appPath: options.appPath,
logLevel: options.logLevel,
});
});
const installResult = await installManagedPluginAssets();
if (!installResult.ok) {
const message = installResult.error || 'Unknown Linux runtime plugin asset install failure.';
log(
'warn',
configuredLogLevel,
`Managed Linux runtime support asset install failed: ${message}`,
);
throw new Error(message);
}
log(
'info',
configuredLogLevel,
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
);
const runtimePluginPath = resolveRuntimePluginPath();
if (runtimePluginPath) {
return;
}
const message =
`Linux managed runtime plugin assets could not be installed. ` +
`Checked path: ${managedPaths.pluginEntrypointPath}. ` +
'Launch aborted before starting mpv.';
log('warn', configuredLogLevel, message);
throw new Error(message);
}
+37 -2
View File
@@ -24,6 +24,7 @@ type SmokeCase = {
artifactsDir: string;
binDir: string;
xdgConfigHome: string;
xdgDataHome: string;
appDataDir: string;
localAppDataDir: string;
homeDir: string;
@@ -64,6 +65,7 @@ function createSmokeCase(name: string): SmokeCase {
const artifactsDir = path.join(root, 'artifacts');
const binDir = path.join(root, 'bin');
const xdgConfigHome = path.join(root, 'xdg');
const xdgDataHome = path.join(root, 'xdg-data');
const appDataDir = path.join(root, 'AppData', 'Roaming');
const localAppDataDir = path.join(root, 'AppData', 'Local');
const homeDir = path.join(root, 'home');
@@ -135,6 +137,7 @@ process.on('SIGTERM', closeAndExit);
fakeAppBasePath,
`#!/usr/bin/env bun
const fs = require('node:fs');
const path = require('node:path');
const logPath = ${JSON.stringify(fakeAppLogPath)};
const startPath = ${JSON.stringify(fakeAppStartLogPath)};
@@ -154,6 +157,25 @@ if (entry.argv.includes('--stop')) {
if (entry.argv.includes('--app-ping')) {
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
}
if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) {
const responseFlagIndex = entry.argv.indexOf('--ensure-linux-runtime-plugin-assets-response-path');
const responsePath = responseFlagIndex >= 0 ? entry.argv[responseFlagIndex + 1] : '';
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(process.env.HOME || '', '.local', 'share');
const dataDir = path.join(xdgDataHome, 'SubMiner');
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
fs.mkdirSync(pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(themePath), { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
fs.writeFileSync(themePath, '/* smoke theme */\\n');
if (responsePath) {
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
}
process.exit(0);
}
process.exit(0);
`,
@@ -164,6 +186,7 @@ process.exit(0);
artifactsDir,
binDir,
xdgConfigHome,
xdgDataHome,
appDataDir,
localAppDataDir,
homeDir,
@@ -181,6 +204,7 @@ function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
...process.env,
HOME: smokeCase.homeDir,
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
XDG_DATA_HOME: smokeCase.xdgDataHome,
APPDATA: smokeCase.appDataDir,
LOCALAPPDATA: smokeCase.localAppDataDir,
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
@@ -495,7 +519,7 @@ test(
);
test(
'launcher start-overlay attaches to a running background app without spawning another app command',
'launcher start-overlay attaches to a running background app without spawning another app start command',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
@@ -530,7 +554,13 @@ test(
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(appEntries.length, 0);
assert.equal(appEntries.length > 0, true);
assert.equal(
appEntries.every((entry) =>
(entry.argv as string[]).includes('--ensure-linux-runtime-plugin-assets'),
),
true,
);
assert.equal(appStartEntries.length, 0);
assert.equal(appStopEntries.length, 0);
assert.equal(controlEntries.length, 1);
@@ -587,6 +617,11 @@ test(
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
);
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
assert.match(result.stdout, /managed plugin\/theme assets/i);
assert.equal(
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
true,
);
});
},
);
+2
View File
@@ -207,6 +207,8 @@ export interface PluginRuntimeConfig {
autoStart: boolean;
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;
overlayLoadingOsd?: boolean;
osdMessages: boolean;
texthookerEnabled: boolean;
}
+5 -5
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -51,6 +51,15 @@ function M.create(ctx)
return reason == "reload" or reason == "redirect"
end
local function has_next_playlist_item()
local playlist_count = mp.get_property_number("playlist-count")
local playlist_pos = mp.get_property_number("playlist-pos")
if type(playlist_count) ~= "number" or type(playlist_pos) ~= "number" then
return false
end
return playlist_count > 0 and playlist_pos >= 0 and playlist_pos < playlist_count - 1
end
local function clear_pending_visible_overlay_hide()
local timer = state.pending_visible_overlay_hide_timer
if timer and timer.kill then
@@ -63,6 +72,9 @@ function M.create(ctx)
local resolve_auto_start_visible_overlay_enabled
local function hide_visible_overlay_after_end_file()
if has_next_playlist_item() then
return
end
if state.visible_overlay_requested == true and not resolve_auto_start_visible_overlay_enabled() then
return
end
@@ -104,6 +116,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 +163,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 +197,7 @@ function M.create(ctx)
.. process.describe_mpv_ipc_socket_match(opts.socket_path)
.. ")"
)
process.stop_overlay_loading_osd()
return
end
@@ -180,6 +209,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 +265,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 +293,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 +310,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 +330,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
+2 -2
View File
@@ -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
+6
View File
@@ -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()
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()
+2 -1
View File
@@ -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",
}
+115 -11
View File
@@ -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
disarm_auto_play_ready_gate({ resume_playback = false })
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 })
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)
if resolve_osd_messages_enabled() then
stop_overlay_loading_osd()
show_osd(AUTO_PLAY_READY_LOADING_OSD)
if type(mp.add_periodic_timer) == "function" then
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
+20 -4
View File
@@ -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
@@ -268,10 +270,6 @@ function M.create(ctx)
return { "--replay-current-subtitle" }
elseif action_id == "playNextSubtitle" then
return { "--play-next-subtitle" }
elseif action_id == "shiftSubDelayPrevLine" then
return { "--shift-sub-delay-prev-line" }
elseif action_id == "shiftSubDelayNextLine" then
return { "--shift-sub-delay-next-line" }
elseif action_id == "cycleRuntimeOption" then
local runtime_option_id = payload and payload.runtimeOptionId or nil
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
@@ -348,6 +346,16 @@ function M.create(ctx)
invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs)
end
local function is_supported_binding(binding)
if binding.actionType == "mpv-command" then
return type(binding.command) == "table" and binding.command[1] ~= nil
end
if binding.actionType == "session-action" then
return build_cli_args(binding.actionId, binding.payload, binding.cliArgs) ~= nil
end
return false
end
local function load_artifact()
local artifact_path = environment.resolve_session_bindings_artifact_path()
local raw = read_file(artifact_path)
@@ -383,6 +391,13 @@ function M.create(ctx)
local generation = state.session_binding_generation
for index, binding in ipairs(artifact.bindings) do
if not is_supported_binding(binding) then
subminer_log(
"warn",
"session-bindings",
"Skipped unsupported session binding from artifact"
)
else
local key_names = key_spec_to_mpv_bindings(binding.key)
if key_names then
for key_index, key_name in ipairs(key_names) do
@@ -405,6 +420,7 @@ function M.create(ctx)
)
end
end
end
remove_binding_names(previous_binding_names)
state.session_binding_names = next_binding_names
+3
View File
@@ -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,
+27 -5
View File
@@ -5,7 +5,7 @@
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections.
- Includes click-to-learn keybinding controls, an AnkiConnect deck dropdown that auto-fills from Yomitan's current mining deck, and AnkiConnect-backed deck, field, and note-type pickers.
- Live-saves changes for subtitle CSS declarations, stats keys, logging level, Anki field mappings, sentence card model, and other annotation and runtime options; search narrows across all categories including on multi-word terms. AI and translation settings remain config-file only.
- Live-saves changes for subtitle CSS declarations, stats keys, logging level, Anki field mappings, sentence card model, and other annotation and runtime options; search narrows across all categories including multi-word terms. AI and translation settings remain config-file only.
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification and configurable update notifications.
- The `subminer` launcher and Linux rofi theme update automatically alongside the app.
@@ -50,15 +50,27 @@
- **Runtime:** The bundled Electron runtime is updated from 39.8.6 to 42.2.0.
- **Subtitle Delay Shortcuts:** Default overlay subtitle delay and step bindings now match mpv's own defaults.
- `z`, `Z`, and `x` adjust sub-delay; `Ctrl+Shift+Left/Right` runs native sub-step and shows the current delay on the OSD.
- The previous SubMiner-only adjacent-cue delay action has been removed.
- **Update Notifications:** New installs default to overlay-only update notifications instead of overlay plus system notifications.
### Fixed
- **macOS Overlay:** Significantly improved overlay focus and stability across a range of scenarios.
- The overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; and opens over fullscreen mpv without switching Spaces.
- Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The compiled mpv window helper is now correctly bundled, preventing the overlay from falling back to a slower startup path on first launch.
- Yomitan popup focus is restored after card mining or popup reload; clicking transparent overlay space correctly closes the popup and returns passthrough to mpv without a hide/reappear cycle.
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player.
- The overlay stays stacked above mpv after click-to-focus events and is suspended while the in-player stats window is open.
- Settings windows (SubMiner and Yomitan) now open above the subtitle overlay; the overlay hides immediately when the character dictionary modal opens, including while AniList lookup is in progress.
- Auto-paused startup is more reliable: the overlay stays interactive during the first measurement gap, startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv subtitle reads are resolved before synthetic warm readiness can resume playback.
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
- The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct Linux bounds reapplication and user-paused playback preserved through readiness gates.
- The visible overlay remains active when mpv advances to the next playlist item, even when the next episode loads after the warm transition delay.
- **Jellyfin Playback:** Resolved a wide range of discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, and duplicate ready signals no longer re-show the overlay.
- Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track.
@@ -84,6 +96,7 @@
- **AniList Progress:** Threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold.
- Season-specific results are preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching status.
- Repeated missing-token checks no longer exhaust AniList retry attempts or create duplicate dead-letter entries for the same episode.
- Manual AniList linking from the stats anime page now strips generated `Season N` suffixes from automatic searches so results match the base title correctly.
- **Anki:** Sentence-audio padding is now opt-in by default; animated AVIF freeze-frame duration is correctly aligned to word audio length without double-counting padding.
- Multi-line sentence mining stays aligned for repeated subtitle text; Kiku duplicate-card detection and merge flow are fixed; clipboard card updates from YouTube use mpv's resolved stream URLs; sentence cards refresh the secondary subtitle before saving.
@@ -97,21 +110,19 @@
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground.
- Builds that cannot apply native updates show a manual-install message instead of a restart prompt. Windows retains the native NSIS update path while routing updater HTTP through the main process.
- Linux updates now correctly create and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; both support assets are auto-installed from the bundled app on first launch if either is missing.
- **Setup - macOS:** First-run setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths.
- `subminer app --setup` opens the setup flow even when SubMiner is already running. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed.
- **Tray App:** Fixed several lifecycle issues: the tray stays running when Yomitan settings are closed; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable.
- Settings loading no longer blocks other tray actions; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized; the session help modal closes correctly without mpv running.
- On Windows, "Open SubMiner Setup" now correctly opens the setup window after first-run setup is complete.
- On Windows, "Open SubMiner Setup" now correctly opens the setup window after first-run setup is complete. System notifications now correctly show the SubMiner app icon when no custom notification image is provided.
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused until subtitle priming and tokenization readiness complete.
- `subminer settings` on macOS no longer emits Electron menu diagnostics and exits cleanly when the window is closed. Linux first-run launcher installs build with a valid Bun shebang; `subminer app` on Linux returns control to the terminal immediately.
- On Windows, managed mpv launches from a background instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
- The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct Linux bounds reapplication and user-paused playback preserved through readiness gates.
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered. Annotations are corrected for Yomitan single-token compounds with internal particles like `目の前`.
- **Subtitle Annotation Prefetch:** Cached annotations and character images are ready for more live subtitle changes without delaying raw subtitle display.
@@ -143,6 +154,16 @@
- **Architecture Docs:** Added a Playback Startup Flow diagram and a Runtime Sockets section and diagram to the IPC + Runtime Contracts page, with cross-reference pointers in the MPV Plugin and Troubleshooting pages.
- **Linux Update Flow:** Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets if either one is missing.
## What's Changed
- Replace subtitle delay actions with native mpv keybindings by @ksyasuda in #120
- fix(stats): strip Season N suffix from AniList title searches by @ksyasuda in #121
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
## Installation
See the README and docs/installation guide for full setup steps.
@@ -151,6 +172,7 @@ See the README and docs/installation guide for full setup steps.
- 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`.
+94 -2
View File
@@ -1122,13 +1122,22 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Whats 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, /## Whats 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, /Whats Changed/);
assert.doesNotMatch(changelog, /What's Changed|Whats 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`',
'',
'## Whats 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, /## Whats 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');
+48 -3
View File
@@ -433,12 +433,45 @@ function resolveContributionsForFragments(
);
}
function isWhatsChangedHeading(line: string): boolean {
return line === "## What's Changed" || line === '## Whats 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[] = ['## Whats 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 {
+230
View File
@@ -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")
+48 -4
View File
@@ -165,6 +165,46 @@ local ctx = {
actionType = "mpv-command",
command = { "sub-seek", 1 },
},
{
key = {
code = "ArrowLeft",
modifiers = { "ctrl", "shift" },
},
actionType = "mpv-command",
command = { "sub-step", -1 },
},
{
key = {
code = "ArrowRight",
modifiers = { "ctrl", "shift" },
},
actionType = "mpv-command",
command = { "sub-step", 1 },
},
{
key = {
code = "KeyZ",
modifiers = {},
},
actionType = "mpv-command",
command = { "add", "sub-delay", -0.1 },
},
{
key = {
code = "KeyZ",
modifiers = { "shift" },
},
actionType = "mpv-command",
command = { "add", "sub-delay", 0.1 },
},
{
key = {
code = "KeyX",
modifiers = {},
},
actionType = "mpv-command",
command = { "add", "sub-delay", 0.1 },
},
{
key = {
code = "BracketRight",
@@ -323,6 +363,11 @@ local expected_mpv_bindings = {
{ keys = "DOWN", command = { "seek", -60 } },
{ keys = "H", command = { "sub-seek", -1 } },
{ keys = "L", command = { "sub-seek", 1 } },
{ keys = "Ctrl+Shift+LEFT", command = { "sub-step", -1 } },
{ keys = "Ctrl+Shift+RIGHT", command = { "sub-step", 1 } },
{ keys = "z", command = { "add", "sub-delay", -0.1 } },
{ keys = "Z", command = { "add", "sub-delay", 0.1 } },
{ keys = "x", command = { "add", "sub-delay", 0.1 } },
{ keys = "q", command = { "quit" } },
{ keys = "Ctrl+w", command = { "quit" } },
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } },
@@ -340,10 +385,6 @@ for _, expected in ipairs(expected_mpv_bindings) do
end
local expected_cli_bindings = {
{ keys = "Shift+]", flag = "--shift-sub-delay-next-line" },
{ keys = "}", flag = "--shift-sub-delay-next-line" },
{ keys = "Shift+[", flag = "--shift-sub-delay-prev-line" },
{ keys = "{", flag = "--shift-sub-delay-prev-line" },
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
@@ -365,6 +406,9 @@ for _, expected in ipairs(expected_cli_bindings) do
assert_true(cli_call[2] == expected.flag, "default session action should pass " .. expected.flag)
end
assert_true(find_binding("Shift+]") == nil, "retired subtitle delay action should not register Shift+]")
assert_true(find_binding("Shift+[") == nil, "retired subtitle delay action should not register Shift+[")
local play_next = find_binding("Ctrl+L")
assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form")
+182
View File
@@ -69,6 +69,12 @@ local function run_plugin_scenario(config)
if name == "osd-height" then
return config.osd_height or 720
end
if name == "playlist-count" then
return config.playlist_count
end
if name == "playlist-pos" then
return config.playlist_pos
end
return nil
end
@@ -627,6 +633,46 @@ do
)
end
do
local scenario = {
process_list = "",
defer_timeouts = true,
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/slow-episode-01.mkv",
media_title = "Slow Episode 1",
playlist_count = 2,
playlist_pos = 0,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for slow warm playlist visibility scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "end-file", { reason = "eof" })
fire_pending_timeouts(recorded)
scenario.path = "/media/slow-episode-02.mkv"
scenario.media_title = "Slow Episode 2"
scenario.playlist_pos = 1
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0,
"slow playlist advance should preserve visible overlay state while the next episode is pending"
)
assert_true(
count_start_calls(recorded.async_calls) == 1,
"slow playlist visibility reuse should not issue another --start command"
)
end
do
local scenario = {
process_list = "",
@@ -900,6 +946,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 +1610,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 +1915,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 = "",
+19
View File
@@ -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> };
+7
View File
@@ -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: {
+190
View File
@@ -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();
+134 -25
View File
@@ -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,24 +1088,61 @@ 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 (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;
}
if (this.mpvClient && this.mpvClient.currentVideoPath) {
try {
const timestamp = this.mpvClient.currentTimePos || 0;
const notificationIconSource = await resolveMediaGenerationInputPath(
this.mpvClient,
'video',
);
const notificationIconSource = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
if (!notificationIconSource) {
throw new Error('No media source available for notification icon');
}
@@ -1043,25 +1151,26 @@ export class AnkiIntegration {
timestamp,
);
if (iconBuffer && iconBuffer.length > 0) {
notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
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);
}
}
return notificationIcon;
}
} catch (err) {
log.warn('Failed to generate notification icon:', (err as Error).message);
}
}
this.notificationCallback('Anki Card Updated', {
body: message,
icon: notificationIconPath,
});
if (notificationIconPath) {
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
}
}
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);
});
-12
View File
@@ -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) {
+56 -1
View File
@@ -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']);
});
+23 -5
View File
@@ -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 });
}
}
+35 -4
View File
@@ -69,6 +69,25 @@ test('parseArgs captures update command and internal launcher paths', () => {
assert.equal(shouldRunYomitanOnlyStartup(args), false);
});
test('parseArgs captures hidden Linux runtime plugin asset ensure command', () => {
const args = parseArgs([
'--ensure-linux-runtime-plugin-assets',
'--ensure-linux-runtime-plugin-assets-response-path',
'/tmp/subminer-plugin-response.json',
]);
assert.equal(args.ensureLinuxRuntimePluginAssets, true);
assert.equal(
args.ensureLinuxRuntimePluginAssetsResponsePath,
'/tmp/subminer-plugin-response.json',
);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(isHeadlessInitialCommand(args), true);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(shouldRunYomitanOnlyStartup(args), false);
});
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
assert.equal(args.launchMpv, true);
@@ -101,8 +120,6 @@ test('parseArgs captures session action forwarding flags', () => {
'--toggle-primary-subtitle-bar',
'--replay-current-subtitle',
'--play-next-subtitle',
'--shift-sub-delay-prev-line',
'--shift-sub-delay-next-line',
'--cycle-runtime-option',
'anki.autoUpdateNewCards:prev',
'--session-action',
@@ -120,8 +137,6 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.togglePrimarySubtitleBar, true);
assert.equal(args.replayCurrentSubtitle, true);
assert.equal(args.playNextSubtitle, true);
assert.equal(args.shiftSubDelayPrevLine, true);
assert.equal(args.shiftSubDelayNextLine, true);
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
assert.equal(args.cycleRuntimeOptionDirection, -1);
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
@@ -131,6 +146,22 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(shouldStartApp(args), true);
});
test('parseArgs ignores retired subtitle delay shift flags', () => {
const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']);
assert.equal(hasExplicitCommand(args), false);
assert.equal(shouldStartApp(args), false);
});
test('parseArgs captures internal playback feedback command', () => {
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
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']);
+35 -20
View File
@@ -41,8 +41,7 @@ export interface CliArgs {
openPlaylistBrowser: boolean;
replayCurrentSubtitle: boolean;
playNextSubtitle: boolean;
shiftSubDelayPrevLine: boolean;
shiftSubDelayNextLine: boolean;
playbackFeedback?: string;
cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1;
sessionAction?: SessionActionDispatchRequest;
@@ -81,6 +80,8 @@ export interface CliArgs {
update?: boolean;
updateLauncherPath?: string;
updateResponsePath?: string;
ensureLinuxRuntimePluginAssets?: boolean;
ensureLinuxRuntimePluginAssetsResponsePath?: string;
autoStartOverlay: boolean;
generateConfig: boolean;
configPath?: string;
@@ -148,8 +149,7 @@ export function parseArgs(argv: string[]): CliArgs {
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
playbackFeedback: undefined,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
@@ -180,6 +180,8 @@ export function parseArgs(argv: string[]): CliArgs {
update: false,
updateLauncherPath: undefined,
updateResponsePath: undefined,
ensureLinuxRuntimePluginAssets: false,
ensureLinuxRuntimePluginAssetsResponsePath: undefined,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -294,9 +296,13 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
else if (arg.startsWith('--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;
@@ -377,7 +383,15 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
else if (arg === '--app-ping') args.appPing = true;
else if (arg === '--update') args.update = true;
else if (arg.startsWith('--update-launcher-path=')) {
else if (arg === '--ensure-linux-runtime-plugin-assets') {
args.ensureLinuxRuntimePluginAssets = true;
} else if (arg.startsWith('--ensure-linux-runtime-plugin-assets-response-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.ensureLinuxRuntimePluginAssetsResponsePath = value;
} else if (arg === '--ensure-linux-runtime-plugin-assets-response-path') {
const value = readValue(argv[i + 1]);
if (value) args.ensureLinuxRuntimePluginAssetsResponsePath = value;
} else if (arg.startsWith('--update-launcher-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.updateLauncherPath = value;
} else if (arg === '--update-launcher-path') {
@@ -554,8 +568,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined ||
@@ -580,13 +593,16 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.texthooker ||
args.appPing ||
args.update ||
args.ensureLinuxRuntimePluginAssets === true ||
args.generateConfig ||
args.help
);
}
export function isHeadlessInitialCommand(args: CliArgs): boolean {
return args.refreshKnownWords || args.update === true;
return (
args.refreshKnownWords || args.update === true || args.ensureLinuxRuntimePluginAssets === true
);
}
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
@@ -629,8 +645,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.openPlaylistBrowser &&
!args.replayCurrentSubtitle &&
!args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined &&
args.copySubtitleCount === undefined &&
@@ -654,6 +669,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.jellyfinPreviewAuth &&
!args.appPing &&
!args.update &&
!args.ensureLinuxRuntimePluginAssets &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig
@@ -695,8 +711,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined ||
@@ -708,7 +723,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.jellyfin ||
args.jellyfinPlay ||
args.texthooker ||
args.update
args.update ||
args.ensureLinuxRuntimePluginAssets
) {
if (args.launchMpv) {
return false;
@@ -755,8 +771,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.openPlaylistBrowser &&
!args.replayCurrentSubtitle &&
!args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined &&
args.copySubtitleCount === undefined &&
@@ -782,6 +797,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.texthooker &&
!args.appPing &&
!args.update &&
!args.ensureLinuxRuntimePluginAssets &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig &&
@@ -820,8 +836,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined ||
+68 -4
View File
@@ -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,
+2
View File
@@ -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,
+6 -1
View File
@@ -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: {
@@ -234,3 +234,16 @@ test('default keybindings include replay and next subtitle controls', () => {
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
});
test('default keybindings mirror mpv subtitle delay and sub-step keys', () => {
const keybindingMap = new Map(
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
);
assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]);
assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]);
assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]);
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]);
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]);
assert.equal(keybindingMap.has('Shift+BracketLeft'), false);
assert.equal(keybindingMap.has('Shift+BracketRight'), false);
});
+22 -2
View File
@@ -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',
+15 -7
View File
@@ -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;
}
@@ -45,8 +55,6 @@ export const SPECIAL_COMMANDS = {
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
} as const;
@@ -62,11 +70,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
{ key: 'ArrowDown', command: ['seek', -60] },
{ key: 'Shift+KeyH', command: ['sub-seek', -1] },
{ key: 'Shift+KeyL', command: ['sub-seek', 1] },
{ key: 'Shift+BracketRight', command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START] },
{
key: 'Shift+BracketLeft',
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
},
{ key: 'Ctrl+Shift+ArrowLeft', command: ['sub-step', -1] },
{ key: 'Ctrl+Shift+ArrowRight', command: ['sub-step', 1] },
{ key: 'KeyZ', command: ['add', 'sub-delay', -0.1] },
{ key: 'Shift+KeyZ', command: ['add', 'sub-delay', 0.1] },
{ key: 'KeyX', command: ['add', 'sub-delay', 0.1] },
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
@@ -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.'],
+24 -8
View File
@@ -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')) {
+18 -7
View File
@@ -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.',
);
}
}
}
+10 -1
View File
@@ -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.') ||
+4
View File
@@ -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
View File
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
+73 -9
View File
@@ -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 () => {
+17 -2
View File
@@ -49,8 +49,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
playbackFeedback: undefined,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
@@ -244,6 +243,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
runUpdateCommand: async (args) => {
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
calls.push('runEnsureLinuxRuntimePluginAssetsCommand');
},
printHelp: () => {
calls.push('printHelp');
},
@@ -252,6 +254,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 +498,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();
@@ -613,6 +627,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
stop: () => {},
hasMainWindow: () => true,
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
},
dispatchSessionAction: async () => {},
+25 -12
View File
@@ -97,6 +97,10 @@ export interface CliCommandServiceDeps {
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runEnsureLinuxRuntimePluginAssetsCommand: (
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
@@ -106,6 +110,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 +133,7 @@ interface MpvCliRuntime {
setSocketPath: (socketPath: string) => void;
getClient: () => MpvClientLike | null;
showOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
}
interface TexthookerCliRuntime {
@@ -180,6 +186,7 @@ interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
}
@@ -290,11 +297,13 @@ export function createCliCommandDepsRuntime(
runStatsCommand: options.jellyfin.runStatsCommand,
runJellyfinCommand: options.jellyfin.runCommand,
runUpdateCommand: options.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
showMpvOsd: options.mpv.showOsd,
showPlaybackFeedback: options.mpv.showPlaybackFeedback,
log: options.log,
logDebug: options.logDebug,
warn: options.warn,
@@ -451,6 +460,19 @@ export function handleCliCommand(
deps.stopApp();
}
});
} else if (args.ensureLinuxRuntimePluginAssets) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps
.runEnsureLinuxRuntimePluginAssetsCommand(args, source)
.catch((err) => {
deps.error('runEnsureLinuxRuntimePluginAssetsCommand failed:', err);
deps.showMpvOsd(`Linux runtime plugin install failed: ${(err as Error).message}`);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode();
} else if (args.triggerFieldGrouping) {
@@ -534,18 +556,9 @@ export function handleCliCommand(
'playNextSubtitle',
'Play next subtitle failed',
);
} else if (args.shiftSubDelayPrevLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayPrevLine' },
'shiftSubDelayPrevLine',
'Shift subtitle delay failed',
);
} else if (args.shiftSubDelayNextLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayNextLine' },
'shiftSubDelayNextLine',
'Shift subtitle delay failed',
);
} else if (args.playbackFeedback) {
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
showFeedback(args.playbackFeedback);
} 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);
-1
View File
@@ -10,7 +10,6 @@ export {
unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut';
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
export {
copyCurrentSubtitle,

Some files were not shown because too many files have changed in this diff Show More