mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
4d010e6a18
|
|||
| 5250ca8214 | |||
| 49f89e6452 | |||
|
89723e2ccb
|
@@ -23,6 +23,7 @@ MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
|||||||
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
|
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
|
||||||
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
|
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
|
||||||
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
|
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
|
||||||
|
PRERELEASE_NOTES := release/prerelease-notes.md
|
||||||
|
|
||||||
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
@@ -161,7 +162,15 @@ build-launcher:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
@printf '%s\n' "[INFO] Removing build artifacts"
|
@printf '%s\n' "[INFO] Removing build artifacts"
|
||||||
@rm -rf dist release
|
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
|
||||||
|
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
|
||||||
|
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
|
||||||
|
rm -rf dist release && \
|
||||||
|
install -d release && \
|
||||||
|
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
|
||||||
|
else \
|
||||||
|
rm -rf dist release; \
|
||||||
|
fi
|
||||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||||
|
|
||||||
generate-config: ensure-bun
|
generate-config: ensure-bun
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: character-dictionary
|
||||||
|
|
||||||
|
- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Controller config and debug shortcuts now stay closed while controller support is disabled and show a notice to enable `controller.enabled` manually.
|
||||||
|
- Controller binding rows now start learn mode from the edit pencil, so clicking edit and pressing a controller button saves the remap.
|
||||||
|
- Controller remaps are now saved per controller profile, binding badges also start learn mode, and row reset buttons restore individual bindings to their defaults.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anilist
|
||||||
|
|
||||||
|
- Used fresh mpv time-position, duration, and subtitle timing events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
|
||||||
|
- Prefer season-specific AniList search results for multi-season files before falling back to the base title.
|
||||||
|
- Show a clear AniList message when the matched season is not in Planning or Watching instead of silently queueing an impossible progress update.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Hid the macOS visible overlay when mpv is no longer the foreground target so other apps and Spaces are not covered by SubMiner subtitles.
|
||||||
|
- Kept the macOS overlay layered above active mpv while stats mouse passthrough is enabled, and treated the frontmost mpv app as the focus signal.
|
||||||
|
- Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop.
|
||||||
|
- Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground.
|
||||||
|
- Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs.
|
||||||
|
- Released the macOS overlay when the helper reports mpv is no longer foreground so other apps are no longer covered.
|
||||||
|
- Reduced macOS window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- Fixed tray update checks for builds that cannot install native app updates, showing a manual install message instead of a restart prompt that cannot apply the update.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: updates
|
area: updates
|
||||||
|
|
||||||
- Avoided native `electron-updater` checks where they are unsafe, so tray and background update checks continue through GitHub release metadata without crashing the app.
|
- Kept signed macOS app updates on the native updater path while preventing eager Squirrel install checks before the user confirms restart.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: release
|
||||||
|
|
||||||
|
- Prerelease note generation now reuses existing reviewed prerelease notes and asks Claude to merge only new fragment material, while `make clean` preserves `release/prerelease-notes.md`.
|
||||||
@@ -138,7 +138,8 @@
|
|||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
"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
|
"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.
|
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||||
|
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
||||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ AniList integration is opt-in. To enable it:
|
|||||||
{
|
{
|
||||||
"anilist": {
|
"anilist": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"accessToken": ""
|
"accessToken": "",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -37,20 +37,20 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
|
|||||||
The update flow:
|
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.
|
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||||
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
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. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||||
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
|
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
|
||||||
|
|
||||||
## Update Queue and Retry
|
## Update Queue and Retry
|
||||||
|
|
||||||
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
|
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
|
||||||
|
|
||||||
| Parameter | Value |
|
| Parameter | Value |
|
||||||
| --- | --- |
|
| ---------------- | ---------- |
|
||||||
| Initial backoff | 30 seconds |
|
| Initial backoff | 30 seconds |
|
||||||
| Maximum backoff | 6 hours |
|
| Maximum backoff | 6 hours |
|
||||||
| Maximum attempts | 8 |
|
| Maximum attempts | 8 |
|
||||||
| Queue capacity | 500 items |
|
| Queue capacity | 500 items |
|
||||||
|
|
||||||
After 8 failed attempts, the update is moved to a dead-letter queue and no longer retried automatically. The queue is persisted across restarts so no updates are lost if SubMiner exits before a retry succeeds.
|
After 8 failed attempts, the update is moved to a dead-letter queue and no longer retried automatically. The queue is persisted across restarts so no updates are lost if SubMiner exits before a retry succeeds.
|
||||||
|
|
||||||
@@ -85,36 +85,37 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
|
|||||||
"collapsibleSections": {
|
"collapsibleSections": {
|
||||||
"description": false,
|
"description": false,
|
||||||
"characterInformation": false,
|
"characterInformation": false,
|
||||||
"voicedBy": false
|
"voicedBy": false,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --- | --- | --- |
|
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `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: `""`) |
|
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
||||||
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
|
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
|
||||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
||||||
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
||||||
|
|
||||||
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
||||||
|
|
||||||
## CLI Commands
|
## CLI Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| --- | --- |
|
| ----------------------- | ------------------------------------------------------------- |
|
||||||
| `--anilist-setup` | Open AniList setup/auth flow helper window |
|
| `--anilist-setup` | Open AniList setup/auth flow helper window |
|
||||||
| `--anilist-status` | Print current token resolution state and retry queue counters |
|
| `--anilist-status` | Print current token resolution state and retry queue counters |
|
||||||
| `--anilist-logout` | Clear stored AniList token from local persisted state |
|
| `--anilist-logout` | Clear stored AniList token from local persisted state |
|
||||||
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
|
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
|
||||||
|
|
||||||
## Troubleshooting
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on:
|
|||||||
```
|
```
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
|
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
@@ -139,7 +139,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
|
|||||||
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
|
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
|
||||||
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
|
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
|
||||||
|
|
||||||
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`:
|
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -608,8 +608,12 @@ Important behavior:
|
|||||||
- Controller input is only active while keyboard-only mode is enabled.
|
- Controller input is only active while keyboard-only mode is enabled.
|
||||||
- Keyboard-only mode continues to work normally without a controller.
|
- Keyboard-only mode continues to work normally without a controller.
|
||||||
- By default SubMiner uses the first connected 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`.
|
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
|
||||||
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
- The `Alt+C` config modal and `Alt+Shift+C` debug modal stay closed while controller support is disabled.
|
||||||
|
- Click the binding badge, edit pencil, or `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
||||||
|
- Click the reset button beside the edit pencil to restore one binding to the built-in default.
|
||||||
|
- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
|
||||||
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
|
- `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.
|
- 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`.
|
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
||||||
@@ -658,6 +662,15 @@ Important behavior:
|
|||||||
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
||||||
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
|
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
|
||||||
},
|
},
|
||||||
|
"profiles": {
|
||||||
|
"Xbox Wireless Controller": {
|
||||||
|
"label": "Xbox Wireless Controller",
|
||||||
|
"bindings": {
|
||||||
|
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
||||||
|
"mineCard": { "kind": "button", "buttonIndex": 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -678,7 +691,7 @@ Default logical mapping:
|
|||||||
- `L3`: toggle mpv pause
|
- `L3`: toggle mpv pause
|
||||||
- `L2` / `R2`: unbound by default
|
- `L2` / `R2`: unbound by default
|
||||||
|
|
||||||
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
|
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors under `controller.profiles["<controller id>"]` for the selected controller. Manual edits are only needed when you want to script or copy exact mappings.
|
||||||
|
|
||||||
If you bind a discrete action to an axis manually, include `direction`:
|
If you bind a discrete action to an axis manually, include `direction`:
|
||||||
|
|
||||||
@@ -692,15 +705,15 @@ If you bind a discrete action to an axis manually, include `direction`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
||||||
|
|
||||||
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
|
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
|
||||||
|
|
||||||
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
|
If one controller reports non-standard raw button numbers, override `controller.profiles["<controller id>"].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile.
|
||||||
|
|
||||||
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
|
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
|
||||||
|
|
||||||
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
||||||
|
|
||||||
### Manual Card Update Shortcuts
|
### Manual Card Update Shortcuts
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
||||||
|
|
||||||
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
After controller support is enabled, the controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||||
|
|
||||||
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,8 @@
|
|||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
"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
|
"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.
|
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||||
|
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
||||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
+7
-6
@@ -283,13 +283,14 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
|
|||||||
### Getting Started
|
### Getting Started
|
||||||
|
|
||||||
1. Connect a controller before or after launching SubMiner.
|
1. Connect a controller before or after launching SubMiner.
|
||||||
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
2. Set `controller.enabled` to `true` in your config.
|
||||||
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
|
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
|
||||||
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
4. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
||||||
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
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. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
6. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
||||||
|
7. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
||||||
|
|
||||||
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
|
By default SubMiner uses the first connected controller after controller support is enabled. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline per controller. The reset button beside each edit pencil restores that binding to its built-in default for the selected controller. `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both modals stay closed while `controller.enabled` is false, and both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
|
||||||
|
|
||||||
### Default Button Mapping
|
### Default Button Mapping
|
||||||
|
|
||||||
@@ -316,7 +317,7 @@ By default SubMiner uses the first connected controller. `Alt+C` opens the contr
|
|||||||
|
|
||||||
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
|
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
|
||||||
|
|
||||||
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
||||||
|
|
||||||
## Keybindings
|
## Keybindings
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -57,8 +57,11 @@
|
|||||||
`*.yml` and `*.blockmap` files under `release/`.
|
`*.yml` and `*.blockmap` files under `release/`.
|
||||||
5. Commit the prerelease prep (package.json version bump + the generated
|
5. Commit the prerelease prep (package.json version bump + the generated
|
||||||
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
||||||
committed file — so review it before committing. Do not run
|
committed file — so review it before committing. If you add more
|
||||||
`bun run changelog:build`.
|
`changes/*.md` fragments for a later beta/RC, rerun
|
||||||
|
`bun run changelog:prerelease-notes --version <version>`; the generator uses
|
||||||
|
the existing prerelease notes as the baseline and asks Claude to merge only
|
||||||
|
the new fragment material. Do not run `bun run changelog:build`.
|
||||||
6. Tag the commit: `git tag v<version>`.
|
6. Tag the commit: `git tag v<version>`.
|
||||||
7. Push commit + tag.
|
7. Push commit + tag.
|
||||||
|
|
||||||
@@ -70,11 +73,11 @@ Notes:
|
|||||||
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
- 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.
|
- 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:check` now rejects tag/package version mismatches.
|
||||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
|
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
- `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.
|
- 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.
|
- Do not tag while `changes/*.md` fragments still exist.
|
||||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
|
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
||||||
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
- If you need to repair a published release body (for example, a prior version’s 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.
|
- 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.
|
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.15.0-beta.3",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
+24
-12
@@ -3,21 +3,33 @@
|
|||||||
## Highlights
|
## Highlights
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Auto-Update:** Tray and `subminer -u` command-line update checks for new SubMiner releases, with app and launcher update prompts, checksum verification, configurable update notifications, and an opt-in prerelease channel for beta and RC builds.
|
**Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`. Checks include checksum verification, configurable notifications, and an opt-in channel for prerelease builds. The `subminer` launcher and Linux rofi theme are also updated automatically.
|
||||||
- **First-Run Setup:** Guided setup flow to install Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. On Windows, a `subminer.cmd` PATH shim is installed so you can type `subminer` in any terminal without adding the main executable to PATH.
|
|
||||||
|
**First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **macOS Overlay:** Transient mpv window appearances no longer incorrectly hide the subtitle overlay; minimizing mpv still hides it as expected. mpv controls are also now clickable before hovering a subtitle bar.
|
**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:** Opening the subtitle sync panel on macOS no longer flashes and dismisses on the first attempt, and no longer leaves stale modal state after syncing.
|
|
||||||
- **Updater Stability:** Linux tray and background update checks now use GitHub release metadata instead of the native Electron updater, preventing crashes. Unsafe native updater paths are avoided on all platforms.
|
**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.
|
||||||
- **Linux Launcher Update:** `subminer -u` on Linux now performs release updates directly from the launcher without requiring the tray app to be running. When already on the latest version it reports up to date without downloading assets. Support asset updates are limited to the Linux rofi theme.
|
|
||||||
- **Linux Launcher Install:** First-run launcher installs on Linux now use a valid Bun shebang so the installed launcher executes correctly.
|
**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.
|
||||||
- **macOS Setup:** First-run setup now correctly recognizes launchers already installed via Homebrew or user PATH directories, and manual installs avoid writing to Homebrew-managed locations.
|
|
||||||
- **Update Dialog:** macOS update dialogs are brought to the front when `subminer --update` is run from the command line.
|
**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.
|
||||||
- **Setup Flow:** `subminer app --setup` now correctly opens the setup window when SubMiner is already running in the background. The standalone setup process also quits after first-run completes, returning the terminal prompt instead of leaving the app open.
|
|
||||||
- **Build:** One-shot `make clean build install` flows now correctly pick up the AppImage produced by the current build rather than a stale previous one.
|
**Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
|
||||||
- **Tray Settings:** Closing Yomitan settings launched from the tray no longer quits the tray app, and loading settings no longer blocks other tray actions. A close button is shown within the Yomitan settings page on Hyprland where native window controls are unavailable. The embedded Yomitan popup preview is disabled in the tray settings window to prevent renderer hangs. Extension refreshes are now serialized to prevent startup race conditions, and session help modals can close correctly without mpv running.
|
|
||||||
|
**Updater — Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists.
|
||||||
|
|
||||||
|
**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
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -509,6 +509,72 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion reuses existing prerelease notes when adding new fragments', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-reuse-existing-notes');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
const existingNotes = [
|
||||||
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
|
'',
|
||||||
|
'## Highlights',
|
||||||
|
'### Added',
|
||||||
|
'- Overlay: Previous beta entry.',
|
||||||
|
'',
|
||||||
|
'## Installation',
|
||||||
|
'',
|
||||||
|
'See the README and docs/installation guide for full setup steps.',
|
||||||
|
'',
|
||||||
|
'## Assets',
|
||||||
|
'',
|
||||||
|
'- Linux: `SubMiner.AppImage`',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
['type: fixed', 'area: launcher', '', '- Fixed launcher prerelease packaging.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stub = recordingRunClaude((input) => {
|
||||||
|
if (!input.includes('Overlay: Previous beta entry.')) {
|
||||||
|
return '### Fixed\n- Launcher: Added only the latest fix.';
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'### Added',
|
||||||
|
'- Overlay: Previous beta entry.',
|
||||||
|
'',
|
||||||
|
'### Fixed',
|
||||||
|
'- Launcher: Added only the latest fix.',
|
||||||
|
].join('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputPath = writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-beta.2',
|
||||||
|
deps: { runClaude: stub.runClaude },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
|
||||||
|
assert.match(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
|
||||||
|
|
||||||
|
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||||
|
assert.match(prereleaseNotes, /- Overlay: Previous beta entry\./);
|
||||||
|
assert.match(prereleaseNotes, /- Launcher: Added only the latest fix\./);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
|
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
|
||||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
const workspace = createWorkspace('prerelease-rc-notes');
|
const workspace = createWorkspace('prerelease-rc-notes');
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ function serializeFragmentsForPrompt(
|
|||||||
mode: PolishMode,
|
mode: PolishMode,
|
||||||
version: string,
|
version: string,
|
||||||
date?: string,
|
date?: string,
|
||||||
|
existingReleaseNotes?: string,
|
||||||
): string {
|
): string {
|
||||||
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
|
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
|
||||||
if (date) {
|
if (date) {
|
||||||
@@ -307,7 +308,11 @@ function serializeFragmentsForPrompt(
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...header, '', ...fragmentBlocks].join('\n\n');
|
const existingNotesBlock = existingReleaseNotes?.trim()
|
||||||
|
? ['EXISTING PRERELEASE NOTES', existingReleaseNotes.trim()]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [...header, '', ...existingNotesBlock, '', ...fragmentBlocks].join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePolishedOutput(
|
function validatePolishedOutput(
|
||||||
@@ -340,10 +345,11 @@ function polishFragmentsWithClaude(
|
|||||||
mode: PolishMode;
|
mode: PolishMode;
|
||||||
version: string;
|
version: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
|
existingReleaseNotes?: string;
|
||||||
deps?: ChangelogFsDeps;
|
deps?: ChangelogFsDeps;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const { mode, version, date } = options;
|
const { mode, version, date, existingReleaseNotes } = options;
|
||||||
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
|
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
|
||||||
|
|
||||||
const filtered =
|
const filtered =
|
||||||
@@ -361,8 +367,18 @@ function polishFragmentsWithClaude(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reuseInstructions = existingReleaseNotes?.trim()
|
||||||
|
? [
|
||||||
|
'## Existing Prerelease Notes',
|
||||||
|
'',
|
||||||
|
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Reuse those highlight bullets as the baseline, preserve their meaning and wording where possible, then merge in only new or changed fragment material. Deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
: '';
|
||||||
const prompt =
|
const prompt =
|
||||||
POLISH_PROMPT_INSTRUCTIONS + serializeFragmentsForPrompt(filtered, mode, version, date);
|
POLISH_PROMPT_INSTRUCTIONS +
|
||||||
|
reuseInstructions +
|
||||||
|
serializeFragmentsForPrompt(filtered, mode, version, date, existingReleaseNotes);
|
||||||
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
|
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
|
||||||
return validatePolishedOutput(output, mode, hasInternalFragments);
|
return validatePolishedOutput(output, mode, hasInternalFragments);
|
||||||
}
|
}
|
||||||
@@ -780,6 +796,8 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
|||||||
verifyRequestedVersionMatchesPackageVersion(options ?? {});
|
verifyRequestedVersionMatchesPackageVersion(options ?? {});
|
||||||
|
|
||||||
const cwd = options?.cwd ?? process.cwd();
|
const cwd = options?.cwd ?? process.cwd();
|
||||||
|
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
|
||||||
|
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
|
||||||
const version = resolveVersion(options ?? {});
|
const version = resolveVersion(options ?? {});
|
||||||
if (!isSupportedPrereleaseVersion(version)) {
|
if (!isSupportedPrereleaseVersion(version)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -792,9 +810,14 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
|||||||
throw new Error('No changelog fragments found in changes/.');
|
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, {
|
const changes = polishFragmentsWithClaude(fragments, {
|
||||||
mode: 'release-notes',
|
mode: 'release-notes',
|
||||||
version,
|
version,
|
||||||
|
existingReleaseNotes,
|
||||||
deps: options?.deps,
|
deps: options?.deps,
|
||||||
});
|
});
|
||||||
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// It works with both bundled and unbundled mpv installations.
|
// It works with both bundled and unbundled mpv installations.
|
||||||
//
|
//
|
||||||
// Usage: swift get-mpv-window-macos.swift
|
// Usage: swift get-mpv-window-macos.swift
|
||||||
// Output: "x,y,width,height" or "not-found"
|
// Output: "x,y,width,height,focused", "minimized", "active", "inactive", or "not-found"
|
||||||
//
|
//
|
||||||
|
|
||||||
import Cocoa
|
import Cocoa
|
||||||
@@ -25,9 +25,16 @@ private struct WindowState {
|
|||||||
let focused: Bool
|
let focused: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct FrontmostApplicationState {
|
||||||
|
let pid: pid_t
|
||||||
|
let isMpv: Bool
|
||||||
|
}
|
||||||
|
|
||||||
private enum WindowLookupResult {
|
private enum WindowLookupResult {
|
||||||
case visible(WindowState)
|
case visible(WindowState)
|
||||||
case minimized
|
case minimized
|
||||||
|
case active
|
||||||
|
case inactive
|
||||||
}
|
}
|
||||||
|
|
||||||
private let targetMpvSocketPath: String? = {
|
private let targetMpvSocketPath: String? = {
|
||||||
@@ -146,8 +153,41 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? {
|
|||||||
return geometry
|
return geometry
|
||||||
}
|
}
|
||||||
|
|
||||||
private func frontmostApplicationPid() -> pid_t? {
|
private func frontmostApplicationState() -> FrontmostApplicationState? {
|
||||||
NSWorkspace.shared.frontmostApplication?.processIdentifier
|
guard let app = NSWorkspace.shared.frontmostApplication else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return FrontmostApplicationState(
|
||||||
|
pid: app.processIdentifier,
|
||||||
|
isMpv: app.localizedName.map(normalizedMpvName) ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplicationState?) -> Bool {
|
||||||
|
guard let frontmost = frontmost else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if frontmost.pid == ownerPid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return frontmost.isMpv && windowHasTargetSocket(ownerPid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
|
||||||
|
guard let frontmost = frontmost, frontmost.isMpv else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if windowHasTargetSocket(frontmost.pid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// When macOS says mpv is frontmost but geometry APIs miss, keep the
|
||||||
|
// overlay stable even if ps cannot expose the socket argument.
|
||||||
|
return targetMpvSocketPath != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||||
@@ -158,7 +198,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
|||||||
return normalizedMpvName(name)
|
return normalizedMpvName(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
let frontmostPid = frontmostApplicationPid()
|
let frontmost = frontmostApplicationState()
|
||||||
var foundMinimizedTargetWindow = false
|
var foundMinimizedTargetWindow = false
|
||||||
|
|
||||||
for app in runningApps {
|
for app in runningApps {
|
||||||
@@ -198,7 +238,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
|||||||
return .visible(
|
return .visible(
|
||||||
WindowState(
|
WindowState(
|
||||||
geometry: geometry,
|
geometry: geometry,
|
||||||
focused: frontmostPid == windowPid
|
focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -217,7 +257,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
|
|||||||
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
|
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
|
||||||
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
|
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
|
||||||
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
|
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
|
||||||
let frontmostPid = frontmostApplicationPid()
|
let frontmost = frontmostApplicationState()
|
||||||
|
|
||||||
for window in windowList {
|
for window in windowList {
|
||||||
guard let ownerName = window[kCGWindowOwnerName as String] as? String,
|
guard let ownerName = window[kCGWindowOwnerName as String] as? String,
|
||||||
@@ -260,7 +300,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
|
|||||||
|
|
||||||
return WindowState(
|
return WindowState(
|
||||||
geometry: geometry,
|
geometry: geometry,
|
||||||
focused: frontmostPid == ownerPid
|
focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +314,13 @@ private let lookupResult: WindowLookupResult? = {
|
|||||||
if let cgWindow = windowStateFromCoreGraphics() {
|
if let cgWindow = windowStateFromCoreGraphics() {
|
||||||
return .visible(cgWindow)
|
return .visible(cgWindow)
|
||||||
}
|
}
|
||||||
|
let frontmost = frontmostApplicationState()
|
||||||
|
if isFrontmostTargetMpv(frontmost) {
|
||||||
|
return .active
|
||||||
|
}
|
||||||
|
if frontmost != nil {
|
||||||
|
return .inactive
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -285,6 +332,10 @@ if let result = lookupResult {
|
|||||||
)
|
)
|
||||||
case .minimized:
|
case .minimized:
|
||||||
print("minimized")
|
print("minimized")
|
||||||
|
case .active:
|
||||||
|
print("active")
|
||||||
|
case .inactive:
|
||||||
|
print("inactive")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print("not-found")
|
print("not-found")
|
||||||
|
|||||||
@@ -31,3 +31,64 @@ test('minimized Accessibility windows are validated by PID and socket before rep
|
|||||||
'target socket must be validated before accepting a minimized window',
|
'target socket must be validated before accepting a minimized window',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('focused mpv window follows the frontmost mpv app signal', () => {
|
||||||
|
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
|
||||||
|
assert.notEqual(focusHelperIndex, -1);
|
||||||
|
|
||||||
|
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
|
||||||
|
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
focusHelperBody.includes('frontmost.pid == ownerPid'),
|
||||||
|
'matching frontmost PID should mark the mpv window focused',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
|
||||||
|
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
|
||||||
|
'Accessibility path should use the shared focused mpv helper',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
|
||||||
|
'CoreGraphics path should use the shared focused mpv helper',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frontmost mpv app emits active state when geometry lookup misses', () => {
|
||||||
|
assert.ok(
|
||||||
|
/case\s+\.active:/.test(source),
|
||||||
|
'helper should expose an active state without window geometry',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('if windowHasTargetSocket(frontmost.pid)'),
|
||||||
|
'active state should still accept a matching target socket when available',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('return targetMpvSocketPath != nil'),
|
||||||
|
'active state should preserve frontmost mpv even if command-line socket detection fails',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('return .active'),
|
||||||
|
'lookup should preserve active mpv state after geometry lookup misses',
|
||||||
|
);
|
||||||
|
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
|
||||||
|
assert.ok(
|
||||||
|
/case\s+\.inactive:/.test(source),
|
||||||
|
'helper should expose an inactive state without window geometry',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('if frontmost != nil'),
|
||||||
|
'helper should distinguish a known non-mpv frontmost app from an unknown miss',
|
||||||
|
);
|
||||||
|
assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
|
||||||
|
assert.ok(
|
||||||
|
source.includes('print("inactive")'),
|
||||||
|
'inactive state should be printed for the tracker',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1453,6 +1453,104 @@ test('parses descriptor-based controller bindings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses controller profiles as per-gamepad binding overrides', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"controller": {
|
||||||
|
"buttonIndices": {
|
||||||
|
"buttonSouth": 0,
|
||||||
|
"leftTrigger": 6
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
||||||
|
"quitMpv": "leftTrigger"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"8BitDo SN30": {
|
||||||
|
"label": "8BitDo SN30",
|
||||||
|
"bindings": {
|
||||||
|
"toggleLookup": { "kind": "button", "buttonIndex": 11 },
|
||||||
|
"leftStickVertical": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Xbox Wireless Controller": {
|
||||||
|
"buttonIndices": {
|
||||||
|
"leftTrigger": 8
|
||||||
|
},
|
||||||
|
"bindings": {
|
||||||
|
"quitMpv": "leftTrigger"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
|
||||||
|
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.toggleLookup, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 11,
|
||||||
|
});
|
||||||
|
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.closeLookup, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.leftStickVertical, {
|
||||||
|
kind: 'axis',
|
||||||
|
axisIndex: 7,
|
||||||
|
dpadFallback: 'none',
|
||||||
|
});
|
||||||
|
assert.deepEqual(config.controller.profiles['Xbox Wireless Controller']?.bindings.quitMpv, {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: 8,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
config.controller.profiles['Xbox Wireless Controller']?.buttonIndices.leftTrigger,
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects reserved controller profile ids from config', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"controller": {
|
||||||
|
"profiles": {
|
||||||
|
"__proto__": { "label": "polluted" },
|
||||||
|
"constructor": { "label": "ctor" },
|
||||||
|
"prototype": { "label": "proto" },
|
||||||
|
"pad-1": { "label": "Pad 1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
|
assert.equal(Object.hasOwn(config.controller.profiles, '__proto__'), false);
|
||||||
|
assert.equal(Object.hasOwn(config.controller.profiles, 'constructor'), false);
|
||||||
|
assert.equal(Object.hasOwn(config.controller.profiles, 'prototype'), false);
|
||||||
|
assert.equal(config.controller.profiles['pad-1']?.label, 'Pad 1');
|
||||||
|
assert.equal(
|
||||||
|
warnings.some((warning) => warning.path === 'controller.profiles.constructor'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
warnings.some((warning) => warning.path === 'controller.profiles.prototype'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('controller descriptor config rejects malformed binding objects', () => {
|
test('controller descriptor config rejects malformed binding objects', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||||
|
|||||||
@@ -239,6 +239,13 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
|
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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) => [
|
...discreteBindings.flatMap((binding) => [
|
||||||
{
|
{
|
||||||
path: `controller.bindings.${binding.id}`,
|
path: `controller.bindings.${binding.id}`,
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import type {
|
||||||
|
ControllerAxisBinding,
|
||||||
|
ControllerAxisDirection,
|
||||||
|
ControllerButtonBinding,
|
||||||
|
ControllerButtonIndicesConfig,
|
||||||
|
ControllerDpadFallback,
|
||||||
|
ResolvedControllerAxisBinding,
|
||||||
|
ResolvedControllerBindingsConfig,
|
||||||
|
ResolvedControllerDiscreteBinding,
|
||||||
|
} from '../../types/runtime';
|
||||||
|
import { ResolveContext } from './context';
|
||||||
|
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||||
|
|
||||||
|
const CONTROLLER_BUTTON_BINDINGS = [
|
||||||
|
'none',
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTROLLER_AXIS_BINDINGS = [
|
||||||
|
'leftStickX',
|
||||||
|
'leftStickY',
|
||||||
|
'rightStickX',
|
||||||
|
'rightStickY',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
||||||
|
leftStickX: 0,
|
||||||
|
leftStickY: 1,
|
||||||
|
rightStickX: 3,
|
||||||
|
rightStickY: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
||||||
|
Exclude<ControllerButtonBinding, 'none'>,
|
||||||
|
keyof Required<ControllerButtonIndicesConfig>
|
||||||
|
> = {
|
||||||
|
select: 'select',
|
||||||
|
buttonSouth: 'buttonSouth',
|
||||||
|
buttonEast: 'buttonEast',
|
||||||
|
buttonNorth: 'buttonNorth',
|
||||||
|
buttonWest: 'buttonWest',
|
||||||
|
leftShoulder: 'leftShoulder',
|
||||||
|
rightShoulder: 'rightShoulder',
|
||||||
|
leftStickPress: 'leftStickPress',
|
||||||
|
rightStickPress: 'rightStickPress',
|
||||||
|
leftTrigger: 'leftTrigger',
|
||||||
|
rightTrigger: 'rightTrigger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
||||||
|
leftStickHorizontal: 'horizontal',
|
||||||
|
leftStickVertical: 'vertical',
|
||||||
|
rightStickHorizontal: 'none',
|
||||||
|
rightStickVertical: 'none',
|
||||||
|
} as const satisfies Record<string, ControllerDpadFallback>;
|
||||||
|
|
||||||
|
const CONTROLLER_BUTTON_INDEX_KEYS = [
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
||||||
|
'toggleLookup',
|
||||||
|
'closeLookup',
|
||||||
|
'toggleKeyboardOnlyMode',
|
||||||
|
'mineCard',
|
||||||
|
'quitMpv',
|
||||||
|
'previousAudio',
|
||||||
|
'nextAudio',
|
||||||
|
'playCurrentAudio',
|
||||||
|
'toggleMpvPause',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTROLLER_AXIS_BINDING_KEYS = [
|
||||||
|
'leftStickHorizontal',
|
||||||
|
'leftStickVertical',
|
||||||
|
'rightStickHorizontal',
|
||||||
|
'rightStickVertical',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
|
|
||||||
|
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
|
||||||
|
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
|
||||||
|
|
||||||
|
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
||||||
|
return value === 'negative' || value === 'positive';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
||||||
|
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLegacyDiscreteBinding(
|
||||||
|
value: ControllerButtonBinding,
|
||||||
|
buttonIndices: Required<ControllerButtonIndicesConfig>,
|
||||||
|
): ResolvedControllerDiscreteBinding {
|
||||||
|
if (value === 'none') {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'button',
|
||||||
|
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLegacyAxisBinding(
|
||||||
|
value: ControllerAxisBinding,
|
||||||
|
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||||
|
): ResolvedControllerAxisBinding {
|
||||||
|
return {
|
||||||
|
kind: 'axis',
|
||||||
|
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
|
||||||
|
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
||||||
|
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||||
|
if (value.kind === 'none') {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
if (value.kind === 'button') {
|
||||||
|
return typeof value.buttonIndex === 'number' &&
|
||||||
|
Number.isInteger(value.buttonIndex) &&
|
||||||
|
value.buttonIndex >= 0
|
||||||
|
? { kind: 'button', buttonIndex: value.buttonIndex }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (value.kind === 'axis') {
|
||||||
|
return typeof value.axisIndex === 'number' &&
|
||||||
|
Number.isInteger(value.axisIndex) &&
|
||||||
|
value.axisIndex >= 0 &&
|
||||||
|
isControllerAxisDirection(value.direction)
|
||||||
|
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAxisBindingObject(
|
||||||
|
value: unknown,
|
||||||
|
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||||
|
): ResolvedControllerAxisBinding | null {
|
||||||
|
if (isObject(value) && value.kind === 'none') {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
if (!isObject(value) || value.kind !== 'axis') return null;
|
||||||
|
if (
|
||||||
|
typeof value.axisIndex !== 'number' ||
|
||||||
|
!Number.isInteger(value.axisIndex) ||
|
||||||
|
value.axisIndex < 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'axis',
|
||||||
|
axisIndex: value.axisIndex,
|
||||||
|
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyControllerButtonIndices(
|
||||||
|
source: unknown,
|
||||||
|
target: ControllerButtonIndicesTarget,
|
||||||
|
pathPrefix: string,
|
||||||
|
warn: ResolveContext['warn'],
|
||||||
|
): void {
|
||||||
|
if (!isObject(source)) return;
|
||||||
|
|
||||||
|
for (const key of CONTROLLER_BUTTON_INDEX_KEYS) {
|
||||||
|
const value = asNumber(source[key]);
|
||||||
|
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
||||||
|
target[key] = value;
|
||||||
|
} else if (source[key] !== undefined) {
|
||||||
|
warn(`${pathPrefix}.${key}`, source[key], target[key], 'Expected non-negative integer.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyControllerBindings(
|
||||||
|
source: unknown,
|
||||||
|
target: ControllerBindingsTarget,
|
||||||
|
buttonIndices: ControllerButtonIndicesTarget,
|
||||||
|
pathPrefix: string,
|
||||||
|
warn: ResolveContext['warn'],
|
||||||
|
): void {
|
||||||
|
if (!isObject(source)) return;
|
||||||
|
|
||||||
|
for (const key of CONTROLLER_DISCRETE_BINDING_KEYS) {
|
||||||
|
const bindingValue = source[key];
|
||||||
|
const legacyValue = asString(bindingValue);
|
||||||
|
if (
|
||||||
|
legacyValue !== undefined &&
|
||||||
|
CONTROLLER_BUTTON_BINDINGS.includes(
|
||||||
|
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
target[key] = resolveLegacyDiscreteBinding(
|
||||||
|
legacyValue as ControllerButtonBinding,
|
||||||
|
buttonIndices,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
||||||
|
if (parsedObject) {
|
||||||
|
target[key] = parsedObject;
|
||||||
|
} else if (bindingValue !== undefined) {
|
||||||
|
warn(
|
||||||
|
`${pathPrefix}.${key}`,
|
||||||
|
bindingValue,
|
||||||
|
target[key],
|
||||||
|
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of CONTROLLER_AXIS_BINDING_KEYS) {
|
||||||
|
const bindingValue = source[key];
|
||||||
|
const legacyValue = asString(bindingValue);
|
||||||
|
if (
|
||||||
|
legacyValue !== undefined &&
|
||||||
|
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
|
||||||
|
) {
|
||||||
|
target[key] = resolveLegacyAxisBinding(legacyValue as ControllerAxisBinding, key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (legacyValue === 'none') {
|
||||||
|
target[key] = { kind: 'none' };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsedObject = parseAxisBindingObject(bindingValue, key);
|
||||||
|
if (parsedObject) {
|
||||||
|
target[key] = parsedObject;
|
||||||
|
} else if (bindingValue !== undefined) {
|
||||||
|
warn(
|
||||||
|
`${pathPrefix}.${key}`,
|
||||||
|
bindingValue,
|
||||||
|
target[key],
|
||||||
|
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyControllerConfig(context: ResolveContext): void {
|
||||||
|
const { src, resolved, warn } = context;
|
||||||
|
if (!isObject(src.controller)) return;
|
||||||
|
|
||||||
|
const enabled = asBoolean(src.controller.enabled);
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
resolved.controller.enabled = enabled;
|
||||||
|
} else if (src.controller.enabled !== undefined) {
|
||||||
|
warn(
|
||||||
|
'controller.enabled',
|
||||||
|
src.controller.enabled,
|
||||||
|
resolved.controller.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
||||||
|
if (preferredGamepadId !== undefined) {
|
||||||
|
resolved.controller.preferredGamepadId = preferredGamepadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
||||||
|
if (preferredGamepadLabel !== undefined) {
|
||||||
|
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
||||||
|
if (smoothScroll !== undefined) {
|
||||||
|
resolved.controller.smoothScroll = smoothScroll;
|
||||||
|
} else if (src.controller.smoothScroll !== undefined) {
|
||||||
|
warn(
|
||||||
|
'controller.smoothScroll',
|
||||||
|
src.controller.smoothScroll,
|
||||||
|
resolved.controller.smoothScroll,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerInputMode = asString(src.controller.triggerInputMode);
|
||||||
|
if (
|
||||||
|
triggerInputMode === 'auto' ||
|
||||||
|
triggerInputMode === 'digital' ||
|
||||||
|
triggerInputMode === 'analog'
|
||||||
|
) {
|
||||||
|
resolved.controller.triggerInputMode = triggerInputMode;
|
||||||
|
} else if (src.controller.triggerInputMode !== undefined) {
|
||||||
|
warn(
|
||||||
|
'controller.triggerInputMode',
|
||||||
|
src.controller.triggerInputMode,
|
||||||
|
resolved.controller.triggerInputMode,
|
||||||
|
"Expected 'auto', 'digital', or 'analog'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundedNumberKeys = [
|
||||||
|
'scrollPixelsPerSecond',
|
||||||
|
'horizontalJumpPixels',
|
||||||
|
'repeatDelayMs',
|
||||||
|
'repeatIntervalMs',
|
||||||
|
] as const;
|
||||||
|
for (const key of boundedNumberKeys) {
|
||||||
|
const value = asNumber(src.controller[key]);
|
||||||
|
if (value !== undefined && Math.floor(value) > 0) {
|
||||||
|
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
||||||
|
} else if (src.controller[key] !== undefined) {
|
||||||
|
warn(
|
||||||
|
`controller.${key}`,
|
||||||
|
src.controller[key],
|
||||||
|
resolved.controller[key],
|
||||||
|
'Expected positive number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
||||||
|
for (const key of deadzoneKeys) {
|
||||||
|
const value = asNumber(src.controller[key]);
|
||||||
|
if (value !== undefined && value >= 0 && value <= 1) {
|
||||||
|
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
||||||
|
} else if (src.controller[key] !== undefined) {
|
||||||
|
warn(
|
||||||
|
`controller.${key}`,
|
||||||
|
src.controller[key],
|
||||||
|
resolved.controller[key],
|
||||||
|
'Expected number between 0 and 1.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyControllerButtonIndices(
|
||||||
|
src.controller.buttonIndices,
|
||||||
|
resolved.controller.buttonIndices,
|
||||||
|
'controller.buttonIndices',
|
||||||
|
warn,
|
||||||
|
);
|
||||||
|
applyControllerBindings(
|
||||||
|
src.controller.bindings,
|
||||||
|
resolved.controller.bindings,
|
||||||
|
resolved.controller.buttonIndices,
|
||||||
|
'controller.bindings',
|
||||||
|
warn,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isObject(src.controller.profiles)) {
|
||||||
|
for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) {
|
||||||
|
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) {
|
||||||
|
warn(
|
||||||
|
`controller.profiles.${profileId}`,
|
||||||
|
rawProfile,
|
||||||
|
undefined,
|
||||||
|
'Reserved profile id is not allowed.',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isObject(rawProfile)) {
|
||||||
|
warn(
|
||||||
|
`controller.profiles.${profileId}`,
|
||||||
|
rawProfile,
|
||||||
|
undefined,
|
||||||
|
'Expected controller profile object.',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = asString(rawProfile.label);
|
||||||
|
if (rawProfile.label !== undefined && label === undefined) {
|
||||||
|
warn(
|
||||||
|
`controller.profiles.${profileId}.label`,
|
||||||
|
rawProfile.label,
|
||||||
|
profileId,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
label: label ?? profileId,
|
||||||
|
buttonIndices: structuredClone(resolved.controller.buttonIndices),
|
||||||
|
bindings: structuredClone(resolved.controller.bindings),
|
||||||
|
};
|
||||||
|
applyControllerButtonIndices(
|
||||||
|
rawProfile.buttonIndices,
|
||||||
|
profile.buttonIndices,
|
||||||
|
`controller.profiles.${profileId}.buttonIndices`,
|
||||||
|
warn,
|
||||||
|
);
|
||||||
|
applyControllerBindings(
|
||||||
|
rawProfile.bindings,
|
||||||
|
profile.bindings,
|
||||||
|
profile.buttonIndices,
|
||||||
|
`controller.profiles.${profileId}.bindings`,
|
||||||
|
warn,
|
||||||
|
);
|
||||||
|
resolved.controller.profiles[profileId] = profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,150 +1,7 @@
|
|||||||
import type {
|
|
||||||
ControllerAxisBinding,
|
|
||||||
ControllerAxisBindingConfig,
|
|
||||||
ControllerAxisDirection,
|
|
||||||
ControllerButtonBinding,
|
|
||||||
ControllerButtonIndicesConfig,
|
|
||||||
ControllerDpadFallback,
|
|
||||||
ControllerDiscreteBindingConfig,
|
|
||||||
ResolvedControllerAxisBinding,
|
|
||||||
ResolvedControllerDiscreteBinding,
|
|
||||||
} from '../../types/runtime';
|
|
||||||
import { ResolveContext } from './context';
|
import { ResolveContext } from './context';
|
||||||
|
import { applyControllerConfig } from './controller';
|
||||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
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 {
|
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
const { src, resolved, warn } = context;
|
||||||
|
|
||||||
@@ -245,203 +102,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isObject(src.controller)) {
|
applyControllerConfig(context);
|
||||||
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)) {
|
if (Array.isArray(src.keybindings)) {
|
||||||
resolved.keybindings = src.keybindings.filter(
|
resolved.keybindings = src.keybindings.filter(
|
||||||
|
|||||||
@@ -32,10 +32,16 @@ test('anilist update queue enqueues, snapshots, and dequeues success', () => {
|
|||||||
const loggerState = createLogger();
|
const loggerState = createLogger();
|
||||||
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||||
|
|
||||||
queue.enqueue('k1', 'Demo', 1);
|
queue.enqueue('k1', 'Demo', 1, 2);
|
||||||
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
|
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
|
||||||
assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
|
assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
|
||||||
assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
key: queue.nextReady(Number.MAX_SAFE_INTEGER)?.key,
|
||||||
|
season: queue.nextReady(Number.MAX_SAFE_INTEGER)?.season,
|
||||||
|
},
|
||||||
|
{ key: 'k1', season: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
queue.markSuccess('k1');
|
queue.markSuccess('k1');
|
||||||
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
|
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const MAX_ITEMS = 500;
|
|||||||
export interface AnilistQueuedUpdate {
|
export interface AnilistQueuedUpdate {
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
season?: number | null;
|
||||||
episode: number;
|
episode: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
attemptCount: number;
|
attemptCount: number;
|
||||||
@@ -28,7 +29,7 @@ export interface AnilistRetryQueueSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnilistUpdateQueue {
|
export interface AnilistUpdateQueue {
|
||||||
enqueue: (key: string, title: string, episode: number) => void;
|
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
|
||||||
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
||||||
markSuccess: (key: string) => void;
|
markSuccess: (key: string) => void;
|
||||||
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
||||||
@@ -106,7 +107,7 @@ export function createAnilistUpdateQueue(
|
|||||||
load();
|
load();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enqueue(key: string, title: string, episode: number): void {
|
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
|
||||||
const existing = pending.find((item) => item.key === key);
|
const existing = pending.find((item) => item.key === key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return;
|
return;
|
||||||
@@ -117,6 +118,7 @@ export function createAnilistUpdateQueue(
|
|||||||
pending.push({
|
pending.push({
|
||||||
key,
|
key,
|
||||||
title,
|
title,
|
||||||
|
season,
|
||||||
episode,
|
episode,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
attemptCount: 0,
|
attemptCount: 0,
|
||||||
|
|||||||
@@ -265,6 +265,125 @@ test('updateAnilistPostWatchProgress skips when progress already reached', async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('updateAnilistPostWatchProgress returns non-retryable error when media is not planning or watching', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let call = 0;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
call += 1;
|
||||||
|
if (call === 1) {
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Media: { id: 33, mediaListEntry: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateAnilistPostWatchProgress('token', 'Missing Show', 2);
|
||||||
|
assert.equal(result.status, 'error');
|
||||||
|
assert.equal(result.retryable, false);
|
||||||
|
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const searchTerms: string[] = [];
|
||||||
|
let call = 0;
|
||||||
|
globalThis.fetch = (async (_input, init) => {
|
||||||
|
call += 1;
|
||||||
|
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
|
||||||
|
if (call === 1) {
|
||||||
|
searchTerms.push(String(body.variables?.search));
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [
|
||||||
|
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
|
||||||
|
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (call === 2) {
|
||||||
|
assert.equal(body.variables?.mediaId, 202);
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Media: { id: 202, mediaListEntry: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
|
||||||
|
season: 2,
|
||||||
|
});
|
||||||
|
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
|
||||||
|
assert.equal(result.status, 'error');
|
||||||
|
assert.equal(result.retryable, false);
|
||||||
|
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
||||||
|
assert.equal(call, 2);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let call = 0;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
call += 1;
|
||||||
|
if (call === 1) {
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [{ id: 44, episodes: 12, title: { english: 'Rewatch Show' } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (call === 2) {
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
Media: { id: 44, mediaListEntry: { progress: 0, status: 'REPEATING' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createJsonResponse({
|
||||||
|
data: {
|
||||||
|
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateAnilistPostWatchProgress('token', 'Rewatch Show', 2);
|
||||||
|
assert.equal(result.status, 'error');
|
||||||
|
assert.equal(result.retryable, false);
|
||||||
|
assert.match(result.message, /marked repeating on AniList/i);
|
||||||
|
assert.equal(call, 2);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('updateAnilistPostWatchProgress returns error when search fails', async () => {
|
test('updateAnilistPostWatchProgress returns error when search fails', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
globalThis.fetch = (async () =>
|
globalThis.fetch = (async () =>
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ export interface AnilistMediaGuess {
|
|||||||
export interface AnilistPostWatchUpdateResult {
|
export interface AnilistPostWatchUpdateResult {
|
||||||
status: 'updated' | 'skipped' | 'error';
|
status: 'updated' | 'skipped' | 'error';
|
||||||
message: string;
|
message: string;
|
||||||
|
retryable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnilistPostWatchUpdateOptions {
|
export interface AnilistPostWatchUpdateOptions {
|
||||||
rateLimiter?: AnilistRateLimiter;
|
rateLimiter?: AnilistRateLimiter;
|
||||||
|
season?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnilistGraphQlError {
|
interface AnilistGraphQlError {
|
||||||
@@ -156,6 +158,28 @@ function normalizeTitle(text: string): string {
|
|||||||
return text.trim().toLowerCase().replace(/\s+/g, ' ');
|
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>(
|
async function anilistGraphQl<T>(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -226,6 +250,15 @@ function pickBestSearchResult(
|
|||||||
return { id: selected.id, title: selectedTitle };
|
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(
|
export async function guessAnilistMediaInfo(
|
||||||
mediaPath: string | null,
|
mediaPath: string | null,
|
||||||
mediaTitle: string | null,
|
mediaTitle: string | null,
|
||||||
@@ -279,27 +312,42 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
episode: number,
|
episode: number,
|
||||||
options: AnilistPostWatchUpdateOptions = {},
|
options: AnilistPostWatchUpdateOptions = {},
|
||||||
): Promise<AnilistPostWatchUpdateResult> {
|
): Promise<AnilistPostWatchUpdateResult> {
|
||||||
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
|
||||||
accessToken,
|
let searchError: string | null = null;
|
||||||
`
|
let pickTitle = title;
|
||||||
query ($search: String!) {
|
const searchCandidates = buildSearchCandidates(title, options.season);
|
||||||
Page(perPage: 5) {
|
for (const search of searchCandidates) {
|
||||||
media(search: $search, type: ANIME) {
|
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||||
id
|
accessToken,
|
||||||
episodes
|
`
|
||||||
title {
|
query ($search: String!) {
|
||||||
romaji
|
Page(perPage: 5) {
|
||||||
english
|
media(search: $search, type: ANIME) {
|
||||||
native
|
id
|
||||||
|
episodes
|
||||||
|
title {
|
||||||
|
romaji
|
||||||
|
english
|
||||||
|
native
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
`,
|
||||||
`,
|
{ search },
|
||||||
{ search: title },
|
options,
|
||||||
options,
|
);
|
||||||
);
|
searchError = firstErrorMessage(searchResponse);
|
||||||
const searchError = firstErrorMessage(searchResponse);
|
if (searchError) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
media = searchResponse.data?.Page?.media ?? [];
|
||||||
|
if (media.length > 0) {
|
||||||
|
pickTitle = search;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (searchError) {
|
if (searchError) {
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -307,8 +355,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = searchResponse.data?.Page?.media ?? [];
|
const picked = pickBestSearchResult(pickTitle, episode, media);
|
||||||
const picked = pickBestSearchResult(title, episode, media);
|
|
||||||
if (!picked) {
|
if (!picked) {
|
||||||
return { status: 'error', message: 'AniList search returned no matches.' };
|
return { status: 'error', message: 'AniList search returned no matches.' };
|
||||||
}
|
}
|
||||||
@@ -337,7 +384,16 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
|
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
|
||||||
|
if (!entry || !isUpdateableListStatus(entry.status)) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
retryable: false,
|
||||||
|
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentProgress = entry.progress ?? 0;
|
||||||
if (typeof currentProgress === 'number' && currentProgress >= episode) {
|
if (typeof currentProgress === 'number' && currentProgress >= episode) {
|
||||||
return {
|
return {
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ function createControllerConfigFixture() {
|
|||||||
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
|
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
|
||||||
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
|
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +219,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
getVisibleOverlayVisibility: () => false,
|
getVisibleOverlayVisibility: () => false,
|
||||||
onOverlayModalClosed: () => {},
|
onOverlayModalClosed: () => {},
|
||||||
|
onOverlayMouseInteractionChanged: (active) => {
|
||||||
|
calls.push(`overlay-interaction:${active}`);
|
||||||
|
},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
quitApp: () => {},
|
quitApp: () => {},
|
||||||
toggleVisibleOverlay: () => {},
|
toggleVisibleOverlay: () => {},
|
||||||
@@ -281,6 +285,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
||||||
deps.clearAnilistToken();
|
deps.clearAnilistToken();
|
||||||
deps.openAnilistSetup();
|
deps.openAnilistSetup();
|
||||||
|
deps.onOverlayMouseInteractionChanged?.(true, null);
|
||||||
assert.deepEqual(deps.getAnilistQueueStatus(), {
|
assert.deepEqual(deps.getAnilistQueueStatus(), {
|
||||||
pending: 1,
|
pending: 1,
|
||||||
ready: 0,
|
ready: 0,
|
||||||
@@ -298,10 +303,37 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
|
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
|
||||||
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
|
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
|
||||||
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
|
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
|
||||||
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
|
assert.deepEqual(calls, [
|
||||||
|
'clearAnilistToken',
|
||||||
|
'openAnilistSetup',
|
||||||
|
'overlay-interaction:true',
|
||||||
|
'retryAnilistQueueNow',
|
||||||
|
]);
|
||||||
assert.equal(deps.getPlaybackPaused(), true);
|
assert.equal(deps.getPlaybackPaused(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
|
||||||
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
registerIpcHandlers(
|
||||||
|
createRegisterIpcDeps({
|
||||||
|
onOverlayMouseInteractionChanged: (active) => {
|
||||||
|
calls.push(`overlay-interaction:${active}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handler = handlers.on.get(IPC_CHANNELS.command.setIgnoreMouseEvents);
|
||||||
|
assert.equal(typeof handler, 'function');
|
||||||
|
|
||||||
|
handler?.({}, true, { forward: true });
|
||||||
|
handler?.({}, false, {});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
|
||||||
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -944,6 +976,58 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registerIpcHandlers accepts per-controller profile config updates', async () => {
|
||||||
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
|
const controllerSaves: unknown[] = [];
|
||||||
|
registerIpcHandlers(
|
||||||
|
createRegisterIpcDeps({
|
||||||
|
saveControllerConfig: async (update) => {
|
||||||
|
controllerSaves.push(update);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig);
|
||||||
|
assert.ok(saveHandler);
|
||||||
|
|
||||||
|
const update = {
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
label: 'Pad One',
|
||||||
|
buttonIndices: {
|
||||||
|
buttonSouth: 11,
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
|
leftStickHorizontal: { kind: 'axis', axisIndex: 6, dpadFallback: 'horizontal' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await saveHandler({}, update);
|
||||||
|
assert.deepEqual(controllerSaves, [update]);
|
||||||
|
|
||||||
|
await assert.rejects(async () => {
|
||||||
|
await saveHandler(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'axis', axisIndex: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, /Invalid controller config payload/);
|
||||||
|
|
||||||
|
await assert.rejects(async () => {
|
||||||
|
await saveHandler({}, JSON.parse('{"profiles":{"__proto__":{"label":"polluted"}}}'));
|
||||||
|
}, /Invalid controller config payload/);
|
||||||
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
|
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const dispatched: SessionActionDispatchRequest[] = [];
|
const dispatched: SessionActionDispatchRequest[] = [];
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export interface IpcServiceDeps {
|
|||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
) => void;
|
) => void;
|
||||||
|
onOverlayMouseInteractionChanged?: (
|
||||||
|
active: boolean,
|
||||||
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
|
) => void;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleDevTools: () => void;
|
toggleDevTools: () => void;
|
||||||
@@ -175,6 +179,10 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
) => void;
|
) => void;
|
||||||
|
onOverlayMouseInteractionChanged?: (
|
||||||
|
active: boolean,
|
||||||
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
|
) => void;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
@@ -233,6 +241,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
return {
|
return {
|
||||||
onOverlayModalClosed: options.onOverlayModalClosed,
|
onOverlayModalClosed: options.onOverlayModalClosed,
|
||||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||||
|
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
|
||||||
openYomitanSettings: options.openYomitanSettings,
|
openYomitanSettings: options.openYomitanSettings,
|
||||||
quitApp: options.quitApp,
|
quitApp: options.quitApp,
|
||||||
toggleDevTools: () => {
|
toggleDevTools: () => {
|
||||||
@@ -349,6 +358,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||||
}
|
}
|
||||||
|
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ function createMainWindowRecorder() {
|
|||||||
setAlwaysOnTop: (flag: boolean) => {
|
setAlwaysOnTop: (flag: boolean) => {
|
||||||
calls.push(`always-on-top:${flag}`);
|
calls.push(`always-on-top:${flag}`);
|
||||||
},
|
},
|
||||||
|
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
|
||||||
|
calls.push(
|
||||||
|
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||||
},
|
},
|
||||||
@@ -538,11 +543,12 @@ test('forced passthrough still shows tracked overlay while bound to mpv on Windo
|
|||||||
assert.ok(calls.includes('sync-windows-z-order'));
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
|
test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
isTracking: () => true,
|
isTracking: () => true,
|
||||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
updateVisibleOverlayVisibility({
|
updateVisibleOverlayVisibility({
|
||||||
@@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority
|
|||||||
forceMousePassthrough: true,
|
forceMousePassthrough: true,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('enforce-order'));
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
forceMousePassthrough: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('always-on-top:false'));
|
assert.ok(calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(calls.includes('all-workspaces:false:plain'));
|
||||||
|
assert.ok(calls.includes('hide'));
|
||||||
assert.ok(!calls.includes('ensure-level'));
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
assert.ok(!calls.includes('enforce-order'));
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
});
|
});
|
||||||
@@ -916,7 +967,8 @@ test('macOS tracked visible overlay starts click-through without passively steal
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('show-inactive'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
assert.ok(!calls.includes('focus'));
|
assert.ok(!calls.includes('focus'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1009,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and click-through during tracker re
|
|||||||
assert.deepEqual(osdMessages, []);
|
assert.deepEqual(osdMessages, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('macOS tracked overlay releases topmost level when mpv loses foreground', () => {
|
test('macOS tracked overlay hides when mpv loses foreground', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
isTracking: () => true,
|
isTracking: () => true,
|
||||||
@@ -1017,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
|
|||||||
isTargetWindowFocused: () => false,
|
isTargetWindowFocused: () => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
updateVisibleOverlayVisibility({
|
updateVisibleOverlayVisibility({
|
||||||
visibleOverlayVisible: true,
|
visibleOverlayVisible: true,
|
||||||
mainWindow: window as never,
|
mainWindow: window as never,
|
||||||
@@ -1046,14 +1101,202 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
|
|||||||
assert.ok(calls.includes('sync-layer'));
|
assert.ok(calls.includes('sync-layer'));
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('always-on-top:false'));
|
assert.ok(calls.includes('always-on-top:false'));
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('all-workspaces:false:plain'));
|
||||||
|
assert.ok(calls.includes('hide'));
|
||||||
assert.ok(calls.includes('sync-shortcuts'));
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
assert.ok(!calls.includes('ensure-level'));
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
assert.ok(!calls.includes('enforce-order'));
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
assert.ok(!calls.includes('focus'));
|
assert.ok(!calls.includes('focus'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
|
||||||
|
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
setFocused(false);
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
overlayInteractionActive: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('update-bounds'));
|
||||||
|
assert.ok(calls.includes('sync-layer'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('enforce-order'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
assert.ok(!calls.includes('hide'));
|
assert.ok(!calls.includes('hide'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('macOS lets an active overlay receive mouse input instead of forcing passthrough', () => {
|
||||||
|
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
setFocused(false);
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
overlayInteractionActive: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||||
|
assert.ok(!calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(!calls.includes('hide'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS focuses an active overlay so lookup trigger keys reach it', () => {
|
||||||
|
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
setFocused(false);
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
overlayInteractionActive: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||||
|
assert.ok(calls.includes('focus'));
|
||||||
|
assert.ok(!calls.includes('hide'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS tracked overlay passively reappears when mpv regains foreground', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
let targetFocused = false;
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => targetFocused,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
const run = () =>
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
run();
|
||||||
|
assert.ok(calls.includes('hide'));
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
targetFocused = true;
|
||||||
|
run();
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('show-inactive'));
|
||||||
|
assert.ok(calls.includes('enforce-order'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
|
assert.ok(!calls.includes('focus'));
|
||||||
|
});
|
||||||
|
|
||||||
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
|
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const osdMessages: string[] = [];
|
const osdMessages: string[] = [];
|
||||||
@@ -1141,7 +1384,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('show-inactive'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
assert.ok(!calls.includes('focus'));
|
assert.ok(!calls.includes('focus'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1438,7 +1682,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
|
|||||||
assert.ok(!calls.includes('show'));
|
assert.ok(!calls.includes('show'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('macOS preserves visible overlay level during non-minimized tracker loss', () => {
|
test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
isTracking: () => false,
|
isTracking: () => false,
|
||||||
@@ -1477,13 +1721,114 @@ test('macOS preserves visible overlay level during non-minimized tracker loss',
|
|||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('sync-layer'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(calls.includes('all-workspaces:false:plain'));
|
||||||
|
assert.ok(calls.includes('hide'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
|
assert.ok(!calls.includes('loading-osd'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS keeps a focused overlay visible during tracker loss', () => {
|
||||||
|
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => false,
|
||||||
|
getGeometry: () => null,
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
isTargetWindowMinimized: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
setFocused(true);
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
showOverlayLoadingOsd: () => {
|
||||||
|
calls.push('loading-osd');
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('sync-layer'));
|
assert.ok(calls.includes('sync-layer'));
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('ensure-level'));
|
assert.ok(calls.includes('ensure-level'));
|
||||||
assert.ok(calls.includes('enforce-order'));
|
assert.ok(calls.includes('enforce-order'));
|
||||||
assert.ok(calls.includes('sync-shortcuts'));
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
assert.ok(!calls.includes('hide'));
|
assert.ok(!calls.includes('hide'));
|
||||||
|
assert.ok(!calls.includes('loading-osd'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', () => {
|
||||||
|
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => false,
|
||||||
|
getGeometry: () => null,
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
isTargetWindowMinimized: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
setFocused(false);
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
overlayInteractionActive: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
showOverlayLoadingOsd: () => {
|
||||||
|
calls.push('loading-osd');
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('sync-layer'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('enforce-order'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
assert.ok(!calls.includes('always-on-top:false'));
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(!calls.includes('hide'));
|
||||||
assert.ok(!calls.includes('loading-osd'));
|
assert.ok(!calls.includes('loading-osd'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
|
|||||||
opacityCapableWindow.setOpacity?.(opacity);
|
opacityCapableWindow.setOpacity?.(opacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function releaseOverlayWindowLevel(window: BrowserWindow): void {
|
||||||
|
window.setAlwaysOnTop(false);
|
||||||
|
const allWorkspacesWindow = window as BrowserWindow & {
|
||||||
|
setVisibleOnAllWorkspaces?: (
|
||||||
|
visible: boolean,
|
||||||
|
options?: { visibleOnFullScreen?: boolean },
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
allWorkspacesWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
|
||||||
|
}
|
||||||
|
|
||||||
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
|
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
|
||||||
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
|
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
|
||||||
if (!pendingTimeout) {
|
if (!pendingTimeout) {
|
||||||
@@ -52,6 +63,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
visibleOverlayVisible: boolean;
|
visibleOverlayVisible: boolean;
|
||||||
modalActive?: boolean;
|
modalActive?: boolean;
|
||||||
forceMousePassthrough?: boolean;
|
forceMousePassthrough?: boolean;
|
||||||
|
overlayInteractionActive?: boolean;
|
||||||
mainWindow: BrowserWindow | null;
|
mainWindow: BrowserWindow | null;
|
||||||
windowTracker: BaseWindowTracker | null;
|
windowTracker: BaseWindowTracker | null;
|
||||||
lastKnownWindowsForegroundProcessName?: string | null;
|
lastKnownWindowsForegroundProcessName?: string | null;
|
||||||
@@ -78,6 +90,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mainWindow = args.mainWindow;
|
const mainWindow = args.mainWindow;
|
||||||
|
const overlayInteractionActive = args.overlayInteractionActive === true;
|
||||||
|
|
||||||
if (args.modalActive) {
|
if (args.modalActive) {
|
||||||
if (args.isWindowsPlatform) {
|
if (args.isWindowsPlatform) {
|
||||||
@@ -93,23 +106,26 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||||
const wasVisible = mainWindow.isVisible();
|
const wasVisible = mainWindow.isVisible();
|
||||||
const isVisibleOverlayFocused =
|
const isVisibleOverlayFocused =
|
||||||
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
|
overlayInteractionActive ||
|
||||||
|
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
|
||||||
const windowTracker = args.windowTracker;
|
const windowTracker = args.windowTracker;
|
||||||
const canReportMacOSTargetMinimized =
|
const canReportMacOSTargetMinimized =
|
||||||
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
|
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
|
||||||
const isTrackedMacOSTargetMinimized =
|
const isTrackedMacOSTargetMinimized =
|
||||||
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
||||||
|
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
|
||||||
const hasTransientMacOSTrackerLoss =
|
const hasTransientMacOSTrackerLoss =
|
||||||
args.isMacOSPlatform &&
|
args.isMacOSPlatform &&
|
||||||
canReportMacOSTargetMinimized &&
|
canReportMacOSTargetMinimized &&
|
||||||
!!windowTracker &&
|
!!windowTracker &&
|
||||||
!windowTracker.isTracking() &&
|
!windowTracker.isTracking() &&
|
||||||
!isTrackedMacOSTargetMinimized &&
|
!isTrackedMacOSTargetMinimized &&
|
||||||
|
trackedMacOSTargetFocused !== false &&
|
||||||
mainWindow.isVisible();
|
mainWindow.isVisible();
|
||||||
const isTrackedMacOSTargetFocused =
|
const isTrackedMacOSTargetFocused =
|
||||||
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
||||||
? true
|
? true
|
||||||
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
|
: (trackedMacOSTargetFocused ?? true);
|
||||||
const shouldReleaseMacOSOverlayLevel =
|
const shouldReleaseMacOSOverlayLevel =
|
||||||
args.isMacOSPlatform &&
|
args.isMacOSPlatform &&
|
||||||
!!args.windowTracker &&
|
!!args.windowTracker &&
|
||||||
@@ -117,7 +133,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
!isVisibleOverlayFocused &&
|
!isVisibleOverlayFocused &&
|
||||||
!isTrackedMacOSTargetFocused;
|
!isTrackedMacOSTargetFocused;
|
||||||
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
|
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
|
||||||
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform;
|
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive;
|
||||||
const shouldDefaultToPassthrough =
|
const shouldDefaultToPassthrough =
|
||||||
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
|
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
|
||||||
const windowsForegroundProcessName =
|
const windowsForegroundProcessName =
|
||||||
@@ -159,14 +175,22 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldReleaseMacOSOverlayLevel) {
|
||||||
|
releaseOverlayWindowLevel(mainWindow);
|
||||||
|
if (wasVisible) {
|
||||||
|
mainWindow.hide();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldBindTrackedWindowsOverlay) {
|
if (shouldBindTrackedWindowsOverlay) {
|
||||||
// On Windows, z-order is enforced by the OS via the owner window mechanism
|
// On Windows, z-order is enforced by the OS via the owner window mechanism
|
||||||
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
|
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
|
||||||
// without any manual z-order management.
|
// without any manual z-order management.
|
||||||
} else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) {
|
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
|
||||||
args.ensureOverlayWindowLevel(mainWindow);
|
args.ensureOverlayWindowLevel(mainWindow);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setAlwaysOnTop(false);
|
releaseOverlayWindowLevel(mainWindow);
|
||||||
}
|
}
|
||||||
if (!wasVisible) {
|
if (!wasVisible) {
|
||||||
const hasWebContents =
|
const hasWebContents =
|
||||||
@@ -179,16 +203,20 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
||||||
// callback will trigger another visibility update when the renderer
|
// callback will trigger another visibility update when the renderer
|
||||||
// has painted its first frame.
|
// has painted its first frame.
|
||||||
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
|
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
|
||||||
setOverlayWindowOpacity(mainWindow, 0);
|
if (args.isWindowsPlatform) {
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
}
|
||||||
mainWindow.showInactive();
|
mainWindow.showInactive();
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
scheduleWindowsOverlayReveal(
|
if (args.isWindowsPlatform) {
|
||||||
mainWindow,
|
scheduleWindowsOverlayReveal(
|
||||||
shouldBindTrackedWindowsOverlay
|
mainWindow,
|
||||||
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
|
shouldBindTrackedWindowsOverlay
|
||||||
: undefined,
|
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
|
||||||
);
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (args.isWindowsPlatform) {
|
if (args.isWindowsPlatform) {
|
||||||
setOverlayWindowOpacity(mainWindow, 0);
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
@@ -209,6 +237,16 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
args.isMacOSPlatform &&
|
||||||
|
overlayInteractionActive &&
|
||||||
|
!forceMousePassthrough &&
|
||||||
|
typeof mainWindow.isFocused === 'function' &&
|
||||||
|
!mainWindow.isFocused()
|
||||||
|
) {
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
|
||||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
@@ -216,6 +254,11 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
return !shouldReleaseMacOSOverlayLevel;
|
return !shouldReleaseMacOSOverlayLevel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
|
||||||
|
shouldEnforceLayerOrder &&
|
||||||
|
!args.isWindowsPlatform &&
|
||||||
|
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
|
||||||
|
|
||||||
const maybeShowOverlayLoadingOsd = (): void => {
|
const maybeShowOverlayLoadingOsd = (): void => {
|
||||||
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
||||||
return;
|
return;
|
||||||
@@ -258,7 +301,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
}
|
}
|
||||||
args.syncPrimaryOverlayWindowLayer('visible');
|
args.syncPrimaryOverlayWindowLayer('visible');
|
||||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||||
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
|
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
|
||||||
args.enforceOverlayLayerOrder();
|
args.enforceOverlayLayerOrder();
|
||||||
}
|
}
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
@@ -290,6 +333,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
|
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
|
||||||
const hasActiveMacOSTargetSignal =
|
const hasActiveMacOSTargetSignal =
|
||||||
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
|
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
|
||||||
|
const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive;
|
||||||
const canReportMacOSTargetMinimized =
|
const canReportMacOSTargetMinimized =
|
||||||
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
|
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
|
||||||
const isTrackedMacOSTargetMinimized =
|
const isTrackedMacOSTargetMinimized =
|
||||||
@@ -298,6 +342,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
(args.isMacOSPlatform &&
|
(args.isMacOSPlatform &&
|
||||||
!isTrackedMacOSTargetMinimized &&
|
!isTrackedMacOSTargetMinimized &&
|
||||||
(hasRetainedTrackedGeometry ||
|
(hasRetainedTrackedGeometry ||
|
||||||
|
(mainWindow.isVisible() && hasActiveMacOSOverlaySignal) ||
|
||||||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
|
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
|
||||||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
|
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
|
||||||
(args.isWindowsPlatform &&
|
(args.isWindowsPlatform &&
|
||||||
@@ -315,7 +360,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
}
|
}
|
||||||
args.syncPrimaryOverlayWindowLayer('visible');
|
args.syncPrimaryOverlayWindowLayer('visible');
|
||||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||||
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
|
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
|
||||||
args.enforceOverlayLayerOrder();
|
args.enforceOverlayLayerOrder();
|
||||||
}
|
}
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
|
|||||||
@@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: {
|
|||||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||||
ensureOverlayWindowLevel: () => void;
|
ensureOverlayWindowLevel: () => void;
|
||||||
moveWindowTop: () => void;
|
moveWindowTop: () => void;
|
||||||
onWindowsVisibleOverlayBlur?: () => void;
|
onVisibleOverlayBlur?: () => void;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (platform === 'win32' && options.kind === 'visible') {
|
if (platform === 'win32' && options.kind === 'visible') {
|
||||||
options.onWindowsVisibleOverlayBlur?.();
|
options.onVisibleOverlayBlur?.();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (platform === 'darwin' && options.kind === 'visible') {
|
if (platform === 'darwin' && options.kind === 'visible') {
|
||||||
|
options.onVisibleOverlayBlur?.();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
calls.push('move-top');
|
calls.push('move-top');
|
||||||
},
|
},
|
||||||
onWindowsVisibleOverlayBlur: () => {
|
onVisibleOverlayBlur: () => {
|
||||||
calls.push('windows-visible-blur');
|
calls.push('visible-blur');
|
||||||
},
|
},
|
||||||
platform: 'win32',
|
platform: 'win32',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, false);
|
assert.equal(handled, false);
|
||||||
assert.deepEqual(calls, ['windows-visible-blur']);
|
assert.deepEqual(calls, ['visible-blur']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
|
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
|
||||||
@@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => {
|
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
const handled = handleOverlayWindowBlurred({
|
const handled = handleOverlayWindowBlurred({
|
||||||
@@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
calls.push('move-top');
|
calls.push('move-top');
|
||||||
},
|
},
|
||||||
onWindowsVisibleOverlayBlur: () => {
|
onVisibleOverlayBlur: () => {
|
||||||
calls.push('windows-visible-blur');
|
calls.push('visible-blur');
|
||||||
},
|
},
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, false);
|
assert.equal(handled, false);
|
||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, ['visible-blur']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function createOverlayWindow(
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
window.moveTop();
|
window.moveTop();
|
||||||
},
|
},
|
||||||
onWindowsVisibleOverlayBlur:
|
onVisibleOverlayBlur:
|
||||||
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
|
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
|
|||||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||||
|
|
||||||
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
||||||
|
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
||||||
|
Partial<Pick<BrowserWindow, 'showInactive'>>;
|
||||||
|
|
||||||
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
|
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -104,6 +106,23 @@ export function promoteStatsWindowLevel(
|
|||||||
window.moveTop();
|
window.moveTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function presentStatsWindow(
|
||||||
|
window: StatsWindowPresentationController,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): void {
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
if (window.showInactive) {
|
||||||
|
window.showInactive();
|
||||||
|
} else {
|
||||||
|
window.show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
|
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
|
||||||
query: Record<string, string>;
|
query: Record<string, string>;
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
buildStatsWindowLoadFileOptions,
|
buildStatsWindowLoadFileOptions,
|
||||||
buildStatsWindowOptions,
|
buildStatsWindowOptions,
|
||||||
|
presentStatsWindow,
|
||||||
promoteStatsWindowLevel,
|
promoteStatsWindowLevel,
|
||||||
resolveStatsWindowOuterBoundsForContent,
|
resolveStatsWindowOuterBoundsForContent,
|
||||||
shouldHideStatsWindowForInput,
|
shouldHideStatsWindowForInput,
|
||||||
@@ -230,3 +231,45 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
|
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
presentStatsWindow(
|
||||||
|
{
|
||||||
|
show: () => {
|
||||||
|
calls.push('show');
|
||||||
|
},
|
||||||
|
showInactive: () => {
|
||||||
|
calls.push('show-inactive');
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
calls.push('focus');
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
'darwin',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['show-inactive']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presentStatsWindow shows and focuses on non-macOS platforms', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
presentStatsWindow(
|
||||||
|
{
|
||||||
|
show: () => {
|
||||||
|
calls.push('show');
|
||||||
|
},
|
||||||
|
showInactive: () => {
|
||||||
|
calls.push('show-inactive');
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
calls.push('focus');
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
'linux',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['show', 'focus']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
|||||||
import {
|
import {
|
||||||
buildStatsWindowLoadFileOptions,
|
buildStatsWindowLoadFileOptions,
|
||||||
buildStatsWindowOptions,
|
buildStatsWindowOptions,
|
||||||
|
presentStatsWindow,
|
||||||
promoteStatsWindowLevel,
|
promoteStatsWindowLevel,
|
||||||
resolveStatsWindowOuterBoundsForContent,
|
resolveStatsWindowOuterBoundsForContent,
|
||||||
shouldHideStatsWindowForInput,
|
shouldHideStatsWindowForInput,
|
||||||
@@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
|
|||||||
const bounds = options.resolveBounds();
|
const bounds = options.resolveBounds();
|
||||||
let placementBounds = syncStatsWindowBounds(window, bounds);
|
let placementBounds = syncStatsWindowBounds(window, bounds);
|
||||||
promoteStatsWindowLevel(window);
|
promoteStatsWindowLevel(window);
|
||||||
window.show();
|
presentStatsWindow(window);
|
||||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||||
if (
|
if (
|
||||||
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
|
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
|
||||||
) {
|
) {
|
||||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||||
}
|
}
|
||||||
window.focus();
|
|
||||||
options.onVisibilityChanged?.(true);
|
options.onVisibilityChanged?.(true);
|
||||||
promoteStatsWindowLevel(window);
|
promoteStatsWindowLevel(window);
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-17
@@ -2069,6 +2069,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
|
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
|
||||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
||||||
|
getOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||||
getWindowTracker: () => appState.windowTracker,
|
getWindowTracker: () => appState.windowTracker,
|
||||||
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||||
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
||||||
@@ -2112,23 +2113,24 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
||||||
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||||
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||||
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||||
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||||
|
let visibleOverlayInteractionActive = false;
|
||||||
|
|
||||||
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
|
function clearVisibleOverlayBlurRefreshTimeouts(): void {
|
||||||
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
|
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
windowsVisibleOverlayBlurRefreshTimeouts = [];
|
visibleOverlayBlurRefreshTimeouts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||||
@@ -2329,20 +2331,22 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleVisibleOverlayBlurRefresh(): void {
|
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
if (process.platform === 'win32') {
|
||||||
clearWindowsVisibleOverlayBlurRefreshTimeouts();
|
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||||
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
}
|
||||||
|
clearVisibleOverlayBlurRefreshTimeouts();
|
||||||
|
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||||
const refreshTimeout = setTimeout(() => {
|
const refreshTimeout = setTimeout(() => {
|
||||||
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
|
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
|
||||||
(timeout) => timeout !== refreshTimeout,
|
(timeout) => timeout !== refreshTimeout,
|
||||||
);
|
);
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3043,6 +3047,7 @@ const {
|
|||||||
resetAnilistMediaTracking,
|
resetAnilistMediaTracking,
|
||||||
getAnilistMediaGuessRuntimeState,
|
getAnilistMediaGuessRuntimeState,
|
||||||
setAnilistMediaGuessRuntimeState,
|
setAnilistMediaGuessRuntimeState,
|
||||||
|
recordAnilistMediaDuration,
|
||||||
resetAnilistMediaGuessState,
|
resetAnilistMediaGuessState,
|
||||||
maybeProbeAnilistDuration,
|
maybeProbeAnilistDuration,
|
||||||
ensureAnilistMediaGuess,
|
ensureAnilistMediaGuess,
|
||||||
@@ -3146,6 +3151,13 @@ const {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recordMediaDurationMainDeps: {
|
||||||
|
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
|
||||||
|
getState: () => getAnilistMediaGuessRuntimeState(),
|
||||||
|
setState: (state) => {
|
||||||
|
setAnilistMediaGuessRuntimeState(state);
|
||||||
|
},
|
||||||
|
},
|
||||||
resetMediaGuessStateMainDeps: {
|
resetMediaGuessStateMainDeps: {
|
||||||
setMediaGuess: (value) => {
|
setMediaGuess: (value) => {
|
||||||
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
|
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
|
||||||
@@ -3197,9 +3209,10 @@ const {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
|
||||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||||
rateLimiter: anilistRateLimiter,
|
rateLimiter: anilistRateLimiter,
|
||||||
|
season,
|
||||||
}),
|
}),
|
||||||
markSuccess: (key) => {
|
markSuccess: (key) => {
|
||||||
anilistUpdateQueue.markSuccess(key);
|
anilistUpdateQueue.markSuccess(key);
|
||||||
@@ -3230,13 +3243,13 @@ const {
|
|||||||
resetAnilistMediaTracking(mediaKey);
|
resetAnilistMediaTracking(mediaKey);
|
||||||
},
|
},
|
||||||
getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
|
getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||||
maybeProbeAnilistDuration: (mediaKey) => maybeProbeAnilistDuration(mediaKey),
|
maybeProbeAnilistDuration: (mediaKey, options) => maybeProbeAnilistDuration(mediaKey, options),
|
||||||
ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey),
|
ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey),
|
||||||
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
|
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
|
||||||
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
||||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||||
enqueueRetry: (key, title, episode) => {
|
enqueueRetry: (key, title, episode, season) => {
|
||||||
anilistUpdateQueue.enqueue(key, title, episode);
|
anilistUpdateQueue.enqueue(key, title, episode, season);
|
||||||
},
|
},
|
||||||
markRetryFailure: (key, message) => {
|
markRetryFailure: (key, message) => {
|
||||||
anilistUpdateQueue.markFailure(key, message);
|
anilistUpdateQueue.markFailure(key, message);
|
||||||
@@ -3245,9 +3258,10 @@ const {
|
|||||||
anilistUpdateQueue.markSuccess(key);
|
anilistUpdateQueue.markSuccess(key);
|
||||||
},
|
},
|
||||||
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
|
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
|
||||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
|
||||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||||
rateLimiter: anilistRateLimiter,
|
rateLimiter: anilistRateLimiter,
|
||||||
|
season,
|
||||||
}),
|
}),
|
||||||
rememberAttemptedUpdateKey: (key) => {
|
rememberAttemptedUpdateKey: (key) => {
|
||||||
rememberAnilistAttemptedUpdate(key);
|
rememberAnilistAttemptedUpdate(key);
|
||||||
@@ -3984,7 +3998,10 @@ const {
|
|||||||
reportJellyfinRemoteStopped: () => {
|
reportJellyfinRemoteStopped: () => {
|
||||||
void reportJellyfinRemoteStopped();
|
void reportJellyfinRemoteStopped();
|
||||||
},
|
},
|
||||||
maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
|
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
|
||||||
|
recordAnilistMediaDuration: (durationSec) => {
|
||||||
|
recordAnilistMediaDuration(durationSec);
|
||||||
|
},
|
||||||
logSubtitleTimingError: (message, error) => logger.error(message, error),
|
logSubtitleTimingError: (message, error) => logger.error(message, error),
|
||||||
broadcastToOverlayWindows: (channel, payload) => {
|
broadcastToOverlayWindows: (channel, payload) => {
|
||||||
broadcastToOverlayWindows(channel, payload);
|
broadcastToOverlayWindows(channel, payload);
|
||||||
@@ -4697,6 +4714,8 @@ function getUpdateService() {
|
|||||||
showUpdateAvailableDialog: (version) =>
|
showUpdateAvailableDialog: (version) =>
|
||||||
updateDialogPresenter.showUpdateAvailableDialog(version),
|
updateDialogPresenter.showUpdateAvailableDialog(version),
|
||||||
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
|
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
|
||||||
|
showManualUpdateRequiredDialog: (version) =>
|
||||||
|
updateDialogPresenter.showManualUpdateRequiredDialog(version),
|
||||||
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
||||||
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
|
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
|
||||||
quitAndInstall: () => appUpdater.quitAndInstall(),
|
quitAndInstall: () => appUpdater.quitAndInstall(),
|
||||||
@@ -5124,6 +5143,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
onOverlayModalOpened: (modal) => {
|
onOverlayModalOpened: (modal) => {
|
||||||
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
||||||
},
|
},
|
||||||
|
onOverlayMouseInteractionChanged: (active, senderWindow) => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || senderWindow !== mainWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (visibleOverlayInteractionActive === active) {
|
||||||
|
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
|
||||||
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visibleOverlayInteractionActive = active;
|
||||||
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
|
},
|
||||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
quitApp: () => requestAppQuit(),
|
quitApp: () => requestAppQuit(),
|
||||||
|
|||||||
@@ -1459,7 +1459,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
|
test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let searchQueryCount = 0;
|
let searchQueryCount = 0;
|
||||||
@@ -1567,11 +1567,18 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data',
|
|||||||
});
|
});
|
||||||
|
|
||||||
const first = await runtime.getOrCreateCurrentSnapshot();
|
const first = await runtime.getOrCreateCurrentSnapshot();
|
||||||
|
assert.equal(searchQueryCount, 1);
|
||||||
|
assert.equal(characterQueryCount, 1);
|
||||||
|
|
||||||
|
fs.rmSync(path.join(userDataPath, 'character-dictionaries', 'anilist-resolution-cache.json'), {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
|
||||||
const second = await runtime.getOrCreateCurrentSnapshot();
|
const second = await runtime.getOrCreateCurrentSnapshot();
|
||||||
|
|
||||||
assert.equal(first.fromCache, false);
|
assert.equal(first.fromCache, false);
|
||||||
assert.equal(second.fromCache, true);
|
assert.equal(second.fromCache, true);
|
||||||
assert.equal(searchQueryCount, 2);
|
assert.equal(searchQueryCount, 1);
|
||||||
assert.equal(characterQueryCount, 1);
|
assert.equal(characterQueryCount, 1);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')),
|
fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')),
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
getMergedZipPath,
|
getMergedZipPath,
|
||||||
getSnapshotPath,
|
getSnapshotPath,
|
||||||
normalizeMergedMediaIds,
|
normalizeMergedMediaIds,
|
||||||
|
readCachedMediaResolution,
|
||||||
|
readCachedSnapshots,
|
||||||
readSnapshot,
|
readSnapshot,
|
||||||
|
writeCachedMediaResolution,
|
||||||
writeSnapshot,
|
writeSnapshot,
|
||||||
} from './character-dictionary-runtime/cache';
|
} from './character-dictionary-runtime/cache';
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +44,7 @@ import type {
|
|||||||
CharacterDictionaryManualSelectionResult,
|
CharacterDictionaryManualSelectionResult,
|
||||||
CharacterDictionaryManualSelectionSnapshot,
|
CharacterDictionaryManualSelectionSnapshot,
|
||||||
CharacterDictionaryRuntimeDeps,
|
CharacterDictionaryRuntimeDeps,
|
||||||
|
CharacterDictionarySnapshot,
|
||||||
CharacterDictionarySnapshotImage,
|
CharacterDictionarySnapshotImage,
|
||||||
CharacterDictionarySnapshotProgress,
|
CharacterDictionarySnapshotProgress,
|
||||||
CharacterDictionarySnapshotProgressCallbacks,
|
CharacterDictionarySnapshotProgressCallbacks,
|
||||||
@@ -204,6 +208,26 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findCachedSnapshotForSeriesKey = (
|
||||||
|
seriesKey: string,
|
||||||
|
): CharacterDictionarySnapshot | null => {
|
||||||
|
return (
|
||||||
|
readCachedSnapshots(outputDir).find((snapshot) => {
|
||||||
|
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
|
||||||
|
mediaPath: null,
|
||||||
|
mediaTitle: snapshot.mediaTitle,
|
||||||
|
guess: {
|
||||||
|
title: snapshot.mediaTitle,
|
||||||
|
season: null,
|
||||||
|
episode: null,
|
||||||
|
source: 'fallback',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return snapshotSeriesKey === seriesKey;
|
||||||
|
}) ?? null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resolveCurrentMedia = async (
|
const resolveCurrentMedia = async (
|
||||||
targetPath?: string,
|
targetPath?: string,
|
||||||
beforeRequest?: () => Promise<void>,
|
beforeRequest?: () => Promise<void>,
|
||||||
@@ -228,7 +252,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
staleMediaIds: override.staleMediaIds,
|
staleMediaIds: override.staleMediaIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachedResolution = readCachedMediaResolution(outputDir, seriesKey);
|
||||||
|
if (cachedResolution) {
|
||||||
|
const cachedSnapshot = readSnapshot(getSnapshotPath(outputDir, cachedResolution.mediaId));
|
||||||
|
if (cachedSnapshot) {
|
||||||
|
deps.logInfo?.(
|
||||||
|
`[dictionary] cached AniList match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: cachedSnapshot.mediaId,
|
||||||
|
title: cachedSnapshot.mediaTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey);
|
||||||
|
if (cachedSnapshot) {
|
||||||
|
writeCachedMediaResolution(outputDir, {
|
||||||
|
seriesKey,
|
||||||
|
mediaId: cachedSnapshot.mediaId,
|
||||||
|
mediaTitle: cachedSnapshot.mediaTitle,
|
||||||
|
});
|
||||||
|
deps.logInfo?.(
|
||||||
|
`[dictionary] cached snapshot match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: cachedSnapshot.mediaId,
|
||||||
|
title: cachedSnapshot.mediaTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
|
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
|
||||||
|
writeCachedMediaResolution(outputDir, {
|
||||||
|
seriesKey,
|
||||||
|
mediaId: resolved.id,
|
||||||
|
mediaTitle: resolved.title,
|
||||||
|
});
|
||||||
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
|
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
|
||||||
return resolved;
|
return resolved;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,102 @@ export function getMergedZipPath(outputDir: string): string {
|
|||||||
return path.join(outputDir, 'merged.zip');
|
return path.join(outputDir, 'merged.zip');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaResolutionCacheEntry = {
|
||||||
|
seriesKey: string;
|
||||||
|
mediaId: number;
|
||||||
|
mediaTitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaResolutionCacheFile = {
|
||||||
|
entries?: MediaResolutionCacheEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMediaResolutionCachePath(outputDir: string): string {
|
||||||
|
return path.join(outputDir, 'anilist-resolution-cache.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMediaResolutionEntry(value: unknown): MediaResolutionCacheEntry | null {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const raw = value as Partial<MediaResolutionCacheEntry>;
|
||||||
|
const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : '';
|
||||||
|
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
|
||||||
|
if (typeof raw.mediaId !== 'number' || !Number.isFinite(raw.mediaId)) return null;
|
||||||
|
const mediaId = Math.floor(raw.mediaId);
|
||||||
|
if (!seriesKey || mediaId <= 0 || !mediaTitle) return null;
|
||||||
|
return {
|
||||||
|
seriesKey,
|
||||||
|
mediaId,
|
||||||
|
mediaTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMediaResolutionEntries(outputDir: string): MediaResolutionCacheEntry[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
fs.readFileSync(getMediaResolutionCachePath(outputDir), 'utf8'),
|
||||||
|
) as MediaResolutionCacheFile;
|
||||||
|
if (!Array.isArray(parsed.entries)) return [];
|
||||||
|
const byKey = new Map<string, MediaResolutionCacheEntry>();
|
||||||
|
for (const value of parsed.entries) {
|
||||||
|
const normalized = normalizeMediaResolutionEntry(value);
|
||||||
|
if (normalized) byKey.set(normalized.seriesKey, normalized);
|
||||||
|
}
|
||||||
|
return [...byKey.values()];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMediaResolutionEntries(
|
||||||
|
outputDir: string,
|
||||||
|
entries: MediaResolutionCacheEntry[],
|
||||||
|
): void {
|
||||||
|
ensureDir(outputDir);
|
||||||
|
fs.writeFileSync(
|
||||||
|
getMediaResolutionCachePath(outputDir),
|
||||||
|
JSON.stringify({ entries }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCachedMediaResolution(
|
||||||
|
outputDir: string,
|
||||||
|
seriesKey: string,
|
||||||
|
): MediaResolutionCacheEntry | null {
|
||||||
|
const normalizedKey = seriesKey.trim();
|
||||||
|
if (!normalizedKey) return null;
|
||||||
|
return (
|
||||||
|
readMediaResolutionEntries(outputDir).find((entry) => entry.seriesKey === normalizedKey) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCachedMediaResolution(
|
||||||
|
outputDir: string,
|
||||||
|
entry: MediaResolutionCacheEntry,
|
||||||
|
): void {
|
||||||
|
const normalized = normalizeMediaResolutionEntry(entry);
|
||||||
|
if (!normalized) return;
|
||||||
|
const remaining = readMediaResolutionEntries(outputDir).filter(
|
||||||
|
(existing) => existing.seriesKey !== normalized.seriesKey,
|
||||||
|
);
|
||||||
|
writeMediaResolutionEntries(outputDir, [...remaining, normalized]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCachedSnapshots(outputDir: string): CharacterDictionarySnapshot[] {
|
||||||
|
let entries: fs.Dirent[] = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && /^anilist-\d+\.json$/.test(entry.name))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name))
|
||||||
|
.map((entry) => readSnapshot(path.join(getSnapshotsDir(outputDir), entry.name)))
|
||||||
|
.filter((snapshot): snapshot is CharacterDictionarySnapshot => snapshot !== null);
|
||||||
|
}
|
||||||
|
|
||||||
export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null {
|
export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(snapshotPath, 'utf8');
|
const raw = fs.readFileSync(snapshotPath, 'utf8');
|
||||||
|
|||||||
@@ -75,3 +75,67 @@ test('applyControllerConfigUpdate detaches updated binding values from the patch
|
|||||||
|
|
||||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 7 });
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,66 @@ import type { ControllerConfigUpdate, RawConfig } from '../types';
|
|||||||
|
|
||||||
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
||||||
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
|
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(
|
export function applyControllerConfigUpdate(
|
||||||
currentController: RawConfig['controller'] | undefined,
|
currentController: RawConfig['controller'] | undefined,
|
||||||
@@ -12,26 +72,38 @@ export function applyControllerConfigUpdate(
|
|||||||
...update,
|
...update,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (currentController?.buttonIndices || update.buttonIndices) {
|
const buttonIndices = mergeButtonIndexPatch(
|
||||||
nextController.buttonIndices = {
|
currentController?.buttonIndices,
|
||||||
...(currentController?.buttonIndices ?? {}),
|
update.buttonIndices,
|
||||||
...(update.buttonIndices ?? {}),
|
);
|
||||||
};
|
if (buttonIndices) {
|
||||||
|
nextController.buttonIndices = buttonIndices;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentController?.bindings || update.bindings) {
|
const bindings = mergeBindingPatch(currentController?.bindings, update.bindings);
|
||||||
const nextBindings: RawControllerBindings = {
|
if (bindings) {
|
||||||
...(currentController?.bindings ?? {}),
|
nextController.bindings = bindings;
|
||||||
};
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(update.bindings ?? {}) as Array<
|
if (currentController?.profiles || update.profiles) {
|
||||||
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
|
const nextProfiles: RawControllerProfiles = {};
|
||||||
|
for (const [profileId, profile] of Object.entries(currentController?.profiles ?? {}) as Array<
|
||||||
|
[string, RawControllerProfile]
|
||||||
>) {
|
>) {
|
||||||
if (value === undefined) continue;
|
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue;
|
||||||
(nextBindings as Record<string, unknown>)[key] = structuredClone(value);
|
nextProfiles[profileId] = profile;
|
||||||
}
|
}
|
||||||
|
for (const [profileId, profileUpdate] of Object.entries(update.profiles ?? {}) as Array<
|
||||||
nextController.bindings = nextBindings;
|
[string, RawControllerProfile | undefined]
|
||||||
|
>) {
|
||||||
|
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue;
|
||||||
|
if (profileUpdate === undefined) continue;
|
||||||
|
nextProfiles[profileId] = mergeControllerProfile(
|
||||||
|
currentController?.profiles?.[profileId],
|
||||||
|
profileUpdate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nextController.profiles = nextProfiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextController;
|
return nextController;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
||||||
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
||||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||||
|
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
|
||||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||||
@@ -229,6 +230,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
||||||
onOverlayModalClosed: params.onOverlayModalClosed,
|
onOverlayModalClosed: params.onOverlayModalClosed,
|
||||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||||
|
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
|
||||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||||
openYomitanSettings: params.openYomitanSettings,
|
openYomitanSettings: params.openYomitanSettings,
|
||||||
quitApp: params.quitApp,
|
quitApp: params.quitApp,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
|||||||
getModalActive: () => boolean;
|
getModalActive: () => boolean;
|
||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
getForceMousePassthrough: () => boolean;
|
getForceMousePassthrough: () => boolean;
|
||||||
|
getOverlayInteractionActive?: () => boolean;
|
||||||
getWindowTracker: () => BaseWindowTracker | null;
|
getWindowTracker: () => BaseWindowTracker | null;
|
||||||
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||||
getWindowsOverlayProcessName?: () => string | null;
|
getWindowsOverlayProcessName?: () => string | null;
|
||||||
@@ -49,6 +50,7 @@ export function createOverlayVisibilityRuntimeService(
|
|||||||
visibleOverlayVisible,
|
visibleOverlayVisible,
|
||||||
modalActive: deps.getModalActive(),
|
modalActive: deps.getModalActive(),
|
||||||
forceMousePassthrough,
|
forceMousePassthrough,
|
||||||
|
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
|
||||||
mainWindow,
|
mainWindow,
|
||||||
windowTracker,
|
windowTracker,
|
||||||
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||||
|
|||||||
@@ -30,6 +30,36 @@ test('maybeProbeAnilistDuration updates state with probed duration', async () =>
|
|||||||
assert.equal(state.mediaDurationSec, 321);
|
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 () => {
|
test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
|
||||||
let state: AnilistMediaGuessRuntimeState = {
|
let state: AnilistMediaGuessRuntimeState = {
|
||||||
mediaKey: '/tmp/video.mkv',
|
mediaKey: '/tmp/video.mkv',
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ type GuessAnilistMediaInfo = (
|
|||||||
mediaTitle: string | null,
|
mediaTitle: string | null,
|
||||||
) => Promise<AnilistMediaGuess | null>;
|
) => Promise<AnilistMediaGuess | null>;
|
||||||
|
|
||||||
|
type AnilistDurationProbeOptions = {
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function createMaybeProbeAnilistDurationHandler(deps: {
|
export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||||
getState: () => AnilistMediaGuessRuntimeState;
|
getState: () => AnilistMediaGuessRuntimeState;
|
||||||
setState: (state: AnilistMediaGuessRuntimeState) => void;
|
setState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||||
@@ -22,7 +26,10 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
|
|||||||
requestMpvDuration: () => Promise<unknown>;
|
requestMpvDuration: () => Promise<unknown>;
|
||||||
logWarn: (message: string, error: unknown) => void;
|
logWarn: (message: string, error: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
return async (mediaKey: string): Promise<number | null> => {
|
return async (
|
||||||
|
mediaKey: string,
|
||||||
|
options: AnilistDurationProbeOptions = {},
|
||||||
|
): Promise<number | null> => {
|
||||||
const state = deps.getState();
|
const state = deps.getState();
|
||||||
if (state.mediaKey !== mediaKey) {
|
if (state.mediaKey !== mediaKey) {
|
||||||
return null;
|
return null;
|
||||||
@@ -34,7 +41,7 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
|
|||||||
return state.mediaDurationSec;
|
return state.mediaDurationSec;
|
||||||
}
|
}
|
||||||
const now = deps.now();
|
const now = deps.now();
|
||||||
if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
|
if (!options.force && now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||||
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
|
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
|
||||||
|
createBuildRecordAnilistMediaDurationMainDepsHandler,
|
||||||
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
||||||
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
||||||
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||||
@@ -70,3 +71,32 @@ test('reset anilist media guess state main deps builder maps callbacks', () => {
|
|||||||
deps.setMediaGuessPromise(null);
|
deps.setMediaGuessPromise(null);
|
||||||
assert.deepEqual(calls, ['guess', 'promise']);
|
assert.deepEqual(calls, ['guess', 'promise']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('record anilist media duration main deps builder maps callbacks', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const state = {
|
||||||
|
mediaKey: '/tmp/video.mkv',
|
||||||
|
mediaDurationSec: null,
|
||||||
|
mediaGuess: null,
|
||||||
|
mediaGuessPromise: null,
|
||||||
|
lastDurationProbeAtMs: 0,
|
||||||
|
};
|
||||||
|
const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({
|
||||||
|
getCurrentMediaKey: () => {
|
||||||
|
calls.push('key');
|
||||||
|
return '/tmp/video.mkv';
|
||||||
|
},
|
||||||
|
getState: () => {
|
||||||
|
calls.push('get');
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
setState: () => {
|
||||||
|
calls.push('set');
|
||||||
|
},
|
||||||
|
})();
|
||||||
|
|
||||||
|
assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv');
|
||||||
|
deps.getState();
|
||||||
|
deps.setState(state);
|
||||||
|
assert.deepEqual(calls, ['key', 'get', 'set']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||||
createGetCurrentAnilistMediaKeyHandler,
|
createGetCurrentAnilistMediaKeyHandler,
|
||||||
|
createRecordAnilistMediaDurationHandler,
|
||||||
createResetAnilistMediaGuessStateHandler,
|
createResetAnilistMediaGuessStateHandler,
|
||||||
createResetAnilistMediaTrackingHandler,
|
createResetAnilistMediaTrackingHandler,
|
||||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||||
@@ -18,6 +19,9 @@ type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
|||||||
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
||||||
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
||||||
>[0];
|
>[0];
|
||||||
|
type RecordAnilistMediaDurationMainDeps = Parameters<
|
||||||
|
typeof createRecordAnilistMediaDurationHandler
|
||||||
|
>[0];
|
||||||
type ResetAnilistMediaGuessStateMainDeps = Parameters<
|
type ResetAnilistMediaGuessStateMainDeps = Parameters<
|
||||||
typeof createResetAnilistMediaGuessStateHandler
|
typeof createResetAnilistMediaGuessStateHandler
|
||||||
>[0];
|
>[0];
|
||||||
@@ -66,6 +70,16 @@ export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createBuildRecordAnilistMediaDurationMainDepsHandler(
|
||||||
|
deps: RecordAnilistMediaDurationMainDeps,
|
||||||
|
) {
|
||||||
|
return (): RecordAnilistMediaDurationMainDeps => ({
|
||||||
|
getCurrentMediaKey: () => deps.getCurrentMediaKey(),
|
||||||
|
getState: () => deps.getState(),
|
||||||
|
setState: (state) => deps.setState(state),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
|
export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
|
||||||
deps: ResetAnilistMediaGuessStateMainDeps,
|
deps: ResetAnilistMediaGuessStateMainDeps,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||||
createGetCurrentAnilistMediaKeyHandler,
|
createGetCurrentAnilistMediaKeyHandler,
|
||||||
|
createRecordAnilistMediaDurationHandler,
|
||||||
createResetAnilistMediaGuessStateHandler,
|
createResetAnilistMediaGuessStateHandler,
|
||||||
createResetAnilistMediaTrackingHandler,
|
createResetAnilistMediaTrackingHandler,
|
||||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||||
@@ -176,3 +177,57 @@ test('reset anilist media guess state clears guess and in-flight promise', () =>
|
|||||||
assert.equal(state.mediaDurationSec, 240);
|
assert.equal(state.mediaDurationSec, 240);
|
||||||
assert.equal(state.lastDurationProbeAtMs, 321);
|
assert.equal(state.lastDurationProbeAtMs, 321);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('record anilist media duration stores observed mpv duration for current media', () => {
|
||||||
|
const existingPromise = Promise.resolve(null);
|
||||||
|
let state = {
|
||||||
|
mediaKey: '/tmp/video.mkv' as string | null,
|
||||||
|
mediaDurationSec: null as number | null,
|
||||||
|
mediaGuess: { title: 'guess' } as { title: string } | null,
|
||||||
|
mediaGuessPromise: existingPromise as Promise<unknown> | null,
|
||||||
|
lastDurationProbeAtMs: 321,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordDuration = createRecordAnilistMediaDurationHandler({
|
||||||
|
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||||
|
getState: () => state as never,
|
||||||
|
setState: (nextState) => {
|
||||||
|
state = nextState as never;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
recordDuration(1440);
|
||||||
|
|
||||||
|
assert.equal(state.mediaDurationSec, 1440);
|
||||||
|
assert.deepEqual(state.mediaGuess, { title: 'guess' });
|
||||||
|
assert.equal(state.mediaGuessPromise, existingPromise);
|
||||||
|
assert.equal(state.lastDurationProbeAtMs, 321);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('record anilist media duration resets stale media state when media key changes', () => {
|
||||||
|
let state = {
|
||||||
|
mediaKey: '/tmp/old.mkv' as string | null,
|
||||||
|
mediaDurationSec: 120 as number | null,
|
||||||
|
mediaGuess: { title: 'old' } as { title: string } | null,
|
||||||
|
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
|
||||||
|
lastDurationProbeAtMs: 321,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordDuration = createRecordAnilistMediaDurationHandler({
|
||||||
|
getCurrentMediaKey: () => '/tmp/new.mkv',
|
||||||
|
getState: () => state as never,
|
||||||
|
setState: (nextState) => {
|
||||||
|
state = nextState as never;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
recordDuration(1440);
|
||||||
|
|
||||||
|
assert.deepEqual(state, {
|
||||||
|
mediaKey: '/tmp/new.mkv',
|
||||||
|
mediaDurationSec: 1440,
|
||||||
|
mediaGuess: null,
|
||||||
|
mediaGuessPromise: null,
|
||||||
|
lastDurationProbeAtMs: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -61,6 +61,37 @@ export function createSetAnilistMediaGuessRuntimeStateHandler(deps: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createRecordAnilistMediaDurationHandler(deps: {
|
||||||
|
getCurrentMediaKey: () => string | null;
|
||||||
|
getState: () => AnilistMediaGuessRuntimeState;
|
||||||
|
setState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||||
|
}) {
|
||||||
|
return (durationSec: number): void => {
|
||||||
|
if (!Number.isFinite(durationSec) || durationSec <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mediaKey = deps.getCurrentMediaKey();
|
||||||
|
if (!mediaKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = deps.getState();
|
||||||
|
if (state.mediaKey === mediaKey) {
|
||||||
|
deps.setState({
|
||||||
|
...state,
|
||||||
|
mediaDurationSec: durationSec,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.setState({
|
||||||
|
mediaKey,
|
||||||
|
mediaDurationSec: durationSec,
|
||||||
|
mediaGuess: null,
|
||||||
|
mediaGuessPromise: null,
|
||||||
|
lastDurationProbeAtMs: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createResetAnilistMediaGuessStateHandler(deps: {
|
export function createResetAnilistMediaGuessStateHandler(deps: {
|
||||||
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
||||||
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ test('process next anilist retry update main deps builder maps callbacks', async
|
|||||||
setLastAttemptAt: () => calls.push('attempt'),
|
setLastAttemptAt: () => calls.push('attempt'),
|
||||||
setLastError: () => calls.push('error'),
|
setLastError: () => calls.push('error'),
|
||||||
refreshAnilistClientSecretState: async () => 'token',
|
refreshAnilistClientSecretState: async () => 'token',
|
||||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
|
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||||
|
status: 'updated',
|
||||||
|
message: `ok:${season}`,
|
||||||
|
}),
|
||||||
markSuccess: () => calls.push('success'),
|
markSuccess: () => calls.push('success'),
|
||||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||||
markFailure: () => calls.push('failure'),
|
markFailure: () => calls.push('failure'),
|
||||||
@@ -26,9 +29,9 @@ test('process next anilist retry update main deps builder maps callbacks', async
|
|||||||
deps.setLastAttemptAt(1);
|
deps.setLastAttemptAt(1);
|
||||||
deps.setLastError('x');
|
deps.setLastError('x');
|
||||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
|
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
|
||||||
status: 'updated',
|
status: 'updated',
|
||||||
message: 'ok',
|
message: 'ok:2',
|
||||||
});
|
});
|
||||||
deps.markSuccess('k');
|
deps.markSuccess('k');
|
||||||
deps.rememberAttemptedUpdateKey('k');
|
deps.rememberAttemptedUpdateKey('k');
|
||||||
@@ -58,16 +61,22 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
|||||||
getTrackedMediaKey: () => 'media',
|
getTrackedMediaKey: () => 'media',
|
||||||
resetTrackedMedia: () => calls.push('reset'),
|
resetTrackedMedia: () => calls.push('reset'),
|
||||||
getWatchedSeconds: () => 100,
|
getWatchedSeconds: () => 100,
|
||||||
maybeProbeAnilistDuration: async () => 120,
|
maybeProbeAnilistDuration: async (_mediaKey, options) => {
|
||||||
|
calls.push(`probe:${options?.force === true}`);
|
||||||
|
return 120;
|
||||||
|
},
|
||||||
ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }),
|
ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }),
|
||||||
hasAttemptedUpdateKey: () => false,
|
hasAttemptedUpdateKey: () => false,
|
||||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||||
refreshAnilistClientSecretState: async () => 'token',
|
refreshAnilistClientSecretState: async () => 'token',
|
||||||
enqueueRetry: () => calls.push('enqueue'),
|
enqueueRetry: (_key, _title, _episode, season) => calls.push(`enqueue:${season}`),
|
||||||
markRetryFailure: () => calls.push('retry-fail'),
|
markRetryFailure: () => calls.push('retry-fail'),
|
||||||
markRetrySuccess: () => calls.push('retry-ok'),
|
markRetrySuccess: () => calls.push('retry-ok'),
|
||||||
refreshRetryQueueState: () => calls.push('refresh'),
|
refreshRetryQueueState: () => calls.push('refresh'),
|
||||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'done' }),
|
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||||
|
status: 'updated',
|
||||||
|
message: `done:${season}`,
|
||||||
|
}),
|
||||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||||
showMpvOsd: () => calls.push('osd'),
|
showMpvOsd: () => calls.push('osd'),
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
@@ -84,7 +93,7 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
|||||||
assert.equal(deps.getTrackedMediaKey(), 'media');
|
assert.equal(deps.getTrackedMediaKey(), 'media');
|
||||||
deps.resetTrackedMedia('media');
|
deps.resetTrackedMedia('media');
|
||||||
assert.equal(deps.getWatchedSeconds(), 100);
|
assert.equal(deps.getWatchedSeconds(), 100);
|
||||||
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
|
assert.equal(await deps.maybeProbeAnilistDuration('media', { force: true }), 120);
|
||||||
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
|
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
|
||||||
title: 'x',
|
title: 'x',
|
||||||
season: null,
|
season: null,
|
||||||
@@ -93,13 +102,13 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
|||||||
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
|
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
|
||||||
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
|
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
|
||||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||||
deps.enqueueRetry('k', 't', 1);
|
deps.enqueueRetry('k', 't', 1, 2);
|
||||||
deps.markRetryFailure('k', 'bad');
|
deps.markRetryFailure('k', 'bad');
|
||||||
deps.markRetrySuccess('k');
|
deps.markRetrySuccess('k');
|
||||||
deps.refreshRetryQueueState();
|
deps.refreshRetryQueueState();
|
||||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
|
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
|
||||||
status: 'updated',
|
status: 'updated',
|
||||||
message: 'done',
|
message: 'done:2',
|
||||||
});
|
});
|
||||||
deps.rememberAttemptedUpdateKey('k');
|
deps.rememberAttemptedUpdateKey('k');
|
||||||
deps.showMpvOsd('ok');
|
deps.showMpvOsd('ok');
|
||||||
@@ -110,7 +119,8 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
|||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'in-flight',
|
'in-flight',
|
||||||
'reset',
|
'reset',
|
||||||
'enqueue',
|
'probe:true',
|
||||||
|
'enqueue:2',
|
||||||
'retry-fail',
|
'retry-fail',
|
||||||
'retry-ok',
|
'retry-ok',
|
||||||
'refresh',
|
'refresh',
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
|
|||||||
setLastAttemptAt: (value: number) => deps.setLastAttemptAt(value),
|
setLastAttemptAt: (value: number) => deps.setLastAttemptAt(value),
|
||||||
setLastError: (value: string | null) => deps.setLastError(value),
|
setLastError: (value: string | null) => deps.setLastError(value),
|
||||||
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
||||||
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
|
updateAnilistPostWatchProgress: (
|
||||||
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
|
accessToken: string,
|
||||||
|
title: string,
|
||||||
|
episode: number,
|
||||||
|
season?: number | null,
|
||||||
|
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
|
||||||
markSuccess: (key: string) => deps.markSuccess(key),
|
markSuccess: (key: string) => deps.markSuccess(key),
|
||||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||||
markFailure: (key: string, message: string) => deps.markFailure(key, message),
|
markFailure: (key: string, message: string) => deps.markFailure(key, message),
|
||||||
@@ -42,18 +46,23 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
|
|||||||
getTrackedMediaKey: () => deps.getTrackedMediaKey(),
|
getTrackedMediaKey: () => deps.getTrackedMediaKey(),
|
||||||
resetTrackedMedia: (mediaKey) => deps.resetTrackedMedia(mediaKey),
|
resetTrackedMedia: (mediaKey) => deps.resetTrackedMedia(mediaKey),
|
||||||
getWatchedSeconds: () => deps.getWatchedSeconds(),
|
getWatchedSeconds: () => deps.getWatchedSeconds(),
|
||||||
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
|
maybeProbeAnilistDuration: (mediaKey: string, options) =>
|
||||||
|
deps.maybeProbeAnilistDuration(mediaKey, options),
|
||||||
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||||
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
|
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
|
||||||
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
|
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
|
||||||
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
||||||
enqueueRetry: (key: string, title: string, episode: number) =>
|
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) =>
|
||||||
deps.enqueueRetry(key, title, episode),
|
deps.enqueueRetry(key, title, episode, season),
|
||||||
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
|
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
|
||||||
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
|
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
|
||||||
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
|
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
|
||||||
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
|
updateAnilistPostWatchProgress: (
|
||||||
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
|
accessToken: string,
|
||||||
|
title: string,
|
||||||
|
episode: number,
|
||||||
|
season?: number | null,
|
||||||
|
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
|
||||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
|
|||||||
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
|
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const handler = createProcessNextAnilistRetryUpdateHandler({
|
const handler = createProcessNextAnilistRetryUpdateHandler({
|
||||||
nextReady: () => ({ key: 'k1', title: 'Show', season: null, episode: 1 }),
|
nextReady: () => ({ key: 'k1', title: 'Show', season: 2, episode: 1 }),
|
||||||
refreshRetryQueueState: () => calls.push('refresh'),
|
refreshRetryQueueState: () => calls.push('refresh'),
|
||||||
setLastAttemptAt: () => calls.push('attempt'),
|
setLastAttemptAt: () => calls.push('attempt'),
|
||||||
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
|
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
|
||||||
refreshAnilistClientSecretState: async () => 'token',
|
refreshAnilistClientSecretState: async () => 'token',
|
||||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'updated ok' }),
|
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||||
|
status: 'updated',
|
||||||
|
message: `updated ok:${season}`,
|
||||||
|
}),
|
||||||
markSuccess: () => calls.push('success'),
|
markSuccess: () => calls.push('success'),
|
||||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||||
markFailure: () => calls.push('failure'),
|
markFailure: () => calls.push('failure'),
|
||||||
@@ -34,7 +37,7 @@ test('createProcessNextAnilistRetryUpdateHandler handles successful retry', asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await handler();
|
const result = await handler();
|
||||||
assert.deepEqual(result, { ok: true, message: 'updated ok' });
|
assert.deepEqual(result, { ok: true, message: 'updated ok:2' });
|
||||||
assert.ok(calls.includes('success'));
|
assert.ok(calls.includes('success'));
|
||||||
assert.ok(calls.includes('remember'));
|
assert.ok(calls.includes('remember'));
|
||||||
});
|
});
|
||||||
@@ -93,7 +96,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
|
|||||||
calls.push('probe');
|
calls.push('probe');
|
||||||
return 1000;
|
return 1000;
|
||||||
},
|
},
|
||||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 3 }),
|
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
|
||||||
hasAttemptedUpdateKey: () => false,
|
hasAttemptedUpdateKey: () => false,
|
||||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||||
refreshAnilistClientSecretState: async () => 'token',
|
refreshAnilistClientSecretState: async () => 'token',
|
||||||
@@ -121,6 +124,106 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
|
|||||||
assert.ok(calls.includes('osd:updated ok'));
|
assert.ok(calls.includes('osd:updated ok'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createMaybeRunAnilistPostWatchUpdateHandler shows permanent AniList update errors without queueing retry', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const attemptedKeys = new Set<string>();
|
||||||
|
let updateCalls = 0;
|
||||||
|
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||||
|
getInFlight: () => false,
|
||||||
|
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||||
|
getResolvedConfig: () => ({}),
|
||||||
|
isAnilistTrackingEnabled: () => true,
|
||||||
|
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||||
|
hasMpvClient: () => true,
|
||||||
|
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||||
|
resetTrackedMedia: () => {},
|
||||||
|
getWatchedSeconds: () => 1000,
|
||||||
|
maybeProbeAnilistDuration: async () => 1000,
|
||||||
|
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 2 }),
|
||||||
|
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
|
||||||
|
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||||
|
refreshAnilistClientSecretState: async () => 'token',
|
||||||
|
enqueueRetry: () => calls.push('enqueue'),
|
||||||
|
markRetryFailure: () => calls.push('mark-failure'),
|
||||||
|
markRetrySuccess: () => calls.push('mark-success'),
|
||||||
|
refreshRetryQueueState: () => calls.push('refresh'),
|
||||||
|
updateAnilistPostWatchProgress: async () => {
|
||||||
|
updateCalls += 1;
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
retryable: false,
|
||||||
|
message:
|
||||||
|
'AniList update not possible: Show is not in your AniList Planning or Watching list.',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
rememberAttemptedUpdateKey: (key) => {
|
||||||
|
attemptedKeys.add(key);
|
||||||
|
calls.push(`remember:${key}`);
|
||||||
|
},
|
||||||
|
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
minWatchSeconds: 600,
|
||||||
|
minWatchRatio: 0.85,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler();
|
||||||
|
await handler();
|
||||||
|
|
||||||
|
assert.equal(updateCalls, 1);
|
||||||
|
assert.equal(calls.includes('enqueue'), false);
|
||||||
|
assert.equal(calls.includes('mark-failure'), false);
|
||||||
|
assert.ok(calls.some((call) => call.startsWith('remember:')));
|
||||||
|
assert.ok(calls.includes('refresh'));
|
||||||
|
assert.ok(calls.some((call) => call.startsWith('osd:AniList update not possible')));
|
||||||
|
assert.ok(calls.some((call) => call.startsWith('warn:AniList update not possible')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds from time-position events', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let durationProbeOptions: unknown = null;
|
||||||
|
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||||
|
getInFlight: () => false,
|
||||||
|
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||||
|
getResolvedConfig: () => ({}),
|
||||||
|
isAnilistTrackingEnabled: () => true,
|
||||||
|
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||||
|
hasMpvClient: () => true,
|
||||||
|
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||||
|
resetTrackedMedia: () => {},
|
||||||
|
getWatchedSeconds: () => 0,
|
||||||
|
maybeProbeAnilistDuration: async (_mediaKey, options) => {
|
||||||
|
durationProbeOptions = options;
|
||||||
|
return 1000;
|
||||||
|
},
|
||||||
|
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 2, episode: 8 }),
|
||||||
|
hasAttemptedUpdateKey: () => false,
|
||||||
|
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||||
|
refreshAnilistClientSecretState: async () => 'token',
|
||||||
|
enqueueRetry: () => calls.push('enqueue'),
|
||||||
|
markRetryFailure: () => calls.push('mark-failure'),
|
||||||
|
markRetrySuccess: () => calls.push('mark-success'),
|
||||||
|
refreshRetryQueueState: () => calls.push('refresh'),
|
||||||
|
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => {
|
||||||
|
calls.push(`update:${season}`);
|
||||||
|
return { status: 'updated', message: 'updated ok' };
|
||||||
|
},
|
||||||
|
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||||
|
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
minWatchSeconds: 600,
|
||||||
|
minWatchRatio: 0.85,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler({ watchedSeconds: 850 });
|
||||||
|
|
||||||
|
assert.deepEqual(durationProbeOptions, { force: true });
|
||||||
|
assert.ok(calls.includes('update:2'));
|
||||||
|
assert.ok(calls.includes('remember'));
|
||||||
|
assert.ok(calls.includes('osd:updated ok'));
|
||||||
|
});
|
||||||
|
|
||||||
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
|
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
|
|||||||
@@ -2,22 +2,30 @@ import { isYoutubeMediaPath } from './youtube-playback';
|
|||||||
|
|
||||||
type AnilistGuess = {
|
type AnilistGuess = {
|
||||||
title: string;
|
title: string;
|
||||||
|
season: number | null;
|
||||||
episode: number | null;
|
episode: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AnilistUpdateResult = {
|
type AnilistUpdateResult = {
|
||||||
status: 'updated' | 'skipped' | 'error';
|
status: 'updated' | 'skipped' | 'error';
|
||||||
message: string;
|
message: string;
|
||||||
|
retryable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RetryQueueItem = {
|
type RetryQueueItem = {
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
season?: number | null;
|
||||||
episode: number;
|
episode: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AnilistPostWatchRunOptions = {
|
type AnilistPostWatchRunOptions = {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
watchedSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnilistDurationProbeOptions = {
|
||||||
|
force?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
|
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
|
||||||
@@ -49,6 +57,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
title: string,
|
title: string,
|
||||||
episode: number,
|
episode: number,
|
||||||
|
season?: number | null,
|
||||||
) => Promise<AnilistUpdateResult>;
|
) => Promise<AnilistUpdateResult>;
|
||||||
markSuccess: (key: string) => void;
|
markSuccess: (key: string) => void;
|
||||||
rememberAttemptedUpdateKey: (key: string) => void;
|
rememberAttemptedUpdateKey: (key: string) => void;
|
||||||
@@ -74,6 +83,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
|||||||
accessToken,
|
accessToken,
|
||||||
queued.title,
|
queued.title,
|
||||||
queued.episode,
|
queued.episode,
|
||||||
|
queued.season ?? null,
|
||||||
);
|
);
|
||||||
if (result.status === 'updated' || result.status === 'skipped') {
|
if (result.status === 'updated' || result.status === 'skipped') {
|
||||||
deps.markSuccess(queued.key);
|
deps.markSuccess(queued.key);
|
||||||
@@ -101,12 +111,15 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
getTrackedMediaKey: () => string | null;
|
getTrackedMediaKey: () => string | null;
|
||||||
resetTrackedMedia: (mediaKey: string | null) => void;
|
resetTrackedMedia: (mediaKey: string | null) => void;
|
||||||
getWatchedSeconds: () => number;
|
getWatchedSeconds: () => number;
|
||||||
maybeProbeAnilistDuration: (mediaKey: string) => Promise<number | null>;
|
maybeProbeAnilistDuration: (
|
||||||
|
mediaKey: string,
|
||||||
|
options?: AnilistDurationProbeOptions,
|
||||||
|
) => Promise<number | null>;
|
||||||
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistGuess | null>;
|
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistGuess | null>;
|
||||||
hasAttemptedUpdateKey: (key: string) => boolean;
|
hasAttemptedUpdateKey: (key: string) => boolean;
|
||||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
||||||
refreshAnilistClientSecretState: () => Promise<string | null>;
|
refreshAnilistClientSecretState: () => Promise<string | null>;
|
||||||
enqueueRetry: (key: string, title: string, episode: number) => void;
|
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) => void;
|
||||||
markRetryFailure: (key: string, message: string) => void;
|
markRetryFailure: (key: string, message: string) => void;
|
||||||
markRetrySuccess: (key: string) => void;
|
markRetrySuccess: (key: string) => void;
|
||||||
refreshRetryQueueState: () => void;
|
refreshRetryQueueState: () => void;
|
||||||
@@ -114,6 +127,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
title: string,
|
title: string,
|
||||||
episode: number,
|
episode: number,
|
||||||
|
season?: number | null,
|
||||||
) => Promise<AnilistUpdateResult>;
|
) => Promise<AnilistUpdateResult>;
|
||||||
rememberAttemptedUpdateKey: (key: string) => void;
|
rememberAttemptedUpdateKey: (key: string) => void;
|
||||||
showMpvOsd: (message: string) => void;
|
showMpvOsd: (message: string) => void;
|
||||||
@@ -146,7 +160,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
|
|
||||||
let watchedSeconds = 0;
|
let watchedSeconds = 0;
|
||||||
if (!force) {
|
if (!force) {
|
||||||
watchedSeconds = deps.getWatchedSeconds();
|
watchedSeconds =
|
||||||
|
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds)
|
||||||
|
? options.watchedSeconds
|
||||||
|
: deps.getWatchedSeconds();
|
||||||
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
|
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -155,7 +172,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
deps.setInFlight(true);
|
deps.setInFlight(true);
|
||||||
try {
|
try {
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
|
const duration = await deps.maybeProbeAnilistDuration(mediaKey, {
|
||||||
|
force:
|
||||||
|
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds),
|
||||||
|
});
|
||||||
if (!duration || duration <= 0) {
|
if (!duration || duration <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -181,7 +201,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
|
|
||||||
const accessToken = await deps.refreshAnilistClientSecretState();
|
const accessToken = await deps.refreshAnilistClientSecretState();
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
|
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
|
||||||
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
|
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
|
||||||
deps.refreshRetryQueueState();
|
deps.refreshRetryQueueState();
|
||||||
deps.showMpvOsd('AniList: access token not configured');
|
deps.showMpvOsd('AniList: access token not configured');
|
||||||
@@ -192,6 +212,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
accessToken,
|
accessToken,
|
||||||
guess.title,
|
guess.title,
|
||||||
guess.episode,
|
guess.episode,
|
||||||
|
guess.season,
|
||||||
);
|
);
|
||||||
if (result.status === 'updated') {
|
if (result.status === 'updated') {
|
||||||
deps.rememberAttemptedUpdateKey(attemptKey);
|
deps.rememberAttemptedUpdateKey(attemptKey);
|
||||||
@@ -209,7 +230,15 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
|
if (result.retryable === false) {
|
||||||
|
deps.rememberAttemptedUpdateKey(attemptKey);
|
||||||
|
deps.refreshRetryQueueState();
|
||||||
|
deps.showMpvOsd(result.message);
|
||||||
|
deps.logWarn(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
|
||||||
deps.markRetryFailure(attemptKey, result.message);
|
deps.markRetryFailure(attemptKey, result.message);
|
||||||
deps.refreshRetryQueueState();
|
deps.refreshRetryQueueState();
|
||||||
deps.showMpvOsd(`AniList: ${result.message}`);
|
deps.showMpvOsd(`AniList: ${result.message}`);
|
||||||
|
|||||||
@@ -80,6 +80,23 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
|
|||||||
lastDurationProbeAtMsState = value;
|
lastDurationProbeAtMsState = value;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recordMediaDurationMainDeps: {
|
||||||
|
getCurrentMediaKey: () => 'media-key',
|
||||||
|
getState: () => ({
|
||||||
|
mediaKey: mediaKeyState,
|
||||||
|
mediaDurationSec: mediaDurationSecState,
|
||||||
|
mediaGuess: mediaGuessState,
|
||||||
|
mediaGuessPromise: mediaGuessPromiseState,
|
||||||
|
lastDurationProbeAtMs: lastDurationProbeAtMsState,
|
||||||
|
}),
|
||||||
|
setState: (state) => {
|
||||||
|
mediaKeyState = state.mediaKey;
|
||||||
|
mediaDurationSecState = state.mediaDurationSec;
|
||||||
|
mediaGuessState = state.mediaGuess;
|
||||||
|
mediaGuessPromiseState = state.mediaGuessPromise;
|
||||||
|
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
|
||||||
|
},
|
||||||
|
},
|
||||||
resetMediaGuessStateMainDeps: {
|
resetMediaGuessStateMainDeps: {
|
||||||
setMediaGuess: (value) => {
|
setMediaGuess: (value) => {
|
||||||
mediaGuessState = value;
|
mediaGuessState = value;
|
||||||
@@ -192,6 +209,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
|
|||||||
assert.equal(typeof composed.resetAnilistMediaTracking, 'function');
|
assert.equal(typeof composed.resetAnilistMediaTracking, 'function');
|
||||||
assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function');
|
assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function');
|
||||||
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
|
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
|
||||||
|
assert.equal(typeof composed.recordAnilistMediaDuration, 'function');
|
||||||
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
|
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
|
||||||
assert.equal(typeof composed.maybeProbeAnilistDuration, 'function');
|
assert.equal(typeof composed.maybeProbeAnilistDuration, 'function');
|
||||||
assert.equal(typeof composed.ensureAnilistMediaGuess, 'function');
|
assert.equal(typeof composed.ensureAnilistMediaGuess, 'function');
|
||||||
@@ -216,6 +234,9 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
|
|||||||
});
|
});
|
||||||
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90);
|
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90);
|
||||||
|
|
||||||
|
composed.recordAnilistMediaDuration(180);
|
||||||
|
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 180);
|
||||||
|
|
||||||
composed.resetAnilistMediaGuessState();
|
composed.resetAnilistMediaGuessState();
|
||||||
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
|
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createBuildMaybeProbeAnilistDurationMainDepsHandler,
|
createBuildMaybeProbeAnilistDurationMainDepsHandler,
|
||||||
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
|
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
|
||||||
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
|
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
|
||||||
|
createBuildRecordAnilistMediaDurationMainDepsHandler,
|
||||||
createBuildRefreshAnilistClientSecretStateMainDepsHandler,
|
createBuildRefreshAnilistClientSecretStateMainDepsHandler,
|
||||||
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
||||||
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
createMaybeProbeAnilistDurationHandler,
|
createMaybeProbeAnilistDurationHandler,
|
||||||
createMaybeRunAnilistPostWatchUpdateHandler,
|
createMaybeRunAnilistPostWatchUpdateHandler,
|
||||||
createProcessNextAnilistRetryUpdateHandler,
|
createProcessNextAnilistRetryUpdateHandler,
|
||||||
|
createRecordAnilistMediaDurationHandler,
|
||||||
createRefreshAnilistClientSecretStateHandler,
|
createRefreshAnilistClientSecretStateHandler,
|
||||||
createResetAnilistMediaGuessStateHandler,
|
createResetAnilistMediaGuessStateHandler,
|
||||||
createResetAnilistMediaTrackingHandler,
|
createResetAnilistMediaTrackingHandler,
|
||||||
@@ -38,6 +40,9 @@ export type AnilistTrackingComposerOptions = ComposerInputs<{
|
|||||||
setMediaGuessRuntimeStateMainDeps: Parameters<
|
setMediaGuessRuntimeStateMainDeps: Parameters<
|
||||||
typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler
|
typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler
|
||||||
>[0];
|
>[0];
|
||||||
|
recordMediaDurationMainDeps: Parameters<
|
||||||
|
typeof createBuildRecordAnilistMediaDurationMainDepsHandler
|
||||||
|
>[0];
|
||||||
resetMediaGuessStateMainDeps: Parameters<
|
resetMediaGuessStateMainDeps: Parameters<
|
||||||
typeof createBuildResetAnilistMediaGuessStateMainDepsHandler
|
typeof createBuildResetAnilistMediaGuessStateMainDepsHandler
|
||||||
>[0];
|
>[0];
|
||||||
@@ -63,6 +68,7 @@ export type AnilistTrackingComposerResult = ComposerOutputs<{
|
|||||||
setAnilistMediaGuessRuntimeState: ReturnType<
|
setAnilistMediaGuessRuntimeState: ReturnType<
|
||||||
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
||||||
>;
|
>;
|
||||||
|
recordAnilistMediaDuration: ReturnType<typeof createRecordAnilistMediaDurationHandler>;
|
||||||
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
|
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
|
||||||
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
|
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
|
||||||
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
|
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
|
||||||
@@ -94,6 +100,9 @@ export function composeAnilistTrackingHandlers(
|
|||||||
options.setMediaGuessRuntimeStateMainDeps,
|
options.setMediaGuessRuntimeStateMainDeps,
|
||||||
)(),
|
)(),
|
||||||
);
|
);
|
||||||
|
const recordAnilistMediaDuration = createRecordAnilistMediaDurationHandler(
|
||||||
|
createBuildRecordAnilistMediaDurationMainDepsHandler(options.recordMediaDurationMainDeps)(),
|
||||||
|
);
|
||||||
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
|
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
|
||||||
createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(),
|
createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(),
|
||||||
);
|
);
|
||||||
@@ -120,6 +129,7 @@ export function composeAnilistTrackingHandlers(
|
|||||||
resetAnilistMediaTracking,
|
resetAnilistMediaTracking,
|
||||||
getAnilistMediaGuessRuntimeState,
|
getAnilistMediaGuessRuntimeState,
|
||||||
setAnilistMediaGuessRuntimeState,
|
setAnilistMediaGuessRuntimeState,
|
||||||
|
recordAnilistMediaDuration,
|
||||||
resetAnilistMediaGuessState,
|
resetAnilistMediaGuessState,
|
||||||
maybeProbeAnilistDuration,
|
maybeProbeAnilistDuration,
|
||||||
ensureAnilistMediaGuess,
|
ensureAnilistMediaGuess,
|
||||||
|
|||||||
@@ -97,20 +97,38 @@ test('mpv connection handler keeps overlay-initialized non-youtube sessions aliv
|
|||||||
assert.deepEqual(calls, ['presence-refresh', 'report-stop']);
|
assert.deepEqual(calls, ['presence-refresh', 'report-stop']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
|
test('mpv subtitle timing handler skips blank subtitle recording but still checks AniList time', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const handler = createHandleMpvSubtitleTimingHandler({
|
const handler = createHandleMpvSubtitleTimingHandler({
|
||||||
recordImmersionSubtitleLine: () => calls.push('immersion'),
|
recordImmersionSubtitleLine: () => calls.push('immersion'),
|
||||||
hasSubtitleTimingTracker: () => true,
|
hasSubtitleTimingTracker: () => true,
|
||||||
recordSubtitleTiming: () => calls.push('timing'),
|
recordSubtitleTiming: () => calls.push('timing'),
|
||||||
maybeRunAnilistPostWatchUpdate: async () => {
|
maybeRunAnilistPostWatchUpdate: async (options) => {
|
||||||
calls.push('post-watch');
|
calls.push(`post-watch:${options?.watchedSeconds}`);
|
||||||
},
|
},
|
||||||
logError: () => calls.push('error'),
|
logError: () => calls.push('error'),
|
||||||
});
|
});
|
||||||
|
|
||||||
handler({ text: ' ', start: 1, end: 2 });
|
handler({ text: ' ', start: 1, end: 2 });
|
||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, ['post-watch:2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mpv subtitle timing handler runs AniList without timing tracker and passes subtitle time', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const handler = createHandleMpvSubtitleTimingHandler({
|
||||||
|
recordImmersionSubtitleLine: (text, start, end) =>
|
||||||
|
calls.push(`immersion:${text}:${start}:${end}`),
|
||||||
|
hasSubtitleTimingTracker: () => false,
|
||||||
|
recordSubtitleTiming: () => calls.push('timing'),
|
||||||
|
maybeRunAnilistPostWatchUpdate: async (options) => {
|
||||||
|
calls.push(`post-watch:${options?.watchedSeconds}`);
|
||||||
|
},
|
||||||
|
logError: () => calls.push('error'),
|
||||||
|
});
|
||||||
|
|
||||||
|
handler({ text: 'line', start: 899, end: 901 });
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mpv event bindings register all expected events', () => {
|
test('mpv event bindings register all expected events', () => {
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ type MpvEventClient = {
|
|||||||
on: <K extends MpvBindingEventName>(event: K, handler: (payload: any) => void) => void;
|
on: <K extends MpvBindingEventName>(event: K, handler: (payload: any) => void) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AnilistPostWatchRunOptions = {
|
||||||
|
watchedSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function createHandleMpvConnectionChangeHandler(deps: {
|
export function createHandleMpvConnectionChangeHandler(deps: {
|
||||||
reportJellyfinRemoteStopped: () => void;
|
reportJellyfinRemoteStopped: () => void;
|
||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
@@ -57,15 +61,22 @@ export function createHandleMpvSubtitleTimingHandler(deps: {
|
|||||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
||||||
hasSubtitleTimingTracker: () => boolean;
|
hasSubtitleTimingTracker: () => boolean;
|
||||||
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
||||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||||
logError: (message: string, error: unknown) => void;
|
logError: (message: string, error: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
return ({ text, start, end }: { text: string; start: number; end: number }): void => {
|
return ({ text, start, end }: { text: string; start: number; end: number }): void => {
|
||||||
if (!text.trim()) return;
|
const watchedSeconds = Math.max(
|
||||||
deps.recordImmersionSubtitleLine(text, start, end);
|
Number.isFinite(start) ? start : 0,
|
||||||
if (!deps.hasSubtitleTimingTracker()) return;
|
Number.isFinite(end) ? end : 0,
|
||||||
deps.recordSubtitleTiming(text, start, end);
|
);
|
||||||
void deps.maybeRunAnilistPostWatchUpdate().catch((error) => {
|
const options = watchedSeconds > 0 ? { watchedSeconds } : undefined;
|
||||||
|
if (text.trim()) {
|
||||||
|
deps.recordImmersionSubtitleLine(text, start, end);
|
||||||
|
if (deps.hasSubtitleTimingTracker()) {
|
||||||
|
deps.recordSubtitleTiming(text, start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void deps.maybeRunAnilistPostWatchUpdate(options).catch((error) => {
|
||||||
deps.logError('AniList post-watch update failed unexpectedly', error);
|
deps.logError('AniList post-watch update failed unexpectedly', error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -223,6 +223,23 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
|
||||||
|
const watchedSeconds: unknown[] = [];
|
||||||
|
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||||
|
recordPlaybackPosition: () => {},
|
||||||
|
reportJellyfinRemoteProgress: () => {},
|
||||||
|
refreshDiscordPresence: () => {},
|
||||||
|
maybeRunAnilistPostWatchUpdate: async (options) => {
|
||||||
|
watchedSeconds.push(options?.watchedSeconds);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
timeHandler({ time: 850 });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(watchedSeconds, [850]);
|
||||||
|
});
|
||||||
|
|
||||||
test('time-pos handler logs post-watch update rejection without blocking later handlers', async () => {
|
test('time-pos handler logs post-watch update rejection without blocking later handlers', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { SubtitleData } from '../../types';
|
import type { SubtitleData } from '../../types';
|
||||||
|
|
||||||
|
type AnilistPostWatchRunOptions = {
|
||||||
|
watchedSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||||
setCurrentSubText: (text: string) => void;
|
setCurrentSubText: (text: string) => void;
|
||||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||||
@@ -105,7 +109,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
|||||||
recordPlaybackPosition: (time: number) => void;
|
recordPlaybackPosition: (time: number) => void;
|
||||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
maybeRunAnilistPostWatchUpdate?: () => Promise<void>;
|
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||||
logError?: (message: string, error: unknown) => void;
|
logError?: (message: string, error: unknown) => void;
|
||||||
onTimePosUpdate?: (time: number) => void;
|
onTimePosUpdate?: (time: number) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -113,7 +117,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
|||||||
deps.recordPlaybackPosition(time);
|
deps.recordPlaybackPosition(time);
|
||||||
deps.reportJellyfinRemoteProgress(false);
|
deps.reportJellyfinRemoteProgress(false);
|
||||||
deps.refreshDiscordPresence();
|
deps.refreshDiscordPresence();
|
||||||
void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => {
|
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
||||||
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
||||||
});
|
});
|
||||||
deps.onTimePosUpdate?.(time);
|
deps.onTimePosUpdate?.(time);
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
recordImmersionSubtitleLine: (text) => calls.push(`immersion:${text}`),
|
recordImmersionSubtitleLine: (text) => calls.push(`immersion:${text}`),
|
||||||
hasSubtitleTimingTracker: () => false,
|
hasSubtitleTimingTracker: () => false,
|
||||||
recordSubtitleTiming: () => calls.push('record-timing'),
|
recordSubtitleTiming: () => calls.push('record-timing'),
|
||||||
maybeRunAnilistPostWatchUpdate: async () => {
|
maybeRunAnilistPostWatchUpdate: async (options) => {
|
||||||
calls.push('post-watch');
|
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
|
||||||
},
|
},
|
||||||
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
||||||
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
||||||
@@ -74,6 +74,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||||
handlers.get('media-path-change')?.({ path: '' });
|
handlers.get('media-path-change')?.({ path: '' });
|
||||||
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
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('time-pos-change')?.({ time: 2.5 });
|
||||||
handlers.get('pause-change')?.({ paused: true });
|
handlers.get('pause-change')?.({ paused: true });
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
assert.ok(calls.includes('restore-mpv-sub'));
|
assert.ok(calls.includes('restore-mpv-sub'));
|
||||||
assert.ok(calls.includes('reset-guess-state'));
|
assert.ok(calls.includes('reset-guess-state'));
|
||||||
assert.ok(calls.includes('notify-title:Episode 1'));
|
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:normal'));
|
||||||
assert.ok(calls.includes('progress:force'));
|
assert.ok(calls.includes('progress:force'));
|
||||||
assert.ok(calls.includes('presence-refresh'));
|
assert.ok(calls.includes('presence-refresh'));
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
|
|
||||||
type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandlers>>[0];
|
type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandlers>>[0];
|
||||||
|
|
||||||
|
type AnilistPostWatchRunOptions = {
|
||||||
|
watchedSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function createBindMpvMainEventHandlersHandler(deps: {
|
export function createBindMpvMainEventHandlersHandler(deps: {
|
||||||
reportJellyfinRemoteStopped: () => void;
|
reportJellyfinRemoteStopped: () => void;
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
syncOverlayMpvSubtitleSuppression: () => void;
|
||||||
@@ -34,7 +38,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
||||||
hasSubtitleTimingTracker: () => boolean;
|
hasSubtitleTimingTracker: () => boolean;
|
||||||
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
||||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||||
|
|
||||||
setCurrentSubText: (text: string) => void;
|
setCurrentSubText: (text: string) => void;
|
||||||
@@ -103,7 +107,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
deps.recordImmersionSubtitleLine(text, start, end),
|
deps.recordImmersionSubtitleLine(text, start, end),
|
||||||
hasSubtitleTimingTracker: () => deps.hasSubtitleTimingTracker(),
|
hasSubtitleTimingTracker: () => deps.hasSubtitleTimingTracker(),
|
||||||
recordSubtitleTiming: (text, start, end) => deps.recordSubtitleTiming(text, start, end),
|
recordSubtitleTiming: (text, start, end) => deps.recordSubtitleTiming(text, start, end),
|
||||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
|
||||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||||
});
|
});
|
||||||
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
|
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
|
||||||
@@ -149,7 +153,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
|
||||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||||
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
|
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
|
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
|
||||||
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
|
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
|
||||||
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
|
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
|
||||||
|
recordMediaDuration: (durationSec: number) => calls.push(`immersion-duration:${durationSec}`),
|
||||||
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
|
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
|
||||||
},
|
},
|
||||||
subtitleTimingTracker: {
|
subtitleTimingTracker: {
|
||||||
@@ -40,6 +41,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
maybeRunAnilistPostWatchUpdate: async () => {
|
maybeRunAnilistPostWatchUpdate: async () => {
|
||||||
calls.push('anilist-post-watch');
|
calls.push('anilist-post-watch');
|
||||||
},
|
},
|
||||||
|
recordAnilistMediaDuration: (durationSec) => calls.push(`anilist-duration:${durationSec}`),
|
||||||
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
|
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
|
||||||
broadcastToOverlayWindows: (channel, payload) =>
|
broadcastToOverlayWindows: (channel, payload) =>
|
||||||
calls.push(`broadcast:${channel}:${String(payload)}`),
|
calls.push(`broadcast:${channel}:${String(payload)}`),
|
||||||
@@ -95,6 +97,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
deps.resetAnilistMediaGuessState();
|
deps.resetAnilistMediaGuessState();
|
||||||
deps.notifyImmersionTitleUpdate('title');
|
deps.notifyImmersionTitleUpdate('title');
|
||||||
deps.recordPlaybackPosition(10);
|
deps.recordPlaybackPosition(10);
|
||||||
|
deps.recordMediaDuration(1234);
|
||||||
deps.reportJellyfinRemoteProgress(true);
|
deps.reportJellyfinRemoteProgress(true);
|
||||||
deps.onFullscreenChange?.(true);
|
deps.onFullscreenChange?.(true);
|
||||||
deps.recordPauseState(true);
|
deps.recordPauseState(true);
|
||||||
@@ -118,6 +121,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
assert.ok(calls.includes('presence-refresh'));
|
assert.ok(calls.includes('presence-refresh'));
|
||||||
assert.ok(calls.includes('restore-mpv-sub'));
|
assert.ok(calls.includes('restore-mpv-sub'));
|
||||||
assert.ok(calls.includes('reset-sidebar-layout'));
|
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||||
|
assert.ok(calls.includes('immersion-duration:1234'));
|
||||||
|
assert.ok(calls.includes('anilist-duration:1234'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mpv main event main deps wire subtitle callbacks without suppression gate', () => {
|
test('mpv main event main deps wire subtitle callbacks without suppression gate', () => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { MergedToken, SubtitleData } from '../../types';
|
import type { MergedToken, SubtitleData } from '../../types';
|
||||||
|
|
||||||
|
type AnilistPostWatchRunOptions = {
|
||||||
|
watchedSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||||
appState: {
|
appState: {
|
||||||
initialArgs?: {
|
initialArgs?: {
|
||||||
@@ -42,7 +46,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
reportJellyfinRemoteStopped: () => void;
|
reportJellyfinRemoteStopped: () => void;
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
syncOverlayMpvSubtitleSuppression: () => void;
|
||||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||||
|
recordAnilistMediaDuration?: (durationSec: number) => void;
|
||||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||||
@@ -126,7 +131,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
||||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
|
||||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
||||||
|
deps.maybeRunAnilistPostWatchUpdate(options),
|
||||||
logSubtitleTimingError: (message: string, error: unknown) =>
|
logSubtitleTimingError: (message: string, error: unknown) =>
|
||||||
deps.logSubtitleTimingError(message, error),
|
deps.logSubtitleTimingError(message, error),
|
||||||
setCurrentSubText: (text: string) => {
|
setCurrentSubText: (text: string) => {
|
||||||
@@ -179,6 +185,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
recordMediaDuration: (durationSec: number) => {
|
recordMediaDuration: (durationSec: number) => {
|
||||||
deps.ensureImmersionTrackerInitialized();
|
deps.ensureImmersionTrackerInitialized();
|
||||||
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
|
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
|
||||||
|
deps.recordAnilistMediaDuration?.(durationSec);
|
||||||
},
|
},
|
||||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
getModalActive: () => true,
|
getModalActive: () => true,
|
||||||
getVisibleOverlayVisible: () => true,
|
getVisibleOverlayVisible: () => true,
|
||||||
getForceMousePassthrough: () => true,
|
getForceMousePassthrough: () => true,
|
||||||
|
getOverlayInteractionActive: () => true,
|
||||||
getWindowTracker: () => tracker,
|
getWindowTracker: () => tracker,
|
||||||
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
||||||
getWindowsOverlayProcessName: () => 'subminer',
|
getWindowsOverlayProcessName: () => 'subminer',
|
||||||
@@ -40,6 +41,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
assert.equal(deps.getModalActive(), true);
|
assert.equal(deps.getModalActive(), true);
|
||||||
assert.equal(deps.getVisibleOverlayVisible(), true);
|
assert.equal(deps.getVisibleOverlayVisible(), true);
|
||||||
assert.equal(deps.getForceMousePassthrough(), true);
|
assert.equal(deps.getForceMousePassthrough(), true);
|
||||||
|
assert.equal(deps.getOverlayInteractionActive?.(), true);
|
||||||
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
||||||
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
||||||
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
|||||||
getModalActive: () => deps.getModalActive(),
|
getModalActive: () => deps.getModalActive(),
|
||||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||||
|
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
|
||||||
getWindowTracker: () => deps.getWindowTracker(),
|
getWindowTracker: () => deps.getWindowTracker(),
|
||||||
getLastKnownWindowsForegroundProcessName: () =>
|
getLastKnownWindowsForegroundProcessName: () =>
|
||||||
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ type UpdaterLogger = {
|
|||||||
|
|
||||||
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
|
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
|
||||||
const logged: string[] = [];
|
const logged: string[] = [];
|
||||||
const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = {
|
const updater: ElectronAutoUpdaterLike & {
|
||||||
|
autoInstallOnAppQuit: boolean;
|
||||||
|
logger?: UpdaterLogger | null;
|
||||||
|
} = {
|
||||||
autoDownload: true,
|
autoDownload: true,
|
||||||
|
autoInstallOnAppQuit: true,
|
||||||
allowPrerelease: true,
|
allowPrerelease: true,
|
||||||
allowDowngrade: true,
|
allowDowngrade: true,
|
||||||
logger: null,
|
logger: null,
|
||||||
@@ -31,6 +35,7 @@ test('configureAutoUpdater disables eager update behavior and suppresses info lo
|
|||||||
configureAutoUpdater(updater, (message) => logged.push(message));
|
configureAutoUpdater(updater, (message) => logged.push(message));
|
||||||
|
|
||||||
assert.equal(updater.autoDownload, false);
|
assert.equal(updater.autoDownload, false);
|
||||||
|
assert.equal(updater.autoInstallOnAppQuit, false);
|
||||||
assert.equal(updater.allowPrerelease, false);
|
assert.equal(updater.allowPrerelease, false);
|
||||||
assert.equal(updater.allowDowngrade, false);
|
assert.equal(updater.allowDowngrade, false);
|
||||||
assert.ok(updater.logger);
|
assert.ok(updater.logger);
|
||||||
@@ -180,16 +185,18 @@ test('mac native updater is unsupported for ad-hoc signed app bundles', async ()
|
|||||||
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
|
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mac native updater is supported for Developer ID signed app bundles', async () => {
|
test('mac native updater supports Developer ID signed packaged app bundles', async () => {
|
||||||
|
const logged: string[] = [];
|
||||||
const supported = await isNativeUpdaterSupported({
|
const supported = await isNativeUpdaterSupported({
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
isPackaged: true,
|
isPackaged: true,
|
||||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||||
readCodeSignature: () =>
|
log: (message) => logged.push(message),
|
||||||
['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'),
|
readCodeSignature: async () => 'Authority=Developer ID Application: Kyle Yasuda (EPJ9P4RWTC)',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(supported, true);
|
assert.equal(supported, true);
|
||||||
|
assert.deepEqual(logged, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
|
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface ElectronUpdaterLoggerLike {
|
|||||||
|
|
||||||
export interface ElectronAutoUpdaterLike {
|
export interface ElectronAutoUpdaterLike {
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
|
autoInstallOnAppQuit?: boolean;
|
||||||
allowPrerelease: boolean;
|
allowPrerelease: boolean;
|
||||||
allowDowngrade: boolean;
|
allowDowngrade: boolean;
|
||||||
logger?: ElectronUpdaterLoggerLike | null;
|
logger?: ElectronUpdaterLoggerLike | null;
|
||||||
@@ -120,6 +121,8 @@ export function configureAutoUpdater(
|
|||||||
channel: UpdateChannel = 'stable',
|
channel: UpdateChannel = 'stable',
|
||||||
): ElectronAutoUpdaterLike {
|
): ElectronAutoUpdaterLike {
|
||||||
updater.autoDownload = false;
|
updater.autoDownload = false;
|
||||||
|
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
|
||||||
|
updater.autoInstallOnAppQuit = false;
|
||||||
updater.allowPrerelease = channel === 'prerelease';
|
updater.allowPrerelease = channel === 'prerelease';
|
||||||
updater.allowDowngrade = false;
|
updater.allowDowngrade = false;
|
||||||
updater.logger = {
|
updater.logger = {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createUpdateDialogPresenter, type ShowMessageBox } from './update-dialogs';
|
import {
|
||||||
|
createUpdateDialogPresenter,
|
||||||
|
showManualUpdateRequiredDialog,
|
||||||
|
type ShowMessageBox,
|
||||||
|
} from './update-dialogs';
|
||||||
|
|
||||||
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
|
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -35,3 +39,26 @@ test('update dialog presenter does not focus app before showing non-macOS dialog
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
|
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manual update required dialog explains that automatic install is unavailable', async () => {
|
||||||
|
let shown:
|
||||||
|
| {
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
buttons?: string[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const showMessageBox: ShowMessageBox = async (options) => {
|
||||||
|
shown = options;
|
||||||
|
return { response: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
await showManualUpdateRequiredDialog(showMessageBox, '0.15.0-beta.1');
|
||||||
|
|
||||||
|
assert.equal(shown?.type, 'warning');
|
||||||
|
assert.equal(shown?.message, 'Manual install required');
|
||||||
|
assert.match(shown?.detail ?? '', /SubMiner v0\.15\.0-beta\.1 is available/);
|
||||||
|
assert.match(shown?.detail ?? '', /cannot install app updates automatically/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
|||||||
showUpdateAvailableDialog(showFocusedMessageBox, version),
|
showUpdateAvailableDialog(showFocusedMessageBox, version),
|
||||||
showUpdateFailedDialog: (message: string) =>
|
showUpdateFailedDialog: (message: string) =>
|
||||||
showUpdateFailedDialog(showFocusedMessageBox, message),
|
showUpdateFailedDialog(showFocusedMessageBox, message),
|
||||||
|
showManualUpdateRequiredDialog: (version: string) =>
|
||||||
|
showManualUpdateRequiredDialog(showFocusedMessageBox, version),
|
||||||
showRestartDialog: () => showRestartDialog(showFocusedMessageBox),
|
showRestartDialog: () => showRestartDialog(showFocusedMessageBox),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,19 @@ export async function showRestartDialog(showMessageBox: ShowMessageBox): Promise
|
|||||||
return result.response === 0 ? 'restart' : 'later';
|
return result.response === 0 ? 'restart' : 'later';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function showManualUpdateRequiredDialog(
|
||||||
|
showMessageBox: ShowMessageBox,
|
||||||
|
version: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await showMessageBox({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'SubMiner Updates',
|
||||||
|
message: 'Manual install required',
|
||||||
|
detail: `SubMiner v${version} is available, but this build cannot install app updates automatically. Download and install the latest release, then reopen SubMiner.`,
|
||||||
|
buttons: ['Close'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function showUpdateFailedDialog(
|
export async function showUpdateFailedDialog(
|
||||||
showMessageBox: ShowMessageBox,
|
showMessageBox: ShowMessageBox,
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
|
|||||||
showUpdateFailedDialog: async (message) => {
|
showUpdateFailedDialog: async (message) => {
|
||||||
calls.push(`failed:${message}`);
|
calls.push(`failed:${message}`);
|
||||||
},
|
},
|
||||||
|
showManualUpdateRequiredDialog: async (version) => {
|
||||||
|
calls.push(`manual-install:${version}`);
|
||||||
|
},
|
||||||
downloadAppUpdate: async () => {
|
downloadAppUpdate: async () => {
|
||||||
calls.push('download');
|
calls.push('download');
|
||||||
},
|
},
|
||||||
@@ -115,7 +118,44 @@ test('manual update check reports available when no update asset was applied', a
|
|||||||
const result = await service.checkForUpdates({ source: 'manual' });
|
const result = await service.checkForUpdates({ source: 'manual' });
|
||||||
|
|
||||||
assert.equal(result.status, 'update-available');
|
assert.equal(result.status, 'update-available');
|
||||||
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable']);
|
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable', 'manual-install:0.15.0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual update check does not prompt restart when only launcher updates', async () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
|
||||||
|
fetchLatestStableRelease: async () => ({
|
||||||
|
tag_name: 'v0.15.0',
|
||||||
|
prerelease: false,
|
||||||
|
draft: false,
|
||||||
|
assets: [],
|
||||||
|
}),
|
||||||
|
showUpdateAvailableDialog: async (version) => {
|
||||||
|
calls.push(`available-dialog:${version}`);
|
||||||
|
return 'update';
|
||||||
|
},
|
||||||
|
updateLauncher: async (_launcherPath, channel) => {
|
||||||
|
calls.push(`launcher:${channel}`);
|
||||||
|
return { status: 'updated' };
|
||||||
|
},
|
||||||
|
showRestartDialog: async () => {
|
||||||
|
calls.push('restart-dialog');
|
||||||
|
return 'restart';
|
||||||
|
},
|
||||||
|
quitAndInstall: () => {
|
||||||
|
calls.push('quit-install');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const service = createUpdateService(deps);
|
||||||
|
|
||||||
|
const result = await service.checkForUpdates({ source: 'manual' });
|
||||||
|
|
||||||
|
assert.equal(result.status, 'update-available');
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'available-dialog:0.15.0',
|
||||||
|
'launcher:stable',
|
||||||
|
'manual-install:0.15.0',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('automatic update check skips inside configured interval', async () => {
|
test('automatic update check skips inside configured interval', async () => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface UpdateServiceDeps {
|
|||||||
showNoUpdateDialog: (version: string) => Promise<void>;
|
showNoUpdateDialog: (version: string) => Promise<void>;
|
||||||
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
|
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
|
||||||
showUpdateFailedDialog: (message: string) => Promise<void>;
|
showUpdateFailedDialog: (message: string) => Promise<void>;
|
||||||
|
showManualUpdateRequiredDialog: (version: string) => Promise<void>;
|
||||||
downloadAppUpdate: () => Promise<void>;
|
downloadAppUpdate: () => Promise<void>;
|
||||||
showRestartDialog: () => Promise<'restart' | 'later'>;
|
showRestartDialog: () => Promise<'restart' | 'later'>;
|
||||||
quitAndInstall: () => void | Promise<void>;
|
quitAndInstall: () => void | Promise<void>;
|
||||||
@@ -158,8 +159,9 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
|||||||
return { status: 'update-available', version: latest.version };
|
return { status: 'update-available', version: latest.version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canInstallAppUpdate = appUpdate.available && appUpdate.canUpdate !== false;
|
||||||
let appUpdateApplied = false;
|
let appUpdateApplied = false;
|
||||||
if (appUpdate.available && appUpdate.canUpdate !== false) {
|
if (canInstallAppUpdate) {
|
||||||
await deps.downloadAppUpdate();
|
await deps.downloadAppUpdate();
|
||||||
appUpdateApplied = true;
|
appUpdateApplied = true;
|
||||||
}
|
}
|
||||||
@@ -168,8 +170,8 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
|||||||
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const launcherUpdateApplied = launcherResult.status === 'updated';
|
if (!appUpdateApplied) {
|
||||||
if (!appUpdateApplied && !launcherUpdateApplied) {
|
await deps.showManualUpdateRequiredDialog(latest.version);
|
||||||
return { status: 'update-available', version: latest.version };
|
return { status: 'update-available', version: latest.version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,12 @@ test('release packaging stages generated launcher as an app resource', () => {
|
|||||||
assert.match(packageJson.scripts['build:launcher'] ?? '', /--banner='#!\/usr\/bin\/env bun'/);
|
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', () => {
|
test('config example generation runs directly from source without unrelated bundle prerequisites', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
packageJson.scripts['generate:config-example'],
|
packageJson.scripts['generate:config-example'],
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ResolvedControllerConfig, ResolvedControllerProfileConfig } from '../types';
|
||||||
|
|
||||||
|
export function getControllerProfile(
|
||||||
|
config: ResolvedControllerConfig | null,
|
||||||
|
gamepadId: string | null | undefined,
|
||||||
|
): ResolvedControllerProfileConfig | null {
|
||||||
|
if (!config || !gamepadId) return null;
|
||||||
|
return config.profiles[gamepadId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveControllerConfigForGamepad(
|
||||||
|
config: ResolvedControllerConfig,
|
||||||
|
gamepadId: string | null | undefined,
|
||||||
|
): ResolvedControllerConfig {
|
||||||
|
const profile = getControllerProfile(config, gamepadId);
|
||||||
|
if (!profile) return config;
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
buttonIndices: profile.buttonIndices,
|
||||||
|
bindings: profile.bindings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -67,5 +67,5 @@ export function createControllerStatusIndicator(
|
|||||||
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
|
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { update };
|
return { show, update };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ function createControllerConfig(
|
|||||||
...(buttonIndexOverrides ?? {}),
|
...(buttonIndexOverrides ?? {}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
...restOverrides,
|
...restOverrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -449,6 +450,60 @@ test('gamepad controller maps left stick horizontal movement to token selection
|
|||||||
assert.deepEqual(calls, [1, 1, -1]);
|
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', () => {
|
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const scrollCalls: number[] = [];
|
const scrollCalls: number[] = [];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ResolvedControllerConfig,
|
ResolvedControllerConfig,
|
||||||
ResolvedControllerDiscreteBinding,
|
ResolvedControllerDiscreteBinding,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||||
|
|
||||||
type ControllerButtonState = {
|
type ControllerButtonState = {
|
||||||
value: number;
|
value: number;
|
||||||
@@ -410,87 +411,101 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
|||||||
resetHeldAction(jumpHold);
|
resetHeldAction(jumpHold);
|
||||||
}
|
}
|
||||||
|
|
||||||
let interactionAllowed =
|
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
|
||||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
|
||||||
if (config.enabled) {
|
if (activeConfig.enabled) {
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'toggleKeyboardOnlyMode',
|
'toggleKeyboardOnlyMode',
|
||||||
config.bindings.toggleKeyboardOnlyMode,
|
activeConfig.bindings.toggleKeyboardOnlyMode,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.toggleKeyboardMode,
|
options.toggleKeyboardMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interactionAllowed =
|
const interactionAllowed =
|
||||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
activeConfig.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
||||||
|
|
||||||
if (!interactionAllowed) {
|
if (!interactionAllowed) {
|
||||||
syncBlockedInteractionState(activeGamepad, config, now);
|
syncBlockedInteractionState(activeGamepad, activeConfig, now);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'toggleLookup',
|
'toggleLookup',
|
||||||
config.bindings.toggleLookup,
|
activeConfig.bindings.toggleLookup,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.toggleLookup,
|
options.toggleLookup,
|
||||||
);
|
);
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'closeLookup',
|
'closeLookup',
|
||||||
config.bindings.closeLookup,
|
activeConfig.bindings.closeLookup,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.closeLookup,
|
options.closeLookup,
|
||||||
);
|
);
|
||||||
handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard);
|
handleActionEdge(
|
||||||
handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv);
|
'mineCard',
|
||||||
|
activeConfig.bindings.mineCard,
|
||||||
|
activeGamepad,
|
||||||
|
activeConfig,
|
||||||
|
options.mineCard,
|
||||||
|
);
|
||||||
|
handleActionEdge(
|
||||||
|
'quitMpv',
|
||||||
|
activeConfig.bindings.quitMpv,
|
||||||
|
activeGamepad,
|
||||||
|
activeConfig,
|
||||||
|
options.quitMpv,
|
||||||
|
);
|
||||||
|
|
||||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
|
||||||
|
|
||||||
if (options.getLookupWindowOpen()) {
|
if (options.getLookupWindowOpen()) {
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'previousAudio',
|
'previousAudio',
|
||||||
config.bindings.previousAudio,
|
activeConfig.bindings.previousAudio,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.previousAudio,
|
options.previousAudio,
|
||||||
);
|
);
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'nextAudio',
|
'nextAudio',
|
||||||
config.bindings.nextAudio,
|
activeConfig.bindings.nextAudio,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.nextAudio,
|
options.nextAudio,
|
||||||
);
|
);
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'playCurrentAudio',
|
'playCurrentAudio',
|
||||||
config.bindings.playCurrentAudio,
|
activeConfig.bindings.playCurrentAudio,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.playCurrentAudio,
|
options.playCurrentAudio,
|
||||||
);
|
);
|
||||||
|
|
||||||
const primaryScroll = resolveAxisBindingValue(
|
const primaryScroll = resolveAxisBindingValue(
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config.bindings.leftStickVertical,
|
activeConfig.bindings.leftStickVertical,
|
||||||
config.triggerDeadzone,
|
activeConfig.triggerDeadzone,
|
||||||
config.stickDeadzone,
|
activeConfig.stickDeadzone,
|
||||||
);
|
);
|
||||||
if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) {
|
if (elapsedMs > 0 && Math.abs(primaryScroll) >= activeConfig.stickDeadzone) {
|
||||||
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
options.scrollPopup(
|
||||||
|
(primaryScroll * activeConfig.scrollPixelsPerSecond * elapsedMs) / 1000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleJumpAxis(
|
handleJumpAxis(
|
||||||
resolveAxisBindingValue(
|
resolveAxisBindingValue(
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config.bindings.rightStickVertical,
|
activeConfig.bindings.rightStickVertical,
|
||||||
config.triggerDeadzone,
|
activeConfig.triggerDeadzone,
|
||||||
activationThreshold,
|
activationThreshold,
|
||||||
),
|
),
|
||||||
now,
|
now,
|
||||||
config,
|
activeConfig,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
resetHeldAction(jumpHold);
|
resetHeldAction(jumpHold);
|
||||||
@@ -498,21 +513,21 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
|||||||
|
|
||||||
handleActionEdge(
|
handleActionEdge(
|
||||||
'toggleMpvPause',
|
'toggleMpvPause',
|
||||||
config.bindings.toggleMpvPause,
|
activeConfig.bindings.toggleMpvPause,
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config,
|
activeConfig,
|
||||||
options.toggleMpvPause,
|
options.toggleMpvPause,
|
||||||
);
|
);
|
||||||
|
|
||||||
handleSelectionAxis(
|
handleSelectionAxis(
|
||||||
resolveAxisBindingValue(
|
resolveAxisBindingValue(
|
||||||
activeGamepad,
|
activeGamepad,
|
||||||
config.bindings.leftStickHorizontal,
|
activeConfig.bindings.leftStickHorizontal,
|
||||||
config.triggerDeadzone,
|
activeConfig.triggerDeadzone,
|
||||||
activationThreshold,
|
activationThreshold,
|
||||||
),
|
),
|
||||||
now,
|
now,
|
||||||
config,
|
activeConfig,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -987,7 +987,7 @@ test('keyboard mode: configured controller select binding opens locally without
|
|||||||
|
|
||||||
assert.equal(openControllerSelectCount(), 1);
|
assert.equal(openControllerSelectCount(), 1);
|
||||||
assert.deepEqual(testGlobals.sessionActions, []);
|
assert.deepEqual(testGlobals.sessionActions, []);
|
||||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-select']);
|
assert.deepEqual(testGlobals.openedModalNotifications, []);
|
||||||
} finally {
|
} finally {
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
@@ -1017,7 +1017,7 @@ test('keyboard mode: configured controller debug binding opens locally without d
|
|||||||
|
|
||||||
assert.equal(openControllerDebugCount(), 1);
|
assert.equal(openControllerDebugCount(), 1);
|
||||||
assert.deepEqual(testGlobals.sessionActions, []);
|
assert.deepEqual(testGlobals.sessionActions, []);
|
||||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
assert.deepEqual(testGlobals.openedModalNotifications, []);
|
||||||
} finally {
|
} finally {
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
@@ -1049,7 +1049,7 @@ test('keyboard mode: configured controller debug binding is not swallowed while
|
|||||||
|
|
||||||
assert.equal(openControllerDebugCount(), 1);
|
assert.equal(openControllerDebugCount(), 1);
|
||||||
assert.deepEqual(testGlobals.sessionActions, []);
|
assert.deepEqual(testGlobals.sessionActions, []);
|
||||||
assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']);
|
assert.deepEqual(testGlobals.openedModalNotifications, []);
|
||||||
} finally {
|
} finally {
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,13 +203,11 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
|
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
|
||||||
options.openControllerSelectModal?.();
|
options.openControllerSelectModal?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') {
|
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') {
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
|
||||||
options.openControllerDebugModal?.();
|
options.openControllerDebugModal?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,3 +144,69 @@ test('controller config form renders rows and dispatches learn clear reset callb
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('controller config form starts learn from badge or edit and resets from row button', () => {
|
||||||
|
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createFakeElement(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const container = createFakeElement();
|
||||||
|
const form = createControllerConfigForm({
|
||||||
|
container: container as never,
|
||||||
|
getBindings: () =>
|
||||||
|
({
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||||
|
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||||
|
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||||
|
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||||
|
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||||
|
previousAudio: { kind: 'none' },
|
||||||
|
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||||
|
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||||
|
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||||
|
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||||
|
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||||
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
|
}) as never,
|
||||||
|
getLearningActionId: () => null,
|
||||||
|
getDpadLearningActionId: () => null,
|
||||||
|
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
|
||||||
|
onClear: (actionId) => calls.push(`clear:${actionId}`),
|
||||||
|
onReset: (actionId) => calls.push(`reset:${actionId}`),
|
||||||
|
onDpadLearn: (actionId) => calls.push(`dpadLearn:${actionId}`),
|
||||||
|
onDpadClear: (actionId) => calls.push(`dpadClear:${actionId}`),
|
||||||
|
onDpadReset: (actionId) => calls.push(`dpadReset:${actionId}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
form.render();
|
||||||
|
|
||||||
|
const firstRow = container.children[1];
|
||||||
|
const right = firstRow.children[1];
|
||||||
|
const badge = right.children[0];
|
||||||
|
const resetButton = right.children[1];
|
||||||
|
const editButton = right.children[2];
|
||||||
|
|
||||||
|
badge.dispatch('click');
|
||||||
|
resetButton.dispatch('click');
|
||||||
|
editButton.dispatch('click');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'learn:toggleLookup:discrete',
|
||||||
|
'reset:toggleLookup',
|
||||||
|
'learn:toggleLookup:discrete',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (previousDocumentDescriptor) {
|
||||||
|
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(globalThis, 'document');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -278,6 +278,17 @@ export function createControllerConfigForm(options: {
|
|||||||
formatFriendlyBindingLabel(binding),
|
formatFriendlyBindingLabel(binding),
|
||||||
binding.kind === 'none',
|
binding.kind === 'none',
|
||||||
isExpanded,
|
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', () => {
|
row.addEventListener('click', () => {
|
||||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||||
@@ -321,6 +332,17 @@ export function createControllerConfigForm(options: {
|
|||||||
formatFriendlyStickLabel(binding),
|
formatFriendlyStickLabel(binding),
|
||||||
binding.kind === 'none',
|
binding.kind === 'none',
|
||||||
isExpanded,
|
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', () => {
|
row.addEventListener('click', () => {
|
||||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||||
@@ -366,6 +388,17 @@ export function createControllerConfigForm(options: {
|
|||||||
badgeText,
|
badgeText,
|
||||||
dpadFallback === 'none',
|
dpadFallback === 'none',
|
||||||
isExpanded,
|
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', () => {
|
row.addEventListener('click', () => {
|
||||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||||
@@ -400,6 +433,10 @@ export function createControllerConfigForm(options: {
|
|||||||
badgeText: string,
|
badgeText: string,
|
||||||
isDisabled: boolean,
|
isDisabled: boolean,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
|
editLabel: string,
|
||||||
|
onEdit: (e: Event) => void,
|
||||||
|
resetLabel: string,
|
||||||
|
onReset: (e: Event) => void,
|
||||||
): HTMLDivElement {
|
): HTMLDivElement {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'controller-config-row';
|
row.className = 'controller-config-row';
|
||||||
@@ -412,16 +449,33 @@ export function createControllerConfigForm(options: {
|
|||||||
const right = document.createElement('div');
|
const right = document.createElement('div');
|
||||||
right.className = 'controller-config-right';
|
right.className = 'controller-config-right';
|
||||||
|
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('button');
|
||||||
|
badge.type = 'button';
|
||||||
badge.className = 'controller-config-badge';
|
badge.className = 'controller-config-badge';
|
||||||
if (isDisabled) badge.classList.add('disabled');
|
if (isDisabled) badge.classList.add('disabled');
|
||||||
|
badge.setAttribute('aria-label', editLabel);
|
||||||
|
badge.title = editLabel;
|
||||||
badge.textContent = badgeText;
|
badge.textContent = badgeText;
|
||||||
|
badge.addEventListener('click', onEdit);
|
||||||
|
|
||||||
const editIcon = document.createElement('span');
|
const resetIcon = document.createElement('button');
|
||||||
|
resetIcon.type = 'button';
|
||||||
|
resetIcon.className = 'controller-config-reset-icon';
|
||||||
|
resetIcon.setAttribute('aria-label', resetLabel);
|
||||||
|
resetIcon.title = resetLabel;
|
||||||
|
resetIcon.textContent = '\u21ba';
|
||||||
|
resetIcon.addEventListener('click', onReset);
|
||||||
|
|
||||||
|
const editIcon = document.createElement('button');
|
||||||
|
editIcon.type = 'button';
|
||||||
editIcon.className = 'controller-config-edit-icon';
|
editIcon.className = 'controller-config-edit-icon';
|
||||||
|
editIcon.setAttribute('aria-label', editLabel);
|
||||||
|
editIcon.title = editLabel;
|
||||||
editIcon.textContent = '\u270E';
|
editIcon.textContent = '\u270E';
|
||||||
|
editIcon.addEventListener('click', onEdit);
|
||||||
|
|
||||||
right.appendChild(badge);
|
right.appendChild(badge);
|
||||||
|
right.appendChild(resetIcon);
|
||||||
right.appendChild(editIcon);
|
right.appendChild(editIcon);
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(right);
|
row.appendChild(right);
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ test('controller debug modal renders active controller axes, buttons, and config
|
|||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -99,6 +100,7 @@ test('controller debug modal renders active controller axes, buttons, and config
|
|||||||
const modal = createControllerDebugModal(ctx as never, {
|
const modal = createControllerDebugModal(ctx as never, {
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.openControllerDebugModal();
|
modal.openControllerDebugModal();
|
||||||
@@ -189,6 +191,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
|||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -217,6 +220,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
|||||||
const modal = createControllerDebugModal(ctx as never, {
|
const modal = createControllerDebugModal(ctx as never, {
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.wireDomEvents();
|
modal.wireDomEvents();
|
||||||
@@ -244,3 +248,97 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('controller debug modal stays closed and notifies when controller support is disabled', () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
let disabledNotices = 0;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.controllerConfig = {
|
||||||
|
enabled: false,
|
||||||
|
preferredGamepadId: '',
|
||||||
|
preferredGamepadLabel: '',
|
||||||
|
smoothScroll: true,
|
||||||
|
scrollPixelsPerSecond: 900,
|
||||||
|
horizontalJumpPixels: 160,
|
||||||
|
stickDeadzone: 0.2,
|
||||||
|
triggerInputMode: 'auto',
|
||||||
|
triggerDeadzone: 0.5,
|
||||||
|
repeatDelayMs: 320,
|
||||||
|
repeatIntervalMs: 120,
|
||||||
|
buttonIndices: {
|
||||||
|
select: 6,
|
||||||
|
buttonSouth: 0,
|
||||||
|
buttonEast: 1,
|
||||||
|
buttonWest: 2,
|
||||||
|
buttonNorth: 3,
|
||||||
|
leftShoulder: 4,
|
||||||
|
rightShoulder: 5,
|
||||||
|
leftStickPress: 9,
|
||||||
|
rightStickPress: 10,
|
||||||
|
leftTrigger: 6,
|
||||||
|
rightTrigger: 7,
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||||
|
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||||
|
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||||
|
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||||
|
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||||
|
previousAudio: { kind: 'none' },
|
||||||
|
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||||
|
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||||
|
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||||
|
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||||
|
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||||
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
|
},
|
||||||
|
profiles: {},
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList() },
|
||||||
|
controllerDebugModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
},
|
||||||
|
controllerDebugClose: { addEventListener: () => {} },
|
||||||
|
controllerDebugCopy: { addEventListener: () => {} },
|
||||||
|
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||||
|
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||||
|
controllerDebugSummary: { textContent: '' },
|
||||||
|
controllerDebugAxes: { textContent: '' },
|
||||||
|
controllerDebugButtons: { textContent: '' },
|
||||||
|
controllerDebugButtonIndices: { textContent: '' },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
const modal = createControllerDebugModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {
|
||||||
|
disabledNotices += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(modal.openControllerDebugModal(), false);
|
||||||
|
|
||||||
|
assert.equal(state.controllerDebugModalOpen, false);
|
||||||
|
assert.equal(ctx.dom.controllerDebugModal.classList.contains('hidden'), true);
|
||||||
|
assert.equal(disabledNotices, 1);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||||
|
|
||||||
function formatAxes(values: number[]): string {
|
function formatAxes(values: number[]): string {
|
||||||
if (values.length === 0) return 'No controller axes available.';
|
if (values.length === 0) return 'No controller axes available.';
|
||||||
@@ -50,6 +51,7 @@ export function createControllerDebugModal(
|
|||||||
options: {
|
options: {
|
||||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
syncSettingsModalSubtitleSuppression: () => void;
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
|
notifyControllerDisabled: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -114,8 +116,11 @@ export function createControllerDebugModal(
|
|||||||
: 'Connect a controller and press any button to populate raw input values.';
|
: 'Connect a controller and press any button to populate raw input values.';
|
||||||
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
||||||
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
|
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
|
||||||
|
const activeConfig = ctx.state.controllerConfig
|
||||||
|
? resolveControllerConfigForGamepad(ctx.state.controllerConfig, ctx.state.activeGamepadId)
|
||||||
|
: null;
|
||||||
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
|
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
|
||||||
ctx.state.controllerConfig?.buttonIndices ?? null,
|
activeConfig?.buttonIndices ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +141,11 @@ export function createControllerDebugModal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openControllerDebugModal(): void {
|
function openControllerDebugModal(): boolean {
|
||||||
|
if (ctx.state.controllerConfig?.enabled !== true) {
|
||||||
|
options.notifyControllerDisabled();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
ctx.state.controllerDebugModalOpen = true;
|
ctx.state.controllerDebugModalOpen = true;
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
@@ -144,6 +153,7 @@ export function createControllerDebugModal(
|
|||||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
||||||
hideToast();
|
hideToast();
|
||||||
render();
|
render();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeControllerDebugModal(): void {
|
function closeControllerDebugModal(): void {
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ function buildContext() {
|
|||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
};
|
};
|
||||||
state.connectedGamepads = [
|
state.connectedGamepads = [
|
||||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||||
@@ -201,6 +202,7 @@ test('controller select modal saves preferred controller from dropdown selection
|
|||||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.wireDomEvents();
|
modal.wireDomEvents();
|
||||||
@@ -246,6 +248,7 @@ test('controller select modal learn mode captures fresh button input and persist
|
|||||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.wireDomEvents();
|
modal.wireDomEvents();
|
||||||
@@ -276,6 +279,192 @@ test('controller select modal learn mode captures fresh button input and persist
|
|||||||
|
|
||||||
await Promise.resolve();
|
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), {
|
assert.deepEqual(saved.at(-1), {
|
||||||
bindings: {
|
bindings: {
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
@@ -290,6 +479,99 @@ test('controller select modal learn mode captures fresh button input and persist
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('controller select modal edit control starts learn mode and persists binding', async () => {
|
||||||
|
const domHandle = installFakeDom();
|
||||||
|
const saved: unknown[] = [];
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerConfig: async (update: unknown) => {
|
||||||
|
saved.push(update);
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { state, dom } = buildContext();
|
||||||
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.wireDomEvents();
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
|
||||||
|
const firstRow = dom.controllerConfigList.children[1];
|
||||||
|
const right = firstRow.children[1];
|
||||||
|
const editButton = right.children[2];
|
||||||
|
editButton.dispatch('click');
|
||||||
|
|
||||||
|
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
||||||
|
value: 0,
|
||||||
|
pressed: false,
|
||||||
|
touched: false,
|
||||||
|
}));
|
||||||
|
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||||
|
modal.updateDevices();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(saved.at(-1), {
|
||||||
|
profiles: {
|
||||||
|
'pad-1': {
|
||||||
|
bindings: {
|
||||||
|
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
domHandle.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal stays closed and notifies when controller support is disabled', async () => {
|
||||||
|
const domHandle = installFakeDom();
|
||||||
|
let disabledNotices = 0;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerConfig: async () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { state, dom } = buildContext();
|
||||||
|
if (state.controllerConfig) state.controllerConfig.enabled = false;
|
||||||
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {
|
||||||
|
disabledNotices += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(modal.openControllerSelectModal(), false);
|
||||||
|
|
||||||
|
assert.equal(state.controllerSelectModalOpen, false);
|
||||||
|
assert.equal(dom.controllerSelectModal.classList.contains('hidden'), true);
|
||||||
|
assert.equal(disabledNotices, 1);
|
||||||
|
} finally {
|
||||||
|
domHandle.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
|
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
|
||||||
const domHandle = installFakeDom();
|
const domHandle = installFakeDom();
|
||||||
|
|
||||||
@@ -315,6 +597,7 @@ test('controller select modal uses unique picker values for duplicate controller
|
|||||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
notifyControllerDisabled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.wireDomEvents();
|
modal.wireDomEvents();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||||
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
||||||
import {
|
import {
|
||||||
createControllerConfigForm,
|
createControllerConfigForm,
|
||||||
@@ -24,6 +25,7 @@ export function createControllerSelectModal(
|
|||||||
options: {
|
options: {
|
||||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
syncSettingsModalSubtitleSuppression: () => void;
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
|
notifyControllerDisabled: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let selectedControllerKey: string | null = null;
|
let selectedControllerKey: string | null = null;
|
||||||
@@ -38,10 +40,24 @@ export function createControllerSelectModal(
|
|||||||
let dpadLearningActionId: ControllerBindingKey | null = null;
|
let dpadLearningActionId: ControllerBindingKey | null = null;
|
||||||
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | 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({
|
const controllerConfigForm = createControllerConfigForm({
|
||||||
container: ctx.dom.controllerConfigList,
|
container: ctx.dom.controllerConfigList,
|
||||||
getBindings: () =>
|
getBindings: () =>
|
||||||
ctx.state.controllerConfig?.bindings ?? {
|
getSelectedControllerConfig()?.bindings ?? {
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||||
@@ -67,7 +83,7 @@ export function createControllerSelectModal(
|
|||||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
||||||
});
|
});
|
||||||
const currentBinding = config?.bindings[actionId];
|
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
||||||
const currentDpadFallback =
|
const currentDpadFallback =
|
||||||
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
|
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
|
||||||
? currentBinding.dpadFallback
|
? currentBinding.dpadFallback
|
||||||
@@ -216,6 +232,51 @@ export function createControllerSelectModal(
|
|||||||
...update.bindings,
|
...update.bindings,
|
||||||
} as typeof ctx.state.controllerConfig.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(
|
async function saveBinding(
|
||||||
@@ -224,11 +285,7 @@ export function createControllerSelectModal(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const definition = getControllerBindingDefinition(actionId);
|
const definition = getControllerBindingDefinition(actionId);
|
||||||
try {
|
try {
|
||||||
await saveControllerConfig({
|
await saveControllerConfig(buildBindingConfigUpdate(actionId, binding));
|
||||||
bindings: {
|
|
||||||
[actionId]: binding,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
learningActionId = null;
|
learningActionId = null;
|
||||||
dpadLearningActionId = null;
|
dpadLearningActionId = null;
|
||||||
bindingCapture = null;
|
bindingCapture = null;
|
||||||
@@ -245,11 +302,11 @@ export function createControllerSelectModal(
|
|||||||
dpadFallback: import('../../types').ControllerDpadFallback,
|
dpadFallback: import('../../types').ControllerDpadFallback,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const definition = getControllerBindingDefinition(actionId);
|
const definition = getControllerBindingDefinition(actionId);
|
||||||
const currentBinding = ctx.state.controllerConfig?.bindings[actionId];
|
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
||||||
if (!currentBinding || currentBinding.kind !== 'axis') return;
|
if (!currentBinding || currentBinding.kind !== 'axis') return;
|
||||||
const updated = { ...currentBinding, dpadFallback };
|
const updated = { ...currentBinding, dpadFallback };
|
||||||
try {
|
try {
|
||||||
await saveControllerConfig({ bindings: { [actionId]: updated } });
|
await saveControllerConfig(buildBindingConfigUpdate(actionId, updated));
|
||||||
dpadLearningActionId = null;
|
dpadLearningActionId = null;
|
||||||
bindingCapture = null;
|
bindingCapture = null;
|
||||||
controllerConfigForm.render();
|
controllerConfigForm.render();
|
||||||
@@ -330,7 +387,11 @@ export function createControllerSelectModal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openControllerSelectModal(): void {
|
function openControllerSelectModal(): boolean {
|
||||||
|
if (ctx.state.controllerConfig?.enabled !== true) {
|
||||||
|
options.notifyControllerDisabled();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
ctx.state.controllerSelectModalOpen = true;
|
ctx.state.controllerSelectModalOpen = true;
|
||||||
syncSelectedIndexToCurrentController();
|
syncSelectedIndexToCurrentController();
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
@@ -346,6 +407,7 @@ export function createControllerSelectModal(
|
|||||||
} else {
|
} else {
|
||||||
setStatus('Choose a controller or click Learn to remap an action.');
|
setStatus('Choose a controller or click Learn to remap an action.');
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeControllerSelectModal(): void {
|
function closeControllerSelectModal(): void {
|
||||||
@@ -387,6 +449,7 @@ export function createControllerSelectModal(
|
|||||||
);
|
);
|
||||||
syncSelectedControllerId();
|
syncSelectedControllerId();
|
||||||
renderPicker();
|
renderPicker();
|
||||||
|
controllerConfigForm.render();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -400,6 +463,7 @@ export function createControllerSelectModal(
|
|||||||
);
|
);
|
||||||
syncSelectedControllerId();
|
syncSelectedControllerId();
|
||||||
renderPicker();
|
renderPicker();
|
||||||
|
controllerConfigForm.render();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -429,6 +493,7 @@ export function createControllerSelectModal(
|
|||||||
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
||||||
syncSelectedControllerId();
|
syncSelectedControllerId();
|
||||||
renderPicker();
|
renderPicker();
|
||||||
|
controllerConfigForm.render();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,10 +128,12 @@ const subsyncModal = createSubsyncModal(ctx, {
|
|||||||
const controllerSelectModal = createControllerSelectModal(ctx, {
|
const controllerSelectModal = createControllerSelectModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
notifyControllerDisabled: showControllerDisabledNotice,
|
||||||
});
|
});
|
||||||
const controllerDebugModal = createControllerDebugModal(ctx, {
|
const controllerDebugModal = createControllerDebugModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
notifyControllerDisabled: showControllerDisabledNotice,
|
||||||
});
|
});
|
||||||
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
||||||
const sessionHelpModal = createSessionHelpModal(ctx, {
|
const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||||
@@ -183,10 +185,14 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||||
openControllerSelectModal: () => {
|
openControllerSelectModal: () => {
|
||||||
controllerSelectModal.openControllerSelectModal();
|
if (controllerSelectModal.openControllerSelectModal()) {
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
openControllerDebugModal: () => {
|
openControllerDebugModal: () => {
|
||||||
controllerDebugModal.openControllerDebugModal();
|
if (controllerDebugModal.openControllerDebugModal()) {
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
appendClipboardVideoToQueue: () => {
|
appendClipboardVideoToQueue: () => {
|
||||||
void window.electronAPI.appendClipboardVideoToQueue();
|
void window.electronAPI.appendClipboardVideoToQueue();
|
||||||
@@ -291,6 +297,12 @@ function applyControllerSnapshot(snapshot: {
|
|||||||
controllerDebugModal.updateSnapshot();
|
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 {
|
function emitControllerPopupScroll(deltaPixels: number): void {
|
||||||
if (deltaPixels === 0) return;
|
if (deltaPixels === 0) return;
|
||||||
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
||||||
@@ -311,7 +323,7 @@ function startControllerPolling(): void {
|
|||||||
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
||||||
getConfig: () =>
|
getConfig: () =>
|
||||||
ctx.state.controllerConfig ?? {
|
ctx.state.controllerConfig ?? {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
preferredGamepadId: '',
|
preferredGamepadId: '',
|
||||||
preferredGamepadLabel: '',
|
preferredGamepadLabel: '',
|
||||||
smoothScroll: true,
|
smoothScroll: true,
|
||||||
@@ -350,6 +362,7 @@ function startControllerPolling(): void {
|
|||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||||
},
|
},
|
||||||
|
profiles: {},
|
||||||
},
|
},
|
||||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||||
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
||||||
@@ -461,14 +474,16 @@ function registerModalOpenHandlers(): void {
|
|||||||
});
|
});
|
||||||
window.electronAPI.onOpenControllerSelect(() => {
|
window.electronAPI.onOpenControllerSelect(() => {
|
||||||
runGuarded('controller-select:open', () => {
|
runGuarded('controller-select:open', () => {
|
||||||
controllerSelectModal.openControllerSelectModal();
|
if (controllerSelectModal.openControllerSelectModal()) {
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
window.electronAPI.onOpenControllerDebug(() => {
|
window.electronAPI.onOpenControllerDebug(() => {
|
||||||
runGuarded('controller-debug:open', () => {
|
runGuarded('controller-debug:open', () => {
|
||||||
controllerDebugModal.openControllerDebugModal();
|
if (controllerDebugModal.openControllerDebugModal()) {
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
window.electronAPI.onOpenJimaku(() => {
|
window.electronAPI.onOpenJimaku(() => {
|
||||||
|
|||||||
+15
-1
@@ -1694,14 +1694,17 @@ iframe[id^='yomitan-popup'],
|
|||||||
}
|
}
|
||||||
|
|
||||||
.controller-config-badge {
|
.controller-config-badge {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
background: rgba(138, 173, 244, 0.12);
|
background: rgba(138, 173, 244, 0.12);
|
||||||
color: var(--ctp-blue);
|
color: var(--ctp-blue);
|
||||||
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1710,12 +1713,23 @@ iframe[id^='yomitan-popup'],
|
|||||||
color: var(--ctp-overlay0);
|
color: var(--ctp-overlay0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controller-config-reset-icon,
|
||||||
.controller-config-edit-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;
|
font-size: 14px;
|
||||||
color: var(--ctp-overlay0);
|
color: var(--ctp-overlay0);
|
||||||
|
cursor: pointer;
|
||||||
transition: color 120ms ease;
|
transition: color 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controller-config-row:hover .controller-config-reset-icon,
|
||||||
.controller-config-row:hover .controller-config-edit-icon {
|
.controller-config-row:hover .controller-config-edit-icon {
|
||||||
color: var(--ctp-overlay2);
|
color: var(--ctp-overlay2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type { SessionActionId, SessionActionPayload } from '../../types/session-
|
|||||||
import type { SubtitlePosition } from '../../types/subtitle';
|
import type { SubtitlePosition } from '../../types/subtitle';
|
||||||
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
|
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
|
||||||
|
|
||||||
|
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
|
|
||||||
const SESSION_ACTION_IDS: SessionActionId[] = [
|
const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||||
'toggleStatsOverlay',
|
'toggleStatsOverlay',
|
||||||
'toggleVisibleOverlay',
|
'toggleVisibleOverlay',
|
||||||
@@ -166,6 +168,67 @@ function parseAxisBinding(value: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseControllerButtonIndices(
|
||||||
|
value: unknown,
|
||||||
|
): ControllerConfigUpdate['buttonIndices'] | null {
|
||||||
|
if (!isObject(value)) return null;
|
||||||
|
const buttonIndices: NonNullable<ControllerConfigUpdate['buttonIndices']> = {};
|
||||||
|
const keys = [
|
||||||
|
'select',
|
||||||
|
'buttonSouth',
|
||||||
|
'buttonEast',
|
||||||
|
'buttonNorth',
|
||||||
|
'buttonWest',
|
||||||
|
'leftShoulder',
|
||||||
|
'rightShoulder',
|
||||||
|
'leftStickPress',
|
||||||
|
'rightStickPress',
|
||||||
|
'leftTrigger',
|
||||||
|
'rightTrigger',
|
||||||
|
] as const;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (value[key] === undefined) continue;
|
||||||
|
if (!isInteger(value[key]) || value[key] < 0) return null;
|
||||||
|
buttonIndices[key] = value[key];
|
||||||
|
}
|
||||||
|
return buttonIndices;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindings'] | null {
|
||||||
|
if (!isObject(value)) return null;
|
||||||
|
const bindings: NonNullable<ControllerConfigUpdate['bindings']> = {};
|
||||||
|
const discreteKeys = [
|
||||||
|
'toggleLookup',
|
||||||
|
'closeLookup',
|
||||||
|
'toggleKeyboardOnlyMode',
|
||||||
|
'mineCard',
|
||||||
|
'quitMpv',
|
||||||
|
'previousAudio',
|
||||||
|
'nextAudio',
|
||||||
|
'playCurrentAudio',
|
||||||
|
'toggleMpvPause',
|
||||||
|
] as const;
|
||||||
|
for (const key of discreteKeys) {
|
||||||
|
if (value[key] === undefined) continue;
|
||||||
|
const parsed = parseDiscreteBinding(value[key]);
|
||||||
|
if (!parsed) return null;
|
||||||
|
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
||||||
|
}
|
||||||
|
const axisKeys = [
|
||||||
|
'leftStickHorizontal',
|
||||||
|
'leftStickVertical',
|
||||||
|
'rightStickHorizontal',
|
||||||
|
'rightStickVertical',
|
||||||
|
] as const;
|
||||||
|
for (const key of axisKeys) {
|
||||||
|
if (value[key] === undefined) continue;
|
||||||
|
const parsed = parseAxisBinding(value[key]);
|
||||||
|
if (!parsed) return null;
|
||||||
|
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
|
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
|
||||||
if (!isObject(value)) return null;
|
if (!isObject(value)) return null;
|
||||||
const update: ControllerConfigUpdate = {};
|
const update: ControllerConfigUpdate = {};
|
||||||
@@ -182,40 +245,42 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
|
|||||||
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
||||||
update.preferredGamepadLabel = value.preferredGamepadLabel;
|
update.preferredGamepadLabel = value.preferredGamepadLabel;
|
||||||
}
|
}
|
||||||
|
if (value.buttonIndices !== undefined) {
|
||||||
|
const parsed = parseControllerButtonIndices(value.buttonIndices);
|
||||||
|
if (!parsed) return null;
|
||||||
|
update.buttonIndices = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
if (value.bindings !== undefined) {
|
if (value.bindings !== undefined) {
|
||||||
if (!isObject(value.bindings)) return null;
|
const parsed = parseControllerBindings(value.bindings);
|
||||||
const bindings: NonNullable<ControllerConfigUpdate['bindings']> = {};
|
if (!parsed) return null;
|
||||||
const discreteKeys = [
|
update.bindings = parsed;
|
||||||
'toggleLookup',
|
}
|
||||||
'closeLookup',
|
|
||||||
'toggleKeyboardOnlyMode',
|
if (value.profiles !== undefined) {
|
||||||
'mineCard',
|
if (!isObject(value.profiles)) return null;
|
||||||
'quitMpv',
|
const profiles: NonNullable<ControllerConfigUpdate['profiles']> = Object.create(null);
|
||||||
'previousAudio',
|
for (const [profileId, rawProfile] of Object.entries(value.profiles)) {
|
||||||
'nextAudio',
|
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) return null;
|
||||||
'playCurrentAudio',
|
if (!isObject(rawProfile)) return null;
|
||||||
'toggleMpvPause',
|
const profile: NonNullable<ControllerConfigUpdate['profiles']>[string] = {};
|
||||||
] as const;
|
if (rawProfile.label !== undefined) {
|
||||||
for (const key of discreteKeys) {
|
if (typeof rawProfile.label !== 'string') return null;
|
||||||
if (value.bindings[key] === undefined) continue;
|
profile.label = rawProfile.label;
|
||||||
const parsed = parseDiscreteBinding(value.bindings[key]);
|
}
|
||||||
if (!parsed) return null;
|
if (rawProfile.buttonIndices !== undefined) {
|
||||||
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
const parsed = parseControllerButtonIndices(rawProfile.buttonIndices);
|
||||||
|
if (!parsed) return null;
|
||||||
|
profile.buttonIndices = parsed;
|
||||||
|
}
|
||||||
|
if (rawProfile.bindings !== undefined) {
|
||||||
|
const parsed = parseControllerBindings(rawProfile.bindings);
|
||||||
|
if (!parsed) return null;
|
||||||
|
profile.bindings = parsed;
|
||||||
|
}
|
||||||
|
profiles[profileId] = profile;
|
||||||
}
|
}
|
||||||
const axisKeys = [
|
update.profiles = profiles;
|
||||||
'leftStickHorizontal',
|
|
||||||
'leftStickVertical',
|
|
||||||
'rightStickHorizontal',
|
|
||||||
'rightStickVertical',
|
|
||||||
] as const;
|
|
||||||
for (const key of axisKeys) {
|
|
||||||
if (value.bindings[key] === undefined) continue;
|
|
||||||
const parsed = parseAxisBinding(value.bindings[key]);
|
|
||||||
if (!parsed) return null;
|
|
||||||
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
|
||||||
}
|
|
||||||
update.bindings = bindings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return update;
|
return update;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
ControllerButtonIndicesConfig,
|
ControllerButtonIndicesConfig,
|
||||||
ControllerConfig,
|
ControllerConfig,
|
||||||
|
ResolvedControllerProfileConfig,
|
||||||
ControllerTriggerInputMode,
|
ControllerTriggerInputMode,
|
||||||
Keybinding,
|
Keybinding,
|
||||||
ResolvedControllerBindingsConfig,
|
ResolvedControllerBindingsConfig,
|
||||||
@@ -164,6 +165,7 @@ export interface ResolvedConfig {
|
|||||||
repeatIntervalMs: number;
|
repeatIntervalMs: number;
|
||||||
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
||||||
bindings: Required<ResolvedControllerBindingsConfig>;
|
bindings: Required<ResolvedControllerBindingsConfig>;
|
||||||
|
profiles: Record<string, ResolvedControllerProfileConfig>;
|
||||||
};
|
};
|
||||||
ankiConnect: AnkiConnectConfig & {
|
ankiConnect: AnkiConnectConfig & {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -227,6 +227,18 @@ export interface ControllerButtonIndicesConfig {
|
|||||||
rightTrigger?: number;
|
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 {
|
export interface ControllerConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
preferredGamepadId?: string;
|
preferredGamepadId?: string;
|
||||||
@@ -241,6 +253,7 @@ export interface ControllerConfig {
|
|||||||
repeatIntervalMs?: number;
|
repeatIntervalMs?: number;
|
||||||
buttonIndices?: ControllerButtonIndicesConfig;
|
buttonIndices?: ControllerButtonIndicesConfig;
|
||||||
bindings?: ControllerBindingsConfig;
|
bindings?: ControllerBindingsConfig;
|
||||||
|
profiles?: Record<string, ControllerProfileConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControllerPreferenceUpdate {
|
export interface ControllerPreferenceUpdate {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { MacOSWindowTracker, parseMacOSHelperOutput } from './macos-tracker';
|
import {
|
||||||
|
isCompiledMacOSHelperCurrent,
|
||||||
|
MacOSWindowTracker,
|
||||||
|
parseMacOSHelperOutput,
|
||||||
|
} from './macos-tracker';
|
||||||
|
|
||||||
test('parseMacOSHelperOutput parses minimized state', () => {
|
test('parseMacOSHelperOutput parses minimized state', () => {
|
||||||
assert.deepEqual(parseMacOSHelperOutput('minimized'), {
|
assert.deepEqual(parseMacOSHelperOutput('minimized'), {
|
||||||
@@ -10,6 +17,99 @@ test('parseMacOSHelperOutput parses minimized state', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseMacOSHelperOutput parses active focused state without geometry', () => {
|
||||||
|
assert.deepEqual(parseMacOSHelperOutput('active'), {
|
||||||
|
geometry: null,
|
||||||
|
focused: true,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseMacOSHelperOutput parses inactive state without geometry', () => {
|
||||||
|
assert.deepEqual(parseMacOSHelperOutput('inactive'), {
|
||||||
|
geometry: null,
|
||||||
|
focused: false,
|
||||||
|
inactive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isCompiledMacOSHelperCurrent rejects binaries older than the Swift source', () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'subminer-macos-helper-'));
|
||||||
|
try {
|
||||||
|
const binaryPath = join(tempDir, 'get-mpv-window-macos');
|
||||||
|
const sourcePath = join(tempDir, 'get-mpv-window-macos.swift');
|
||||||
|
writeFileSync(binaryPath, 'binary');
|
||||||
|
writeFileSync(sourcePath, 'source');
|
||||||
|
|
||||||
|
const older = new Date('2026-01-01T00:00:00Z');
|
||||||
|
const newer = new Date('2026-01-01T00:00:05Z');
|
||||||
|
utimesSync(binaryPath, older, older);
|
||||||
|
utimesSync(sourcePath, newer, newer);
|
||||||
|
|
||||||
|
assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), false);
|
||||||
|
|
||||||
|
utimesSync(binaryPath, newer, newer);
|
||||||
|
utimesSync(sourcePath, older, older);
|
||||||
|
|
||||||
|
assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), true);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker slows polling while focused target is stable', async () => {
|
||||||
|
const scheduledDelays: number[] = [];
|
||||||
|
let callIndex = 0;
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper',
|
||||||
|
helperType: 'binary',
|
||||||
|
}),
|
||||||
|
runHelper: async () => {
|
||||||
|
callIndex += 1;
|
||||||
|
return { stdout: '10,20,1280,720,1', stderr: '' };
|
||||||
|
},
|
||||||
|
fastPollIntervalMs: 250,
|
||||||
|
stablePollIntervalMs: 1_000,
|
||||||
|
setPollTimeout: ((_callback: () => void, delayMs: number) => {
|
||||||
|
scheduledDelays.push(delayMs);
|
||||||
|
return {} as ReturnType<typeof setTimeout>;
|
||||||
|
}) as never,
|
||||||
|
clearPollTimeout: (() => {}) as never,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
tracker.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
tracker.stop();
|
||||||
|
|
||||||
|
assert.equal(callIndex, 1);
|
||||||
|
assert.deepEqual(scheduledDelays, [1_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker keeps fast polling while target is not focused', async () => {
|
||||||
|
const scheduledDelays: number[] = [];
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper',
|
||||||
|
helperType: 'binary',
|
||||||
|
}),
|
||||||
|
runHelper: async () => ({ stdout: '10,20,1280,720,0', stderr: '' }),
|
||||||
|
fastPollIntervalMs: 250,
|
||||||
|
stablePollIntervalMs: 1_000,
|
||||||
|
setPollTimeout: ((_callback: () => void, delayMs: number) => {
|
||||||
|
scheduledDelays.push(delayMs);
|
||||||
|
return {} as ReturnType<typeof setTimeout>;
|
||||||
|
}) as never,
|
||||||
|
clearPollTimeout: (() => {}) as never,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
tracker.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
tracker.stop();
|
||||||
|
|
||||||
|
assert.deepEqual(scheduledDelays, [250]);
|
||||||
|
});
|
||||||
|
|
||||||
test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => {
|
test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => {
|
||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
const outputs = [
|
const outputs = [
|
||||||
@@ -55,10 +155,221 @@ test('MacOSWindowTracker keeps the last geometry through a single helper miss',
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker preserves target focus on helper not-found while retaining geometry', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
const focusChanges: boolean[] = [];
|
||||||
|
const outputs = [
|
||||||
|
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||||
|
{ stdout: 'not-found', stderr: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
|
trackingLossGraceMs: 1_500,
|
||||||
|
});
|
||||||
|
tracker.onWindowFocusChange = (focused) => {
|
||||||
|
focusChanges.push(focused);
|
||||||
|
};
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
assert.deepEqual(focusChanges, [true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker keeps focused fullscreen target through active helper misses after grace', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
let now = 1_000;
|
||||||
|
const outputs = [
|
||||||
|
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||||
|
{ stdout: 'active', stderr: '' },
|
||||||
|
{ stdout: 'active', stderr: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
|
now: () => now,
|
||||||
|
trackingLossGraceMs: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker drops previously focused target after repeated not-found misses exceed grace', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
let now = 1_000;
|
||||||
|
const focusChanges: boolean[] = [];
|
||||||
|
const outputs = [
|
||||||
|
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||||
|
{ stdout: 'not-found', stderr: '' },
|
||||||
|
{ stdout: 'not-found', stderr: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
|
now: () => now,
|
||||||
|
trackingLossGraceMs: 500,
|
||||||
|
});
|
||||||
|
tracker.onWindowFocusChange = (focused) => {
|
||||||
|
focusChanges.push(focused);
|
||||||
|
};
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
assert.deepEqual(focusChanges, [true]);
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), false);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
assert.equal(tracker.getGeometry(), null);
|
||||||
|
assert.deepEqual(focusChanges, [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker drops previously focused target after repeated helper execution failures exceed grace', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
let now = 1_000;
|
||||||
|
const focusChanges: boolean[] = [];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => {
|
||||||
|
callIndex += 1;
|
||||||
|
if (callIndex === 1) {
|
||||||
|
return { stdout: '10,20,1280,720,1', stderr: '' };
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error('helper timed out'), { stderr: 'timeout' });
|
||||||
|
},
|
||||||
|
now: () => now,
|
||||||
|
trackingLossGraceMs: 500,
|
||||||
|
});
|
||||||
|
tracker.onWindowFocusChange = (focused) => {
|
||||||
|
focusChanges.push(focused);
|
||||||
|
};
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), false);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
assert.equal(tracker.getGeometry(), null);
|
||||||
|
assert.deepEqual(focusChanges, [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker marks target unfocused on explicit inactive helper signal', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
const focusChanges: boolean[] = [];
|
||||||
|
const outputs = [
|
||||||
|
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||||
|
{ stdout: 'inactive', stderr: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
|
trackingLossGraceMs: 1_500,
|
||||||
|
});
|
||||||
|
tracker.onWindowFocusChange = (focused) => {
|
||||||
|
focusChanges.push(focused);
|
||||||
|
};
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
assert.deepEqual(focusChanges, [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
|
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
|
||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
const outputs = [
|
const outputs = [
|
||||||
{ stdout: '10,20,1280,720,1', stderr: '' },
|
{ stdout: '10,20,1280,720,0', stderr: '' },
|
||||||
{ stdout: 'not-found', stderr: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
{ stdout: 'not-found', stderr: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
];
|
];
|
||||||
@@ -75,6 +386,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async
|
|||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.equal(tracker.isTracking(), true);
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
|
||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
@@ -84,6 +396,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.equal(tracker.isTracking(), false);
|
assert.equal(tracker.isTracking(), false);
|
||||||
assert.equal(tracker.getGeometry(), null);
|
assert.equal(tracker.getGeometry(), null);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => {
|
test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => {
|
||||||
@@ -137,7 +450,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () =>
|
|||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
let now = 1_000;
|
let now = 1_000;
|
||||||
const outputs = [
|
const outputs = [
|
||||||
{ stdout: '10,20,1280,720,1', stderr: '' },
|
{ stdout: '10,20,1280,720,0', stderr: '' },
|
||||||
{ stdout: 'not-found', stderr: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
{ stdout: 'not-found', stderr: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
{ stdout: 'not-found', stderr: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
@@ -156,6 +469,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () =>
|
|||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.equal(tracker.isTracking(), true);
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
|
||||||
now += 250;
|
now += 250;
|
||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { createLogger } from '../logger';
|
|||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
|
|
||||||
const log = createLogger('tracker').child('macos');
|
const log = createLogger('tracker').child('macos');
|
||||||
|
const MACOS_FAST_POLL_INTERVAL_MS = 250;
|
||||||
|
const MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
type MacOSTrackerRunnerResult = {
|
type MacOSTrackerRunnerResult = {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
@@ -42,6 +44,10 @@ type MacOSTrackerDeps = {
|
|||||||
trackingLossGraceMs?: number;
|
trackingLossGraceMs?: number;
|
||||||
minimizedTrackingLossGraceMs?: number;
|
minimizedTrackingLossGraceMs?: number;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
|
fastPollIntervalMs?: number;
|
||||||
|
stablePollIntervalMs?: number;
|
||||||
|
setPollTimeout?: typeof setTimeout;
|
||||||
|
clearPollTimeout?: typeof clearTimeout;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MacOSHelperWindowState =
|
export type MacOSHelperWindowState =
|
||||||
@@ -49,11 +55,29 @@ export type MacOSHelperWindowState =
|
|||||||
geometry: WindowGeometry;
|
geometry: WindowGeometry;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
minimized?: false;
|
minimized?: false;
|
||||||
|
active?: false;
|
||||||
|
inactive?: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
geometry: null;
|
||||||
|
focused: true;
|
||||||
|
active: true;
|
||||||
|
minimized?: false;
|
||||||
|
inactive?: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
geometry: null;
|
||||||
|
focused: false;
|
||||||
|
inactive: true;
|
||||||
|
active?: false;
|
||||||
|
minimized?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
geometry: null;
|
geometry: null;
|
||||||
focused: false;
|
focused: false;
|
||||||
minimized: true;
|
minimized: true;
|
||||||
|
active?: false;
|
||||||
|
inactive?: false;
|
||||||
};
|
};
|
||||||
|
|
||||||
function runHelperWithExecFile(
|
function runHelperWithExecFile(
|
||||||
@@ -90,6 +114,25 @@ function runHelperWithExecFile(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCompiledMacOSHelperCurrent(
|
||||||
|
binaryPath: string,
|
||||||
|
sourcePath: string,
|
||||||
|
helperFs: Pick<typeof fs, 'existsSync' | 'statSync'> = fs,
|
||||||
|
): boolean {
|
||||||
|
if (!helperFs.existsSync(binaryPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!helperFs.existsSync(sourcePath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return helperFs.statSync(binaryPath).mtimeMs >= helperFs.statSync(sourcePath).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null {
|
export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null {
|
||||||
const trimmed = result.trim();
|
const trimmed = result.trim();
|
||||||
if (trimmed === 'minimized') {
|
if (trimmed === 'minimized') {
|
||||||
@@ -99,6 +142,20 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
|
|||||||
minimized: true,
|
minimized: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (trimmed === 'active') {
|
||||||
|
return {
|
||||||
|
geometry: null,
|
||||||
|
focused: true,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (trimmed === 'inactive') {
|
||||||
|
return {
|
||||||
|
geometry: null,
|
||||||
|
focused: false,
|
||||||
|
inactive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (!trimmed || trimmed === 'not-found') {
|
if (!trimmed || trimmed === 'not-found') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -138,8 +195,9 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MacOSWindowTracker extends BaseWindowTracker {
|
export class MacOSWindowTracker extends BaseWindowTracker {
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private pollInFlight = false;
|
private pollInFlight = false;
|
||||||
|
private started = false;
|
||||||
private helperPath: string | null = null;
|
private helperPath: string | null = null;
|
||||||
private helperType: 'binary' | 'swift' | null = null;
|
private helperType: 'binary' | 'swift' | null = null;
|
||||||
private lastExecErrorFingerprint: string | null = null;
|
private lastExecErrorFingerprint: string | null = null;
|
||||||
@@ -154,6 +212,10 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
private readonly trackingLossGraceMs: number;
|
private readonly trackingLossGraceMs: number;
|
||||||
private readonly minimizedTrackingLossGraceMs: number;
|
private readonly minimizedTrackingLossGraceMs: number;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
|
private readonly fastPollIntervalMs: number;
|
||||||
|
private readonly stablePollIntervalMs: number;
|
||||||
|
private readonly setPollTimeout: typeof setTimeout;
|
||||||
|
private readonly clearPollTimeout: typeof clearTimeout;
|
||||||
private consecutiveMisses = 0;
|
private consecutiveMisses = 0;
|
||||||
private trackingLossStartedAtMs: number | null = null;
|
private trackingLossStartedAtMs: number | null = null;
|
||||||
private targetWindowMinimized = false;
|
private targetWindowMinimized = false;
|
||||||
@@ -169,6 +231,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
|
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
|
||||||
);
|
);
|
||||||
this.now = deps.now ?? (() => Date.now());
|
this.now = deps.now ?? (() => Date.now());
|
||||||
|
this.fastPollIntervalMs = Math.max(
|
||||||
|
50,
|
||||||
|
Math.floor(deps.fastPollIntervalMs ?? MACOS_FAST_POLL_INTERVAL_MS),
|
||||||
|
);
|
||||||
|
this.stablePollIntervalMs = Math.max(
|
||||||
|
this.fastPollIntervalMs,
|
||||||
|
Math.floor(deps.stablePollIntervalMs ?? MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS),
|
||||||
|
);
|
||||||
|
this.setPollTimeout = deps.setPollTimeout ?? setTimeout;
|
||||||
|
this.clearPollTimeout = deps.clearPollTimeout ?? clearTimeout;
|
||||||
const resolvedHelper = deps.resolveHelper?.() ?? null;
|
const resolvedHelper = deps.resolveHelper?.() ?? null;
|
||||||
if (resolvedHelper) {
|
if (resolvedHelper) {
|
||||||
this.helperPath = resolvedHelper.helperPath;
|
this.helperPath = resolvedHelper.helperPath;
|
||||||
@@ -216,15 +288,15 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectHelper(): void {
|
private tryUseCompiledHelper(candidatePath: string, sourcePath: string): boolean {
|
||||||
const shouldFilterBySocket = this.targetMpvSocketPath !== null;
|
if (!isCompiledMacOSHelperCurrent(candidatePath, sourcePath)) {
|
||||||
|
return false;
|
||||||
// Fall back to Swift helper first when filtering by socket path to avoid
|
|
||||||
// stale prebuilt binaries that don't support the new socket filter argument.
|
|
||||||
const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift');
|
|
||||||
if (shouldFilterBySocket && this.tryUseHelper(swiftPath, 'swift')) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return this.tryUseHelper(candidatePath, 'binary');
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectHelper(): void {
|
||||||
|
const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift');
|
||||||
|
|
||||||
// Prefer resources path (outside asar) in packaged apps.
|
// Prefer resources path (outside asar) in packaged apps.
|
||||||
const resourcesPath = process.resourcesPath;
|
const resourcesPath = process.resourcesPath;
|
||||||
@@ -235,9 +307,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dist binary path (development / unpacked installs).
|
// Built source runs from dist/window-trackers, so the compiled helper is a sibling of dist.
|
||||||
const distBinaryPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos');
|
const bundledBinaryPath = path.join(__dirname, '..', 'scripts', 'get-mpv-window-macos');
|
||||||
if (this.tryUseHelper(distBinaryPath, 'binary')) {
|
if (this.tryUseCompiledHelper(bundledBinaryPath, swiftPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source-tree/manual helper build path.
|
||||||
|
const sourceTreeBinaryPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'scripts',
|
||||||
|
'get-mpv-window-macos',
|
||||||
|
);
|
||||||
|
if (this.tryUseCompiledHelper(sourceTreeBinaryPath, swiftPath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,15 +353,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
this.pollGeometry();
|
this.pollGeometry();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.pollInterval) {
|
this.started = false;
|
||||||
clearInterval(this.pollInterval);
|
this.clearScheduledPoll();
|
||||||
this.pollInterval = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override isTargetWindowMinimized(): boolean {
|
override isTargetWindowMinimized(): boolean {
|
||||||
@@ -303,7 +388,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
return this.now() - this.trackingLossStartedAtMs > graceMs;
|
return this.now() - this.trackingLossStartedAtMs > graceMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldPreserveFocusedTargetOnMiss(): boolean {
|
||||||
|
return this.isTracking() && this.isTargetWindowFocused() && this.getGeometry() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
|
private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
|
||||||
|
if (this.shouldPreserveFocusedTargetOnMiss()) {
|
||||||
|
if (this.trackingLossStartedAtMs === null) {
|
||||||
|
this.trackingLossStartedAtMs = this.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.now() - this.trackingLossStartedAtMs <= graceMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.consecutiveMisses += 1;
|
this.consecutiveMisses += 1;
|
||||||
if (this.shouldDropTracking(graceMs)) {
|
if (this.shouldDropTracking(graceMs)) {
|
||||||
this.updateGeometry(null);
|
this.updateGeometry(null);
|
||||||
@@ -311,6 +410,39 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveNextPollIntervalMs(): number {
|
||||||
|
if (
|
||||||
|
this.isTracking() &&
|
||||||
|
this.isTargetWindowFocused() &&
|
||||||
|
!this.targetWindowMinimized &&
|
||||||
|
this.getGeometry() !== null
|
||||||
|
) {
|
||||||
|
return this.stablePollIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.fastPollIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearScheduledPoll(): void {
|
||||||
|
if (!this.pollTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearPollTimeout(this.pollTimeout);
|
||||||
|
this.pollTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNextPoll(): void {
|
||||||
|
if (!this.started || this.pollTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pollTimeout = this.setPollTimeout(() => {
|
||||||
|
this.pollTimeout = null;
|
||||||
|
this.pollGeometry();
|
||||||
|
}, this.resolveNextPollIntervalMs());
|
||||||
|
}
|
||||||
|
|
||||||
private pollGeometry(): void {
|
private pollGeometry(): void {
|
||||||
if (this.pollInFlight || !this.helperPath || !this.helperType) {
|
if (this.pollInFlight || !this.helperPath || !this.helperType) {
|
||||||
return;
|
return;
|
||||||
@@ -327,10 +459,22 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
|
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (parsed.active) {
|
||||||
|
this.resetTrackingLossState();
|
||||||
|
this.targetWindowMinimized = false;
|
||||||
|
this.updateTargetWindowFocused(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.inactive) {
|
||||||
|
this.targetWindowMinimized = false;
|
||||||
|
this.updateTargetWindowFocused(false);
|
||||||
|
this.registerTrackingMiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.resetTrackingLossState();
|
this.resetTrackingLossState();
|
||||||
this.targetWindowMinimized = false;
|
this.targetWindowMinimized = false;
|
||||||
this.updateFocus(parsed.focused);
|
this.updateGeometry(parsed.geometry, parsed.focused);
|
||||||
this.updateGeometry(parsed.geometry);
|
this.updateTargetWindowFocused(parsed.focused);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +496,7 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.pollInFlight = false;
|
this.pollInFlight = false;
|
||||||
|
this.scheduleNextPoll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user