Compare commits

..

6 Commits

Author SHA1 Message Date
sudacode f044877c83 fix(macos): drop target after grace period on repeated tracking misses
- registerTrackingMiss was resetting grace state on every miss, so focus was never released; now starts timer on first miss and drops after grace elapses
- update two tests to assert focus is dropped (not preserved) once grace expires
- add IPC test for setIgnoreMouseEvents → onOverlayMouseInteractionChanged mapping
2026-05-16 19:04:16 -07:00
sudacode fe201a2d2f fix(macos): keep overlay interactive when mpv loses foreground
- Track overlay mouse interaction state via IPC setIgnoreMouseEvents hook
- Skip macOS hide/passthrough when overlayInteractionActive is set
- Focus overlay window so lookup keys reach it during interaction
- Record mpv duration events into AniList media state for threshold checks
2026-05-16 18:48:45 -07:00
sudacode 215e0f804b fix(anilist): pass fresh time-pos to post-watch threshold check
- Thread live mpv time-position through to AniList watched-seconds check
- Prevents missed progress updates when the cached value lags behind playback
2026-05-16 17:48:55 -07:00
sudacode a36e628512 fix(macos): release overlay when mpv loses focus 2026-05-16 17:41:58 -07:00
sudacode b6272b229e fix(character-dictionary): cache AniList media resolution to skip repeat
- Add anilist-resolution-cache.json to persist seriesKey→mediaId mappings
- Skip AniList search when a cached resolution or matching snapshot exists
- Expose readCachedSnapshots and readCachedMediaResolution from cache module
2026-05-16 16:19:22 -07:00
sudacode 3a7d650a70 fix(macos): hide overlay when mpv loses foreground and open stats inacti
- Add "active" Swift helper output when mpv is frontmost but geometry is temporarily unavailable, preserving overlay through transient tracker misses
- Use showInactive for overlay and stats window on macOS to avoid switching Spaces over fullscreen mpv
- Disable autoInstallOnAppQuit to prevent premature Squirrel install before user confirms restart
2026-05-16 15:36:44 -07:00
56 changed files with 578 additions and 2272 deletions
+1 -10
View File
@@ -23,7 +23,6 @@ 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)
@@ -162,15 +161,7 @@ build-launcher:
clean:
@printf '%s\n' "[INFO] Removing build artifacts"
@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 -rf dist release
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
generate-config: ensure-bun
@@ -1,6 +0,0 @@
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.
+1 -3
View File
@@ -1,6 +1,4 @@
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.
- Used fresh mpv time-position and duration events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
-4
View File
@@ -1,4 +0,0 @@
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`.
+1 -2
View File
@@ -138,8 +138,7 @@
"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.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================
+11 -12
View File
@@ -17,8 +17,8 @@ AniList integration is opt-in. To enable it:
{
"anilist": {
"enabled": true,
"accessToken": "",
},
"accessToken": ""
}
}
```
@@ -37,8 +37,8 @@ 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. 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.
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.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
## Update Queue and Retry
@@ -46,7 +46,7 @@ The update flow:
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 |
@@ -85,15 +85,15 @@ 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`) |
@@ -106,7 +106,7 @@ See the [Character Dictionary](/character-dictionary) page for full details on t
## 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 |
@@ -115,7 +115,6 @@ See the [Character Dictionary](/character-dictionary) page for full details on t
## 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.
+5 -18
View File
@@ -608,12 +608,8 @@ 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`.
- 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.
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- `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`.
@@ -662,15 +658,6 @@ 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 },
},
},
},
},
}
```
@@ -691,7 +678,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 under `controller.profiles["<controller id>"]` for the selected controller. 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 for you, so 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`:
@@ -705,15 +692,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` 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.
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.
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 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 your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
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 profile `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 `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.
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.
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.
+1 -2
View File
@@ -138,8 +138,7 @@
"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.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================
+6 -7
View File
@@ -283,14 +283,13 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
### Getting Started
1. Connect a controller before or after launching SubMiner.
2. Set `controller.enabled` to `true` in your config.
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
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.
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.
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`.
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`.
### Default Button Mapping
@@ -317,7 +316,7 @@ By default SubMiner uses the first connected controller after controller support
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. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
## Keybindings
+4 -7
View File
@@ -57,11 +57,8 @@
`*.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. 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`.
committed file — so review it before committing. Do not run
`bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
@@ -73,11 +70,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. 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:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
- `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. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
- 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.3",
"version": "0.15.0-beta.2",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
-44
View File
@@ -1,44 +0,0 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
## Highlights
### Added
**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:** 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:** 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 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.
**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.
**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.
**Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
**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.
**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
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
-66
View File
@@ -509,72 +509,6 @@ 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');
+3 -26
View File
@@ -290,7 +290,6 @@ function serializeFragmentsForPrompt(
mode: PolishMode,
version: string,
date?: string,
existingReleaseNotes?: string,
): string {
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
if (date) {
@@ -308,11 +307,7 @@ function serializeFragmentsForPrompt(
].join('\n');
});
const existingNotesBlock = existingReleaseNotes?.trim()
? ['EXISTING PRERELEASE NOTES', existingReleaseNotes.trim()]
: [];
return [...header, '', ...existingNotesBlock, '', ...fragmentBlocks].join('\n\n');
return [...header, '', ...fragmentBlocks].join('\n\n');
}
function validatePolishedOutput(
@@ -345,11 +340,10 @@ function polishFragmentsWithClaude(
mode: PolishMode;
version: string;
date?: string;
existingReleaseNotes?: string;
deps?: ChangelogFsDeps;
},
): string {
const { mode, version, date, existingReleaseNotes } = options;
const { mode, version, date } = options;
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
const filtered =
@@ -367,18 +361,8 @@ 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 +
reuseInstructions +
serializeFragmentsForPrompt(filtered, mode, version, date, existingReleaseNotes);
POLISH_PROMPT_INSTRUCTIONS + serializeFragmentsForPrompt(filtered, mode, version, date);
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
return validatePolishedOutput(output, mode, hasInternalFragments);
}
@@ -796,8 +780,6 @@ 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(
@@ -810,14 +792,9 @@ 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, {
-98
View File
@@ -1453,104 +1453,6 @@ 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,7 +74,6 @@ 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,13 +239,6 @@ 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
@@ -1,423 +0,0 @@
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;
}
}
}
+341 -2
View File
@@ -1,7 +1,150 @@
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;
@@ -102,7 +245,203 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
}
applyControllerConfig(context);
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'.",
);
}
}
}
}
if (Array.isArray(src.keybindings)) {
resolved.keybindings = src.keybindings.filter(
@@ -32,16 +32,10 @@ test('anilist update queue enqueues, snapshots, and dequeues success', () => {
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
queue.enqueue('k1', 'Demo', 1, 2);
queue.enqueue('k1', 'Demo', 1);
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
assert.deepEqual(
{
key: queue.nextReady(Number.MAX_SAFE_INTEGER)?.key,
season: queue.nextReady(Number.MAX_SAFE_INTEGER)?.season,
},
{ key: 'k1', season: 2 },
);
assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
queue.markSuccess('k1');
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
@@ -9,7 +9,6 @@ const MAX_ITEMS = 500;
export interface AnilistQueuedUpdate {
key: string;
title: string;
season?: number | null;
episode: number;
createdAt: number;
attemptCount: number;
@@ -29,7 +28,7 @@ export interface AnilistRetryQueueSnapshot {
}
export interface AnilistUpdateQueue {
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
enqueue: (key: string, title: string, episode: number) => void;
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
markSuccess: (key: string) => void;
markFailure: (key: string, reason: string, nowMs?: number) => void;
@@ -107,7 +106,7 @@ export function createAnilistUpdateQueue(
load();
return {
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
enqueue(key: string, title: string, episode: number): void {
const existing = pending.find((item) => item.key === key);
if (existing) {
return;
@@ -118,7 +117,6 @@ export function createAnilistUpdateQueue(
pending.push({
key,
title,
season,
episode,
createdAt: Date.now(),
attemptCount: 0,
@@ -265,125 +265,6 @@ 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 () =>
+5 -61
View File
@@ -18,12 +18,10 @@ export interface AnilistMediaGuess {
export interface AnilistPostWatchUpdateResult {
status: 'updated' | 'skipped' | 'error';
message: string;
retryable?: boolean;
}
export interface AnilistPostWatchUpdateOptions {
rateLimiter?: AnilistRateLimiter;
season?: number | null;
}
interface AnilistGraphQlError {
@@ -158,28 +156,6 @@ 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,
@@ -250,15 +226,6 @@ 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,
@@ -312,11 +279,6 @@ export async function updateAnilistPostWatchProgress(
episode: number,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistPostWatchUpdateResult> {
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,
`
@@ -334,20 +296,10 @@ export async function updateAnilistPostWatchProgress(
}
}
`,
{ search },
{ search: title },
options,
);
searchError = firstErrorMessage(searchResponse);
if (searchError) {
break;
}
media = searchResponse.data?.Page?.media ?? [];
if (media.length > 0) {
pickTitle = search;
break;
}
}
const searchError = firstErrorMessage(searchResponse);
if (searchError) {
return {
status: 'error',
@@ -355,7 +307,8 @@ export async function updateAnilistPostWatchProgress(
};
}
const picked = pickBestSearchResult(pickTitle, episode, media);
const media = searchResponse.data?.Page?.media ?? [];
const picked = pickBestSearchResult(title, episode, media);
if (!picked) {
return { status: 'error', message: 'AniList search returned no matches.' };
}
@@ -384,16 +337,7 @@ export async function updateAnilistPostWatchProgress(
};
}
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;
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
if (typeof currentProgress === 'number' && currentProgress >= episode) {
return {
status: 'skipped',
-53
View File
@@ -83,7 +83,6 @@ 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: {},
};
}
@@ -976,58 +975,6 @@ 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[] = [];
+5 -7
View File
@@ -3209,10 +3209,9 @@ const {
);
},
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
season,
}),
markSuccess: (key) => {
anilistUpdateQueue.markSuccess(key);
@@ -3243,13 +3242,13 @@ const {
resetAnilistMediaTracking(mediaKey);
},
getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
maybeProbeAnilistDuration: (mediaKey, options) => maybeProbeAnilistDuration(mediaKey, options),
maybeProbeAnilistDuration: (mediaKey) => maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey),
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
enqueueRetry: (key, title, episode, season) => {
anilistUpdateQueue.enqueue(key, title, episode, season);
enqueueRetry: (key, title, episode) => {
anilistUpdateQueue.enqueue(key, title, episode);
},
markRetryFailure: (key, message) => {
anilistUpdateQueue.markFailure(key, message);
@@ -3258,10 +3257,9 @@ const {
anilistUpdateQueue.markSuccess(key);
},
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
season,
}),
rememberAttemptedUpdateKey: (key) => {
rememberAnilistAttemptedUpdate(key);
-64
View File
@@ -75,67 +75,3 @@ 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');
});
+16 -88
View File
@@ -2,66 +2,6 @@ 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,
@@ -72,38 +12,26 @@ export function applyControllerConfigUpdate(
...update,
};
const buttonIndices = mergeButtonIndexPatch(
currentController?.buttonIndices,
update.buttonIndices,
);
if (buttonIndices) {
nextController.buttonIndices = buttonIndices;
if (currentController?.buttonIndices || update.buttonIndices) {
nextController.buttonIndices = {
...(currentController?.buttonIndices ?? {}),
...(update.buttonIndices ?? {}),
};
}
const bindings = mergeBindingPatch(currentController?.bindings, update.bindings);
if (bindings) {
nextController.bindings = bindings;
if (currentController?.bindings || update.bindings) {
const nextBindings: RawControllerBindings = {
...(currentController?.bindings ?? {}),
};
for (const [key, value] of Object.entries(update.bindings ?? {}) as Array<
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
>) {
if (value === undefined) continue;
(nextBindings as Record<string, unknown>)[key] = structuredClone(value);
}
if (currentController?.profiles || update.profiles) {
const nextProfiles: RawControllerProfiles = {};
for (const [profileId, profile] of Object.entries(currentController?.profiles ?? {}) as Array<
[string, RawControllerProfile]
>) {
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue;
nextProfiles[profileId] = profile;
}
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;
nextController.bindings = nextBindings;
}
return nextController;
@@ -30,36 +30,6 @@ 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',
+2 -9
View File
@@ -14,10 +14,6 @@ type GuessAnilistMediaInfo = (
mediaTitle: string | null,
) => Promise<AnilistMediaGuess | null>;
type AnilistDurationProbeOptions = {
force?: boolean;
};
export function createMaybeProbeAnilistDurationHandler(deps: {
getState: () => AnilistMediaGuessRuntimeState;
setState: (state: AnilistMediaGuessRuntimeState) => void;
@@ -26,10 +22,7 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
requestMpvDuration: () => Promise<unknown>;
logWarn: (message: string, error: unknown) => void;
}) {
return async (
mediaKey: string,
options: AnilistDurationProbeOptions = {},
): Promise<number | null> => {
return async (mediaKey: string): Promise<number | null> => {
const state = deps.getState();
if (state.mediaKey !== mediaKey) {
return null;
@@ -41,7 +34,7 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
return state.mediaDurationSec;
}
const now = deps.now();
if (!options.force && now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
return null;
}
@@ -13,10 +13,7 @@ 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 (_accessToken, _title, _episode, season) => ({
status: 'updated',
message: `ok:${season}`,
}),
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
markSuccess: () => calls.push('success'),
rememberAttemptedUpdateKey: () => calls.push('remember'),
markFailure: () => calls.push('failure'),
@@ -29,9 +26,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, 2), {
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
status: 'updated',
message: 'ok:2',
message: 'ok',
});
deps.markSuccess('k');
deps.rememberAttemptedUpdateKey('k');
@@ -61,22 +58,16 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
getTrackedMediaKey: () => 'media',
resetTrackedMedia: () => calls.push('reset'),
getWatchedSeconds: () => 100,
maybeProbeAnilistDuration: async (_mediaKey, options) => {
calls.push(`probe:${options?.force === true}`);
return 120;
},
maybeProbeAnilistDuration: async () => 120,
ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: (_key, _title, _episode, season) => calls.push(`enqueue:${season}`),
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('retry-fail'),
markRetrySuccess: () => calls.push('retry-ok'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
status: 'updated',
message: `done:${season}`,
}),
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'done' }),
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: () => calls.push('osd'),
logInfo: (message) => calls.push(`info:${message}`),
@@ -93,7 +84,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', { force: true }), 120);
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
title: 'x',
season: null,
@@ -102,13 +93,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, 2);
deps.enqueueRetry('k', 't', 1);
deps.markRetryFailure('k', 'bad');
deps.markRetrySuccess('k');
deps.refreshRetryQueueState();
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
status: 'updated',
message: 'done:2',
message: 'done',
});
deps.rememberAttemptedUpdateKey('k');
deps.showMpvOsd('ok');
@@ -119,8 +110,7 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
assert.deepEqual(calls, [
'in-flight',
'reset',
'probe:true',
'enqueue:2',
'enqueue',
'retry-fail',
'retry-ok',
'refresh',
@@ -19,12 +19,8 @@ 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,
season?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
markSuccess: (key: string) => deps.markSuccess(key),
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
markFailure: (key: string, message: string) => deps.markFailure(key, message),
@@ -46,23 +42,18 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
getTrackedMediaKey: () => deps.getTrackedMediaKey(),
resetTrackedMedia: (mediaKey) => deps.resetTrackedMedia(mediaKey),
getWatchedSeconds: () => deps.getWatchedSeconds(),
maybeProbeAnilistDuration: (mediaKey: string, options) =>
deps.maybeProbeAnilistDuration(mediaKey, options),
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
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, season?: number | null) =>
deps.enqueueRetry(key, title, episode, season),
enqueueRetry: (key: string, title: string, episode: number) =>
deps.enqueueRetry(key, title, episode),
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,
season?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
logInfo: (message: string) => deps.logInfo(message),
+9 -72
View File
@@ -20,15 +20,12 @@ 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: 2, episode: 1 }),
nextReady: () => ({ key: 'k1', title: 'Show', season: null, episode: 1 }),
refreshRetryQueueState: () => calls.push('refresh'),
setLastAttemptAt: () => calls.push('attempt'),
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
refreshAnilistClientSecretState: async () => 'token',
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
status: 'updated',
message: `updated ok:${season}`,
}),
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'updated ok' }),
markSuccess: () => calls.push('success'),
rememberAttemptedUpdateKey: () => calls.push('remember'),
markFailure: () => calls.push('failure'),
@@ -37,7 +34,7 @@ test('createProcessNextAnilistRetryUpdateHandler handles successful retry', asyn
});
const result = await handler();
assert.deepEqual(result, { ok: true, message: 'updated ok:2' });
assert.deepEqual(result, { ok: true, message: 'updated ok' });
assert.ok(calls.includes('success'));
assert.ok(calls.includes('remember'));
});
@@ -96,7 +93,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
calls.push('probe');
return 1000;
},
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 3 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
@@ -124,64 +121,8 @@ 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}`),
@@ -192,11 +133,8 @@ test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 0,
maybeProbeAnilistDuration: async (_mediaKey, options) => {
durationProbeOptions = options;
return 1000;
},
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 2, episode: 8 }),
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 8 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
@@ -204,8 +142,8 @@ test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => {
calls.push(`update:${season}`);
updateAnilistPostWatchProgress: async () => {
calls.push('update');
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
@@ -218,8 +156,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds
await handler({ watchedSeconds: 850 });
assert.deepEqual(durationProbeOptions, { force: true });
assert.ok(calls.includes('update:2'));
assert.ok(calls.includes('update'));
assert.ok(calls.includes('remember'));
assert.ok(calls.includes('osd:updated ok'));
});
+5 -30
View File
@@ -2,20 +2,17 @@ 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;
};
@@ -24,10 +21,6 @@ type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
type AnilistDurationProbeOptions = {
force?: boolean;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
return `${mediaKey}::${episode}`;
}
@@ -57,7 +50,6 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
accessToken: string,
title: string,
episode: number,
season?: number | null,
) => Promise<AnilistUpdateResult>;
markSuccess: (key: string) => void;
rememberAttemptedUpdateKey: (key: string) => void;
@@ -83,7 +75,6 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
accessToken,
queued.title,
queued.episode,
queued.season ?? null,
);
if (result.status === 'updated' || result.status === 'skipped') {
deps.markSuccess(queued.key);
@@ -111,15 +102,12 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
getTrackedMediaKey: () => string | null;
resetTrackedMedia: (mediaKey: string | null) => void;
getWatchedSeconds: () => number;
maybeProbeAnilistDuration: (
mediaKey: string,
options?: AnilistDurationProbeOptions,
) => Promise<number | null>;
maybeProbeAnilistDuration: (mediaKey: string) => 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, season?: number | null) => void;
enqueueRetry: (key: string, title: string, episode: number) => void;
markRetryFailure: (key: string, message: string) => void;
markRetrySuccess: (key: string) => void;
refreshRetryQueueState: () => void;
@@ -127,7 +115,6 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
accessToken: string,
title: string,
episode: number,
season?: number | null,
) => Promise<AnilistUpdateResult>;
rememberAttemptedUpdateKey: (key: string) => void;
showMpvOsd: (message: string) => void;
@@ -172,10 +159,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
deps.setInFlight(true);
try {
if (!force) {
const duration = await deps.maybeProbeAnilistDuration(mediaKey, {
force:
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds),
});
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
if (!duration || duration <= 0) {
return;
}
@@ -201,7 +185,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
const accessToken = await deps.refreshAnilistClientSecretState();
if (!accessToken) {
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
deps.refreshRetryQueueState();
deps.showMpvOsd('AniList: access token not configured');
@@ -212,7 +196,6 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
accessToken,
guess.title,
guess.episode,
guess.season,
);
if (result.status === 'updated') {
deps.rememberAttemptedUpdateKey(attemptKey);
@@ -230,15 +213,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
return;
}
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.enqueueRetry(attemptKey, guess.title, guess.episode);
deps.markRetryFailure(attemptKey, result.message);
deps.refreshRetryQueueState();
deps.showMpvOsd(`AniList: ${result.message}`);
@@ -97,38 +97,20 @@ test('mpv connection handler keeps overlay-initialized non-youtube sessions aliv
assert.deepEqual(calls, ['presence-refresh', 'report-stop']);
});
test('mpv subtitle timing handler skips blank subtitle recording but still checks AniList time', () => {
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: () => calls.push('immersion'),
hasSubtitleTimingTracker: () => true,
recordSubtitleTiming: () => calls.push('timing'),
maybeRunAnilistPostWatchUpdate: async (options) => {
calls.push(`post-watch:${options?.watchedSeconds}`);
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('post-watch');
},
logError: () => calls.push('error'),
});
handler({ text: ' ', start: 1, end: 2 });
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']);
assert.deepEqual(calls, []);
});
test('mpv event bindings register all expected events', () => {
+4 -15
View File
@@ -19,10 +19,6 @@ 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;
@@ -61,22 +57,15 @@ export function createHandleMpvSubtitleTimingHandler(deps: {
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
hasSubtitleTimingTracker: () => boolean;
recordSubtitleTiming: (text: string, start: number, end: number) => void;
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logError: (message: string, error: unknown) => void;
}) {
return ({ text, start, end }: { text: string; start: number; end: number }): void => {
const watchedSeconds = Math.max(
Number.isFinite(start) ? start : 0,
Number.isFinite(end) ? end : 0,
);
const options = watchedSeconds > 0 ? { watchedSeconds } : undefined;
if (text.trim()) {
if (!text.trim()) return;
deps.recordImmersionSubtitleLine(text, start, end);
if (deps.hasSubtitleTimingTracker()) {
if (!deps.hasSubtitleTimingTracker()) return;
deps.recordSubtitleTiming(text, start, end);
}
}
void deps.maybeRunAnilistPostWatchUpdate(options).catch((error) => {
void deps.maybeRunAnilistPostWatchUpdate().catch((error) => {
deps.logError('AniList post-watch update failed unexpectedly', error);
});
};
@@ -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 (options) => {
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('post-watch');
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
@@ -74,7 +74,6 @@ 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 });
@@ -88,7 +87,6 @@ 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'));
+1 -1
View File
@@ -107,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: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
});
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
-6
View File
@@ -167,12 +167,6 @@ 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
@@ -1,22 +0,0 @@
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 { show, update };
return { update };
}
@@ -93,7 +93,6 @@ function createControllerConfig(
...(buttonIndexOverrides ?? {}),
}),
},
profiles: {},
...restOverrides,
};
}
@@ -450,60 +449,6 @@ 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[] = [];
+34 -49
View File
@@ -5,7 +5,6 @@ import type {
ResolvedControllerConfig,
ResolvedControllerDiscreteBinding,
} from '../../types';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
type ControllerButtonState = {
value: number;
@@ -411,101 +410,87 @@ export function createGamepadController(options: GamepadControllerOptions) {
resetHeldAction(jumpHold);
}
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
if (activeConfig.enabled) {
let interactionAllowed =
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
if (config.enabled) {
handleActionEdge(
'toggleKeyboardOnlyMode',
activeConfig.bindings.toggleKeyboardOnlyMode,
config.bindings.toggleKeyboardOnlyMode,
activeGamepad,
activeConfig,
config,
options.toggleKeyboardMode,
);
}
const interactionAllowed =
activeConfig.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
interactionAllowed =
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
if (!interactionAllowed) {
syncBlockedInteractionState(activeGamepad, activeConfig, now);
syncBlockedInteractionState(activeGamepad, config, now);
return;
}
handleActionEdge(
'toggleLookup',
activeConfig.bindings.toggleLookup,
config.bindings.toggleLookup,
activeGamepad,
activeConfig,
config,
options.toggleLookup,
);
handleActionEdge(
'closeLookup',
activeConfig.bindings.closeLookup,
config.bindings.closeLookup,
activeGamepad,
activeConfig,
config,
options.closeLookup,
);
handleActionEdge(
'mineCard',
activeConfig.bindings.mineCard,
activeGamepad,
activeConfig,
options.mineCard,
);
handleActionEdge(
'quitMpv',
activeConfig.bindings.quitMpv,
activeGamepad,
activeConfig,
options.quitMpv,
);
handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard);
handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv);
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
if (options.getLookupWindowOpen()) {
handleActionEdge(
'previousAudio',
activeConfig.bindings.previousAudio,
config.bindings.previousAudio,
activeGamepad,
activeConfig,
config,
options.previousAudio,
);
handleActionEdge(
'nextAudio',
activeConfig.bindings.nextAudio,
config.bindings.nextAudio,
activeGamepad,
activeConfig,
config,
options.nextAudio,
);
handleActionEdge(
'playCurrentAudio',
activeConfig.bindings.playCurrentAudio,
config.bindings.playCurrentAudio,
activeGamepad,
activeConfig,
config,
options.playCurrentAudio,
);
const primaryScroll = resolveAxisBindingValue(
activeGamepad,
activeConfig.bindings.leftStickVertical,
activeConfig.triggerDeadzone,
activeConfig.stickDeadzone,
);
if (elapsedMs > 0 && Math.abs(primaryScroll) >= activeConfig.stickDeadzone) {
options.scrollPopup(
(primaryScroll * activeConfig.scrollPixelsPerSecond * elapsedMs) / 1000,
config.bindings.leftStickVertical,
config.triggerDeadzone,
config.stickDeadzone,
);
if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) {
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
}
handleJumpAxis(
resolveAxisBindingValue(
activeGamepad,
activeConfig.bindings.rightStickVertical,
activeConfig.triggerDeadzone,
config.bindings.rightStickVertical,
config.triggerDeadzone,
activationThreshold,
),
now,
activeConfig,
config,
);
} else {
resetHeldAction(jumpHold);
@@ -513,21 +498,21 @@ export function createGamepadController(options: GamepadControllerOptions) {
handleActionEdge(
'toggleMpvPause',
activeConfig.bindings.toggleMpvPause,
config.bindings.toggleMpvPause,
activeGamepad,
activeConfig,
config,
options.toggleMpvPause,
);
handleSelectionAxis(
resolveAxisBindingValue(
activeGamepad,
activeConfig.bindings.leftStickHorizontal,
activeConfig.triggerDeadzone,
config.bindings.leftStickHorizontal,
config.triggerDeadzone,
activationThreshold,
),
now,
activeConfig,
config,
);
}
+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, []);
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-select']);
} 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, []);
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
} 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, []);
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
} finally {
testGlobals.restore();
}
+2
View File
@@ -203,11 +203,13 @@ 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,69 +144,3 @@ 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');
}
}
});
+2 -56
View File
@@ -278,17 +278,6 @@ 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;
@@ -332,17 +321,6 @@ 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;
@@ -388,17 +366,6 @@ 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;
@@ -433,10 +400,6 @@ 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';
@@ -449,33 +412,16 @@ export function createControllerConfigForm(options: {
const right = document.createElement('div');
right.className = 'controller-config-right';
const badge = document.createElement('button');
badge.type = 'button';
const badge = document.createElement('span');
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 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';
const editIcon = document.createElement('span');
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,7 +76,6 @@ 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 = {
@@ -100,7 +99,6 @@ test('controller debug modal renders active controller axes, buttons, and config
const modal = createControllerDebugModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.openControllerDebugModal();
@@ -191,7 +189,6 @@ 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 = {
@@ -220,7 +217,6 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
const modal = createControllerDebugModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
@@ -248,97 +244,3 @@ 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 });
}
});
+2 -12
View File
@@ -1,5 +1,4 @@
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.';
@@ -51,7 +50,6 @@ export function createControllerDebugModal(
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
notifyControllerDisabled: () => void;
},
) {
let toastTimer: ReturnType<typeof setTimeout> | null = null;
@@ -116,11 +114,8 @@ 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(
activeConfig?.buttonIndices ?? null,
ctx.state.controllerConfig?.buttonIndices ?? null,
);
}
@@ -141,11 +136,7 @@ export function createControllerDebugModal(
}
}
function openControllerDebugModal(): boolean {
if (ctx.state.controllerConfig?.enabled !== true) {
options.notifyControllerDisabled();
return false;
}
function openControllerDebugModal(): void {
ctx.state.controllerDebugModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
@@ -153,7 +144,6 @@ export function createControllerDebugModal(
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
hideToast();
render();
return true;
}
function closeControllerDebugModal(): void {
@@ -158,7 +158,6 @@ 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 },
@@ -202,7 +201,6 @@ test('controller select modal saves preferred controller from dropdown selection
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
@@ -248,7 +246,6 @@ 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();
@@ -279,192 +276,6 @@ 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 },
@@ -479,99 +290,6 @@ test('controller select modal learn mode falls back to global bindings without a
}
});
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();
@@ -597,7 +315,6 @@ 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();
+10 -75
View File
@@ -1,5 +1,4 @@
import type { ModalStateReader, RendererContext } from '../context';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
import {
createControllerConfigForm,
@@ -25,7 +24,6 @@ export function createControllerSelectModal(
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
notifyControllerDisabled: () => void;
},
) {
let selectedControllerKey: string | null = null;
@@ -40,24 +38,10 @@ 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: () =>
getSelectedControllerConfig()?.bindings ?? {
ctx.state.controllerConfig?.bindings ?? {
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
@@ -83,7 +67,7 @@ export function createControllerSelectModal(
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
stickDeadzone: config?.stickDeadzone ?? 0.2,
});
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
const currentBinding = config?.bindings[actionId];
const currentDpadFallback =
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
? currentBinding.dpadFallback
@@ -232,51 +216,6 @@ 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(
@@ -285,7 +224,11 @@ export function createControllerSelectModal(
): Promise<void> {
const definition = getControllerBindingDefinition(actionId);
try {
await saveControllerConfig(buildBindingConfigUpdate(actionId, binding));
await saveControllerConfig({
bindings: {
[actionId]: binding,
},
});
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
@@ -302,11 +245,11 @@ export function createControllerSelectModal(
dpadFallback: import('../../types').ControllerDpadFallback,
): Promise<void> {
const definition = getControllerBindingDefinition(actionId);
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
const currentBinding = ctx.state.controllerConfig?.bindings[actionId];
if (!currentBinding || currentBinding.kind !== 'axis') return;
const updated = { ...currentBinding, dpadFallback };
try {
await saveControllerConfig(buildBindingConfigUpdate(actionId, updated));
await saveControllerConfig({ bindings: { [actionId]: updated } });
dpadLearningActionId = null;
bindingCapture = null;
controllerConfigForm.render();
@@ -387,11 +330,7 @@ export function createControllerSelectModal(
}
}
function openControllerSelectModal(): boolean {
if (ctx.state.controllerConfig?.enabled !== true) {
options.notifyControllerDisabled();
return false;
}
function openControllerSelectModal(): void {
ctx.state.controllerSelectModalOpen = true;
syncSelectedIndexToCurrentController();
options.syncSettingsModalSubtitleSuppression();
@@ -407,7 +346,6 @@ export function createControllerSelectModal(
} else {
setStatus('Choose a controller or click Learn to remap an action.');
}
return true;
}
function closeControllerSelectModal(): void {
@@ -449,7 +387,6 @@ export function createControllerSelectModal(
);
syncSelectedControllerId();
renderPicker();
controllerConfigForm.render();
}
return true;
}
@@ -463,7 +400,6 @@ export function createControllerSelectModal(
);
syncSelectedControllerId();
renderPicker();
controllerConfigForm.render();
}
return true;
}
@@ -493,7 +429,6 @@ export function createControllerSelectModal(
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
syncSelectedControllerId();
renderPicker();
controllerConfigForm.render();
}
});
}
+5 -20
View File
@@ -128,12 +128,10 @@ 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, {
@@ -185,14 +183,10 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
openControllerSelectModal: () => {
if (controllerSelectModal.openControllerSelectModal()) {
window.electronAPI.notifyOverlayModalOpened('controller-select');
}
controllerSelectModal.openControllerSelectModal();
},
openControllerDebugModal: () => {
if (controllerDebugModal.openControllerDebugModal()) {
window.electronAPI.notifyOverlayModalOpened('controller-debug');
}
controllerDebugModal.openControllerDebugModal();
},
appendClipboardVideoToQueue: () => {
void window.electronAPI.appendClipboardVideoToQueue();
@@ -297,12 +291,6 @@ 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);
@@ -323,7 +311,7 @@ function startControllerPolling(): void {
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
getConfig: () =>
ctx.state.controllerConfig ?? {
enabled: false,
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
@@ -362,7 +350,6 @@ 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),
@@ -474,16 +461,14 @@ function registerModalOpenHandlers(): void {
});
window.electronAPI.onOpenControllerSelect(() => {
runGuarded('controller-select:open', () => {
if (controllerSelectModal.openControllerSelectModal()) {
controllerSelectModal.openControllerSelectModal();
window.electronAPI.notifyOverlayModalOpened('controller-select');
}
});
});
window.electronAPI.onOpenControllerDebug(() => {
runGuarded('controller-debug:open', () => {
if (controllerDebugModal.openControllerDebugModal()) {
controllerDebugModal.openControllerDebugModal();
window.electronAPI.notifyOverlayModalOpened('controller-debug');
}
});
});
window.electronAPI.onOpenJimaku(() => {
+1 -15
View File
@@ -1694,17 +1694,14 @@ iframe[id^='yomitan-popup'],
}
.controller-config-badge {
display: inline-flex;
align-items: center;
display: inline-block;
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;
}
@@ -1713,23 +1710,12 @@ 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);
}
+28 -93
View File
@@ -16,8 +16,6 @@ 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',
@@ -168,67 +166,6 @@ 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 = {};
@@ -245,42 +182,40 @@ 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) {
const parsed = parseControllerBindings(value.bindings);
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;
update.bindings = parsed;
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
}
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);
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;
profile.buttonIndices = parsed;
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
}
if (rawProfile.bindings !== undefined) {
const parsed = parseControllerBindings(rawProfile.bindings);
if (!parsed) return null;
profile.bindings = parsed;
}
profiles[profileId] = profile;
}
update.profiles = profiles;
update.bindings = bindings;
}
return update;
-2
View File
@@ -21,7 +21,6 @@ import type {
import type {
ControllerButtonIndicesConfig,
ControllerConfig,
ResolvedControllerProfileConfig,
ControllerTriggerInputMode,
Keybinding,
ResolvedControllerBindingsConfig,
@@ -165,7 +164,6 @@ export interface ResolvedConfig {
repeatIntervalMs: number;
buttonIndices: Required<ControllerButtonIndicesConfig>;
bindings: Required<ResolvedControllerBindingsConfig>;
profiles: Record<string, ResolvedControllerProfileConfig>;
};
ankiConnect: AnkiConnectConfig & {
enabled: boolean;
-13
View File
@@ -227,18 +227,6 @@ 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;
@@ -253,7 +241,6 @@ export interface ControllerConfig {
repeatIntervalMs?: number;
buttonIndices?: ControllerButtonIndicesConfig;
bindings?: ControllerBindingsConfig;
profiles?: Record<string, ControllerProfileConfig>;
}
export interface ControllerPreferenceUpdate {