Compare commits

..

3 Commits

92 changed files with 3864 additions and 672 deletions
+10 -1
View File
@@ -23,6 +23,7 @@ MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
PRERELEASE_NOTES := release/prerelease-notes.md
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
ifeq ($(OS),Windows_NT)
@@ -161,7 +162,15 @@ build-launcher:
clean:
@printf '%s\n' "[INFO] Removing build artifacts"
@rm -rf dist release
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
rm -rf dist release && \
install -d release && \
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
else \
rm -rf dist release; \
fi
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
generate-config: ensure-bun
@@ -0,0 +1,4 @@
type: fixed
area: character-dictionary
- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request.
@@ -0,0 +1,6 @@
type: fixed
area: overlay
- Controller config and debug shortcuts now stay closed while controller support is disabled and show a notice to enable `controller.enabled` manually.
- Controller binding rows now start learn mode from the edit pencil, so clicking edit and pressing a controller button saves the remap.
- Controller remaps are now saved per controller profile, binding badges also start learn mode, and row reset buttons restore individual bindings to their defaults.
+6
View File
@@ -0,0 +1,6 @@
type: fixed
area: anilist
- Used fresh mpv time-position, duration, and subtitle timing events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
- Prefer season-specific AniList search results for multi-season files before falling back to the base title.
- Show a clear AniList message when the matched season is not in Planning or Watching instead of silently queueing an impossible progress update.
+10
View File
@@ -0,0 +1,10 @@
type: fixed
area: overlay
- Hid the macOS visible overlay when mpv is no longer the foreground target so other apps and Spaces are not covered by SubMiner subtitles.
- Kept the macOS overlay layered above active mpv while stats mouse passthrough is enabled, and treated the frontmost mpv app as the focus signal.
- Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop.
- Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground.
- Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs.
- Released the macOS overlay when the helper reports mpv is no longer foreground so other apps are no longer covered.
- Reduced macOS window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed
area: updates
- Avoided native `electron-updater` checks where they are unsafe, so tray and background update checks continue through GitHub release metadata without crashing the app.
- Kept signed macOS app updates on the native updater path while preventing eager Squirrel install checks before the user confirms restart.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: release
- Prerelease note generation now reuses existing reviewed prerelease notes and asks Claude to merge only new fragment material, while `make clean` preserves `release/prerelease-notes.md`.
+2 -1
View File
@@ -138,7 +138,8 @@
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================
+29 -28
View File
@@ -17,8 +17,8 @@ AniList integration is opt-in. To enable it:
{
"anilist": {
"enabled": true,
"accessToken": ""
}
"accessToken": "",
},
}
```
@@ -37,20 +37,20 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
The update flow:
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
## Update Queue and Retry
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
| Parameter | Value |
| --- | --- |
| Initial backoff | 30 seconds |
| Maximum backoff | 6 hours |
| Maximum attempts | 8 |
| Queue capacity | 500 items |
| Parameter | Value |
| ---------------- | ---------- |
| Initial backoff | 30 seconds |
| Maximum backoff | 6 hours |
| Maximum attempts | 8 |
| Queue capacity | 500 items |
After 8 failed attempts, the update is moved to a dead-letter queue and no longer retried automatically. The queue is persisted across restarts so no updates are lost if SubMiner exits before a retry succeeds.
@@ -85,36 +85,37 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
"collapsibleSections": {
"description": false,
"characterInformation": false,
"voicedBy": false
}
}
}
"voicedBy": false,
},
},
},
}
```
| Option | Values | Description |
| --- | --- | --- |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
| Option | Values | Description |
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
## CLI Commands
| Command | Description |
| --- | --- |
| `--anilist-setup` | Open AniList setup/auth flow helper window |
| `--anilist-status` | Print current token resolution state and retry queue counters |
| `--anilist-logout` | Clear stored AniList token from local persisted state |
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
| Command | Description |
| ----------------------- | ------------------------------------------------------------- |
| `--anilist-setup` | Open AniList setup/auth flow helper window |
| `--anilist-status` | Print current token resolution state and retry queue counters |
| `--anilist-logout` | Clear stored AniList token from local persisted state |
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
## Troubleshooting
- **Updates not triggering:** Confirm `anilist.enabled` is `true`. SubMiner requires at least 85% of the episode watched and a minimum of 10 minutes. Short episodes or partial watches will not trigger an update.
- **Update not possible:** Add the season to your AniList Planning or Watching list first. SubMiner will not create new AniList list entries automatically.
- **Wrong episode or title matched:** Detection quality is best when `guessit` is installed and on your `PATH`. Without it, SubMiner falls back to internal filename parsing which can be less accurate with unusual naming conventions.
- **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate.
- **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status.
+2 -2
View File
@@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on:
```
::: tip
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
:::
::: warning
@@ -139,7 +139,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`:
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
```jsonc
{
+18 -5
View File
@@ -608,8 +608,12 @@ Important behavior:
- Controller input is only active while keyboard-only mode is enabled.
- Keyboard-only mode continues to work normally without a controller.
- By default SubMiner uses the first connected controller.
- Fresh installs keep controller support disabled until you set `controller.enabled` to `true`.
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- The `Alt+C` config modal and `Alt+Shift+C` debug modal stay closed while controller support is disabled.
- Click the binding badge, edit pencil, or `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- Click the reset button beside the edit pencil to restore one binding to the built-in default.
- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
@@ -658,6 +662,15 @@ Important behavior:
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
},
"profiles": {
"Xbox Wireless Controller": {
"label": "Xbox Wireless Controller",
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
"mineCard": { "kind": "button", "buttonIndex": 2 },
},
},
},
},
}
```
@@ -678,7 +691,7 @@ Default logical mapping:
- `L3`: toggle mpv pause
- `L2` / `R2`: unbound by default
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors under `controller.profiles["<controller id>"]` for the selected controller. Manual edits are only needed when you want to script or copy exact mappings.
If you bind a discrete action to an axis manually, include `direction`:
@@ -692,15 +705,15 @@ If you bind a discrete action to an axis manually, include `direction`:
}
```
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
If one controller reports non-standard raw button numbers, override `controller.profiles["<controller id>"].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile.
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
### Manual Card Update Shortcuts
+1 -1
View File
@@ -65,7 +65,7 @@ With a gamepad connected and keyboard-only mode enabled, the full mining loop wo
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
After controller support is enabled, the controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
+2 -1
View File
@@ -138,7 +138,8 @@
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================
+7 -6
View File
@@ -283,13 +283,14 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
### Getting Started
1. Connect a controller before or after launching SubMiner.
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
2. Set `controller.enabled` to `true` in your config.
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
4. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
5. Click the binding badge, edit pencil, or `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
6. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
7. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
By default SubMiner uses the first connected controller after controller support is enabled. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline per controller. The reset button beside each edit pencil restores that binding to its built-in default for the selected controller. `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both modals stay closed while `controller.enabled` is false, and both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
### Default Button Mapping
@@ -316,7 +317,7 @@ By default SubMiner uses the first connected controller. `Alt+C` opens the contr
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
## Keybindings
+7 -4
View File
@@ -57,8 +57,11 @@
`*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. Do not run
`bun run changelog:build`.
committed file — so review it before committing. If you add more
`changes/*.md` fragments for a later beta/RC, rerun
`bun run changelog:prerelease-notes --version <version>`; the generator uses
the existing prerelease notes as the baseline and asks Claude to merge only
the new fragment material. Do not run `bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
@@ -70,11 +73,11 @@ Notes:
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `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.
- `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.
- 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.
- 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.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.15.0-beta.2",
"version": "0.15.0-beta.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
+17 -9
View File
@@ -3,25 +3,33 @@
## Highlights
### Added
**Auto-Update:** The tray and `subminer -u` now check for SubMiner releases and prompt you to install updates, covering the app itself, the command-line launcher, and Linux rofi themes. Update notifications are configurable, downloads are checksum-verified, and an opt-in prerelease channel lets you receive beta and RC builds.
**Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`. Checks include checksum verification, configurable notifications, and an opt-in channel for prerelease builds. The `subminer` launcher and Linux rofi theme are also updated automatically.
**First-Run Setup:** The app can now install Bun and the `subminer` command-line launcher during first run on Linux, macOS, and Windows. On Windows, a `subminer.cmd` PATH shim lets you type `subminer` in any terminal without manually adding `SubMiner.exe` to PATH.
**First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
### Fixed
**macOS Overlay:** Controls on the mpv window are now clickable before you hover the subtitle bars. Transient mpv window focus misses no longer hide the overlay; minimizing mpv still hides it as expected.
**macOS Overlay:** Significantly improved overlay focus and stability — the overlay now hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is also fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused.
**Subtitle Sync:** The subtitle sync modal on macOS now opens reliably: no more flash-and-hide on the first attempt or stale modal state left after syncing.
**Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing.
**Updater:** Update checks on Linux now use GitHub release metadata instead of the native Electron updater, preventing tray app crashes. macOS builds that cannot auto-install updates show a manual install prompt instead of a non-functional restart prompt. macOS update dialogs are brought to the front when `subminer --update` is run from the launcher.
**Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults.
**Linux Command-Line Updater:** `subminer -u` now performs release updates directly from the launcher without requiring the tray app, and reports "up to date" without downloading assets when already on the latest version.
**AniList Progress:** 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, and a clear message is shown when the matched season is not in Planning or Watching status.
**Launcher Setup:** Linux first-run launcher installs now build with a valid Bun shebang. `subminer app --setup` correctly opens the setup flow even when SubMiner is already running in the background. On macOS, first-run setup recognizes existing launchers in Homebrew or user PATH directories, and manual installs avoid Homebrew-managed locations. The setup window now quits automatically after first-run setup completes, returning control to the terminal.
**Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
**Tray App:** Fixed several issues with tray-launched Yomitan settings: closing the window no longer quits the tray app, a close-only menu replaces the default native menu, and settings loading no longer blocks other tray actions. An in-page close button is now available on Hyprland, where native window controls may be absent. Disabled the embedded popup preview in the settings window to prevent renderer hangs. Fixed a startup race condition that could leave extension loading in an error state, and corrected focus handling for the session help modal when mpv is not running.
**Updater — Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists.
**Build:** One-shot `make clean build install` flows now correctly pick up the AppImage built in the same invocation.
**Updater — macOS:** Update dialogs now come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native updater path without triggering premature Squirrel install checks.
**Setup — macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, returning control to the terminal.
**Launcher — Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
**Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running.
**Build — Linux Install:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
## Installation
+66
View File
@@ -509,6 +509,72 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
}
});
test('writePrereleaseNotesForVersion reuses existing prerelease notes when adding new fragments', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-reuse-existing-notes');
const projectRoot = path.join(workspace, 'SubMiner');
const existingNotes = [
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
'',
'## Highlights',
'### Added',
'- Overlay: Previous beta entry.',
'',
'## Installation',
'',
'See the README and docs/installation guide for full setup steps.',
'',
'## Assets',
'',
'- Linux: `SubMiner.AppImage`',
'',
].join('\n');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2),
'utf8',
);
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: fixed', 'area: launcher', '', '- Fixed launcher prerelease packaging.'].join('\n'),
'utf8',
);
try {
const stub = recordingRunClaude((input) => {
if (!input.includes('Overlay: Previous beta entry.')) {
return '### Fixed\n- Launcher: Added only the latest fix.';
}
return [
'### Added',
'- Overlay: Previous beta entry.',
'',
'### Fixed',
'- Launcher: Added only the latest fix.',
].join('\n');
});
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.2',
deps: { runClaude: stub.runClaude },
});
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
assert.match(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /- Overlay: Previous beta entry\./);
assert.match(prereleaseNotes, /- Launcher: Added only the latest fix\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-rc-notes');
+26 -3
View File
@@ -290,6 +290,7 @@ function serializeFragmentsForPrompt(
mode: PolishMode,
version: string,
date?: string,
existingReleaseNotes?: string,
): string {
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
if (date) {
@@ -307,7 +308,11 @@ function serializeFragmentsForPrompt(
].join('\n');
});
return [...header, '', ...fragmentBlocks].join('\n\n');
const existingNotesBlock = existingReleaseNotes?.trim()
? ['EXISTING PRERELEASE NOTES', existingReleaseNotes.trim()]
: [];
return [...header, '', ...existingNotesBlock, '', ...fragmentBlocks].join('\n\n');
}
function validatePolishedOutput(
@@ -340,10 +345,11 @@ function polishFragmentsWithClaude(
mode: PolishMode;
version: string;
date?: string;
existingReleaseNotes?: string;
deps?: ChangelogFsDeps;
},
): string {
const { mode, version, date } = options;
const { mode, version, date, existingReleaseNotes } = options;
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
const filtered =
@@ -361,8 +367,18 @@ function polishFragmentsWithClaude(
);
}
const reuseInstructions = existingReleaseNotes?.trim()
? [
'## Existing Prerelease Notes',
'',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Reuse those highlight bullets as the baseline, preserve their meaning and wording where possible, then merge in only new or changed fragment material. Deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.',
'',
].join('\n')
: '';
const prompt =
POLISH_PROMPT_INSTRUCTIONS + serializeFragmentsForPrompt(filtered, mode, version, date);
POLISH_PROMPT_INSTRUCTIONS +
reuseInstructions +
serializeFragmentsForPrompt(filtered, mode, version, date, existingReleaseNotes);
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
return validatePolishedOutput(output, mode, hasInternalFragments);
}
@@ -780,6 +796,8 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
verifyRequestedVersionMatchesPackageVersion(options ?? {});
const cwd = options?.cwd ?? process.cwd();
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const version = resolveVersion(options ?? {});
if (!isSupportedPrereleaseVersion(version)) {
throw new Error(
@@ -792,9 +810,14 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
throw new Error('No changelog fragments found in changes/.');
}
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
const existingReleaseNotes = existsSync(prereleaseNotesPath)
? readFileSync(prereleaseNotesPath, 'utf8')
: undefined;
const changes = polishFragmentsWithClaude(fragments, {
mode: 'release-notes',
version,
existingReleaseNotes,
deps: options?.deps,
});
return writeReleaseNotesFile(cwd, changes, options?.deps, {
+58 -7
View File
@@ -7,7 +7,7 @@
// It works with both bundled and unbundled mpv installations.
//
// Usage: swift get-mpv-window-macos.swift
// Output: "x,y,width,height" or "not-found"
// Output: "x,y,width,height,focused", "minimized", "active", "inactive", or "not-found"
//
import Cocoa
@@ -25,9 +25,16 @@ private struct WindowState {
let focused: Bool
}
private struct FrontmostApplicationState {
let pid: pid_t
let isMpv: Bool
}
private enum WindowLookupResult {
case visible(WindowState)
case minimized
case active
case inactive
}
private let targetMpvSocketPath: String? = {
@@ -146,8 +153,41 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? {
return geometry
}
private func frontmostApplicationPid() -> pid_t? {
NSWorkspace.shared.frontmostApplication?.processIdentifier
private func frontmostApplicationState() -> FrontmostApplicationState? {
guard let app = NSWorkspace.shared.frontmostApplication else {
return nil
}
return FrontmostApplicationState(
pid: app.processIdentifier,
isMpv: app.localizedName.map(normalizedMpvName) ?? false
)
}
private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost else {
return false
}
if frontmost.pid == ownerPid {
return true
}
return frontmost.isMpv && windowHasTargetSocket(ownerPid)
}
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost, frontmost.isMpv else {
return false
}
if windowHasTargetSocket(frontmost.pid) {
return true
}
// When macOS says mpv is frontmost but geometry APIs miss, keep the
// overlay stable even if ps cannot expose the socket argument.
return targetMpvSocketPath != nil
}
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
@@ -158,7 +198,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
return normalizedMpvName(name)
}
let frontmostPid = frontmostApplicationPid()
let frontmost = frontmostApplicationState()
var foundMinimizedTargetWindow = false
for app in runningApps {
@@ -198,7 +238,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
return .visible(
WindowState(
geometry: geometry,
focused: frontmostPid == windowPid
focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)
)
)
}
@@ -217,7 +257,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
let frontmostPid = frontmostApplicationPid()
let frontmost = frontmostApplicationState()
for window in windowList {
guard let ownerName = window[kCGWindowOwnerName as String] as? String,
@@ -260,7 +300,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
return WindowState(
geometry: geometry,
focused: frontmostPid == ownerPid
focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)
)
}
@@ -274,6 +314,13 @@ private let lookupResult: WindowLookupResult? = {
if let cgWindow = windowStateFromCoreGraphics() {
return .visible(cgWindow)
}
let frontmost = frontmostApplicationState()
if isFrontmostTargetMpv(frontmost) {
return .active
}
if frontmost != nil {
return .inactive
}
return nil
}()
@@ -285,6 +332,10 @@ if let result = lookupResult {
)
case .minimized:
print("minimized")
case .active:
print("active")
case .inactive:
print("inactive")
}
} else {
print("not-found")
+61
View File
@@ -31,3 +31,64 @@ test('minimized Accessibility windows are validated by PID and socket before rep
'target socket must be validated before accepting a minimized window',
);
});
test('focused mpv window follows the frontmost mpv app signal', () => {
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
assert.notEqual(focusHelperIndex, -1);
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
assert.ok(
focusHelperBody.includes('frontmost.pid == ownerPid'),
'matching frontmost PID should mark the mpv window focused',
);
assert.ok(
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
'Accessibility path should use the shared focused mpv helper',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
'CoreGraphics path should use the shared focused mpv helper',
);
});
test('frontmost mpv app emits active state when geometry lookup misses', () => {
assert.ok(
/case\s+\.active:/.test(source),
'helper should expose an active state without window geometry',
);
assert.ok(
source.includes('if windowHasTargetSocket(frontmost.pid)'),
'active state should still accept a matching target socket when available',
);
assert.ok(
source.includes('return targetMpvSocketPath != nil'),
'active state should preserve frontmost mpv even if command-line socket detection fails',
);
assert.ok(
source.includes('return .active'),
'lookup should preserve active mpv state after geometry lookup misses',
);
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
});
test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
assert.ok(
/case\s+\.inactive:/.test(source),
'helper should expose an inactive state without window geometry',
);
assert.ok(
source.includes('if frontmost != nil'),
'helper should distinguish a known non-mpv frontmost app from an unknown miss',
);
assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
assert.ok(
source.includes('print("inactive")'),
'inactive state should be printed for the tracker',
);
});
+98
View File
@@ -1453,6 +1453,104 @@ test('parses descriptor-based controller bindings', () => {
});
});
test('parses controller profiles as per-gamepad binding overrides', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"buttonIndices": {
"buttonSouth": 0,
"leftTrigger": 6
},
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
"quitMpv": "leftTrigger"
},
"profiles": {
"8BitDo SN30": {
"label": "8BitDo SN30",
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 11 },
"leftStickVertical": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" }
}
},
"Xbox Wireless Controller": {
"buttonIndices": {
"leftTrigger": 8
},
"bindings": {
"quitMpv": "leftTrigger"
}
}
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.toggleLookup, {
kind: 'button',
buttonIndex: 11,
});
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.closeLookup, {
kind: 'button',
buttonIndex: 1,
});
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.leftStickVertical, {
kind: 'axis',
axisIndex: 7,
dpadFallback: 'none',
});
assert.deepEqual(config.controller.profiles['Xbox Wireless Controller']?.bindings.quitMpv, {
kind: 'button',
buttonIndex: 8,
});
assert.equal(
config.controller.profiles['Xbox Wireless Controller']?.buttonIndices.leftTrigger,
8,
);
assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
});
test('rejects reserved controller profile ids from config', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"profiles": {
"__proto__": { "label": "polluted" },
"constructor": { "label": "ctor" },
"prototype": { "label": "proto" },
"pad-1": { "label": "Pad 1" }
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(Object.hasOwn(config.controller.profiles, '__proto__'), false);
assert.equal(Object.hasOwn(config.controller.profiles, 'constructor'), false);
assert.equal(Object.hasOwn(config.controller.profiles, 'prototype'), false);
assert.equal(config.controller.profiles['pad-1']?.label, 'Pad 1');
assert.equal(
warnings.some((warning) => warning.path === 'controller.profiles.constructor'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.profiles.prototype'),
true,
);
});
test('controller descriptor config rejects malformed binding objects', () => {
const dir = makeTempDir();
fs.writeFileSync(
+1
View File
@@ -74,6 +74,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
profiles: {},
},
shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
+7
View File
@@ -239,6 +239,13 @@ export function buildCoreConfigOptionRegistry(
description:
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
},
{
path: 'controller.profiles',
kind: 'object',
defaultValue: defaultConfig.controller.profiles,
description:
'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.',
},
...discreteBindings.flatMap((binding) => [
{
path: `controller.bindings.${binding.id}`,
+423
View File
@@ -0,0 +1,423 @@
import type {
ControllerAxisBinding,
ControllerAxisDirection,
ControllerButtonBinding,
ControllerButtonIndicesConfig,
ControllerDpadFallback,
ResolvedControllerAxisBinding,
ResolvedControllerBindingsConfig,
ResolvedControllerDiscreteBinding,
} from '../../types/runtime';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
const CONTROLLER_BUTTON_BINDINGS = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const CONTROLLER_AXIS_BINDINGS = [
'leftStickX',
'leftStickY',
'rightStickX',
'rightStickY',
] as const;
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
Exclude<ControllerButtonBinding, 'none'>,
keyof Required<ControllerButtonIndicesConfig>
> = {
select: 'select',
buttonSouth: 'buttonSouth',
buttonEast: 'buttonEast',
buttonNorth: 'buttonNorth',
buttonWest: 'buttonWest',
leftShoulder: 'leftShoulder',
rightShoulder: 'rightShoulder',
leftStickPress: 'leftStickPress',
rightStickPress: 'rightStickPress',
leftTrigger: 'leftTrigger',
rightTrigger: 'rightTrigger',
};
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
leftStickHorizontal: 'horizontal',
leftStickVertical: 'vertical',
rightStickHorizontal: 'none',
rightStickVertical: 'none',
} as const satisfies Record<string, ControllerDpadFallback>;
const CONTROLLER_BUTTON_INDEX_KEYS = [
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const CONTROLLER_DISCRETE_BINDING_KEYS = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
const CONTROLLER_AXIS_BINDING_KEYS = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
return value === 'negative' || value === 'positive';
}
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
return value === 'none' || value === 'horizontal' || value === 'vertical';
}
function resolveLegacyDiscreteBinding(
value: ControllerButtonBinding,
buttonIndices: Required<ControllerButtonIndicesConfig>,
): ResolvedControllerDiscreteBinding {
if (value === 'none') {
return { kind: 'none' };
}
return {
kind: 'button',
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
};
}
function resolveLegacyAxisBinding(
value: ControllerAxisBinding,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding {
return {
kind: 'axis',
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
if (!isObject(value) || typeof value.kind !== 'string') return null;
if (value.kind === 'none') {
return { kind: 'none' };
}
if (value.kind === 'button') {
return typeof value.buttonIndex === 'number' &&
Number.isInteger(value.buttonIndex) &&
value.buttonIndex >= 0
? { kind: 'button', buttonIndex: value.buttonIndex }
: null;
}
if (value.kind === 'axis') {
return typeof value.axisIndex === 'number' &&
Number.isInteger(value.axisIndex) &&
value.axisIndex >= 0 &&
isControllerAxisDirection(value.direction)
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
: null;
}
return null;
}
function parseAxisBindingObject(
value: unknown,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding | null {
if (isObject(value) && value.kind === 'none') {
return { kind: 'none' };
}
if (!isObject(value) || value.kind !== 'axis') return null;
if (
typeof value.axisIndex !== 'number' ||
!Number.isInteger(value.axisIndex) ||
value.axisIndex < 0
) {
return null;
}
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
return null;
}
return {
kind: 'axis',
axisIndex: value.axisIndex,
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
function applyControllerButtonIndices(
source: unknown,
target: ControllerButtonIndicesTarget,
pathPrefix: string,
warn: ResolveContext['warn'],
): void {
if (!isObject(source)) return;
for (const key of CONTROLLER_BUTTON_INDEX_KEYS) {
const value = asNumber(source[key]);
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
target[key] = value;
} else if (source[key] !== undefined) {
warn(`${pathPrefix}.${key}`, source[key], target[key], 'Expected non-negative integer.');
}
}
}
function applyControllerBindings(
source: unknown,
target: ControllerBindingsTarget,
buttonIndices: ControllerButtonIndicesTarget,
pathPrefix: string,
warn: ResolveContext['warn'],
): void {
if (!isObject(source)) return;
for (const key of CONTROLLER_DISCRETE_BINDING_KEYS) {
const bindingValue = source[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_BUTTON_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
)
) {
target[key] = resolveLegacyDiscreteBinding(
legacyValue as ControllerButtonBinding,
buttonIndices,
);
continue;
}
const parsedObject = parseDiscreteBindingObject(bindingValue);
if (parsedObject) {
target[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`${pathPrefix}.${key}`,
bindingValue,
target[key],
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
);
}
}
for (const key of CONTROLLER_AXIS_BINDING_KEYS) {
const bindingValue = source[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
) {
target[key] = resolveLegacyAxisBinding(legacyValue as ControllerAxisBinding, key);
continue;
}
if (legacyValue === 'none') {
target[key] = { kind: 'none' };
continue;
}
const parsedObject = parseAxisBindingObject(bindingValue, key);
if (parsedObject) {
target[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`${pathPrefix}.${key}`,
bindingValue,
target[key],
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
);
}
}
}
export function applyControllerConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (!isObject(src.controller)) return;
const enabled = asBoolean(src.controller.enabled);
if (enabled !== undefined) {
resolved.controller.enabled = enabled;
} else if (src.controller.enabled !== undefined) {
warn(
'controller.enabled',
src.controller.enabled,
resolved.controller.enabled,
'Expected boolean.',
);
}
const preferredGamepadId = asString(src.controller.preferredGamepadId);
if (preferredGamepadId !== undefined) {
resolved.controller.preferredGamepadId = preferredGamepadId;
}
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
if (preferredGamepadLabel !== undefined) {
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
}
const smoothScroll = asBoolean(src.controller.smoothScroll);
if (smoothScroll !== undefined) {
resolved.controller.smoothScroll = smoothScroll;
} else if (src.controller.smoothScroll !== undefined) {
warn(
'controller.smoothScroll',
src.controller.smoothScroll,
resolved.controller.smoothScroll,
'Expected boolean.',
);
}
const triggerInputMode = asString(src.controller.triggerInputMode);
if (
triggerInputMode === 'auto' ||
triggerInputMode === 'digital' ||
triggerInputMode === 'analog'
) {
resolved.controller.triggerInputMode = triggerInputMode;
} else if (src.controller.triggerInputMode !== undefined) {
warn(
'controller.triggerInputMode',
src.controller.triggerInputMode,
resolved.controller.triggerInputMode,
"Expected 'auto', 'digital', or 'analog'.",
);
}
const boundedNumberKeys = [
'scrollPixelsPerSecond',
'horizontalJumpPixels',
'repeatDelayMs',
'repeatIntervalMs',
] as const;
for (const key of boundedNumberKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected positive number.',
);
}
}
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
for (const key of deadzoneKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && value >= 0 && value <= 1) {
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected number between 0 and 1.',
);
}
}
applyControllerButtonIndices(
src.controller.buttonIndices,
resolved.controller.buttonIndices,
'controller.buttonIndices',
warn,
);
applyControllerBindings(
src.controller.bindings,
resolved.controller.bindings,
resolved.controller.buttonIndices,
'controller.bindings',
warn,
);
if (isObject(src.controller.profiles)) {
for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) {
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) {
warn(
`controller.profiles.${profileId}`,
rawProfile,
undefined,
'Reserved profile id is not allowed.',
);
continue;
}
if (!isObject(rawProfile)) {
warn(
`controller.profiles.${profileId}`,
rawProfile,
undefined,
'Expected controller profile object.',
);
continue;
}
const label = asString(rawProfile.label);
if (rawProfile.label !== undefined && label === undefined) {
warn(
`controller.profiles.${profileId}.label`,
rawProfile.label,
profileId,
'Expected string.',
);
}
const profile = {
label: label ?? profileId,
buttonIndices: structuredClone(resolved.controller.buttonIndices),
bindings: structuredClone(resolved.controller.bindings),
};
applyControllerButtonIndices(
rawProfile.buttonIndices,
profile.buttonIndices,
`controller.profiles.${profileId}.buttonIndices`,
warn,
);
applyControllerBindings(
rawProfile.bindings,
profile.bindings,
profile.buttonIndices,
`controller.profiles.${profileId}.bindings`,
warn,
);
resolved.controller.profiles[profileId] = profile;
}
}
}
+2 -341
View File
@@ -1,150 +1,7 @@
import type {
ControllerAxisBinding,
ControllerAxisBindingConfig,
ControllerAxisDirection,
ControllerButtonBinding,
ControllerButtonIndicesConfig,
ControllerDpadFallback,
ControllerDiscreteBindingConfig,
ResolvedControllerAxisBinding,
ResolvedControllerDiscreteBinding,
} from '../../types/runtime';
import { ResolveContext } from './context';
import { applyControllerConfig } from './controller';
import { asBoolean, asNumber, asString, isObject } from './shared';
const CONTROLLER_BUTTON_BINDINGS = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const CONTROLLER_AXIS_BINDINGS = [
'leftStickX',
'leftStickY',
'rightStickX',
'rightStickY',
] as const;
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
Exclude<ControllerButtonBinding, 'none'>,
keyof Required<ControllerButtonIndicesConfig>
> = {
select: 'select',
buttonSouth: 'buttonSouth',
buttonEast: 'buttonEast',
buttonNorth: 'buttonNorth',
buttonWest: 'buttonWest',
leftShoulder: 'leftShoulder',
rightShoulder: 'rightShoulder',
leftStickPress: 'leftStickPress',
rightStickPress: 'rightStickPress',
leftTrigger: 'leftTrigger',
rightTrigger: 'rightTrigger',
};
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
leftStickHorizontal: 'horizontal',
leftStickVertical: 'vertical',
rightStickHorizontal: 'none',
rightStickVertical: 'none',
} as const satisfies Record<string, ControllerDpadFallback>;
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
return value === 'negative' || value === 'positive';
}
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
return value === 'none' || value === 'horizontal' || value === 'vertical';
}
function resolveLegacyDiscreteBinding(
value: ControllerButtonBinding,
buttonIndices: Required<ControllerButtonIndicesConfig>,
): ResolvedControllerDiscreteBinding {
if (value === 'none') {
return { kind: 'none' };
}
return {
kind: 'button',
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
};
}
function resolveLegacyAxisBinding(
value: ControllerAxisBinding,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding {
return {
kind: 'axis',
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
if (!isObject(value) || typeof value.kind !== 'string') return null;
if (value.kind === 'none') {
return { kind: 'none' };
}
if (value.kind === 'button') {
return typeof value.buttonIndex === 'number' &&
Number.isInteger(value.buttonIndex) &&
value.buttonIndex >= 0
? { kind: 'button', buttonIndex: value.buttonIndex }
: null;
}
if (value.kind === 'axis') {
return typeof value.axisIndex === 'number' &&
Number.isInteger(value.axisIndex) &&
value.axisIndex >= 0 &&
isControllerAxisDirection(value.direction)
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
: null;
}
return null;
}
function parseAxisBindingObject(
value: unknown,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding | null {
if (isObject(value) && value.kind === 'none') {
return { kind: 'none' };
}
if (!isObject(value) || value.kind !== 'axis') return null;
if (
typeof value.axisIndex !== 'number' ||
!Number.isInteger(value.axisIndex) ||
value.axisIndex < 0
) {
return null;
}
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
return null;
}
return {
kind: 'axis',
axisIndex: value.axisIndex,
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
@@ -245,203 +102,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.controller)) {
const enabled = asBoolean(src.controller.enabled);
if (enabled !== undefined) {
resolved.controller.enabled = enabled;
} else if (src.controller.enabled !== undefined) {
warn(
'controller.enabled',
src.controller.enabled,
resolved.controller.enabled,
'Expected boolean.',
);
}
const preferredGamepadId = asString(src.controller.preferredGamepadId);
if (preferredGamepadId !== undefined) {
resolved.controller.preferredGamepadId = preferredGamepadId;
}
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
if (preferredGamepadLabel !== undefined) {
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
}
const smoothScroll = asBoolean(src.controller.smoothScroll);
if (smoothScroll !== undefined) {
resolved.controller.smoothScroll = smoothScroll;
} else if (src.controller.smoothScroll !== undefined) {
warn(
'controller.smoothScroll',
src.controller.smoothScroll,
resolved.controller.smoothScroll,
'Expected boolean.',
);
}
const triggerInputMode = asString(src.controller.triggerInputMode);
if (
triggerInputMode === 'auto' ||
triggerInputMode === 'digital' ||
triggerInputMode === 'analog'
) {
resolved.controller.triggerInputMode = triggerInputMode;
} else if (src.controller.triggerInputMode !== undefined) {
warn(
'controller.triggerInputMode',
src.controller.triggerInputMode,
resolved.controller.triggerInputMode,
"Expected 'auto', 'digital', or 'analog'.",
);
}
const boundedNumberKeys = [
'scrollPixelsPerSecond',
'horizontalJumpPixels',
'repeatDelayMs',
'repeatIntervalMs',
] as const;
for (const key of boundedNumberKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected positive number.',
);
}
}
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
for (const key of deadzoneKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && value >= 0 && value <= 1) {
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected number between 0 and 1.',
);
}
}
if (isObject(src.controller.buttonIndices)) {
const buttonIndexKeys = [
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
for (const key of buttonIndexKeys) {
const value = asNumber(src.controller.buttonIndices[key]);
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
resolved.controller.buttonIndices[key] = value;
} else if (src.controller.buttonIndices[key] !== undefined) {
warn(
`controller.buttonIndices.${key}`,
src.controller.buttonIndices[key],
resolved.controller.buttonIndices[key],
'Expected non-negative integer.',
);
}
}
}
if (isObject(src.controller.bindings)) {
const buttonBindingKeys = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
for (const key of buttonBindingKeys) {
const bindingValue = src.controller.bindings[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_BUTTON_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
)
) {
resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
legacyValue as ControllerButtonBinding,
resolved.controller.buttonIndices,
);
continue;
}
const parsedObject = parseDiscreteBindingObject(bindingValue);
if (parsedObject) {
resolved.controller.bindings[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`controller.bindings.${key}`,
bindingValue,
resolved.controller.bindings[key],
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
);
}
}
const axisBindingKeys = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
for (const key of axisBindingKeys) {
const bindingValue = src.controller.bindings[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_AXIS_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number],
)
) {
resolved.controller.bindings[key] = resolveLegacyAxisBinding(
legacyValue as ControllerAxisBinding,
key,
);
continue;
}
if (legacyValue === 'none') {
resolved.controller.bindings[key] = { kind: 'none' };
continue;
}
const parsedObject = parseAxisBindingObject(bindingValue, key);
if (parsedObject) {
resolved.controller.bindings[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`controller.bindings.${key}`,
bindingValue,
resolved.controller.bindings[key],
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
);
}
}
}
}
applyControllerConfig(context);
if (Array.isArray(src.keybindings)) {
resolved.keybindings = src.keybindings.filter(
@@ -32,10 +32,16 @@ test('anilist update queue enqueues, snapshots, and dequeues success', () => {
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
queue.enqueue('k1', 'Demo', 1);
queue.enqueue('k1', 'Demo', 1, 2);
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
assert.deepEqual(
{
key: queue.nextReady(Number.MAX_SAFE_INTEGER)?.key,
season: queue.nextReady(Number.MAX_SAFE_INTEGER)?.season,
},
{ key: 'k1', season: 2 },
);
queue.markSuccess('k1');
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
@@ -9,6 +9,7 @@ const MAX_ITEMS = 500;
export interface AnilistQueuedUpdate {
key: string;
title: string;
season?: number | null;
episode: number;
createdAt: number;
attemptCount: number;
@@ -28,7 +29,7 @@ export interface AnilistRetryQueueSnapshot {
}
export interface AnilistUpdateQueue {
enqueue: (key: string, title: string, episode: number) => void;
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
markSuccess: (key: string) => void;
markFailure: (key: string, reason: string, nowMs?: number) => void;
@@ -106,7 +107,7 @@ export function createAnilistUpdateQueue(
load();
return {
enqueue(key: string, title: string, episode: number): void {
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
const existing = pending.find((item) => item.key === key);
if (existing) {
return;
@@ -117,6 +118,7 @@ export function createAnilistUpdateQueue(
pending.push({
key,
title,
season,
episode,
createdAt: Date.now(),
attemptCount: 0,
@@ -265,6 +265,125 @@ test('updateAnilistPostWatchProgress skips when progress already reached', async
}
});
test('updateAnilistPostWatchProgress returns non-retryable error when media is not planning or watching', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }],
},
},
});
}
return createJsonResponse({
data: {
Media: { id: 33, mediaListEntry: null },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Missing Show', 2);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
const originalFetch = globalThis.fetch;
const searchTerms: string[] = [];
let call = 0;
globalThis.fetch = (async (_input, init) => {
call += 1;
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
if (call === 1) {
searchTerms.push(String(body.variables?.search));
return createJsonResponse({
data: {
Page: {
media: [
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
],
},
},
});
}
if (call === 2) {
assert.equal(body.variables?.mediaId, 202);
return createJsonResponse({
data: {
Media: { id: 202, mediaListEntry: null },
},
});
}
return createJsonResponse({
data: {
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
season: 2,
});
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 44, episodes: 12, title: { english: 'Rewatch Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: { id: 44, mediaListEntry: { progress: 0, status: 'REPEATING' } },
},
});
}
return createJsonResponse({
data: {
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Rewatch Show', 2);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /marked repeating on AniList/i);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress returns error when search fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
+77 -21
View File
@@ -18,10 +18,12 @@ export interface AnilistMediaGuess {
export interface AnilistPostWatchUpdateResult {
status: 'updated' | 'skipped' | 'error';
message: string;
retryable?: boolean;
}
export interface AnilistPostWatchUpdateOptions {
rateLimiter?: AnilistRateLimiter;
season?: number | null;
}
interface AnilistGraphQlError {
@@ -156,6 +158,28 @@ function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
function titleMentionsSeason(title: string, season: number): boolean {
const normalized = normalizeTitle(title);
return (
normalized.includes(`season ${season}`) ||
normalized.includes(`s${String(season).padStart(2, '0')}`) ||
normalized.includes(`s${season}`)
);
}
function buildSearchCandidates(title: string, season: number | null | undefined): string[] {
const trimmed = title.trim();
if (!trimmed) return [];
const candidates =
typeof season === 'number' &&
Number.isInteger(season) &&
season > 1 &&
!titleMentionsSeason(trimmed, season)
? [`${trimmed} Season ${season}`, trimmed]
: [trimmed];
return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index);
}
async function anilistGraphQl<T>(
accessToken: string,
query: string,
@@ -226,6 +250,15 @@ function pickBestSearchResult(
return { id: selected.id, title: selectedTitle };
}
function isUpdateableListStatus(status: string | null | undefined): boolean {
return status === 'CURRENT' || status === 'PLANNING';
}
function formatListStatus(status: string | null | undefined): string {
if (!status) return 'not in your AniList Planning or Watching list';
return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`;
}
export async function guessAnilistMediaInfo(
mediaPath: string | null,
mediaTitle: string | null,
@@ -279,27 +312,42 @@ export async function updateAnilistPostWatchProgress(
episode: number,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistPostWatchUpdateResult> {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
`
query ($search: String!) {
Page(perPage: 5) {
media(search: $search, type: ANIME) {
id
episodes
title {
romaji
english
native
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
let searchError: string | null = null;
let pickTitle = title;
const searchCandidates = buildSearchCandidates(title, options.season);
for (const search of searchCandidates) {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
`
query ($search: String!) {
Page(perPage: 5) {
media(search: $search, type: ANIME) {
id
episodes
title {
romaji
english
native
}
}
}
}
}
`,
{ search: title },
options,
);
const searchError = firstErrorMessage(searchResponse);
`,
{ search },
options,
);
searchError = firstErrorMessage(searchResponse);
if (searchError) {
break;
}
media = searchResponse.data?.Page?.media ?? [];
if (media.length > 0) {
pickTitle = search;
break;
}
}
if (searchError) {
return {
status: 'error',
@@ -307,8 +355,7 @@ export async function updateAnilistPostWatchProgress(
};
}
const media = searchResponse.data?.Page?.media ?? [];
const picked = pickBestSearchResult(title, episode, media);
const picked = pickBestSearchResult(pickTitle, episode, media);
if (!picked) {
return { status: 'error', message: 'AniList search returned no matches.' };
}
@@ -337,7 +384,16 @@ export async function updateAnilistPostWatchProgress(
};
}
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
if (!entry || !isUpdateableListStatus(entry.status)) {
return {
status: 'error',
retryable: false,
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
};
}
const currentProgress = entry.progress ?? 0;
if (typeof currentProgress === 'number' && currentProgress >= episode) {
return {
status: 'skipped',
+85 -1
View File
@@ -83,6 +83,7 @@ function createControllerConfigFixture() {
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
},
profiles: {},
};
}
@@ -218,6 +219,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
onOverlayModalClosed: () => {},
onOverlayMouseInteractionChanged: (active) => {
calls.push(`overlay-interaction:${active}`);
},
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
@@ -281,6 +285,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
deps.clearAnilistToken();
deps.openAnilistSetup();
deps.onOverlayMouseInteractionChanged?.(true, null);
assert.deepEqual(deps.getAnilistQueueStatus(), {
pending: 1,
ready: 0,
@@ -298,10 +303,37 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
assert.deepEqual(calls, [
'clearAnilistToken',
'openAnilistSetup',
'overlay-interaction:true',
'retryAnilistQueueNow',
]);
assert.equal(deps.getPlaybackPaused(), true);
});
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
onOverlayMouseInteractionChanged: (active) => {
calls.push(`overlay-interaction:${active}`);
},
}),
registrar,
);
const handler = handlers.on.get(IPC_CHANNELS.command.setIgnoreMouseEvents);
assert.equal(typeof handler, 'function');
handler?.({}, true, { forward: true });
handler?.({}, false, {});
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
});
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -944,6 +976,58 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
]);
});
test('registerIpcHandlers accepts per-controller profile config updates', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const controllerSaves: unknown[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
saveControllerConfig: async (update) => {
controllerSaves.push(update);
},
}),
registrar,
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig);
assert.ok(saveHandler);
const update = {
profiles: {
'pad-1': {
label: 'Pad One',
buttonIndices: {
buttonSouth: 11,
},
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
leftStickHorizontal: { kind: 'axis', axisIndex: 6, dpadFallback: 'horizontal' },
},
},
},
};
await saveHandler({}, update);
assert.deepEqual(controllerSaves, [update]);
await assert.rejects(async () => {
await saveHandler(
{},
{
profiles: {
'pad-1': {
bindings: {
toggleLookup: { kind: 'axis', axisIndex: 0 },
},
},
},
},
);
}, /Invalid controller config payload/);
await assert.rejects(async () => {
await saveHandler({}, JSON.parse('{"profiles":{"__proto__":{"label":"polluted"}}}'));
}, /Invalid controller config payload/);
});
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const dispatched: SessionActionDispatchRequest[] = [];
+10
View File
@@ -44,6 +44,10 @@ export interface IpcServiceDeps {
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayMouseInteractionChanged?: (
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
@@ -175,6 +179,10 @@ export interface IpcDepsRuntimeOptions {
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayMouseInteractionChanged?: (
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
@@ -233,6 +241,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
return {
onOverlayModalClosed: options.onOverlayModalClosed,
onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
openYomitanSettings: options.openYomitanSettings,
quitApp: options.quitApp,
toggleDevTools: () => {
@@ -349,6 +358,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
}
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
},
);
+351 -6
View File
@@ -42,6 +42,11 @@ function createMainWindowRecorder() {
setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`);
},
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
@@ -538,11 +543,12 @@ test('forced passthrough still shows tracked overlay while bound to mpv on Windo
assert.ok(calls.includes('sync-windows-z-order'));
});
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
updateVisibleOverlayVisibility({
@@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('always-on-top:false'));
});
test('forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
@@ -916,7 +967,8 @@ test('macOS tracked visible overlay starts click-through without passively steal
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
@@ -1009,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and click-through during tracker re
assert.deepEqual(osdMessages, []);
});
test('macOS tracked overlay releases topmost level when mpv loses foreground', () => {
test('macOS tracked overlay hides when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
@@ -1017,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
@@ -1046,14 +1101,202 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('show'));
});
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
});
test('macOS lets an active overlay receive mouse input instead of forcing passthrough', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(!calls.includes('mouse-ignore:true:forward'));
assert.ok(!calls.includes('hide'));
});
test('macOS focuses an active overlay so lookup trigger keys reach it', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('focus'));
assert.ok(!calls.includes('hide'));
});
test('macOS tracked overlay passively reappears when mpv regains foreground', () => {
const { window, calls } = createMainWindowRecorder();
let targetFocused = false;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => targetFocused,
};
window.show();
calls.length = 0;
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
run();
assert.ok(calls.includes('hide'));
calls.length = 0;
targetFocused = true;
run();
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
@@ -1141,7 +1384,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
@@ -1438,7 +1682,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
assert.ok(!calls.includes('show'));
});
test('macOS preserves visible overlay level during non-minimized tracker loss', () => {
test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
@@ -1477,13 +1721,114 @@ test('macOS preserves visible overlay level during non-minimized tracker loss',
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('loading-osd'));
});
test('macOS keeps a focused overlay visible during tracker loss', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
setFocused(true);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {
calls.push('loading-osd');
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('loading-osd'));
});
test('macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {
calls.push('loading-osd');
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('loading-osd'));
});
+60 -15
View File
@@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
opacityCapableWindow.setOpacity?.(opacity);
}
function releaseOverlayWindowLevel(window: BrowserWindow): void {
window.setAlwaysOnTop(false);
const allWorkspacesWindow = window as BrowserWindow & {
setVisibleOnAllWorkspaces?: (
visible: boolean,
options?: { visibleOnFullScreen?: boolean },
) => void;
};
allWorkspacesWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
}
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
if (!pendingTimeout) {
@@ -52,6 +63,7 @@ export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
overlayInteractionActive?: boolean;
mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null;
lastKnownWindowsForegroundProcessName?: string | null;
@@ -78,6 +90,7 @@ export function updateVisibleOverlayVisibility(args: {
}
const mainWindow = args.mainWindow;
const overlayInteractionActive = args.overlayInteractionActive === true;
if (args.modalActive) {
if (args.isWindowsPlatform) {
@@ -93,23 +106,26 @@ export function updateVisibleOverlayVisibility(args: {
const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible();
const isVisibleOverlayFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
overlayInteractionActive ||
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
const windowTracker = args.windowTracker;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
const hasTransientMacOSTrackerLoss =
args.isMacOSPlatform &&
canReportMacOSTargetMinimized &&
!!windowTracker &&
!windowTracker.isTracking() &&
!isTrackedMacOSTargetMinimized &&
trackedMacOSTargetFocused !== false &&
mainWindow.isVisible();
const isTrackedMacOSTargetFocused =
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
? true
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
: (trackedMacOSTargetFocused ?? true);
const shouldReleaseMacOSOverlayLevel =
args.isMacOSPlatform &&
!!args.windowTracker &&
@@ -117,7 +133,7 @@ export function updateVisibleOverlayVisibility(args: {
!isVisibleOverlayFocused &&
!isTrackedMacOSTargetFocused;
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform;
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive;
const shouldDefaultToPassthrough =
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
const windowsForegroundProcessName =
@@ -159,14 +175,22 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.setIgnoreMouseEvents(false);
}
if (shouldReleaseMacOSOverlayLevel) {
releaseOverlayWindowLevel(mainWindow);
if (wasVisible) {
mainWindow.hide();
}
return false;
}
if (shouldBindTrackedWindowsOverlay) {
// On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management.
} else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) {
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
args.ensureOverlayWindowLevel(mainWindow);
} else {
mainWindow.setAlwaysOnTop(false);
releaseOverlayWindowLevel(mainWindow);
}
if (!wasVisible) {
const hasWebContents =
@@ -179,16 +203,20 @@ export function updateVisibleOverlayVisibility(args: {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer
// has painted its first frame.
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
setOverlayWindowOpacity(mainWindow, 0);
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
scheduleWindowsOverlayReveal(
mainWindow,
shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined,
);
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(
mainWindow,
shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined,
);
}
} else {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
@@ -209,6 +237,16 @@ export function updateVisibleOverlayVisibility(args: {
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
}
if (
args.isMacOSPlatform &&
overlayInteractionActive &&
!forceMousePassthrough &&
typeof mainWindow.isFocused === 'function' &&
!mainWindow.isFocused()
) {
mainWindow.focus();
}
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
mainWindow.focus();
}
@@ -216,6 +254,11 @@ export function updateVisibleOverlayVisibility(args: {
return !shouldReleaseMacOSOverlayLevel;
};
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
shouldEnforceLayerOrder &&
!args.isWindowsPlatform &&
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
const maybeShowOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
return;
@@ -258,7 +301,7 @@ export function updateVisibleOverlayVisibility(args: {
}
args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
@@ -290,6 +333,7 @@ export function updateVisibleOverlayVisibility(args: {
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
const hasActiveMacOSTargetSignal =
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized =
@@ -298,6 +342,7 @@ export function updateVisibleOverlayVisibility(args: {
(args.isMacOSPlatform &&
!isTrackedMacOSTargetMinimized &&
(hasRetainedTrackedGeometry ||
(mainWindow.isVisible() && hasActiveMacOSOverlaySignal) ||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
(args.isWindowsPlatform &&
@@ -315,7 +360,7 @@ export function updateVisibleOverlayVisibility(args: {
}
args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
+3 -2
View File
@@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: {
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
ensureOverlayWindowLevel: () => void;
moveWindowTop: () => void;
onWindowsVisibleOverlayBlur?: () => void;
onVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean {
const platform = options.platform ?? process.platform;
if (platform === 'win32' && options.kind === 'visible') {
options.onWindowsVisibleOverlayBlur?.();
options.onVisibleOverlayBlur?.();
return false;
}
if (platform === 'darwin' && options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false;
}
+7 -7
View File
@@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'win32',
});
assert.equal(handled, false);
assert.deepEqual(calls, ['windows-visible-blur']);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
@@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => {
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
@@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'darwin',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
+1 -1
View File
@@ -180,7 +180,7 @@ export function createOverlayWindow(
moveWindowTop: () => {
window.moveTop();
},
onWindowsVisibleOverlayBlur:
onVisibleOverlayBlur:
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
});
});
+19
View File
@@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
Partial<Pick<BrowserWindow, 'showInactive'>>;
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
return (
@@ -104,6 +106,23 @@ export function promoteStatsWindowLevel(
window.moveTop();
}
export function presentStatsWindow(
window: StatsWindowPresentationController,
platform: NodeJS.Platform = process.platform,
): void {
if (platform === 'darwin') {
if (window.showInactive) {
window.showInactive();
} else {
window.show();
}
return;
}
window.show();
window.focus();
}
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
query: Record<string, string>;
} {
+43
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput,
@@ -230,3 +231,45 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
});
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
const calls: string[] = [];
presentStatsWindow(
{
show: () => {
calls.push('show');
},
showInactive: () => {
calls.push('show-inactive');
},
focus: () => {
calls.push('focus');
},
} as never,
'darwin',
);
assert.deepEqual(calls, ['show-inactive']);
});
test('presentStatsWindow shows and focuses on non-macOS platforms', () => {
const calls: string[] = [];
presentStatsWindow(
{
show: () => {
calls.push('show');
},
showInactive: () => {
calls.push('show-inactive');
},
focus: () => {
calls.push('focus');
},
} as never,
'linux',
);
assert.deepEqual(calls, ['show', 'focus']);
});
+2 -2
View File
@@ -5,6 +5,7 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput,
@@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
const bounds = options.resolveBounds();
let placementBounds = syncStatsWindowBounds(window, bounds);
promoteStatsWindowLevel(window);
window.show();
presentStatsWindow(window);
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
if (
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
) {
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
}
window.focus();
options.onVisibilityChanged?.(true);
promoteStatsWindowLevel(window);
}
+48 -17
View File
@@ -2069,6 +2069,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () => visibleOverlayInteractionActive,
getWindowTracker: () => appState.windowTracker,
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
@@ -2112,23 +2113,24 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
})(),
);
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
let visibleOverlayInteractionActive = false;
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout);
}
windowsVisibleOverlayBlurRefreshTimeouts = [];
visibleOverlayBlurRefreshTimeouts = [];
}
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
@@ -2329,20 +2331,22 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
}
function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32') {
if (process.platform !== 'win32' && process.platform !== 'darwin') {
return;
}
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
clearWindowsVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
if (process.platform === 'win32') {
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
}
clearVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, delayMs);
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
}
}
@@ -3043,6 +3047,7 @@ const {
resetAnilistMediaTracking,
getAnilistMediaGuessRuntimeState,
setAnilistMediaGuessRuntimeState,
recordAnilistMediaDuration,
resetAnilistMediaGuessState,
maybeProbeAnilistDuration,
ensureAnilistMediaGuess,
@@ -3146,6 +3151,13 @@ const {
);
},
},
recordMediaDurationMainDeps: {
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
getState: () => getAnilistMediaGuessRuntimeState(),
setState: (state) => {
setAnilistMediaGuessRuntimeState(state);
},
},
resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
@@ -3197,9 +3209,10 @@ const {
);
},
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
season,
}),
markSuccess: (key) => {
anilistUpdateQueue.markSuccess(key);
@@ -3230,13 +3243,13 @@ const {
resetAnilistMediaTracking(mediaKey);
},
getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
maybeProbeAnilistDuration: (mediaKey) => maybeProbeAnilistDuration(mediaKey),
maybeProbeAnilistDuration: (mediaKey, options) => maybeProbeAnilistDuration(mediaKey, options),
ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey),
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
enqueueRetry: (key, title, episode) => {
anilistUpdateQueue.enqueue(key, title, episode);
enqueueRetry: (key, title, episode, season) => {
anilistUpdateQueue.enqueue(key, title, episode, season);
},
markRetryFailure: (key, message) => {
anilistUpdateQueue.markFailure(key, message);
@@ -3245,9 +3258,10 @@ const {
anilistUpdateQueue.markSuccess(key);
},
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
season,
}),
rememberAttemptedUpdateKey: (key) => {
rememberAnilistAttemptedUpdate(key);
@@ -3984,7 +3998,10 @@ const {
reportJellyfinRemoteStopped: () => {
void reportJellyfinRemoteStopped();
},
maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
recordAnilistMediaDuration: (durationSec) => {
recordAnilistMediaDuration(durationSec);
},
logSubtitleTimingError: (message, error) => logger.error(message, error),
broadcastToOverlayWindows: (channel, payload) => {
broadcastToOverlayWindows(channel, payload);
@@ -5126,6 +5143,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
onOverlayModalOpened: (modal) => {
overlayModalRuntime.notifyOverlayModalOpened(modal);
},
onOverlayMouseInteractionChanged: (active, senderWindow) => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || senderWindow !== mainWindow) {
return;
}
if (visibleOverlayInteractionActive === active) {
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
return;
}
visibleOverlayInteractionActive = active;
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
},
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(),
@@ -1459,7 +1459,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different
}
});
test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
let searchQueryCount = 0;
@@ -1567,11 +1567,18 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data',
});
const first = await runtime.getOrCreateCurrentSnapshot();
assert.equal(searchQueryCount, 1);
assert.equal(characterQueryCount, 1);
fs.rmSync(path.join(userDataPath, 'character-dictionaries', 'anilist-resolution-cache.json'), {
force: true,
});
const second = await runtime.getOrCreateCurrentSnapshot();
assert.equal(first.fromCache, false);
assert.equal(second.fromCache, true);
assert.equal(searchQueryCount, 2);
assert.equal(searchQueryCount, 1);
assert.equal(characterQueryCount, 1);
assert.equal(
fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')),
+60
View File
@@ -15,7 +15,10 @@ import {
getMergedZipPath,
getSnapshotPath,
normalizeMergedMediaIds,
readCachedMediaResolution,
readCachedSnapshots,
readSnapshot,
writeCachedMediaResolution,
writeSnapshot,
} from './character-dictionary-runtime/cache';
import {
@@ -41,6 +44,7 @@ import type {
CharacterDictionaryManualSelectionResult,
CharacterDictionaryManualSelectionSnapshot,
CharacterDictionaryRuntimeDeps,
CharacterDictionarySnapshot,
CharacterDictionarySnapshotImage,
CharacterDictionarySnapshotProgress,
CharacterDictionarySnapshotProgressCallbacks,
@@ -204,6 +208,26 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
};
};
const findCachedSnapshotForSeriesKey = (
seriesKey: string,
): CharacterDictionarySnapshot | null => {
return (
readCachedSnapshots(outputDir).find((snapshot) => {
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
mediaPath: null,
mediaTitle: snapshot.mediaTitle,
guess: {
title: snapshot.mediaTitle,
season: null,
episode: null,
source: 'fallback',
},
});
return snapshotSeriesKey === seriesKey;
}) ?? null
);
};
const resolveCurrentMedia = async (
targetPath?: string,
beforeRequest?: () => Promise<void>,
@@ -228,7 +252,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
staleMediaIds: override.staleMediaIds,
};
}
const cachedResolution = readCachedMediaResolution(outputDir, seriesKey);
if (cachedResolution) {
const cachedSnapshot = readSnapshot(getSnapshotPath(outputDir, cachedResolution.mediaId));
if (cachedSnapshot) {
deps.logInfo?.(
`[dictionary] cached AniList match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
);
return {
id: cachedSnapshot.mediaId,
title: cachedSnapshot.mediaTitle,
};
}
}
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey);
if (cachedSnapshot) {
writeCachedMediaResolution(outputDir, {
seriesKey,
mediaId: cachedSnapshot.mediaId,
mediaTitle: cachedSnapshot.mediaTitle,
});
deps.logInfo?.(
`[dictionary] cached snapshot match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
);
return {
id: cachedSnapshot.mediaId,
title: cachedSnapshot.mediaTitle,
};
}
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
writeCachedMediaResolution(outputDir, {
seriesKey,
mediaId: resolved.id,
mediaTitle: resolved.title,
});
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
return resolved;
};
@@ -21,6 +21,102 @@ export function getMergedZipPath(outputDir: string): string {
return path.join(outputDir, 'merged.zip');
}
type MediaResolutionCacheEntry = {
seriesKey: string;
mediaId: number;
mediaTitle: string;
};
type MediaResolutionCacheFile = {
entries?: MediaResolutionCacheEntry[];
};
function getMediaResolutionCachePath(outputDir: string): string {
return path.join(outputDir, 'anilist-resolution-cache.json');
}
function normalizeMediaResolutionEntry(value: unknown): MediaResolutionCacheEntry | null {
if (!value || typeof value !== 'object') return null;
const raw = value as Partial<MediaResolutionCacheEntry>;
const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : '';
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
if (typeof raw.mediaId !== 'number' || !Number.isFinite(raw.mediaId)) return null;
const mediaId = Math.floor(raw.mediaId);
if (!seriesKey || mediaId <= 0 || !mediaTitle) return null;
return {
seriesKey,
mediaId,
mediaTitle,
};
}
function readMediaResolutionEntries(outputDir: string): MediaResolutionCacheEntry[] {
try {
const parsed = JSON.parse(
fs.readFileSync(getMediaResolutionCachePath(outputDir), 'utf8'),
) as MediaResolutionCacheFile;
if (!Array.isArray(parsed.entries)) return [];
const byKey = new Map<string, MediaResolutionCacheEntry>();
for (const value of parsed.entries) {
const normalized = normalizeMediaResolutionEntry(value);
if (normalized) byKey.set(normalized.seriesKey, normalized);
}
return [...byKey.values()];
} catch {
return [];
}
}
function writeMediaResolutionEntries(
outputDir: string,
entries: MediaResolutionCacheEntry[],
): void {
ensureDir(outputDir);
fs.writeFileSync(
getMediaResolutionCachePath(outputDir),
JSON.stringify({ entries }, null, 2),
'utf8',
);
}
export function readCachedMediaResolution(
outputDir: string,
seriesKey: string,
): MediaResolutionCacheEntry | null {
const normalizedKey = seriesKey.trim();
if (!normalizedKey) return null;
return (
readMediaResolutionEntries(outputDir).find((entry) => entry.seriesKey === normalizedKey) ?? null
);
}
export function writeCachedMediaResolution(
outputDir: string,
entry: MediaResolutionCacheEntry,
): void {
const normalized = normalizeMediaResolutionEntry(entry);
if (!normalized) return;
const remaining = readMediaResolutionEntries(outputDir).filter(
(existing) => existing.seriesKey !== normalized.seriesKey,
);
writeMediaResolutionEntries(outputDir, [...remaining, normalized]);
}
export function readCachedSnapshots(outputDir: string): CharacterDictionarySnapshot[] {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((entry) => entry.isFile() && /^anilist-\d+\.json$/.test(entry.name))
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => readSnapshot(path.join(getSnapshotsDir(outputDir), entry.name)))
.filter((snapshot): snapshot is CharacterDictionarySnapshot => snapshot !== null);
}
export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null {
try {
const raw = fs.readFileSync(snapshotPath, 'utf8');
+64
View File
@@ -75,3 +75,67 @@ test('applyControllerConfigUpdate detaches updated binding values from the patch
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 7 });
});
test('applyControllerConfigUpdate merges per-controller profile binding leaves', () => {
const next = applyControllerConfigUpdate(
{
profiles: {
'pad-1': {
label: 'Pad 1',
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
},
},
},
},
{
profiles: {
'pad-1': {
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
},
'pad-2': {
label: 'Pad 2',
bindings: {
mineCard: { kind: 'button', buttonIndex: 8 },
},
},
},
},
);
assert.deepEqual(next.profiles?.['pad-1']?.bindings?.toggleLookup, {
kind: 'button',
buttonIndex: 11,
});
assert.deepEqual(next.profiles?.['pad-1']?.bindings?.closeLookup, {
kind: 'button',
buttonIndex: 1,
});
assert.deepEqual(next.profiles?.['pad-2']?.bindings?.mineCard, {
kind: 'button',
buttonIndex: 8,
});
});
test('applyControllerConfigUpdate ignores reserved profile ids', () => {
const reservedProfiles = Object.create(null) as NonNullable<
Parameters<typeof applyControllerConfigUpdate>[1]['profiles']
>;
reservedProfiles.__proto__ = { label: 'polluted' };
reservedProfiles['constructor'] = { label: 'ctor' };
reservedProfiles['prototype'] = { label: 'proto' };
reservedProfiles['pad-1'] = { label: 'Pad 1' };
const next = applyControllerConfigUpdate(undefined, {
profiles: reservedProfiles,
});
assert.equal(Object.getPrototypeOf(next.profiles), Object.prototype);
assert.equal(Object.hasOwn(next.profiles ?? {}, '__proto__'), false);
assert.equal(Object.hasOwn(next.profiles ?? {}, 'constructor'), false);
assert.equal(Object.hasOwn(next.profiles ?? {}, 'prototype'), false);
assert.equal(next.profiles?.['pad-1']?.label, 'Pad 1');
});
+87 -15
View File
@@ -2,6 +2,66 @@ import type { ControllerConfigUpdate, RawConfig } from '../types';
type RawControllerConfig = NonNullable<RawConfig['controller']>;
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
type RawControllerButtonIndices = NonNullable<RawControllerConfig['buttonIndices']>;
type RawControllerProfiles = NonNullable<RawControllerConfig['profiles']>;
type RawControllerProfile = NonNullable<RawControllerProfiles[string]>;
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
function mergeBindingPatch(
currentBindings: RawControllerBindings | undefined,
updateBindings: RawControllerBindings | undefined,
): RawControllerBindings | undefined {
if (!currentBindings && !updateBindings) return undefined;
const nextBindings: RawControllerBindings = {
...(currentBindings ?? {}),
};
for (const [key, value] of Object.entries(updateBindings ?? {}) as Array<
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
>) {
if (value === undefined) continue;
(nextBindings as Record<string, unknown>)[key] = structuredClone(value);
}
return nextBindings;
}
function mergeButtonIndexPatch(
currentButtonIndices: RawControllerButtonIndices | undefined,
updateButtonIndices: RawControllerButtonIndices | undefined,
): RawControllerButtonIndices | undefined {
if (!currentButtonIndices && !updateButtonIndices) return undefined;
return {
...(currentButtonIndices ?? {}),
...(updateButtonIndices ?? {}),
};
}
function mergeControllerProfile(
currentProfile: RawControllerProfile | undefined,
updateProfile: RawControllerProfile,
): RawControllerProfile {
const nextProfile: RawControllerProfile = {
...(currentProfile ?? {}),
...updateProfile,
};
const buttonIndices = mergeButtonIndexPatch(
currentProfile?.buttonIndices,
updateProfile.buttonIndices,
);
if (buttonIndices) {
nextProfile.buttonIndices = buttonIndices;
}
const bindings = mergeBindingPatch(currentProfile?.bindings, updateProfile.bindings);
if (bindings) {
nextProfile.bindings = bindings;
}
return nextProfile;
}
export function applyControllerConfigUpdate(
currentController: RawConfig['controller'] | undefined,
@@ -12,26 +72,38 @@ export function applyControllerConfigUpdate(
...update,
};
if (currentController?.buttonIndices || update.buttonIndices) {
nextController.buttonIndices = {
...(currentController?.buttonIndices ?? {}),
...(update.buttonIndices ?? {}),
};
const buttonIndices = mergeButtonIndexPatch(
currentController?.buttonIndices,
update.buttonIndices,
);
if (buttonIndices) {
nextController.buttonIndices = buttonIndices;
}
if (currentController?.bindings || update.bindings) {
const nextBindings: RawControllerBindings = {
...(currentController?.bindings ?? {}),
};
const bindings = mergeBindingPatch(currentController?.bindings, update.bindings);
if (bindings) {
nextController.bindings = bindings;
}
for (const [key, value] of Object.entries(update.bindings ?? {}) as Array<
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
if (currentController?.profiles || update.profiles) {
const nextProfiles: RawControllerProfiles = {};
for (const [profileId, profile] of Object.entries(currentController?.profiles ?? {}) as Array<
[string, RawControllerProfile]
>) {
if (value === undefined) continue;
(nextBindings as Record<string, unknown>)[key] = structuredClone(value);
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue;
nextProfiles[profileId] = profile;
}
nextController.bindings = nextBindings;
for (const [profileId, profileUpdate] of Object.entries(update.profiles ?? {}) as Array<
[string, RawControllerProfile | undefined]
>) {
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue;
if (profileUpdate === undefined) continue;
nextProfiles[profileId] = mergeControllerProfile(
currentController?.profiles?.[profileId],
profileUpdate,
);
}
nextController.profiles = nextProfiles;
}
return nextController;
+2
View File
@@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp'];
@@ -229,6 +230,7 @@ export function createMainIpcRuntimeServiceDeps(
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
onOverlayModalClosed: params.onOverlayModalClosed,
onOverlayModalOpened: params.onOverlayModalOpened,
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
onYoutubePickerResolve: params.onYoutubePickerResolve,
openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp,
+2
View File
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
getModalActive: () => boolean;
getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean;
getOverlayInteractionActive?: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
getLastKnownWindowsForegroundProcessName?: () => string | null;
getWindowsOverlayProcessName?: () => string | null;
@@ -49,6 +50,7 @@ export function createOverlayVisibilityRuntimeService(
visibleOverlayVisible,
modalActive: deps.getModalActive(),
forceMousePassthrough,
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
mainWindow,
windowTracker,
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
@@ -30,6 +30,36 @@ test('maybeProbeAnilistDuration updates state with probed duration', async () =>
assert.equal(state.mediaDurationSec, 321);
});
test('maybeProbeAnilistDuration force option bypasses retry interval', async () => {
let state: AnilistMediaGuessRuntimeState = {
mediaKey: '/tmp/video.mkv',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 1900,
};
let requestCount = 0;
const probe = createMaybeProbeAnilistDurationHandler({
getState: () => state,
setState: (next) => {
state = next;
},
durationRetryIntervalMs: 1000,
now: () => 2000,
requestMpvDuration: async () => {
requestCount += 1;
return 321;
},
logWarn: () => {},
});
const duration = await probe('/tmp/video.mkv', { force: true });
assert.equal(duration, 321);
assert.equal(requestCount, 1);
assert.equal(state.mediaDurationSec, 321);
});
test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
let state: AnilistMediaGuessRuntimeState = {
mediaKey: '/tmp/video.mkv',
+9 -2
View File
@@ -14,6 +14,10 @@ type GuessAnilistMediaInfo = (
mediaTitle: string | null,
) => Promise<AnilistMediaGuess | null>;
type AnilistDurationProbeOptions = {
force?: boolean;
};
export function createMaybeProbeAnilistDurationHandler(deps: {
getState: () => AnilistMediaGuessRuntimeState;
setState: (state: AnilistMediaGuessRuntimeState) => void;
@@ -22,7 +26,10 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
requestMpvDuration: () => Promise<unknown>;
logWarn: (message: string, error: unknown) => void;
}) {
return async (mediaKey: string): Promise<number | null> => {
return async (
mediaKey: string,
options: AnilistDurationProbeOptions = {},
): Promise<number | null> => {
const state = deps.getState();
if (state.mediaKey !== mediaKey) {
return null;
@@ -34,7 +41,7 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
return state.mediaDurationSec;
}
const now = deps.now();
if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
if (!options.force && now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
return null;
}
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
createBuildRecordAnilistMediaDurationMainDepsHandler,
createBuildResetAnilistMediaGuessStateMainDepsHandler,
createBuildResetAnilistMediaTrackingMainDepsHandler,
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
@@ -70,3 +71,32 @@ test('reset anilist media guess state main deps builder maps callbacks', () => {
deps.setMediaGuessPromise(null);
assert.deepEqual(calls, ['guess', 'promise']);
});
test('record anilist media duration main deps builder maps callbacks', () => {
const calls: string[] = [];
const state = {
mediaKey: '/tmp/video.mkv',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
};
const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({
getCurrentMediaKey: () => {
calls.push('key');
return '/tmp/video.mkv';
},
getState: () => {
calls.push('get');
return state;
},
setState: () => {
calls.push('set');
},
})();
assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv');
deps.getState();
deps.setState(state);
assert.deepEqual(calls, ['key', 'get', 'set']);
});
@@ -1,6 +1,7 @@
import type {
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createRecordAnilistMediaDurationHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
@@ -18,6 +19,9 @@ type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
typeof createSetAnilistMediaGuessRuntimeStateHandler
>[0];
type RecordAnilistMediaDurationMainDeps = Parameters<
typeof createRecordAnilistMediaDurationHandler
>[0];
type ResetAnilistMediaGuessStateMainDeps = Parameters<
typeof createResetAnilistMediaGuessStateHandler
>[0];
@@ -66,6 +70,16 @@ export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
});
}
export function createBuildRecordAnilistMediaDurationMainDepsHandler(
deps: RecordAnilistMediaDurationMainDeps,
) {
return (): RecordAnilistMediaDurationMainDeps => ({
getCurrentMediaKey: () => deps.getCurrentMediaKey(),
getState: () => deps.getState(),
setState: (state) => deps.setState(state),
});
}
export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
deps: ResetAnilistMediaGuessStateMainDeps,
) {
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createRecordAnilistMediaDurationHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
@@ -176,3 +177,57 @@ test('reset anilist media guess state clears guess and in-flight promise', () =>
assert.equal(state.mediaDurationSec, 240);
assert.equal(state.lastDurationProbeAtMs, 321);
});
test('record anilist media duration stores observed mpv duration for current media', () => {
const existingPromise = Promise.resolve(null);
let state = {
mediaKey: '/tmp/video.mkv' as string | null,
mediaDurationSec: null as number | null,
mediaGuess: { title: 'guess' } as { title: string } | null,
mediaGuessPromise: existingPromise as Promise<unknown> | null,
lastDurationProbeAtMs: 321,
};
const recordDuration = createRecordAnilistMediaDurationHandler({
getCurrentMediaKey: () => '/tmp/video.mkv',
getState: () => state as never,
setState: (nextState) => {
state = nextState as never;
},
});
recordDuration(1440);
assert.equal(state.mediaDurationSec, 1440);
assert.deepEqual(state.mediaGuess, { title: 'guess' });
assert.equal(state.mediaGuessPromise, existingPromise);
assert.equal(state.lastDurationProbeAtMs, 321);
});
test('record anilist media duration resets stale media state when media key changes', () => {
let state = {
mediaKey: '/tmp/old.mkv' as string | null,
mediaDurationSec: 120 as number | null,
mediaGuess: { title: 'old' } as { title: string } | null,
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
lastDurationProbeAtMs: 321,
};
const recordDuration = createRecordAnilistMediaDurationHandler({
getCurrentMediaKey: () => '/tmp/new.mkv',
getState: () => state as never,
setState: (nextState) => {
state = nextState as never;
},
});
recordDuration(1440);
assert.deepEqual(state, {
mediaKey: '/tmp/new.mkv',
mediaDurationSec: 1440,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
});
});
+31
View File
@@ -61,6 +61,37 @@ export function createSetAnilistMediaGuessRuntimeStateHandler(deps: {
};
}
export function createRecordAnilistMediaDurationHandler(deps: {
getCurrentMediaKey: () => string | null;
getState: () => AnilistMediaGuessRuntimeState;
setState: (state: AnilistMediaGuessRuntimeState) => void;
}) {
return (durationSec: number): void => {
if (!Number.isFinite(durationSec) || durationSec <= 0) {
return;
}
const mediaKey = deps.getCurrentMediaKey();
if (!mediaKey) {
return;
}
const state = deps.getState();
if (state.mediaKey === mediaKey) {
deps.setState({
...state,
mediaDurationSec: durationSec,
});
return;
}
deps.setState({
mediaKey,
mediaDurationSec: durationSec,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
});
};
}
export function createResetAnilistMediaGuessStateHandler(deps: {
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
@@ -13,7 +13,10 @@ test('process next anilist retry update main deps builder maps callbacks', async
setLastAttemptAt: () => calls.push('attempt'),
setLastError: () => calls.push('error'),
refreshAnilistClientSecretState: async () => 'token',
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
status: 'updated',
message: `ok:${season}`,
}),
markSuccess: () => calls.push('success'),
rememberAttemptedUpdateKey: () => calls.push('remember'),
markFailure: () => calls.push('failure'),
@@ -26,9 +29,9 @@ test('process next anilist retry update main deps builder maps callbacks', async
deps.setLastAttemptAt(1);
deps.setLastError('x');
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
status: 'updated',
message: 'ok',
message: 'ok:2',
});
deps.markSuccess('k');
deps.rememberAttemptedUpdateKey('k');
@@ -58,16 +61,22 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
getTrackedMediaKey: () => 'media',
resetTrackedMedia: () => calls.push('reset'),
getWatchedSeconds: () => 100,
maybeProbeAnilistDuration: async () => 120,
maybeProbeAnilistDuration: async (_mediaKey, options) => {
calls.push(`probe:${options?.force === true}`);
return 120;
},
ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
enqueueRetry: (_key, _title, _episode, season) => calls.push(`enqueue:${season}`),
markRetryFailure: () => calls.push('retry-fail'),
markRetrySuccess: () => calls.push('retry-ok'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'done' }),
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
status: 'updated',
message: `done:${season}`,
}),
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: () => calls.push('osd'),
logInfo: (message) => calls.push(`info:${message}`),
@@ -84,7 +93,7 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
assert.equal(deps.getTrackedMediaKey(), 'media');
deps.resetTrackedMedia('media');
assert.equal(deps.getWatchedSeconds(), 100);
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
assert.equal(await deps.maybeProbeAnilistDuration('media', { force: true }), 120);
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
title: 'x',
season: null,
@@ -93,13 +102,13 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
deps.enqueueRetry('k', 't', 1);
deps.enqueueRetry('k', 't', 1, 2);
deps.markRetryFailure('k', 'bad');
deps.markRetrySuccess('k');
deps.refreshRetryQueueState();
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
status: 'updated',
message: 'done',
message: 'done:2',
});
deps.rememberAttemptedUpdateKey('k');
deps.showMpvOsd('ok');
@@ -110,7 +119,8 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
assert.deepEqual(calls, [
'in-flight',
'reset',
'enqueue',
'probe:true',
'enqueue:2',
'retry-fail',
'retry-ok',
'refresh',
@@ -19,8 +19,12 @@ export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
setLastAttemptAt: (value: number) => deps.setLastAttemptAt(value),
setLastError: (value: string | null) => deps.setLastError(value),
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
updateAnilistPostWatchProgress: (
accessToken: string,
title: string,
episode: number,
season?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
markSuccess: (key: string) => deps.markSuccess(key),
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
markFailure: (key: string, message: string) => deps.markFailure(key, message),
@@ -42,18 +46,23 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
getTrackedMediaKey: () => deps.getTrackedMediaKey(),
resetTrackedMedia: (mediaKey) => deps.resetTrackedMedia(mediaKey),
getWatchedSeconds: () => deps.getWatchedSeconds(),
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
maybeProbeAnilistDuration: (mediaKey: string, options) =>
deps.maybeProbeAnilistDuration(mediaKey, options),
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
enqueueRetry: (key: string, title: string, episode: number) =>
deps.enqueueRetry(key, title, episode),
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) =>
deps.enqueueRetry(key, title, episode, season),
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
updateAnilistPostWatchProgress: (
accessToken: string,
title: string,
episode: number,
season?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
logInfo: (message: string) => deps.logInfo(message),
+107 -4
View File
@@ -20,12 +20,15 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
const calls: string[] = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
nextReady: () => ({ key: 'k1', title: 'Show', season: null, episode: 1 }),
nextReady: () => ({ key: 'k1', title: 'Show', season: 2, episode: 1 }),
refreshRetryQueueState: () => calls.push('refresh'),
setLastAttemptAt: () => calls.push('attempt'),
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
refreshAnilistClientSecretState: async () => 'token',
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'updated ok' }),
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
status: 'updated',
message: `updated ok:${season}`,
}),
markSuccess: () => calls.push('success'),
rememberAttemptedUpdateKey: () => calls.push('remember'),
markFailure: () => calls.push('failure'),
@@ -34,7 +37,7 @@ test('createProcessNextAnilistRetryUpdateHandler handles successful retry', asyn
});
const result = await handler();
assert.deepEqual(result, { ok: true, message: 'updated ok' });
assert.deepEqual(result, { ok: true, message: 'updated ok:2' });
assert.ok(calls.includes('success'));
assert.ok(calls.includes('remember'));
});
@@ -93,7 +96,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
calls.push('probe');
return 1000;
},
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 3 }),
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
@@ -121,6 +124,106 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
assert.ok(calls.includes('osd:updated ok'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler shows permanent AniList update errors without queueing retry', async () => {
const calls: string[] = [];
const attemptedKeys = new Set<string>();
let updateCalls = 0;
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 2 }),
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
updateCalls += 1;
return {
status: 'error',
retryable: false,
message:
'AniList update not possible: Show is not in your AniList Planning or Watching list.',
};
},
rememberAttemptedUpdateKey: (key) => {
attemptedKeys.add(key);
calls.push(`remember:${key}`);
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
await handler();
assert.equal(updateCalls, 1);
assert.equal(calls.includes('enqueue'), false);
assert.equal(calls.includes('mark-failure'), false);
assert.ok(calls.some((call) => call.startsWith('remember:')));
assert.ok(calls.includes('refresh'));
assert.ok(calls.some((call) => call.startsWith('osd:AniList update not possible')));
assert.ok(calls.some((call) => call.startsWith('warn:AniList update not possible')));
});
test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds from time-position events', async () => {
const calls: string[] = [];
let durationProbeOptions: unknown = null;
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 0,
maybeProbeAnilistDuration: async (_mediaKey, options) => {
durationProbeOptions = options;
return 1000;
},
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 2, episode: 8 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => {
calls.push(`update:${season}`);
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler({ watchedSeconds: 850 });
assert.deepEqual(durationProbeOptions, { force: true });
assert.ok(calls.includes('update:2'));
assert.ok(calls.includes('remember'));
assert.ok(calls.includes('osd:updated ok'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
const calls: string[] = [];
let inFlight = false;
+35 -6
View File
@@ -2,22 +2,30 @@ import { isYoutubeMediaPath } from './youtube-playback';
type AnilistGuess = {
title: string;
season: number | null;
episode: number | null;
};
type AnilistUpdateResult = {
status: 'updated' | 'skipped' | 'error';
message: string;
retryable?: boolean;
};
type RetryQueueItem = {
key: string;
title: string;
season?: number | null;
episode: number;
};
type AnilistPostWatchRunOptions = {
force?: boolean;
watchedSeconds?: number;
};
type AnilistDurationProbeOptions = {
force?: boolean;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
@@ -49,6 +57,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
accessToken: string,
title: string,
episode: number,
season?: number | null,
) => Promise<AnilistUpdateResult>;
markSuccess: (key: string) => void;
rememberAttemptedUpdateKey: (key: string) => void;
@@ -74,6 +83,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
accessToken,
queued.title,
queued.episode,
queued.season ?? null,
);
if (result.status === 'updated' || result.status === 'skipped') {
deps.markSuccess(queued.key);
@@ -101,12 +111,15 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
getTrackedMediaKey: () => string | null;
resetTrackedMedia: (mediaKey: string | null) => void;
getWatchedSeconds: () => number;
maybeProbeAnilistDuration: (mediaKey: string) => Promise<number | null>;
maybeProbeAnilistDuration: (
mediaKey: string,
options?: AnilistDurationProbeOptions,
) => Promise<number | null>;
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistGuess | null>;
hasAttemptedUpdateKey: (key: string) => boolean;
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
refreshAnilistClientSecretState: () => Promise<string | null>;
enqueueRetry: (key: string, title: string, episode: number) => void;
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) => void;
markRetryFailure: (key: string, message: string) => void;
markRetrySuccess: (key: string) => void;
refreshRetryQueueState: () => void;
@@ -114,6 +127,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
accessToken: string,
title: string,
episode: number,
season?: number | null,
) => Promise<AnilistUpdateResult>;
rememberAttemptedUpdateKey: (key: string) => void;
showMpvOsd: (message: string) => void;
@@ -146,7 +160,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
let watchedSeconds = 0;
if (!force) {
watchedSeconds = deps.getWatchedSeconds();
watchedSeconds =
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds)
? options.watchedSeconds
: deps.getWatchedSeconds();
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
return;
}
@@ -155,7 +172,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
deps.setInFlight(true);
try {
if (!force) {
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
const duration = await deps.maybeProbeAnilistDuration(mediaKey, {
force:
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds),
});
if (!duration || duration <= 0) {
return;
}
@@ -181,7 +201,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
const accessToken = await deps.refreshAnilistClientSecretState();
if (!accessToken) {
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
deps.refreshRetryQueueState();
deps.showMpvOsd('AniList: access token not configured');
@@ -192,6 +212,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
accessToken,
guess.title,
guess.episode,
guess.season,
);
if (result.status === 'updated') {
deps.rememberAttemptedUpdateKey(attemptKey);
@@ -209,7 +230,15 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
return;
}
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
if (result.retryable === false) {
deps.rememberAttemptedUpdateKey(attemptKey);
deps.refreshRetryQueueState();
deps.showMpvOsd(result.message);
deps.logWarn(result.message);
return;
}
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
deps.markRetryFailure(attemptKey, result.message);
deps.refreshRetryQueueState();
deps.showMpvOsd(`AniList: ${result.message}`);
@@ -80,6 +80,23 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
lastDurationProbeAtMsState = value;
},
},
recordMediaDurationMainDeps: {
getCurrentMediaKey: () => 'media-key',
getState: () => ({
mediaKey: mediaKeyState,
mediaDurationSec: mediaDurationSecState,
mediaGuess: mediaGuessState,
mediaGuessPromise: mediaGuessPromiseState,
lastDurationProbeAtMs: lastDurationProbeAtMsState,
}),
setState: (state) => {
mediaKeyState = state.mediaKey;
mediaDurationSecState = state.mediaDurationSec;
mediaGuessState = state.mediaGuess;
mediaGuessPromiseState = state.mediaGuessPromise;
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
},
},
resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => {
mediaGuessState = value;
@@ -192,6 +209,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
assert.equal(typeof composed.resetAnilistMediaTracking, 'function');
assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function');
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
assert.equal(typeof composed.recordAnilistMediaDuration, 'function');
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
assert.equal(typeof composed.maybeProbeAnilistDuration, 'function');
assert.equal(typeof composed.ensureAnilistMediaGuess, 'function');
@@ -216,6 +234,9 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
});
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90);
composed.recordAnilistMediaDuration(180);
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 180);
composed.resetAnilistMediaGuessState();
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
@@ -5,6 +5,7 @@ import {
createBuildMaybeProbeAnilistDurationMainDepsHandler,
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
createBuildRecordAnilistMediaDurationMainDepsHandler,
createBuildRefreshAnilistClientSecretStateMainDepsHandler,
createBuildResetAnilistMediaGuessStateMainDepsHandler,
createBuildResetAnilistMediaTrackingMainDepsHandler,
@@ -15,6 +16,7 @@ import {
createMaybeProbeAnilistDurationHandler,
createMaybeRunAnilistPostWatchUpdateHandler,
createProcessNextAnilistRetryUpdateHandler,
createRecordAnilistMediaDurationHandler,
createRefreshAnilistClientSecretStateHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
@@ -38,6 +40,9 @@ export type AnilistTrackingComposerOptions = ComposerInputs<{
setMediaGuessRuntimeStateMainDeps: Parameters<
typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler
>[0];
recordMediaDurationMainDeps: Parameters<
typeof createBuildRecordAnilistMediaDurationMainDepsHandler
>[0];
resetMediaGuessStateMainDeps: Parameters<
typeof createBuildResetAnilistMediaGuessStateMainDepsHandler
>[0];
@@ -63,6 +68,7 @@ export type AnilistTrackingComposerResult = ComposerOutputs<{
setAnilistMediaGuessRuntimeState: ReturnType<
typeof createSetAnilistMediaGuessRuntimeStateHandler
>;
recordAnilistMediaDuration: ReturnType<typeof createRecordAnilistMediaDurationHandler>;
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
@@ -94,6 +100,9 @@ export function composeAnilistTrackingHandlers(
options.setMediaGuessRuntimeStateMainDeps,
)(),
);
const recordAnilistMediaDuration = createRecordAnilistMediaDurationHandler(
createBuildRecordAnilistMediaDurationMainDepsHandler(options.recordMediaDurationMainDeps)(),
);
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(),
);
@@ -120,6 +129,7 @@ export function composeAnilistTrackingHandlers(
resetAnilistMediaTracking,
getAnilistMediaGuessRuntimeState,
setAnilistMediaGuessRuntimeState,
recordAnilistMediaDuration,
resetAnilistMediaGuessState,
maybeProbeAnilistDuration,
ensureAnilistMediaGuess,
@@ -97,20 +97,38 @@ test('mpv connection handler keeps overlay-initialized non-youtube sessions aliv
assert.deepEqual(calls, ['presence-refresh', 'report-stop']);
});
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
test('mpv subtitle timing handler skips blank subtitle recording but still checks AniList time', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: () => calls.push('immersion'),
hasSubtitleTimingTracker: () => true,
recordSubtitleTiming: () => calls.push('timing'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('post-watch');
maybeRunAnilistPostWatchUpdate: async (options) => {
calls.push(`post-watch:${options?.watchedSeconds}`);
},
logError: () => calls.push('error'),
});
handler({ text: ' ', start: 1, end: 2 });
assert.deepEqual(calls, []);
assert.deepEqual(calls, ['post-watch:2']);
});
test('mpv subtitle timing handler runs AniList without timing tracker and passes subtitle time', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: (text, start, end) =>
calls.push(`immersion:${text}:${start}:${end}`),
hasSubtitleTimingTracker: () => false,
recordSubtitleTiming: () => calls.push('timing'),
maybeRunAnilistPostWatchUpdate: async (options) => {
calls.push(`post-watch:${options?.watchedSeconds}`);
},
logError: () => calls.push('error'),
});
handler({ text: 'line', start: 899, end: 901 });
assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']);
});
test('mpv event bindings register all expected events', () => {
+17 -6
View File
@@ -19,6 +19,10 @@ type MpvEventClient = {
on: <K extends MpvBindingEventName>(event: K, handler: (payload: any) => void) => void;
};
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void;
@@ -57,15 +61,22 @@ export function createHandleMpvSubtitleTimingHandler(deps: {
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
hasSubtitleTimingTracker: () => boolean;
recordSubtitleTiming: (text: string, start: number, end: number) => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
logError: (message: string, error: unknown) => void;
}) {
return ({ text, start, end }: { text: string; start: number; end: number }): void => {
if (!text.trim()) return;
deps.recordImmersionSubtitleLine(text, start, end);
if (!deps.hasSubtitleTimingTracker()) return;
deps.recordSubtitleTiming(text, start, end);
void deps.maybeRunAnilistPostWatchUpdate().catch((error) => {
const watchedSeconds = Math.max(
Number.isFinite(start) ? start : 0,
Number.isFinite(end) ? end : 0,
);
const options = watchedSeconds > 0 ? { watchedSeconds } : undefined;
if (text.trim()) {
deps.recordImmersionSubtitleLine(text, start, end);
if (deps.hasSubtitleTimingTracker()) {
deps.recordSubtitleTiming(text, start, end);
}
}
void deps.maybeRunAnilistPostWatchUpdate(options).catch((error) => {
deps.logError('AniList post-watch update failed unexpectedly', error);
});
};
@@ -223,6 +223,23 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
]);
});
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
const watchedSeconds: unknown[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: () => {},
reportJellyfinRemoteProgress: () => {},
refreshDiscordPresence: () => {},
maybeRunAnilistPostWatchUpdate: async (options) => {
watchedSeconds.push(options?.watchedSeconds);
},
});
timeHandler({ time: 850 });
await Promise.resolve();
assert.deepEqual(watchedSeconds, [850]);
});
test('time-pos handler logs post-watch update rejection without blocking later handlers', async () => {
const calls: string[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
+6 -2
View File
@@ -1,5 +1,9 @@
import type { SubtitleData } from '../../types';
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createHandleMpvSubtitleChangeHandler(deps: {
setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
@@ -105,7 +109,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
recordPlaybackPosition: (time: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
refreshDiscordPresence: () => void;
maybeRunAnilistPostWatchUpdate?: () => Promise<void>;
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
logError?: (message: string, error: unknown) => void;
onTimePosUpdate?: (time: number) => void;
}) {
@@ -113,7 +117,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
deps.recordPlaybackPosition(time);
deps.reportJellyfinRemoteProgress(false);
deps.refreshDiscordPresence();
void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => {
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
deps.logError?.('AniList post-watch update failed unexpectedly', error);
});
deps.onTimePosUpdate?.(time);
@@ -23,8 +23,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
recordImmersionSubtitleLine: (text) => calls.push(`immersion:${text}`),
hasSubtitleTimingTracker: () => false,
recordSubtitleTiming: () => calls.push('record-timing'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('post-watch');
maybeRunAnilistPostWatchUpdate: async (options) => {
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
@@ -74,6 +74,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
handlers.get('media-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
handlers.get('time-pos-change')?.({ time: 2.5 });
handlers.get('pause-change')?.({ paused: true });
@@ -87,6 +88,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-guess-state'));
assert.ok(calls.includes('notify-title:Episode 1'));
assert.ok(calls.includes('post-watch:901'));
assert.ok(calls.includes('progress:normal'));
assert.ok(calls.includes('progress:force'));
assert.ok(calls.includes('presence-refresh'));
+7 -3
View File
@@ -18,6 +18,10 @@ import {
type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandlers>>[0];
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
@@ -34,7 +38,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
hasSubtitleTimingTracker: () => boolean;
recordSubtitleTiming: (text: string, start: number, end: number) => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void;
setCurrentSubText: (text: string) => void;
@@ -103,7 +107,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
deps.recordImmersionSubtitleLine(text, start, end),
hasSubtitleTimingTracker: () => deps.hasSubtitleTimingTracker(),
recordSubtitleTiming: (text, start, end) => deps.recordSubtitleTiming(text, start, end),
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
});
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
@@ -149,7 +153,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
});
@@ -16,6 +16,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
recordMediaDuration: (durationSec: number) => calls.push(`immersion-duration:${durationSec}`),
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
},
subtitleTimingTracker: {
@@ -40,6 +41,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('anilist-post-watch');
},
recordAnilistMediaDuration: (durationSec) => calls.push(`anilist-duration:${durationSec}`),
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
@@ -95,6 +97,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate('title');
deps.recordPlaybackPosition(10);
deps.recordMediaDuration(1234);
deps.reportJellyfinRemoteProgress(true);
deps.onFullscreenChange?.(true);
deps.recordPauseState(true);
@@ -118,6 +121,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-sidebar-layout'));
assert.ok(calls.includes('immersion-duration:1234'));
assert.ok(calls.includes('anilist-duration:1234'));
});
test('mpv main event main deps wire subtitle callbacks without suppression gate', () => {
+9 -2
View File
@@ -1,5 +1,9 @@
import type { MergedToken, SubtitleData } from '../../types';
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: {
initialArgs?: {
@@ -42,7 +46,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
quitApp: () => void;
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
recordAnilistMediaDuration?: (durationSec: number) => void;
logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
@@ -126,7 +131,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) =>
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
deps.maybeRunAnilistPostWatchUpdate(options),
logSubtitleTimingError: (message: string, error: unknown) =>
deps.logSubtitleTimingError(message, error),
setCurrentSubText: (text: string) => {
@@ -179,6 +185,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordMediaDuration: (durationSec: number) => {
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
deps.recordAnilistMediaDuration?.(durationSec);
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
@@ -15,6 +15,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
getModalActive: () => true,
getVisibleOverlayVisible: () => true,
getForceMousePassthrough: () => true,
getOverlayInteractionActive: () => true,
getWindowTracker: () => tracker,
getLastKnownWindowsForegroundProcessName: () => 'mpv',
getWindowsOverlayProcessName: () => 'subminer',
@@ -40,6 +41,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
assert.equal(deps.getModalActive(), true);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getForceMousePassthrough(), true);
assert.equal(deps.getOverlayInteractionActive?.(), true);
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
@@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
getModalActive: () => deps.getModalActive(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
getWindowTracker: () => deps.getWindowTracker(),
getLastKnownWindowsForegroundProcessName: () =>
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
+11 -4
View File
@@ -18,8 +18,12 @@ type UpdaterLogger = {
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
const logged: string[] = [];
const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = {
const updater: ElectronAutoUpdaterLike & {
autoInstallOnAppQuit: boolean;
logger?: UpdaterLogger | null;
} = {
autoDownload: true,
autoInstallOnAppQuit: true,
allowPrerelease: true,
allowDowngrade: true,
logger: null,
@@ -31,6 +35,7 @@ test('configureAutoUpdater disables eager update behavior and suppresses info lo
configureAutoUpdater(updater, (message) => logged.push(message));
assert.equal(updater.autoDownload, false);
assert.equal(updater.autoInstallOnAppQuit, false);
assert.equal(updater.allowPrerelease, false);
assert.equal(updater.allowDowngrade, false);
assert.ok(updater.logger);
@@ -180,16 +185,18 @@ test('mac native updater is unsupported for ad-hoc signed app bundles', async ()
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
});
test('mac native updater is supported for Developer ID signed app bundles', async () => {
test('mac native updater supports Developer ID signed packaged app bundles', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'darwin',
isPackaged: true,
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
readCodeSignature: () =>
['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'),
log: (message) => logged.push(message),
readCodeSignature: async () => 'Authority=Developer ID Application: Kyle Yasuda (EPJ9P4RWTC)',
});
assert.equal(supported, true);
assert.deepEqual(logged, []);
});
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
+3
View File
@@ -20,6 +20,7 @@ export interface ElectronUpdaterLoggerLike {
export interface ElectronAutoUpdaterLike {
autoDownload: boolean;
autoInstallOnAppQuit?: boolean;
allowPrerelease: boolean;
allowDowngrade: boolean;
logger?: ElectronUpdaterLoggerLike | null;
@@ -120,6 +121,8 @@ export function configureAutoUpdater(
channel: UpdateChannel = 'stable',
): ElectronAutoUpdaterLike {
updater.autoDownload = false;
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
updater.autoInstallOnAppQuit = false;
updater.allowPrerelease = channel === 'prerelease';
updater.allowDowngrade = false;
updater.logger = {
+6
View File
@@ -167,6 +167,12 @@ test('release packaging stages generated launcher as an app resource', () => {
assert.match(packageJson.scripts['build:launcher'] ?? '', /--banner='#!\/usr\/bin\/env bun'/);
});
test('Makefile clean preserves committed prerelease notes', () => {
assert.match(makefile, /PRERELEASE_NOTES_BACKUP/);
assert.match(makefile, /release\/prerelease-notes\.md/);
assert.doesNotMatch(makefile, /clean:[\s\S]*@rm -rf dist release\n/);
});
test('config example generation runs directly from source without unrelated bundle prerequisites', () => {
assert.equal(
packageJson.scripts['generate:config-example'],
+22
View File
@@ -0,0 +1,22 @@
import type { ResolvedControllerConfig, ResolvedControllerProfileConfig } from '../types';
export function getControllerProfile(
config: ResolvedControllerConfig | null,
gamepadId: string | null | undefined,
): ResolvedControllerProfileConfig | null {
if (!config || !gamepadId) return null;
return config.profiles[gamepadId] ?? null;
}
export function resolveControllerConfigForGamepad(
config: ResolvedControllerConfig,
gamepadId: string | null | undefined,
): ResolvedControllerConfig {
const profile = getControllerProfile(config, gamepadId);
if (!profile) return config;
return {
...config,
buttonIndices: profile.buttonIndices,
bindings: profile.bindings,
};
}
+1 -1
View File
@@ -67,5 +67,5 @@ export function createControllerStatusIndicator(
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
}
return { update };
return { show, update };
}
@@ -93,6 +93,7 @@ function createControllerConfig(
...(buttonIndexOverrides ?? {}),
}),
},
profiles: {},
...restOverrides,
};
}
@@ -449,6 +450,60 @@ test('gamepad controller maps left stick horizontal movement to token selection
assert.deepEqual(calls, [1, 1, -1]);
});
test('gamepad controller uses active controller profile bindings before global bindings', () => {
let lookupToggles = 0;
const buttons = Array.from({ length: 12 }, () => ({
value: 0,
pressed: false,
touched: false,
}));
buttons[11] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-profile', { buttons })],
getConfig: () =>
({
...createControllerConfig({
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 0 },
},
}),
profiles: {
'pad-profile': {
label: 'Profile Pad',
buttonIndices: DEFAULT_BUTTON_INDICES,
bindings: {
...createControllerConfig().bindings,
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
},
},
}) as ResolvedControllerConfig,
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {
lookupToggles += 1;
},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.equal(lookupToggles, 1);
});
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
const calls: string[] = [];
const scrollCalls: number[] = [];
+49 -34
View File
@@ -5,6 +5,7 @@ import type {
ResolvedControllerConfig,
ResolvedControllerDiscreteBinding,
} from '../../types';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
type ControllerButtonState = {
value: number;
@@ -410,87 +411,101 @@ export function createGamepadController(options: GamepadControllerOptions) {
resetHeldAction(jumpHold);
}
let interactionAllowed =
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
if (config.enabled) {
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
if (activeConfig.enabled) {
handleActionEdge(
'toggleKeyboardOnlyMode',
config.bindings.toggleKeyboardOnlyMode,
activeConfig.bindings.toggleKeyboardOnlyMode,
activeGamepad,
config,
activeConfig,
options.toggleKeyboardMode,
);
}
interactionAllowed =
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
const interactionAllowed =
activeConfig.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
if (!interactionAllowed) {
syncBlockedInteractionState(activeGamepad, config, now);
syncBlockedInteractionState(activeGamepad, activeConfig, now);
return;
}
handleActionEdge(
'toggleLookup',
config.bindings.toggleLookup,
activeConfig.bindings.toggleLookup,
activeGamepad,
config,
activeConfig,
options.toggleLookup,
);
handleActionEdge(
'closeLookup',
config.bindings.closeLookup,
activeConfig.bindings.closeLookup,
activeGamepad,
config,
activeConfig,
options.closeLookup,
);
handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard);
handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv);
handleActionEdge(
'mineCard',
activeConfig.bindings.mineCard,
activeGamepad,
activeConfig,
options.mineCard,
);
handleActionEdge(
'quitMpv',
activeConfig.bindings.quitMpv,
activeGamepad,
activeConfig,
options.quitMpv,
);
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
if (options.getLookupWindowOpen()) {
handleActionEdge(
'previousAudio',
config.bindings.previousAudio,
activeConfig.bindings.previousAudio,
activeGamepad,
config,
activeConfig,
options.previousAudio,
);
handleActionEdge(
'nextAudio',
config.bindings.nextAudio,
activeConfig.bindings.nextAudio,
activeGamepad,
config,
activeConfig,
options.nextAudio,
);
handleActionEdge(
'playCurrentAudio',
config.bindings.playCurrentAudio,
activeConfig.bindings.playCurrentAudio,
activeGamepad,
config,
activeConfig,
options.playCurrentAudio,
);
const primaryScroll = resolveAxisBindingValue(
activeGamepad,
config.bindings.leftStickVertical,
config.triggerDeadzone,
config.stickDeadzone,
activeConfig.bindings.leftStickVertical,
activeConfig.triggerDeadzone,
activeConfig.stickDeadzone,
);
if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) {
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
if (elapsedMs > 0 && Math.abs(primaryScroll) >= activeConfig.stickDeadzone) {
options.scrollPopup(
(primaryScroll * activeConfig.scrollPixelsPerSecond * elapsedMs) / 1000,
);
}
handleJumpAxis(
resolveAxisBindingValue(
activeGamepad,
config.bindings.rightStickVertical,
config.triggerDeadzone,
activeConfig.bindings.rightStickVertical,
activeConfig.triggerDeadzone,
activationThreshold,
),
now,
config,
activeConfig,
);
} else {
resetHeldAction(jumpHold);
@@ -498,21 +513,21 @@ export function createGamepadController(options: GamepadControllerOptions) {
handleActionEdge(
'toggleMpvPause',
config.bindings.toggleMpvPause,
activeConfig.bindings.toggleMpvPause,
activeGamepad,
config,
activeConfig,
options.toggleMpvPause,
);
handleSelectionAxis(
resolveAxisBindingValue(
activeGamepad,
config.bindings.leftStickHorizontal,
config.triggerDeadzone,
activeConfig.bindings.leftStickHorizontal,
activeConfig.triggerDeadzone,
activationThreshold,
),
now,
config,
activeConfig,
);
}
+3 -3
View File
@@ -987,7 +987,7 @@ test('keyboard mode: configured controller select binding opens locally without
assert.equal(openControllerSelectCount(), 1);
assert.deepEqual(testGlobals.sessionActions, []);
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-select']);
assert.deepEqual(testGlobals.openedModalNotifications, []);
} finally {
testGlobals.restore();
}
@@ -1017,7 +1017,7 @@ test('keyboard mode: configured controller debug binding opens locally without d
assert.equal(openControllerDebugCount(), 1);
assert.deepEqual(testGlobals.sessionActions, []);
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
assert.deepEqual(testGlobals.openedModalNotifications, []);
} finally {
testGlobals.restore();
}
@@ -1049,7 +1049,7 @@ test('keyboard mode: configured controller debug binding is not swallowed while
assert.equal(openControllerDebugCount(), 1);
assert.deepEqual(testGlobals.sessionActions, []);
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
assert.deepEqual(testGlobals.openedModalNotifications, []);
} finally {
testGlobals.restore();
}
-2
View File
@@ -203,13 +203,11 @@ export function createKeyboardHandlers(
}
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
window.electronAPI.notifyOverlayModalOpened('controller-select');
options.openControllerSelectModal?.();
return;
}
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') {
window.electronAPI.notifyOverlayModalOpened('controller-debug');
options.openControllerDebugModal?.();
return;
}
@@ -144,3 +144,69 @@ test('controller config form renders rows and dispatches learn clear reset callb
}
}
});
test('controller config form starts learn from badge or edit and resets from row button', () => {
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createFakeElement(),
},
});
try {
const calls: string[] = [];
const container = createFakeElement();
const form = createControllerConfigForm({
container: container as never,
getBindings: () =>
({
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: { kind: 'none' },
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
}) as never,
getLearningActionId: () => null,
getDpadLearningActionId: () => null,
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
onClear: (actionId) => calls.push(`clear:${actionId}`),
onReset: (actionId) => calls.push(`reset:${actionId}`),
onDpadLearn: (actionId) => calls.push(`dpadLearn:${actionId}`),
onDpadClear: (actionId) => calls.push(`dpadClear:${actionId}`),
onDpadReset: (actionId) => calls.push(`dpadReset:${actionId}`),
});
form.render();
const firstRow = container.children[1];
const right = firstRow.children[1];
const badge = right.children[0];
const resetButton = right.children[1];
const editButton = right.children[2];
badge.dispatch('click');
resetButton.dispatch('click');
editButton.dispatch('click');
assert.deepEqual(calls, [
'learn:toggleLookup:discrete',
'reset:toggleLookup',
'learn:toggleLookup:discrete',
]);
} finally {
if (previousDocumentDescriptor) {
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
}
});
+56 -2
View File
@@ -278,6 +278,17 @@ export function createControllerConfigForm(options: {
formatFriendlyBindingLabel(binding),
binding.kind === 'none',
isExpanded,
`Learn ${definition.label}`,
(e) => {
e.stopPropagation();
expandedRowKey = rowKey;
options.onLearn(definition.id, definition.bindingType);
},
`Reset ${definition.label}`,
(e) => {
e.stopPropagation();
options.onReset(definition.id);
},
);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
@@ -321,6 +332,17 @@ export function createControllerConfigForm(options: {
formatFriendlyStickLabel(binding),
binding.kind === 'none',
isExpanded,
`Learn ${definition.label} stick`,
(e) => {
e.stopPropagation();
expandedRowKey = rowKey;
options.onLearn(definition.id, 'axis');
},
`Reset ${definition.label} stick`,
(e) => {
e.stopPropagation();
options.onReset(definition.id);
},
);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
@@ -366,6 +388,17 @@ export function createControllerConfigForm(options: {
badgeText,
dpadFallback === 'none',
isExpanded,
`Learn ${definition.label} D-pad`,
(e) => {
e.stopPropagation();
expandedRowKey = rowKey;
options.onDpadLearn(definition.id);
},
`Reset ${definition.label} D-pad`,
(e) => {
e.stopPropagation();
options.onDpadReset(definition.id);
},
);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
@@ -400,6 +433,10 @@ export function createControllerConfigForm(options: {
badgeText: string,
isDisabled: boolean,
isExpanded: boolean,
editLabel: string,
onEdit: (e: Event) => void,
resetLabel: string,
onReset: (e: Event) => void,
): HTMLDivElement {
const row = document.createElement('div');
row.className = 'controller-config-row';
@@ -412,16 +449,33 @@ export function createControllerConfigForm(options: {
const right = document.createElement('div');
right.className = 'controller-config-right';
const badge = document.createElement('span');
const badge = document.createElement('button');
badge.type = 'button';
badge.className = 'controller-config-badge';
if (isDisabled) badge.classList.add('disabled');
badge.setAttribute('aria-label', editLabel);
badge.title = editLabel;
badge.textContent = badgeText;
badge.addEventListener('click', onEdit);
const editIcon = document.createElement('span');
const resetIcon = document.createElement('button');
resetIcon.type = 'button';
resetIcon.className = 'controller-config-reset-icon';
resetIcon.setAttribute('aria-label', resetLabel);
resetIcon.title = resetLabel;
resetIcon.textContent = '\u21ba';
resetIcon.addEventListener('click', onReset);
const editIcon = document.createElement('button');
editIcon.type = 'button';
editIcon.className = 'controller-config-edit-icon';
editIcon.setAttribute('aria-label', editLabel);
editIcon.title = editLabel;
editIcon.textContent = '\u270E';
editIcon.addEventListener('click', onEdit);
right.appendChild(badge);
right.appendChild(resetIcon);
right.appendChild(editIcon);
row.appendChild(label);
row.appendChild(right);
@@ -76,6 +76,7 @@ test('controller debug modal renders active controller axes, buttons, and config
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
profiles: {},
};
const ctx = {
@@ -99,6 +100,7 @@ test('controller debug modal renders active controller axes, buttons, and config
const modal = createControllerDebugModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.openControllerDebugModal();
@@ -189,6 +191,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
profiles: {},
};
const ctx = {
@@ -217,6 +220,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
const modal = createControllerDebugModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
@@ -244,3 +248,97 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
});
}
});
test('controller debug modal stays closed and notifies when controller support is disabled', () => {
const globals = globalThis as typeof globalThis & { window?: unknown };
const previousWindow = globals.window;
let disabledNotices = 0;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
notifyOverlayModalClosed: () => {},
},
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: false,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: { kind: 'none' },
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
profiles: {},
};
const ctx = {
dom: {
overlay: { classList: createClassList() },
controllerDebugModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerDebugClose: { addEventListener: () => {} },
controllerDebugCopy: { addEventListener: () => {} },
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
controllerDebugStatus: { textContent: '', classList: createClassList() },
controllerDebugSummary: { textContent: '' },
controllerDebugAxes: { textContent: '' },
controllerDebugButtons: { textContent: '' },
controllerDebugButtonIndices: { textContent: '' },
},
state,
};
const modal = createControllerDebugModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {
disabledNotices += 1;
},
});
assert.equal(modal.openControllerDebugModal(), false);
assert.equal(state.controllerDebugModalOpen, false);
assert.equal(ctx.dom.controllerDebugModal.classList.contains('hidden'), true);
assert.equal(disabledNotices, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
+12 -2
View File
@@ -1,4 +1,5 @@
import type { ModalStateReader, RendererContext } from '../context';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
function formatAxes(values: number[]): string {
if (values.length === 0) return 'No controller axes available.';
@@ -50,6 +51,7 @@ export function createControllerDebugModal(
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
notifyControllerDisabled: () => void;
},
) {
let toastTimer: ReturnType<typeof setTimeout> | null = null;
@@ -114,8 +116,11 @@ export function createControllerDebugModal(
: 'Connect a controller and press any button to populate raw input values.';
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
const activeConfig = ctx.state.controllerConfig
? resolveControllerConfigForGamepad(ctx.state.controllerConfig, ctx.state.activeGamepadId)
: null;
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
ctx.state.controllerConfig?.buttonIndices ?? null,
activeConfig?.buttonIndices ?? null,
);
}
@@ -136,7 +141,11 @@ export function createControllerDebugModal(
}
}
function openControllerDebugModal(): void {
function openControllerDebugModal(): boolean {
if (ctx.state.controllerConfig?.enabled !== true) {
options.notifyControllerDisabled();
return false;
}
ctx.state.controllerDebugModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
@@ -144,6 +153,7 @@ export function createControllerDebugModal(
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
hideToast();
render();
return true;
}
function closeControllerDebugModal(): void {
@@ -158,6 +158,7 @@ function buildContext() {
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
profiles: {},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
@@ -201,6 +202,7 @@ test('controller select modal saves preferred controller from dropdown selection
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
@@ -246,6 +248,7 @@ test('controller select modal learn mode captures fresh button input and persist
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
@@ -276,6 +279,192 @@ test('controller select modal learn mode captures fresh button input and persist
await Promise.resolve();
assert.deepEqual(saved.at(-1), {
profiles: {
'pad-1': {
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
},
},
});
assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.bindings.toggleLookup, {
kind: 'button',
buttonIndex: 11,
});
} finally {
domHandle.restore();
}
});
test('controller select modal reset control stores the default binding in the selected profile', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
if (state.controllerConfig) {
state.controllerConfig.profiles = {
'pad-1': {
label: 'pad-1',
buttonIndices: state.controllerConfig.buttonIndices,
bindings: {
...state.controllerConfig.bindings,
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
},
};
}
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const firstRow = dom.controllerConfigList.children[1];
const right = firstRow.children[1];
const resetButton = right.children[1];
resetButton.dispatch('click');
await Promise.resolve();
assert.deepEqual(saved.at(-1), {
profiles: {
'pad-1': {
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 0 },
},
},
},
});
assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.bindings.toggleLookup, {
kind: 'button',
buttonIndex: 0,
});
} finally {
domHandle.restore();
}
});
test('controller select modal binding badge starts learn mode and persists binding', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const firstRow = dom.controllerConfigList.children[1];
const right = firstRow.children[1];
const badge = right.children[0];
badge.dispatch('click');
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
value: 0,
pressed: false,
touched: false,
}));
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
modal.updateDevices();
await Promise.resolve();
assert.deepEqual(saved.at(-1), {
profiles: {
'pad-1': {
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
},
},
});
} finally {
domHandle.restore();
}
});
test('controller select modal learn mode falls back to global bindings without a controller', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
state.connectedGamepads = [];
state.activeGamepadId = null;
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const firstRow = dom.controllerConfigList.children[1];
firstRow.dispatch('click');
const editPanel = dom.controllerConfigList.children[2];
const learnButton = editPanel.children[0].children[1].children[0];
learnButton.dispatch('click');
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
value: 0,
pressed: false,
touched: false,
}));
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
modal.updateDevices();
await Promise.resolve();
assert.deepEqual(saved.at(-1), {
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
@@ -290,6 +479,99 @@ test('controller select modal learn mode captures fresh button input and persist
}
});
test('controller select modal edit control starts learn mode and persists binding', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const firstRow = dom.controllerConfigList.children[1];
const right = firstRow.children[1];
const editButton = right.children[2];
editButton.dispatch('click');
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
value: 0,
pressed: false,
touched: false,
}));
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
modal.updateDevices();
await Promise.resolve();
assert.deepEqual(saved.at(-1), {
profiles: {
'pad-1': {
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
},
},
});
} finally {
domHandle.restore();
}
});
test('controller select modal stays closed and notifies when controller support is disabled', async () => {
const domHandle = installFakeDom();
let disabledNotices = 0;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
if (state.controllerConfig) state.controllerConfig.enabled = false;
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {
disabledNotices += 1;
},
});
assert.equal(modal.openControllerSelectModal(), false);
assert.equal(state.controllerSelectModalOpen, false);
assert.equal(dom.controllerSelectModal.classList.contains('hidden'), true);
assert.equal(disabledNotices, 1);
} finally {
domHandle.restore();
}
});
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
const domHandle = installFakeDom();
@@ -315,6 +597,7 @@ test('controller select modal uses unique picker values for duplicate controller
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
+75 -10
View File
@@ -1,4 +1,5 @@
import type { ModalStateReader, RendererContext } from '../context';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
import {
createControllerConfigForm,
@@ -24,6 +25,7 @@ export function createControllerSelectModal(
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
notifyControllerDisabled: () => void;
},
) {
let selectedControllerKey: string | null = null;
@@ -38,10 +40,24 @@ export function createControllerSelectModal(
let dpadLearningActionId: ControllerBindingKey | null = null;
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
function getSelectedController() {
return ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex] ?? null;
}
function getSelectedControllerId(): string | null {
return getSelectedController()?.id ?? null;
}
function getSelectedControllerConfig() {
const config = ctx.state.controllerConfig;
if (!config) return null;
return resolveControllerConfigForGamepad(config, getSelectedControllerId());
}
const controllerConfigForm = createControllerConfigForm({
container: ctx.dom.controllerConfigList,
getBindings: () =>
ctx.state.controllerConfig?.bindings ?? {
getSelectedControllerConfig()?.bindings ?? {
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
@@ -67,7 +83,7 @@ export function createControllerSelectModal(
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
stickDeadzone: config?.stickDeadzone ?? 0.2,
});
const currentBinding = config?.bindings[actionId];
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
const currentDpadFallback =
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
? currentBinding.dpadFallback
@@ -216,6 +232,51 @@ export function createControllerSelectModal(
...update.bindings,
} as typeof ctx.state.controllerConfig.bindings;
}
if (update.profiles) {
ctx.state.controllerConfig.profiles = ctx.state.controllerConfig.profiles ?? {};
for (const [profileId, profileUpdate] of Object.entries(update.profiles)) {
const currentProfile = ctx.state.controllerConfig.profiles[profileId];
const baseProfile = currentProfile ?? {
label: profileUpdate.label ?? profileId,
buttonIndices: ctx.state.controllerConfig.buttonIndices,
bindings: ctx.state.controllerConfig.bindings,
};
ctx.state.controllerConfig.profiles[profileId] = {
label: profileUpdate.label ?? baseProfile.label,
buttonIndices: {
...baseProfile.buttonIndices,
...(profileUpdate.buttonIndices ?? {}),
},
bindings: {
...baseProfile.bindings,
...(profileUpdate.bindings ?? {}),
},
} as (typeof ctx.state.controllerConfig.profiles)[string];
}
}
}
function buildBindingConfigUpdate(
actionId: ControllerBindingKey,
binding: ControllerBindingValue,
): Parameters<typeof window.electronAPI.saveControllerConfig>[0] {
const selected = getSelectedController();
if (!selected) {
return {
bindings: {
[actionId]: binding,
},
};
}
return {
profiles: {
[selected.id]: {
bindings: {
[actionId]: binding,
},
},
},
};
}
async function saveBinding(
@@ -224,11 +285,7 @@ export function createControllerSelectModal(
): Promise<void> {
const definition = getControllerBindingDefinition(actionId);
try {
await saveControllerConfig({
bindings: {
[actionId]: binding,
},
});
await saveControllerConfig(buildBindingConfigUpdate(actionId, binding));
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
@@ -245,11 +302,11 @@ export function createControllerSelectModal(
dpadFallback: import('../../types').ControllerDpadFallback,
): Promise<void> {
const definition = getControllerBindingDefinition(actionId);
const currentBinding = ctx.state.controllerConfig?.bindings[actionId];
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
if (!currentBinding || currentBinding.kind !== 'axis') return;
const updated = { ...currentBinding, dpadFallback };
try {
await saveControllerConfig({ bindings: { [actionId]: updated } });
await saveControllerConfig(buildBindingConfigUpdate(actionId, updated));
dpadLearningActionId = null;
bindingCapture = null;
controllerConfigForm.render();
@@ -330,7 +387,11 @@ export function createControllerSelectModal(
}
}
function openControllerSelectModal(): void {
function openControllerSelectModal(): boolean {
if (ctx.state.controllerConfig?.enabled !== true) {
options.notifyControllerDisabled();
return false;
}
ctx.state.controllerSelectModalOpen = true;
syncSelectedIndexToCurrentController();
options.syncSettingsModalSubtitleSuppression();
@@ -346,6 +407,7 @@ export function createControllerSelectModal(
} else {
setStatus('Choose a controller or click Learn to remap an action.');
}
return true;
}
function closeControllerSelectModal(): void {
@@ -387,6 +449,7 @@ export function createControllerSelectModal(
);
syncSelectedControllerId();
renderPicker();
controllerConfigForm.render();
}
return true;
}
@@ -400,6 +463,7 @@ export function createControllerSelectModal(
);
syncSelectedControllerId();
renderPicker();
controllerConfigForm.render();
}
return true;
}
@@ -429,6 +493,7 @@ export function createControllerSelectModal(
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
syncSelectedControllerId();
renderPicker();
controllerConfigForm.render();
}
});
}
+22 -7
View File
@@ -128,10 +128,12 @@ const subsyncModal = createSubsyncModal(ctx, {
const controllerSelectModal = createControllerSelectModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
notifyControllerDisabled: showControllerDisabledNotice,
});
const controllerDebugModal = createControllerDebugModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
notifyControllerDisabled: showControllerDisabledNotice,
});
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
const sessionHelpModal = createSessionHelpModal(ctx, {
@@ -183,10 +185,14 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
openControllerSelectModal: () => {
controllerSelectModal.openControllerSelectModal();
if (controllerSelectModal.openControllerSelectModal()) {
window.electronAPI.notifyOverlayModalOpened('controller-select');
}
},
openControllerDebugModal: () => {
controllerDebugModal.openControllerDebugModal();
if (controllerDebugModal.openControllerDebugModal()) {
window.electronAPI.notifyOverlayModalOpened('controller-debug');
}
},
appendClipboardVideoToQueue: () => {
void window.electronAPI.appendClipboardVideoToQueue();
@@ -291,6 +297,12 @@ function applyControllerSnapshot(snapshot: {
controllerDebugModal.updateSnapshot();
}
function showControllerDisabledNotice(): void {
controllerStatusIndicator.show(
'Controller support disabled. Set controller.enabled to true in config to use controller tools.',
);
}
function emitControllerPopupScroll(deltaPixels: number): void {
if (deltaPixels === 0) return;
keyboardHandlers.scrollPopupByController(0, deltaPixels);
@@ -311,7 +323,7 @@ function startControllerPolling(): void {
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
getConfig: () =>
ctx.state.controllerConfig ?? {
enabled: true,
enabled: false,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
@@ -350,6 +362,7 @@ function startControllerPolling(): void {
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
profiles: {},
},
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
@@ -461,14 +474,16 @@ function registerModalOpenHandlers(): void {
});
window.electronAPI.onOpenControllerSelect(() => {
runGuarded('controller-select:open', () => {
controllerSelectModal.openControllerSelectModal();
window.electronAPI.notifyOverlayModalOpened('controller-select');
if (controllerSelectModal.openControllerSelectModal()) {
window.electronAPI.notifyOverlayModalOpened('controller-select');
}
});
});
window.electronAPI.onOpenControllerDebug(() => {
runGuarded('controller-debug:open', () => {
controllerDebugModal.openControllerDebugModal();
window.electronAPI.notifyOverlayModalOpened('controller-debug');
if (controllerDebugModal.openControllerDebugModal()) {
window.electronAPI.notifyOverlayModalOpened('controller-debug');
}
});
});
window.electronAPI.onOpenJimaku(() => {
+15 -1
View File
@@ -1694,14 +1694,17 @@ iframe[id^='yomitan-popup'],
}
.controller-config-badge {
display: inline-block;
display: inline-flex;
align-items: center;
padding: 2px 8px;
border: 0;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
background: rgba(138, 173, 244, 0.12);
color: var(--ctp-blue);
cursor: pointer;
white-space: nowrap;
}
@@ -1710,12 +1713,23 @@ iframe[id^='yomitan-popup'],
color: var(--ctp-overlay0);
}
.controller-config-reset-icon,
.controller-config-edit-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
background: transparent;
font-size: 14px;
color: var(--ctp-overlay0);
cursor: pointer;
transition: color 120ms ease;
}
.controller-config-row:hover .controller-config-reset-icon,
.controller-config-row:hover .controller-config-edit-icon {
color: var(--ctp-overlay2);
}
+96 -31
View File
@@ -16,6 +16,8 @@ import type { SessionActionId, SessionActionPayload } from '../../types/session-
import type { SubtitlePosition } from '../../types/subtitle';
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
const SESSION_ACTION_IDS: SessionActionId[] = [
'toggleStatsOverlay',
'toggleVisibleOverlay',
@@ -166,6 +168,67 @@ function parseAxisBinding(value: unknown) {
};
}
function parseControllerButtonIndices(
value: unknown,
): ControllerConfigUpdate['buttonIndices'] | null {
if (!isObject(value)) return null;
const buttonIndices: NonNullable<ControllerConfigUpdate['buttonIndices']> = {};
const keys = [
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
for (const key of keys) {
if (value[key] === undefined) continue;
if (!isInteger(value[key]) || value[key] < 0) return null;
buttonIndices[key] = value[key];
}
return buttonIndices;
}
function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindings'] | null {
if (!isObject(value)) return null;
const bindings: NonNullable<ControllerConfigUpdate['bindings']> = {};
const discreteKeys = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
for (const key of discreteKeys) {
if (value[key] === undefined) continue;
const parsed = parseDiscreteBinding(value[key]);
if (!parsed) return null;
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
}
const axisKeys = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
for (const key of axisKeys) {
if (value[key] === undefined) continue;
const parsed = parseAxisBinding(value[key]);
if (!parsed) return null;
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
}
return bindings;
}
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
if (!isObject(value)) return null;
const update: ControllerConfigUpdate = {};
@@ -182,40 +245,42 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
if (typeof value.preferredGamepadLabel !== 'string') return null;
update.preferredGamepadLabel = value.preferredGamepadLabel;
}
if (value.buttonIndices !== undefined) {
const parsed = parseControllerButtonIndices(value.buttonIndices);
if (!parsed) return null;
update.buttonIndices = parsed;
}
if (value.bindings !== undefined) {
if (!isObject(value.bindings)) return null;
const bindings: NonNullable<ControllerConfigUpdate['bindings']> = {};
const discreteKeys = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
for (const key of discreteKeys) {
if (value.bindings[key] === undefined) continue;
const parsed = parseDiscreteBinding(value.bindings[key]);
if (!parsed) return null;
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
const parsed = parseControllerBindings(value.bindings);
if (!parsed) return null;
update.bindings = parsed;
}
if (value.profiles !== undefined) {
if (!isObject(value.profiles)) return null;
const profiles: NonNullable<ControllerConfigUpdate['profiles']> = Object.create(null);
for (const [profileId, rawProfile] of Object.entries(value.profiles)) {
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) return null;
if (!isObject(rawProfile)) return null;
const profile: NonNullable<ControllerConfigUpdate['profiles']>[string] = {};
if (rawProfile.label !== undefined) {
if (typeof rawProfile.label !== 'string') return null;
profile.label = rawProfile.label;
}
if (rawProfile.buttonIndices !== undefined) {
const parsed = parseControllerButtonIndices(rawProfile.buttonIndices);
if (!parsed) return null;
profile.buttonIndices = parsed;
}
if (rawProfile.bindings !== undefined) {
const parsed = parseControllerBindings(rawProfile.bindings);
if (!parsed) return null;
profile.bindings = parsed;
}
profiles[profileId] = profile;
}
const axisKeys = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
for (const key of axisKeys) {
if (value.bindings[key] === undefined) continue;
const parsed = parseAxisBinding(value.bindings[key]);
if (!parsed) return null;
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
}
update.bindings = bindings;
update.profiles = profiles;
}
return update;
+2
View File
@@ -21,6 +21,7 @@ import type {
import type {
ControllerButtonIndicesConfig,
ControllerConfig,
ResolvedControllerProfileConfig,
ControllerTriggerInputMode,
Keybinding,
ResolvedControllerBindingsConfig,
@@ -164,6 +165,7 @@ export interface ResolvedConfig {
repeatIntervalMs: number;
buttonIndices: Required<ControllerButtonIndicesConfig>;
bindings: Required<ResolvedControllerBindingsConfig>;
profiles: Record<string, ResolvedControllerProfileConfig>;
};
ankiConnect: AnkiConnectConfig & {
enabled: boolean;
+13
View File
@@ -227,6 +227,18 @@ export interface ControllerButtonIndicesConfig {
rightTrigger?: number;
}
export interface ControllerProfileConfig {
label?: string;
buttonIndices?: ControllerButtonIndicesConfig;
bindings?: ControllerBindingsConfig;
}
export interface ResolvedControllerProfileConfig {
label: string;
buttonIndices: Required<ControllerButtonIndicesConfig>;
bindings: Required<ResolvedControllerBindingsConfig>;
}
export interface ControllerConfig {
enabled?: boolean;
preferredGamepadId?: string;
@@ -241,6 +253,7 @@ export interface ControllerConfig {
repeatIntervalMs?: number;
buttonIndices?: ControllerButtonIndicesConfig;
bindings?: ControllerBindingsConfig;
profiles?: Record<string, ControllerProfileConfig>;
}
export interface ControllerPreferenceUpdate {
+317 -3
View File
@@ -1,6 +1,13 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { MacOSWindowTracker, parseMacOSHelperOutput } from './macos-tracker';
import {
isCompiledMacOSHelperCurrent,
MacOSWindowTracker,
parseMacOSHelperOutput,
} from './macos-tracker';
test('parseMacOSHelperOutput parses minimized state', () => {
assert.deepEqual(parseMacOSHelperOutput('minimized'), {
@@ -10,6 +17,99 @@ test('parseMacOSHelperOutput parses minimized state', () => {
});
});
test('parseMacOSHelperOutput parses active focused state without geometry', () => {
assert.deepEqual(parseMacOSHelperOutput('active'), {
geometry: null,
focused: true,
active: true,
});
});
test('parseMacOSHelperOutput parses inactive state without geometry', () => {
assert.deepEqual(parseMacOSHelperOutput('inactive'), {
geometry: null,
focused: false,
inactive: true,
});
});
test('isCompiledMacOSHelperCurrent rejects binaries older than the Swift source', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'subminer-macos-helper-'));
try {
const binaryPath = join(tempDir, 'get-mpv-window-macos');
const sourcePath = join(tempDir, 'get-mpv-window-macos.swift');
writeFileSync(binaryPath, 'binary');
writeFileSync(sourcePath, 'source');
const older = new Date('2026-01-01T00:00:00Z');
const newer = new Date('2026-01-01T00:00:05Z');
utimesSync(binaryPath, older, older);
utimesSync(sourcePath, newer, newer);
assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), false);
utimesSync(binaryPath, newer, newer);
utimesSync(sourcePath, older, older);
assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), true);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('MacOSWindowTracker slows polling while focused target is stable', async () => {
const scheduledDelays: number[] = [];
let callIndex = 0;
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
resolveHelper: () => ({
helperPath: 'helper',
helperType: 'binary',
}),
runHelper: async () => {
callIndex += 1;
return { stdout: '10,20,1280,720,1', stderr: '' };
},
fastPollIntervalMs: 250,
stablePollIntervalMs: 1_000,
setPollTimeout: ((_callback: () => void, delayMs: number) => {
scheduledDelays.push(delayMs);
return {} as ReturnType<typeof setTimeout>;
}) as never,
clearPollTimeout: (() => {}) as never,
} as never);
tracker.start();
await new Promise((resolve) => setTimeout(resolve, 0));
tracker.stop();
assert.equal(callIndex, 1);
assert.deepEqual(scheduledDelays, [1_000]);
});
test('MacOSWindowTracker keeps fast polling while target is not focused', async () => {
const scheduledDelays: number[] = [];
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
resolveHelper: () => ({
helperPath: 'helper',
helperType: 'binary',
}),
runHelper: async () => ({ stdout: '10,20,1280,720,0', stderr: '' }),
fastPollIntervalMs: 250,
stablePollIntervalMs: 1_000,
setPollTimeout: ((_callback: () => void, delayMs: number) => {
scheduledDelays.push(delayMs);
return {} as ReturnType<typeof setTimeout>;
}) as never,
clearPollTimeout: (() => {}) as never,
} as never);
tracker.start();
await new Promise((resolve) => setTimeout(resolve, 0));
tracker.stop();
assert.deepEqual(scheduledDelays, [250]);
});
test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => {
let callIndex = 0;
const outputs = [
@@ -55,10 +155,221 @@ test('MacOSWindowTracker keeps the last geometry through a single helper miss',
});
});
test('MacOSWindowTracker preserves target focus on helper not-found while retaining geometry', async () => {
let callIndex = 0;
const focusChanges: boolean[] = [];
const outputs = [
{ stdout: '10,20,1280,720,1', stderr: '' },
{ stdout: 'not-found', stderr: '' },
];
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
resolveHelper: () => ({
helperPath: 'helper.swift',
helperType: 'swift',
}),
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
trackingLossGraceMs: 1_500,
});
tracker.onWindowFocusChange = (focused) => {
focusChanges.push(focused);
};
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTargetWindowFocused(), true);
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), {
x: 10,
y: 20,
width: 1280,
height: 720,
});
assert.equal(tracker.isTargetWindowFocused(), true);
assert.deepEqual(focusChanges, [true]);
});
test('MacOSWindowTracker keeps focused fullscreen target through active helper misses after grace', async () => {
let callIndex = 0;
let now = 1_000;
const outputs = [
{ stdout: '10,20,1280,720,1', stderr: '' },
{ stdout: 'active', stderr: '' },
{ stdout: 'active', stderr: '' },
];
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
resolveHelper: () => ({
helperPath: 'helper.swift',
helperType: 'swift',
}),
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
trackingLossGraceMs: 500,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowFocused(), true);
now += 1_000;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
now += 1_000;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowFocused(), true);
assert.deepEqual(tracker.getGeometry(), {
x: 10,
y: 20,
width: 1280,
height: 720,
});
});
test('MacOSWindowTracker drops previously focused target after repeated not-found misses exceed grace', async () => {
let callIndex = 0;
let now = 1_000;
const focusChanges: boolean[] = [];
const outputs = [
{ stdout: '10,20,1280,720,1', stderr: '' },
{ stdout: 'not-found', stderr: '' },
{ stdout: 'not-found', stderr: '' },
];
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
resolveHelper: () => ({
helperPath: 'helper.swift',
helperType: 'swift',
}),
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
trackingLossGraceMs: 500,
});
tracker.onWindowFocusChange = (focused) => {
focusChanges.push(focused);
};
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowFocused(), true);
now += 1_000;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowFocused(), true);
assert.deepEqual(tracker.getGeometry(), {
x: 10,
y: 20,
width: 1280,
height: 720,
});
assert.deepEqual(focusChanges, [true]);
now += 1_000;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), false);
assert.equal(tracker.isTargetWindowFocused(), false);
assert.equal(tracker.getGeometry(), null);
assert.deepEqual(focusChanges, [true, false]);
});
test('MacOSWindowTracker drops previously focused target after repeated helper execution failures exceed grace', async () => {
let callIndex = 0;
let now = 1_000;
const focusChanges: boolean[] = [];
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
resolveHelper: () => ({
helperPath: 'helper.swift',
helperType: 'swift',
}),
runHelper: async () => {
callIndex += 1;
if (callIndex === 1) {
return { stdout: '10,20,1280,720,1', stderr: '' };
}
throw Object.assign(new Error('helper timed out'), { stderr: 'timeout' });
},
now: () => now,
trackingLossGraceMs: 500,
});
tracker.onWindowFocusChange = (focused) => {
focusChanges.push(focused);
};
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
now += 1_000;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
now += 1_000;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), false);
assert.equal(tracker.isTargetWindowFocused(), false);
assert.equal(tracker.getGeometry(), null);
assert.deepEqual(focusChanges, [true, false]);
});
test('MacOSWindowTracker marks target unfocused on explicit inactive helper signal', async () => {
let callIndex = 0;
const focusChanges: boolean[] = [];
const outputs = [
{ stdout: '10,20,1280,720,1', stderr: '' },
{ stdout: 'inactive', stderr: '' },
];
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
resolveHelper: () => ({
helperPath: 'helper.swift',
helperType: 'swift',
}),
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
trackingLossGraceMs: 1_500,
});
tracker.onWindowFocusChange = (focused) => {
focusChanges.push(focused);
};
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), {
x: 10,
y: 20,
width: 1280,
height: 720,
});
assert.equal(tracker.isTargetWindowFocused(), false);
assert.deepEqual(focusChanges, [true, false]);
});
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
let callIndex = 0;
const outputs = [
{ stdout: '10,20,1280,720,1', stderr: '' },
{ stdout: '10,20,1280,720,0', stderr: '' },
{ stdout: 'not-found', stderr: '' },
{ stdout: 'not-found', stderr: '' },
];
@@ -75,6 +386,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowFocused(), false);
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -84,6 +396,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), false);
assert.equal(tracker.getGeometry(), null);
assert.equal(tracker.isTargetWindowFocused(), false);
});
test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => {
@@ -137,7 +450,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () =>
let callIndex = 0;
let now = 1_000;
const outputs = [
{ stdout: '10,20,1280,720,1', stderr: '' },
{ stdout: '10,20,1280,720,0', stderr: '' },
{ stdout: 'not-found', stderr: '' },
{ stdout: 'not-found', stderr: '' },
{ stdout: 'not-found', stderr: '' },
@@ -156,6 +469,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () =>
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowFocused(), false);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
+164 -19
View File
@@ -25,6 +25,8 @@ import { createLogger } from '../logger';
import type { WindowGeometry } from '../types';
const log = createLogger('tracker').child('macos');
const MACOS_FAST_POLL_INTERVAL_MS = 250;
const MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS = 1_000;
type MacOSTrackerRunnerResult = {
stdout: string;
@@ -42,6 +44,10 @@ type MacOSTrackerDeps = {
trackingLossGraceMs?: number;
minimizedTrackingLossGraceMs?: number;
now?: () => number;
fastPollIntervalMs?: number;
stablePollIntervalMs?: number;
setPollTimeout?: typeof setTimeout;
clearPollTimeout?: typeof clearTimeout;
};
export type MacOSHelperWindowState =
@@ -49,11 +55,29 @@ export type MacOSHelperWindowState =
geometry: WindowGeometry;
focused: boolean;
minimized?: false;
active?: false;
inactive?: false;
}
| {
geometry: null;
focused: true;
active: true;
minimized?: false;
inactive?: false;
}
| {
geometry: null;
focused: false;
inactive: true;
active?: false;
minimized?: false;
}
| {
geometry: null;
focused: false;
minimized: true;
active?: false;
inactive?: false;
};
function runHelperWithExecFile(
@@ -90,6 +114,25 @@ function runHelperWithExecFile(
});
}
export function isCompiledMacOSHelperCurrent(
binaryPath: string,
sourcePath: string,
helperFs: Pick<typeof fs, 'existsSync' | 'statSync'> = fs,
): boolean {
if (!helperFs.existsSync(binaryPath)) {
return false;
}
if (!helperFs.existsSync(sourcePath)) {
return true;
}
try {
return helperFs.statSync(binaryPath).mtimeMs >= helperFs.statSync(sourcePath).mtimeMs;
} catch {
return false;
}
}
export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null {
const trimmed = result.trim();
if (trimmed === 'minimized') {
@@ -99,6 +142,20 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
minimized: true,
};
}
if (trimmed === 'active') {
return {
geometry: null,
focused: true,
active: true,
};
}
if (trimmed === 'inactive') {
return {
geometry: null,
focused: false,
inactive: true,
};
}
if (!trimmed || trimmed === 'not-found') {
return null;
}
@@ -138,8 +195,9 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
}
export class MacOSWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
private pollInFlight = false;
private started = false;
private helperPath: string | null = null;
private helperType: 'binary' | 'swift' | null = null;
private lastExecErrorFingerprint: string | null = null;
@@ -154,6 +212,10 @@ export class MacOSWindowTracker extends BaseWindowTracker {
private readonly trackingLossGraceMs: number;
private readonly minimizedTrackingLossGraceMs: number;
private readonly now: () => number;
private readonly fastPollIntervalMs: number;
private readonly stablePollIntervalMs: number;
private readonly setPollTimeout: typeof setTimeout;
private readonly clearPollTimeout: typeof clearTimeout;
private consecutiveMisses = 0;
private trackingLossStartedAtMs: number | null = null;
private targetWindowMinimized = false;
@@ -169,6 +231,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
);
this.now = deps.now ?? (() => Date.now());
this.fastPollIntervalMs = Math.max(
50,
Math.floor(deps.fastPollIntervalMs ?? MACOS_FAST_POLL_INTERVAL_MS),
);
this.stablePollIntervalMs = Math.max(
this.fastPollIntervalMs,
Math.floor(deps.stablePollIntervalMs ?? MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS),
);
this.setPollTimeout = deps.setPollTimeout ?? setTimeout;
this.clearPollTimeout = deps.clearPollTimeout ?? clearTimeout;
const resolvedHelper = deps.resolveHelper?.() ?? null;
if (resolvedHelper) {
this.helperPath = resolvedHelper.helperPath;
@@ -216,15 +288,15 @@ export class MacOSWindowTracker extends BaseWindowTracker {
return true;
}
private detectHelper(): void {
const shouldFilterBySocket = this.targetMpvSocketPath !== null;
// Fall back to Swift helper first when filtering by socket path to avoid
// stale prebuilt binaries that don't support the new socket filter argument.
const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift');
if (shouldFilterBySocket && this.tryUseHelper(swiftPath, 'swift')) {
return;
private tryUseCompiledHelper(candidatePath: string, sourcePath: string): boolean {
if (!isCompiledMacOSHelperCurrent(candidatePath, sourcePath)) {
return false;
}
return this.tryUseHelper(candidatePath, 'binary');
}
private detectHelper(): void {
const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift');
// Prefer resources path (outside asar) in packaged apps.
const resourcesPath = process.resourcesPath;
@@ -235,9 +307,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
}
}
// Dist binary path (development / unpacked installs).
const distBinaryPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos');
if (this.tryUseHelper(distBinaryPath, 'binary')) {
// Built source runs from dist/window-trackers, so the compiled helper is a sibling of dist.
const bundledBinaryPath = path.join(__dirname, '..', 'scripts', 'get-mpv-window-macos');
if (this.tryUseCompiledHelper(bundledBinaryPath, swiftPath)) {
return;
}
// Source-tree/manual helper build path.
const sourceTreeBinaryPath = path.join(
__dirname,
'..',
'..',
'scripts',
'get-mpv-window-macos',
);
if (this.tryUseCompiledHelper(sourceTreeBinaryPath, swiftPath)) {
return;
}
@@ -269,15 +353,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
}
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
if (this.started) {
return;
}
this.started = true;
this.pollGeometry();
}
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
this.started = false;
this.clearScheduledPoll();
}
override isTargetWindowMinimized(): boolean {
@@ -303,7 +388,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
return this.now() - this.trackingLossStartedAtMs > graceMs;
}
private shouldPreserveFocusedTargetOnMiss(): boolean {
return this.isTracking() && this.isTargetWindowFocused() && this.getGeometry() !== null;
}
private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
if (this.shouldPreserveFocusedTargetOnMiss()) {
if (this.trackingLossStartedAtMs === null) {
this.trackingLossStartedAtMs = this.now();
return;
}
if (this.now() - this.trackingLossStartedAtMs <= graceMs) {
return;
}
}
this.consecutiveMisses += 1;
if (this.shouldDropTracking(graceMs)) {
this.updateGeometry(null);
@@ -311,6 +410,39 @@ export class MacOSWindowTracker extends BaseWindowTracker {
}
}
private resolveNextPollIntervalMs(): number {
if (
this.isTracking() &&
this.isTargetWindowFocused() &&
!this.targetWindowMinimized &&
this.getGeometry() !== null
) {
return this.stablePollIntervalMs;
}
return this.fastPollIntervalMs;
}
private clearScheduledPoll(): void {
if (!this.pollTimeout) {
return;
}
this.clearPollTimeout(this.pollTimeout);
this.pollTimeout = null;
}
private scheduleNextPoll(): void {
if (!this.started || this.pollTimeout) {
return;
}
this.pollTimeout = this.setPollTimeout(() => {
this.pollTimeout = null;
this.pollGeometry();
}, this.resolveNextPollIntervalMs());
}
private pollGeometry(): void {
if (this.pollInFlight || !this.helperPath || !this.helperType) {
return;
@@ -327,10 +459,22 @@ export class MacOSWindowTracker extends BaseWindowTracker {
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
return;
}
if (parsed.active) {
this.resetTrackingLossState();
this.targetWindowMinimized = false;
this.updateTargetWindowFocused(true);
return;
}
if (parsed.inactive) {
this.targetWindowMinimized = false;
this.updateTargetWindowFocused(false);
this.registerTrackingMiss();
return;
}
this.resetTrackingLossState();
this.targetWindowMinimized = false;
this.updateFocus(parsed.focused);
this.updateGeometry(parsed.geometry);
this.updateGeometry(parsed.geometry, parsed.focused);
this.updateTargetWindowFocused(parsed.focused);
return;
}
@@ -352,6 +496,7 @@ export class MacOSWindowTracker extends BaseWindowTracker {
})
.finally(() => {
this.pollInFlight = false;
this.scheduleNextPoll();
});
}
}