Compare commits

..

16 Commits

Author SHA1 Message Date
87fbe6c002 Address second CodeRabbit review round 2026-04-10 03:18:22 -07:00
e06f12634f fix(session-bindings): guard optional stats config in compile path 2026-04-10 02:56:53 -07:00
48f74db239 feat: wire session bindings through main, ipc, and cli runtime 2026-04-10 02:54:01 -07:00
fd6dea9d33 fix(ci): sync bun.lock for bun 1.3.5 frozen install 2026-04-10 02:52:25 -07:00
0cdd79da9a Fix Windows CodeRabbit review follow-ups 2026-04-10 02:29:28 -07:00
3e7573c9fc Fix Windows overlay z-order on minimize/restore and improve hover stability
Use native synchronous z-order binding (koffi) instead of async PowerShell
for overlay positioning, eliminating the 200-500ms delay that left the overlay
behind mpv after restore. Hide the overlay immediately when mpv is minimized
so the full show/reveal/z-order flow triggers cleanly on restore.

Also adds hover suppression after visibility recovery and window resize to
prevent spurious auto-pause, Windows secondary subtitle titlebar fix, and
z-order sync burst retries on geometry changes.
2026-04-10 01:55:09 -07:00
20a0efe572 Fix Windows secondary hover titlebar blocking 2026-04-10 01:54:12 -07:00
7698258f61 Fix Windows overlay tracking, z-order, and startup visibility
- switch Windows overlay tracking to native win32 polling with native owner and z-order helpers
- keep the visible overlay and stats overlay aligned across focus handoff, transient tracker misses, and minimize/restore cycles
- start the visible overlay click-through and hide the initial opaque startup frame until the tracked transparent state settles
- add a backlog task for the inconsistent mpv y-t overlay toggle after menu toggles
2026-04-10 01:00:53 -07:00
ac25213255 fix: exclude prerelease tags from stable workflow 2026-04-09 00:40:19 -07:00
a5dbe055fc chore: prep 0.12.0-beta.1 prerelease workflow 2026-04-09 00:26:38 -07:00
04742b1806 Fix Yomitan blur guard 2026-04-09 00:09:15 -07:00
f0e15c5dc4 Reconcile Yomitan observer on setup 2026-04-09 00:03:53 -07:00
9145c730b5 Use pushed subminer-yomitan fork commit 2026-04-08 23:56:43 -07:00
cf86817cd8 Fix overlay subtitle drop routing 2026-04-08 01:40:38 -07:00
3f7de73734 Keep overlay interactive while Yomitan popup is visible 2026-04-07 22:25:46 -07:00
de9b887798 Fix nested Yomitan popup focus loss 2026-04-07 21:45:12 -07:00
165 changed files with 7816 additions and 5922 deletions

389
.github/workflows/prerelease.yml vendored Normal file
View File

@@ -0,0 +1,389 @@
name: Prerelease
on:
push:
tags:
- 'v*-beta.*'
- 'v*-rc.*'
concurrency:
group: prerelease-${{ github.ref }}
cancel-in-progress: false
jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Lint stats (formatting)
run: bun run lint:stats
- name: Build (TypeScript check)
run: bun run typecheck
- name: Test suite (source)
run: bun run test:fast
- name: Coverage suite (maintained source lane)
run: bun run test:coverage:src
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-test-src
path: coverage/test-src/lcov.info
if-no-files-found: error
- name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src
- name: Upload launcher smoke artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: launcher-smoke
path: .tmp/launcher-smoke/**
if-no-files-found: ignore
- name: Build (bundle)
run: bun run build
- name: Immersion SQLite verification
run: bun run test:immersion:sqlite:dist
- name: Dist smoke suite
run: bun run test:smoke:dist
build-linux:
needs: [quality-gate]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
run: |
cd vendor/texthooker-ui
bun install
bun run build
- name: Build AppImage
run: bun run build:appimage
- name: Build unversioned AppImage
run: |
shopt -s nullglob
appimages=(release/SubMiner-*.AppImage)
if [ "${#appimages[@]}" -eq 0 ]; then
echo "No versioned AppImage found to create unversioned artifact."
ls -la release
exit 1
fi
cp "${appimages[0]}" release/SubMiner.AppImage
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: appimage
path: release/*.AppImage
build-macos:
needs: [quality-gate]
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Validate macOS signing/notarization secrets
run: |
missing=0
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
if [ -z "${!name}" ]; then
echo "Missing required secret: $name"
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "Set all required macOS signing/notarization secrets and rerun."
exit 1
fi
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
run: |
cd vendor/texthooker-ui
bun install
bun run build
- name: Build signed + notarized macOS artifacts
run: bun run build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos
path: |
release/*.dmg
release/*.zip
build-windows:
needs: [quality-gate]
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
stats/node_modules
vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd stats && bun install --frozen-lockfile
- name: Build texthooker-ui
shell: powershell
run: |
Set-Location vendor/texthooker-ui
bun install
bun run build
- name: Build unsigned Windows artifacts
run: bun run build:win:unsigned
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: windows
path: |
release/*.exe
release/*.zip
if-no-files-found: error
release:
needs: [build-linux, build-macos, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download AppImage
uses: actions/download-artifact@v4
with:
name: appimage
path: release
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: macos
path: release
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
name: windows
path: release
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build Bun subminer wrapper
run: make build-launcher
- name: Verify Bun subminer wrapper
run: dist/launcher/subminer --help >/dev/null
- name: Enforce generated launcher workflow
run: bash scripts/verify-generated-launcher.sh
- name: Verify generated config examples
run: bun run verify:config-example
- name: Package optional assets bundle
run: |
tar -czf "release/subminer-assets.tar.gz" \
config.example.jsonc \
plugin/subminer \
plugin/subminer.conf \
assets/themes/subminer.rasi
- name: Generate checksums
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
fi
sha256sum "${files[@]}" > release/SHA256SUMS.txt
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Generate prerelease notes from pending fragments
run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}"
- name: Publish Prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
gh release edit "${{ steps.version.outputs.VERSION }}" \
--draft=false \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md
else
gh release create "${{ steps.version.outputs.VERSION }}" \
--latest=false \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md
fi
shopt -s nullglob
artifacts=(
release/*.AppImage
release/*.dmg
release/*.exe
release/*.zip
release/*.tar.gz
release/SHA256SUMS.txt
dist/launcher/subminer
)
if [ "${#artifacts[@]}" -eq 0 ]; then
echo "No release artifacts found for upload."
exit 1
fi
for asset in "${artifacts[@]}"; do
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
done

View File

@@ -4,6 +4,8 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
- '!v*-beta.*'
- '!v*-rc.*'
concurrency: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}

View File

@@ -1,34 +0,0 @@
---
id: TASK-285
title: Rename anime visibility filter heading to title visibility
status: Done
assignee:
- codex
created_date: '2026-04-10 00:00'
updated_date: '2026-04-10 00:00'
labels:
- stats
- ui
- bug
milestone: m-1
dependencies: []
references:
- stats/src/components/trends/TrendsTab.tsx
- stats/src/components/trends/TrendsTab.test.tsx
priority: low
ordinal: 200000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Align the library cumulative trends filter UI with the new terminology by renaming the hardcoded anime visibility heading to title visibility.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The trends filter heading uses `Title Visibility`
- [x] #2 The component behavior and props stay unchanged
- [x] #3 A regression test covers the rendered heading text
<!-- AC:END -->

View File

@@ -0,0 +1,54 @@
---
id: TASK-285
title: Investigate inconsistent mpv y-t overlay toggle after menu toggle
status: To Do
assignee: []
created_date: '2026-04-07 22:55'
updated_date: '2026-04-07 22:55'
labels:
- bug
- overlay
- keyboard
- mpv
dependencies: []
references:
- plugin/subminer/process.lua
- plugin/subminer/ui.lua
- src/renderer/handlers/keyboard.ts
- src/main/runtime/autoplay-ready-gate.ts
- src/core/services/overlay-window-input.ts
- backlog/tasks/task-248 - Fix-macOS-visible-overlay-toggle-getting-immediately-restored.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
User report: toggling the visible overlay with mpv `y-t` is inconsistent. After manually toggling through the `y-y` menu, `y-t` may allow one hide, but after toggling back on it can stop hiding the overlay again, forcing the user back into the menu path.
Initial assessment:
- no active backlog item currently tracks this exact report
- nearest prior work is `TASK-248`, which fixed a macOS-specific visible-overlay restore bug and is marked done
- current targeted regressions for the old fix surface pass, including plugin ready-signal suppression, focused-overlay `y-t` proxy dispatch, autoplay-ready gate deduplication, and blur-path restacking guards
This should be treated as a fresh investigation unless reproduction proves it is the same closed macOS issue resurfacing on the current build.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Reproduce the reported `y-t` / `y-y` inconsistency on the affected platform and identify the exact event sequence
- [ ] #2 Determine whether the failure is in mpv plugin command dispatch, focused-overlay key forwarding, or main-process visible-overlay state transitions
- [ ] #3 Fix the inconsistency so repeated hide/show/hide cycles work from `y-t` without requiring menu recovery
- [ ] #4 Add regression coverage for the reproduced failing sequence
- [ ] #5 Record whether this is a regression of `TASK-248` or a distinct bug
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce the report with platform/build details and capture whether the failing `y-t` press originates in raw mpv or the focused overlay y-chord proxy path.
2. Trace visible-overlay state mutations across plugin toggle commands, autoplay-ready callbacks, and main-process visibility/window blur handling.
3. Patch the narrowest failing path and add regression coverage for the exact hide/show/hide sequence.
4. Re-run targeted plugin, overlay visibility, overlay window, and renderer keyboard suites before broader verification.
<!-- SECTION:PLAN:END -->

View File

@@ -12,6 +12,7 @@
"commander": "^14.0.3", "commander": "^14.0.3",
"hono": "^4.12.7", "hono": "^4.12.7",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22", "libsql": "^0.5.22",
"ws": "^8.19.0", "ws": "^8.19.0",
}, },
@@ -478,6 +479,8 @@
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"koffi": ["koffi@2.15.6", "", {}, "sha512-WQBpM5uo74UQ17UpsFN+PUOrQQg4/nYdey4SGVluQun2drYYfePziLLWdSmFb4wSdWlJC1aimXQnjhPCheRKuw=="],
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="], "libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],

View File

@@ -0,0 +1,5 @@
type: internal
area: release
- Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
- Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.

View File

@@ -30,3 +30,9 @@ Rules:
- each non-empty body line becomes a bullet - each non-empty body line becomes a bullet
- `README.md` is ignored by the generator - `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment - if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
Prerelease notes:
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes

View File

@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.

View File

@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Addressed the latest CodeRabbit follow-ups on the Windows overlay flow, including exact mpv target resolution, lower-overlay helper arguments, Win32 failure detection, and overlay cleanup on tracker loss.

View File

@@ -0,0 +1,11 @@
type: fixed
area: overlay
- Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus.
- Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably.
- Fixed Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click.
- Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open.
- Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line.
- Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears.
- Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles.
- Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area.

View File

@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed Windows secondary subtitle hover mode so the expanded hover hit area no longer blocks the native minimize, maximize, and close buttons.

View File

@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.

View File

@@ -1,11 +0,0 @@
type: changed
area: stats
- Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
- Trends add a 365-day range next to the existing 7d/30d/90d/all options.
- Library detail view gets a delete-episode action that removes the video and all its sessions.
- Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen.
- Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
- Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
- Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
- Session timeline no longer plots seek-forward/seek-backward markers — they were too noisy on sessions with lots of rewinds.

View File

@@ -1,4 +0,0 @@
type: changed
area: stats
- Replaced the "Library — Per Day" section on the Stats → Trends page with a "Library — Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector.

View File

@@ -2,6 +2,8 @@
# Releasing # Releasing
## Stable Release
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`. 1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples. 2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
3. Run `bun run changelog:lint`. 3. Run `bun run changelog:lint`.
@@ -24,15 +26,37 @@
10. Tag the commit: `git tag v<version>`. 10. Tag the commit: `git tag v<version>`.
11. Push commit + tag. 11. Push commit + tag.
## Prerelease
1. Confirm release-facing docs and pending `changes/*.md` fragments are current.
2. Run `bun run changelog:lint`.
3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`.
4. Run the prerelease gate locally:
`bun run changelog:prerelease-notes --version <version>`
`bun run verify:config-example`
`bun run typecheck`
`bun run test:fast`
`bun run test:env`
`bun run build`
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes.
Notes: Notes:
- Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`. - Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`.
- 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:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes. - In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
- 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.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`. - If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication. - Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed. - AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.

View File

@@ -1,184 +0,0 @@
# Library Summary Replaces Per-Day Trends — Design
**Status:** Draft
**Date:** 2026-04-09
**Scope:** `stats/` frontend, `src/core/services/immersion-tracker/query-trends.ts` backend
## Problem
The "Library — Per Day" section on the stats Trends tab (`stats/src/components/trends/TrendsTab.tsx:224-254`) renders six stacked-area charts — Videos, Watch Time, Cards, Words, Lookups, and Lookups/100w, each broken down per title per day.
In practice these charts are not useful:
- Most titles only have activity on one or two days in a window, so they render as isolated bumps on a noisy baseline.
- Stacking 7+ titles with mostly-zero days makes individual lines hard to follow.
- The top "Activity" and "Period Trends" sections already answer "what am I doing per day" globally.
- The "Library — Cumulative" section directly below already answers "which titles am I progressing through" with less noise.
The per-day section occupies significant vertical space without carrying its weight, and the user has confirmed it should be replaced.
## Goal
Replace the six per-day stacked charts with a single "Library — Summary" section that surfaces per-title aggregate statistics over the selected date range. The new view should make it trivially easy to answer: "For the selected window, which titles am I spending time on, how much mining output have they produced, and how efficient is my lookup rate on each?"
## Non-goals
- Changing the "Library — Cumulative" section (stays as-is).
- Changing the "Activity", "Period Trends", or "Patterns" sections.
- Adding a new API endpoint — the existing dashboard endpoint is extended in place.
- Renaming internal `anime*` data-model identifiers (`animeId`, `imm_anime`, etc.). Those stay per the convention established in `c5e778d7`; only new fields/types/user-visible strings use generic "title"/"library" wording.
- Supporting a true all-time library view on the Trends tab. If that's ever wanted, it belongs on a different tab.
## Solution Overview
Delete the "Library — Per Day" section. In its place, add "Library — Summary", composed of:
1. A horizontal-bar leaderboard chart of watch time per title (top 10, descending).
2. A sortable table of every title with activity in the selected window, with columns: Title, Watch Time, Videos, Sessions, Cards, Words, Lookups, Lookups/100w, Date Range.
Both controls are scoped to the top-of-page date range selector. The existing shared Anime Visibility filter continues to work — it now gates Summary + Cumulative instead of Per-Day + Cumulative.
## Backend
### New type
Add to `stats/src/types/stats.ts` and the backend query module:
```ts
type LibrarySummaryRow = {
title: string; // display title — anime series, YouTube video title, etc.
watchTimeMin: number; // sum(total_active_min) across the window
videos: number; // distinct video_id count
sessions: number; // session count from imm_sessions
cards: number; // sum(total_cards)
words: number; // sum(total_tokens_seen)
lookups: number; // sum(lookup_count) from imm_sessions
lookupsPerHundred: number | null; // lookups / words * 100, null when words == 0
firstWatched: number; // min(rollup_day) as epoch day, within the window
lastWatched: number; // max(rollup_day) as epoch day, within the window
};
```
### Query changes in `src/core/services/immersion-tracker/query-trends.ts`
- Add `librarySummary: LibrarySummaryRow[]` to `TrendsDashboardQueryResult`.
- Populate it from a single aggregating query over `imm_daily_rollups` joined to `imm_videos``imm_anime`, filtered by `rollup_day` within the selected window. Session count and lookup count come from `imm_sessions` aggregated by `video_id` and then grouped by the parent library entry. Use a single query (or at most two joined/unioned) — no N+1.
- `imm_anime` is the generic library-grouping table; anime series, YouTube videos, and yt-dlp imports all land there. The internal table name stays `imm_anime`; only the new field uses generic naming.
- Return rows pre-sorted by `watchTimeMin` descending so the leaderboard is zero-cost and the table default sort matches.
- Emit `lookupsPerHundred: null` when `words == 0`.
### Removed from API response
Drop the entire `animePerDay` field from `TrendsDashboardQueryResult` (both backend in `src/core/services/immersion-tracker/query-trends.ts` and frontend in `stats/src/types/stats.ts`).
Internally, the existing helpers (`buildPerAnimeFromDailyRollups`, `buildEpisodesPerAnimeFromDailyRollups`) are still used as intermediates to build `animeCumulative.*` via `buildCumulativePerAnime`. Keep those helpers — just scope their output to local variables inside `getTrendsDashboard` instead of exposing them on the response. The `buildPerAnimeFromSessions` call for lookups and the `buildLookupsPerHundredPerAnime` helper become unused and can be deleted.
Before removing `animePerDay` from the frontend type, verify no other file under `stats/src/` references it. Based on current inspection, only `TrendsTab.tsx` and `stats/src/types/stats.ts` touch it.
## Frontend
### New component: `stats/src/components/trends/LibrarySummarySection.tsx`
Owns the header, leaderboard chart, visibility-filtered data, and the table. Keeps `TrendsTab.tsx` from growing. Component props: `{ rows: LibrarySummaryRow[]; hiddenTitles: ReadonlySet<string>; windowStart: Date; windowEnd: Date }`.
Internal state: `useState<{ column: ColumnId; direction: 'asc' | 'desc' }>` for sort, defaulting to `{ column: 'watchTimeMin', direction: 'desc' }`.
### Layout
Replaces `TrendsTab.tsx:224-254`:
```
[SectionHeader: "Library — Summary"]
[AnimeVisibilityFilter — unchanged, shared with Cumulative below]
[Card, col-span-full: Leaderboard — horizontal bar chart, ~260px tall]
[Card, col-span-full: Sortable table, auto height up to ~480px with internal scroll]
```
Both cards use the existing chart/card wrapper styling.
### Leaderboard chart
- Recharts horizontal bar chart (matches the rest of the page — existing charts use `recharts`, not ECharts).
- Top 10 titles by watch time. If fewer titles have activity, render what's there.
- Y-axis: title (category), truncated with ellipsis at container width; full title visible in the Recharts tooltip.
- X-axis: minutes (number).
- Use `layout="vertical"` with `YAxis dataKey="title" type="category"` and `XAxis type="number"`.
- Single series color: `#8aadf4` (matching the existing Watch Time color).
- Reuse `CHART_DEFAULTS`, `CHART_THEME`, `TOOLTIP_CONTENT_STYLE` from `stats/src/lib/chart-theme.ts` so theming matches the rest of the dashboard.
- Chart order is fixed at watch-time desc regardless of table sort — the leaderboard's meaning is fixed.
### Table
- Plain HTML `<table>` with Tailwind classes. No new deps.
- Columns, in order:
1. **Title** — left-aligned, sticky, truncated with ellipsis, full title on hover.
2. **Watch Time** — formatted `Xh Ym` when ≥60 min, else `Xm`.
3. **Videos** — integer.
4. **Sessions** — integer.
5. **Cards** — integer.
6. **Words** — integer.
7. **Lookups** — integer.
8. **Lookups/100w** — one decimal place, `—` when null.
9. **Date Range**`Mon D → Mon D` using the title's `firstWatched` / `lastWatched` within the window.
- Click a column header to sort; click again to reverse. Visual arrow on the active column.
- Numeric columns right-aligned.
- Null `lookupsPerHundred` sorts as the lowest value in both directions (consistent with "no data").
- Row hover highlight; no row click action (read-only view).
- Empty state: "No library activity in the selected window."
### Visibility filter integration
Hiding a title via `AnimeVisibilityFilter` removes it from both the leaderboard and the table. The filter's set of available titles is built from the union of titles that appear in `librarySummary` and the existing `animeCumulative.*` arrays (matches current behavior in `buildAnimeVisibilityOptions`).
### `TrendsTab.tsx` changes
- Remove the `filteredEpisodesPerAnime`, `filteredWatchTimePerAnime`, `filteredCardsPerAnime`, `filteredWordsPerAnime`, `filteredLookupsPerAnime`, `filteredLookupsPerHundredPerAnime` locals.
- Remove the six `<StackedTrendChart>` calls in the "Library — Per Day" section.
- Remove the `<SectionHeader>Library — Per Day</SectionHeader>` and the `<AnimeVisibilityFilter>` from that position.
- Insert `<SectionHeader>Library — Summary</SectionHeader>` + `<AnimeVisibilityFilter>` + `<LibrarySummarySection>` in the same place.
- Update `buildAnimeVisibilityOptions` input to use `librarySummary` titles instead of the six dropped `animePerDay.*` arrays.
## Data flow
1. `useTrends(range, groupBy)` calls `/api/stats/trends/dashboard`.
2. Response now includes `librarySummary` (sorted by watch time desc).
3. `TrendsTab` holds the shared `hiddenAnime` set (unchanged).
4. `LibrarySummarySection` receives `librarySummary` + `hiddenAnime`, filters out hidden rows, renders the leaderboard from the top-10 slice of the filtered list, renders the table from the filtered list with local sort state applied.
5. Date-range selector changes trigger a new fetch; `groupBy` toggle does not affect the summary section (it's always window-total).
## Edge cases
- **No activity in window:** Section renders header + empty-state card. Leaderboard card hidden. Visibility filter hidden.
- **One title only:** Leaderboard renders a single bar; table renders one row. No special-casing.
- **Title with zero words but non-zero lookups:** `lookupsPerHundred` is `null`, rendered as `—`. Sort treats null as lowest.
- **Title with zero cards/lookups/words but non-zero watch time:** Normal zero rendering, still shown.
- **Very long titles:** Ellipsis in chart y-axis labels and table title column; full title in `title` attribute / ECharts tooltip.
- **Mixed sources (anime + YouTube):** No special case — both land in `imm_anime` and are grouped uniformly.
## Testing
### Backend (`query-trends.ts`)
New unit tests, following the existing pattern:
1. Empty window returns `librarySummary: []`.
2. Single title with a few rollups: all aggregates are correct; `firstWatched`/`lastWatched` match the bounding days within the window.
3. Multiple titles: rows returned sorted by watch time desc.
4. Mixed sources (anime-style + YouTube-style entries in `imm_anime`): both appear in the summary with their own aggregates.
5. Title with `words == 0`: `lookupsPerHundred` is `null`.
6. Date range excludes some rollups: excluded rollups are not counted; `firstWatched`/`lastWatched` reflect only within-window activity.
7. `sessions` and `lookups` come from `imm_sessions`, not `imm_daily_rollups`, and are correctly attributed to the parent library entry.
### Frontend
- Existing Trends tab smoke test should continue to pass after wiring.
- Optional: a targeted render test for `LibrarySummarySection` (empty state, single title, sort toggle, visibility filter interaction). Not required for merge if the smoke test exercises the happy path.
## Release / docs
- One fragment in `changes/*.md` summarizing the replacement.
- No user-facing docs (`docs-site/`) changes unless the per-day section was documented there — verify during implementation.
## Open items
None.

View File

@@ -1,347 +0,0 @@
# Stats Dashboard Feedback Pass — Design
Date: 2026-04-09
Scope: Stats dashboard UX follow-ups from user feedback (items 17).
Delivery: **Single PR**, broken into logically scoped commits.
## Goals
Address seven concrete pieces of feedback against the Statistics menu:
1. Library — collapse episodes behind a per-series dropdown.
2. Sessions — roll up multiple sessions of the same episode within a day.
3. Trends — add a 365d range option.
4. Library — delete an episode (video) from its detail view.
5. Vocabulary — tighten spacing between word and reading in the Top 50 table.
6. Episode detail — hide cards whose Anki notes have been deleted.
7. Trend/watch charts — add gridlines, fix tick legibility, unify theming.
Out of scope for this pass: English-token ingestion cleanup and Overview stat-card drill-downs (feedback items 8 and 9). Those require a larger design decision and a migration respectively.
## Files touched (inventory)
Dashboard (`stats/src/`):
- `components/library/LibraryTab.tsx` — collapsible groups (item 1).
- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4).
- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2).
- `components/trends/DateRangeSelector.tsx`, `hooks/useTrends.ts`, `lib/api-client.ts`, `lib/api-client.test.ts` — 365d (item 3).
- `components/vocabulary/FrequencyRankTable.tsx` — word/reading column collapse (item 5).
- `components/anime/EpisodeDetail.tsx` — filter deleted Anki cards (item 6).
- `components/trends/TrendChart.tsx`, `components/trends/StackedTrendChart.tsx`, `components/overview/WatchTimeChart.tsx`, `lib/chart-theme.ts` — chart clarity (item 7).
- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`.
Backend (`src/core/services/`):
- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3).
- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3).
- `stats-server.ts` — passthrough if range validation lives here (check before editing).
- `__tests__/stats-server.test.ts` — 365d coverage (item 3).
## Commit plan
One PR, one feature per commit. Order picks low-risk mechanical changes first so failures in later commits don't block merging of earlier ones.
1. `feat(stats): add 365d range to trends dashboard` (item 3)
2. `fix(stats): tighten word/reading column in Top 50 table` (item 5)
3. `fix(stats): hide cards deleted from Anki in episode detail` (item 6)
4. `feat(stats): delete episode from library detail view` (item 4)
5. `feat(stats): collapsible series groups in library` (item 1)
6. `feat(stats): roll up same-episode sessions within a day` (item 2)
7. `feat(stats): gridlines and unified theme for trend charts` (item 7)
Each commit must pass `bun run typecheck`, `bun run test:fast`, and any change-specific checks listed below.
---
## Item 1 — Library collapsible series groups
### Current behavior
`LibraryTab.tsx` groups media via `groupMediaLibraryItems` and always renders the full grid of `MediaCard`s beneath each group header.
### Target behavior
Each group header becomes clickable. Groups with `items.length > 1` default to **collapsed**; single-video groups stay expanded (collapsing them would be visual noise).
### Implementation
- State: `const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(...)`. Initialize from `grouped` where `items.length > 1`.
- Toggle helper: `toggleGroup(key: string)` adds/removes from the set.
- Group header: wrap in a `<button>` with `aria-expanded` and a chevron icon (`▶`/`▼`). Keep the existing cover + title + subtitle layout inside the button.
- Children grid is conditionally rendered on `!collapsedGroups.has(group.key)`.
- Header summary (`N videos · duration · cards`) stays visible in both states so collapsed groups remain informative.
### Tests
- New `LibraryTab.test.tsx` (if not already present — check first) covering:
- Multi-video group renders collapsed on first mount.
- Single-video group renders expanded on first mount.
- Clicking the header toggles visibility.
- Header summary is visible in both states.
---
## Item 2 — Sessions episode rollup within a day
### Current behavior
`SessionsTab.tsx:10-24` groups sessions by day label only (`formatSessionDayLabel(startedAtMs)`). Multiple sessions of the same episode on the same day show as independent rows. `MediaSessionList.tsx` has the same problem inside the library detail view.
### Target behavior
Within each day, sessions with the same `videoId` collapse into one parent row showing combined totals. A chevron reveals the individual sessions. Single-session buckets render flat (no pointless nesting).
### Implementation
- New helper in `stats/src/lib/session-grouping.ts`:
```ts
export interface SessionBucket {
key: string; // videoId as string, or `s-${sessionId}` for singletons
videoId: number | null;
sessions: SessionSummary[];
totalActiveMs: number;
totalCardsMined: number;
representativeSession: SessionSummary; // most recent, for header display
}
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[];
```
Sessions missing a `videoId` become singleton buckets.
- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket:
- `sessions.length === 1`: existing `SessionRow` behavior, unchanged.
- `sessions.length >= 2`: render a **bucket row** that looks like `SessionRow` but shows combined totals and session count (e.g. `3 sessions · 1h 24m · 12 cards`). Chevron state stored in a second `Set<string>` on bucket key. Expanded buckets render the child `SessionRow`s indented (`pl-8`) beneath the header.
- `MediaSessionList.tsx`: within the media detail view, a single video's sessions are all the same `videoId` by definition — grouping here is by day only, and within a day multiple sessions render nested under a day header. Re-use the same visual pattern; factor the bucket row into a shared `SessionBucketRow` component.
### Delete semantics
- Deleting a bucket header offers "Delete all N sessions in this group" (reuse `confirmDayGroupDelete` pattern with a bucket-specific message, or add `confirmBucketDelete`).
- Deleting an individual session from inside an expanded bucket keeps the existing single-delete flow.
### Tests
- `session-grouping.test.ts`:
- Empty input → empty output.
- All unique videos → N singleton buckets.
- Two sessions same videoId → one bucket with correct totals and representative (most recent start time).
- Missing videoId → singleton bucket keyed by sessionId.
- `SessionsTab.test.tsx` (extend or add) verifying the rendered bucket rows expand/collapse and delete hooks fire with the right ID set.
---
## Item 3 — 365d trends range
### Backend
`src/core/services/immersion-tracker/query-trends.ts`:
- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';`
- Add `'365d': 365` to `TREND_DAY_LIMITS`.
- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic.
`src/core/services/stats-server.ts`:
- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it.
### Frontend
- `hooks/useTrends.ts`: widen the `TimeRange` union.
- `components/trends/DateRangeSelector.tsx`: add `'365d'` to the options list. Display label stays as `365d`.
- `lib/api-client.ts` / `api-client.test.ts`: if the client validates ranges, add `365d`.
### Tests
- `query.test.ts`: extend the existing range table to cover `365d` returning 365 days of data.
- `stats-server.test.ts`: ensure the route accepts `range=365d`.
- `api-client.test.ts`: ensure the client emits the new range.
### Change-specific checks
- `bun run test:config` is not required here (no schema/defaults change).
- Run `bun run typecheck` + `bun run test:fast`.
---
## Item 4 — Delete episode from library detail
### Current behavior
`MediaDetailView.tsx` provides session-level delete only. The backend `deleteVideo` exists (`query-maintenance.ts:509`), the API is exposed at `stats-server.ts:559`, and `api-client.deleteVideo` is already wired (`stats/src/lib/api-client.ts:146`). `EpisodeList.tsx:46` already uses it from the anime tab.
### Target behavior
A "Delete Episode" action in `MediaHeader` (top-right, small, `text-ctp-red`), gated by `confirmEpisodeDelete(title)`. On success, call `onBack()` and make sure the parent `LibraryTab` refetches.
### Implementation
- Add an `onDeleteEpisode?: () => void` prop to `MediaHeader` and render the button only if provided.
- In `MediaDetailView`:
- New handler `handleDeleteEpisode` that calls `apiClient.deleteVideo(videoId)`, then `onBack()`.
- Reuse `confirmEpisodeDelete` from `stats/src/lib/delete-confirm.ts`.
- In `LibraryTab`:
- `useMediaLibrary` returns fresh data on mount. The simplest fix: pass a `refresh` function from the hook (extend the hook if it doesn't already expose one) and call it when the detail view signals back.
- Alternative: force a remount by incrementing a `libraryVersion` key on the library list. Prefer `refresh` for clarity.
### Tests
- Extend the existing `MediaDetailView.test.tsx`: mock `apiClient.deleteVideo`, click the new button, confirm `onBack` fires after success.
- `useMediaLibrary.test.ts`: if we add a `refresh` method, cover it.
---
## Item 5 — Vocabulary word/reading column collapse
### Current behavior
`FrequencyRankTable.tsx:110-144` uses a 5-column table: `Rank | Word | Reading | POS | Seen`. Word and Reading are auto-sized, producing a large gap.
### Target behavior
Merge Word + Reading into a single column titled "Word". Reading sits immediately after the headword in a muted, smaller style.
### Implementation
- Drop the `<th>Reading</th>` header and cell.
- Word cell becomes:
```tsx
<td className="py-1.5 pr-3">
<span className="text-ctp-text font-medium">{w.headword}</span>
{reading && (
<span className="text-ctp-subtext0 text-xs ml-1.5">
【{reading}】
</span>
)}
</td>
```
where `reading = fullReading(w.headword, w.reading)` and differs from `headword`.
- Keep `fullReading` import from `reading-utils`.
### Tests
- Extend `FrequencyRankTable.test.tsx` (if present — otherwise add a focused test) to assert:
- Headword renders.
- Reading renders when different from headword.
- Reading does not render when equal to headword.
---
## Item 6 — Hide Anki-deleted cards in Cards Mined
### Current behavior
`EpisodeDetail.tsx:109-147` iterates `cardEvents`, fetches note info via `ankiNotesInfo(allNoteIds)`, and for each `noteId` renders a row even if no matching `info` came back — the user sees an empty word with an "Open in Anki" button that leads nowhere.
### Target behavior
After `ankiNotesInfo` resolves:
- Drop `noteId`s that are not in the resolved map.
- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering.
- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone.
### Implementation
- Compute `filteredCardEvents` as a `useMemo` depending on `data.cardEvents` and `noteInfos`.
- Iterate `filteredCardEvents` instead of `cardEvents` in the render.
- Surface a subtle note (optional, muted) "N cards hidden (deleted from Anki)" at the end of the list if any were filtered — helps the user understand why counts here diverge from session totals. Final decision on the note can be made at PR review; default: **show it**.
### Tests
- Add a test in `EpisodeDetail.test.tsx` (add the file if not present) that stubs `ankiNotesInfo` to return only a subset of notes and verifies the missing ones are not rendered.
### Other call sites
- Grep so far shows `ankiNotesInfo` is only used in `EpisodeDetail.tsx`. Re-verify before landing the commit; if another call site appears, apply the same filter.
---
## Item 7 — Trend/watch chart clarity pass
### Current behavior
`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with:
- No `CartesianGrid` → no horizontal reference lines.
- 9px axis ticks → borderline unreadable.
- Height 120 → cramped.
- Tooltip uses raw labels (`04/04` etc.).
- No shared theme object; each chart redefines colors and tooltip styles inline.
`stats/src/lib/chart-theme.ts` already exists and currently exports a single `CHART_THEME` constant with tick/tooltip colors and `barFill`. It will be extended, not replaced, to preserve existing consumers.
### Target behavior
All three charts share a theme, have horizontal gridlines, readable ticks, and sensible tooltips.
### Implementation
Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break):
```ts
export const CHART_THEME = {
tick: '#a5adcb',
tooltipBg: '#363a4f',
tooltipBorder: '#494d64',
tooltipText: '#cad3f5',
tooltipLabel: '#b8c0e0',
barFill: '#8aadf4',
grid: '#494d64',
axisLine: '#494d64',
} as const;
export const CHART_DEFAULTS = {
height: 160,
tickFontSize: 11,
margin: { top: 8, right: 8, bottom: 0, left: 0 },
grid: { strokeDasharray: '3 3', vertical: false },
} as const;
export const TOOLTIP_CONTENT_STYLE = {
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
};
```
Apply to each chart:
- Import `CartesianGrid` from recharts.
- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container.
- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`.
- `YAxis` gains `axisLine={{ stroke: CHART_THEME.axisLine }}`.
- `ResponsiveContainer` height changes from 120 → `CHART_DEFAULTS.height`.
- `Tooltip` `contentStyle` uses `TOOLTIP_CONTENT_STYLE`, and charts pass a `labelFormatter` when the label is a date key (e.g. show `Fri Apr 4`).
### Unit formatters
- `TrendChart` already accepts a `formatter` prop — extend usage sites to pass unit-aware formatters where they aren't already (`formatDuration`, `formatNumber`, etc.).
### Tests
- `chart-theme.test.ts` (if present — otherwise add a trivial snapshot to keep the shape stable).
- `TrendChart` snapshot/render tests: no regression, gridline element present.
---
## Verification gate
Before requesting code review, run:
```
bun run typecheck
bun run test:fast
bun run test:env
bun run test:runtime:compat # dist-sensitive check for the charts
bun run build
bun run test:smoke:dist
```
No docs-site changes are planned in this spec; if `docs-site/` ends up touched (e.g. screenshots), also run `bun run docs:test` and `bun run docs:build`.
No config schema changes → `bun run test:config` and `bun run generate:config-example` are not required.
## Risks and open questions
- **MediaDetailView refresh**: `useMediaLibrary` may not expose a `refresh` function. If it doesn't, the simplest path is adding one; the alternative (keying a remount) works but is harder to test. Decide during implementation.
- **Session bucket delete UX**: "Delete all N sessions in this group" is powerful. The copy must make it clear the underlying sessions are being removed, not just the grouping. Reuse `confirmBucketDelete` wording from existing confirm helpers if possible.
- **Anki-deleted-cards hidden notice**: Showing a subtle "N cards hidden" footer is a call that can be made at PR review.
- **Bucket delete helper**: `confirmBucketDelete` does not currently exist in `delete-confirm.ts`. Implementation either adds it or reuses `confirmDayGroupDelete` with bucket-specific wording — decide during the session-rollup commit.
## Changelog entry
User-visible PR → needs a fragment under `changes/*.md`. Suggested title:
`Stats dashboard: collapsible series, session rollups, 365d trends, chart polish, episode delete.`

View File

@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.11.2", "version": "0.12.0-beta.1",
"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",
@@ -26,6 +26,7 @@
"changelog:lint": "bun run scripts/build-changelog.ts lint", "changelog:lint": "bun run scripts/build-changelog.ts lint",
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check", "changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes", "changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
"changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"format:src": "bash scripts/prettier-scope.sh --write", "format:src": "bash scripts/prettier-scope.sh --write",
@@ -69,7 +70,7 @@
"test:launcher": "bun run test:launcher:src", "test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src", "test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle:src", "test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"generate:config-example": "bun run src/generate-config-example.ts", "generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start", "start": "bun run build && electron . --start",
@@ -112,6 +113,7 @@
"commander": "^14.0.3", "commander": "^14.0.3",
"hono": "^4.12.7", "hono": "^4.12.7",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22", "libsql": "^0.5.22",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },

View File

@@ -14,7 +14,7 @@ function M.init()
local utils = require("mp.utils") local utils = require("mp.utils")
local options_helper = require("options") local options_helper = require("options")
local environment = require("environment").create({ mp = mp }) local environment = require("environment").create({ mp = mp, utils = utils })
local opts = options_helper.load(options_lib, environment.default_socket_path()) local opts = options_helper.load(options_lib, environment.default_socket_path())
local state = require("state").new() local state = require("state").new()
@@ -61,6 +61,9 @@ function M.init()
ctx.process = make_lazy_proxy("process", function() ctx.process = make_lazy_proxy("process", function()
return require("process").create(ctx) return require("process").create(ctx)
end) end)
ctx.session_bindings = make_lazy_proxy("session_bindings", function()
return require("session_bindings").create(ctx)
end)
ctx.ui = make_lazy_proxy("ui", function() ctx.ui = make_lazy_proxy("ui", function()
return require("ui").create(ctx) return require("ui").create(ctx)
end) end)
@@ -72,6 +75,7 @@ function M.init()
end) end)
ctx.ui.register_keybindings() ctx.ui.register_keybindings()
ctx.session_bindings.register_bindings()
ctx.messages.register_script_messages() ctx.messages.register_script_messages()
ctx.lifecycle.register_lifecycle_hooks() ctx.lifecycle.register_lifecycle_hooks()
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded") ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")

View File

@@ -1,7 +1,9 @@
local M = {} local M = {}
local unpack_fn = table.unpack or unpack
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
local utils = ctx.utils
local detected_backend = nil local detected_backend = nil
local app_running_cache_value = nil local app_running_cache_value = nil
@@ -30,6 +32,57 @@ function M.create(ctx)
return "/tmp/subminer-socket" return "/tmp/subminer-socket"
end end
local function path_separator()
return is_windows() and "\\" or "/"
end
local function join_path(...)
local parts = { ... }
if utils and type(utils.join_path) == "function" then
return utils.join_path(unpack_fn(parts))
end
return table.concat(parts, path_separator())
end
local function file_exists(path)
if not utils or type(utils.file_info) ~= "function" then
return false
end
return utils.file_info(path) ~= nil
end
local function resolve_subminer_config_dir()
local home = os.getenv("HOME") or os.getenv("USERPROFILE") or ""
local candidates = {}
if is_windows() then
local app_data = os.getenv("APPDATA") or join_path(home, "AppData", "Roaming")
candidates = {
join_path(app_data, "SubMiner"),
}
else
local xdg_config_home = os.getenv("XDG_CONFIG_HOME")
local primary_base = (type(xdg_config_home) == "string" and xdg_config_home ~= "")
and xdg_config_home
or join_path(home, ".config")
candidates = {
join_path(primary_base, "SubMiner"),
join_path(home, ".config", "SubMiner"),
}
end
for _, dir in ipairs(candidates) do
if file_exists(join_path(dir, "config.jsonc")) or file_exists(join_path(dir, "config.json")) or file_exists(dir) then
return dir
end
end
return candidates[1]
end
local function resolve_session_bindings_artifact_path()
return join_path(resolve_subminer_config_dir(), "session-bindings.json")
end
local function is_linux() local function is_linux()
return not is_windows() and not is_macos() return not is_windows() and not is_macos()
end end
@@ -198,7 +251,10 @@ function M.create(ctx)
is_windows = is_windows, is_windows = is_windows,
is_macos = is_macos, is_macos = is_macos,
is_linux = is_linux, is_linux = is_linux,
join_path = join_path,
default_socket_path = default_socket_path, default_socket_path = default_socket_path,
resolve_subminer_config_dir = resolve_subminer_config_dir,
resolve_session_bindings_artifact_path = resolve_session_bindings_artifact_path,
is_subminer_process_running = is_subminer_process_running, is_subminer_process_running = is_subminer_process_running,
is_subminer_app_running = is_subminer_app_running, is_subminer_app_running = is_subminer_app_running,
is_subminer_app_running_async = is_subminer_app_running_async, is_subminer_app_running_async = is_subminer_app_running_async,

View File

@@ -47,6 +47,9 @@ function M.create(ctx)
mp.register_script_message("subminer-stats-toggle", function() mp.register_script_message("subminer-stats-toggle", function()
mp.osd_message("Stats: press ` (backtick) in overlay", 3) mp.osd_message("Stats: press ` (backtick) in overlay", 3)
end) end)
mp.register_script_message("subminer-reload-session-bindings", function()
ctx.session_bindings.reload_bindings()
end)
end end
return { return {

View File

@@ -0,0 +1,316 @@
local M = {}
local unpack_fn = table.unpack or unpack
local KEY_NAME_MAP = {
Space = "SPACE",
Tab = "TAB",
Enter = "ENTER",
Escape = "ESC",
Backspace = "BS",
Delete = "DEL",
ArrowUp = "UP",
ArrowDown = "DOWN",
ArrowLeft = "LEFT",
ArrowRight = "RIGHT",
Slash = "/",
Backslash = "\\",
Minus = "-",
Equal = "=",
Comma = ",",
Period = ".",
Quote = "'",
Semicolon = ";",
BracketLeft = "[",
BracketRight = "]",
Backquote = "`",
}
local MODIFIER_MAP = {
ctrl = "Ctrl",
alt = "Alt",
shift = "Shift",
meta = "Meta",
}
function M.create(ctx)
local mp = ctx.mp
local utils = ctx.utils
local state = ctx.state
local process = ctx.process
local environment = ctx.environment
local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd
local function read_file(path)
local handle = io.open(path, "r")
if not handle then
return nil
end
local content = handle:read("*a")
handle:close()
return content
end
local function remove_binding_names(names)
for _, name in ipairs(names) do
mp.remove_key_binding(name)
end
for index = #names, 1, -1 do
names[index] = nil
end
end
local function key_code_to_mpv_name(code)
if KEY_NAME_MAP[code] then
return KEY_NAME_MAP[code]
end
local letter = code:match("^Key([A-Z])$")
if letter then
return string.lower(letter)
end
local digit = code:match("^Digit([0-9])$")
if digit then
return digit
end
local function_key = code:match("^(F%d+)$")
if function_key then
return function_key
end
return nil
end
local function key_spec_to_mpv_binding(key)
if type(key) ~= "table" then
return nil
end
local key_name = key_code_to_mpv_name(key.code)
if not key_name then
return nil
end
local parts = {}
for _, modifier in ipairs(key.modifiers or {}) do
local mapped = MODIFIER_MAP[modifier]
if mapped then
parts[#parts + 1] = mapped
end
end
parts[#parts + 1] = key_name
return table.concat(parts, "+")
end
local function build_cli_args(action_id, payload)
if action_id == "toggleVisibleOverlay" then
return { "--toggle-visible-overlay" }
elseif action_id == "copySubtitle" then
return { "--copy-subtitle" }
elseif action_id == "copySubtitleMultiple" then
return { "--copy-subtitle-count", tostring(payload and payload.count or 1) }
elseif action_id == "updateLastCardFromClipboard" then
return { "--update-last-card-from-clipboard" }
elseif action_id == "triggerFieldGrouping" then
return { "--trigger-field-grouping" }
elseif action_id == "triggerSubsync" then
return { "--trigger-subsync" }
elseif action_id == "mineSentence" then
return { "--mine-sentence" }
elseif action_id == "mineSentenceMultiple" then
return { "--mine-sentence-count", tostring(payload and payload.count or 1) }
elseif action_id == "toggleSecondarySub" then
return { "--toggle-secondary-sub" }
elseif action_id == "markAudioCard" then
return { "--mark-audio-card" }
elseif action_id == "openRuntimeOptions" then
return { "--open-runtime-options" }
elseif action_id == "openJimaku" then
return { "--open-jimaku" }
elseif action_id == "openYoutubePicker" then
return { "--open-youtube-picker" }
elseif action_id == "openPlaylistBrowser" then
return { "--open-playlist-browser" }
elseif action_id == "replayCurrentSubtitle" then
return { "--replay-current-subtitle" }
elseif action_id == "playNextSubtitle" then
return { "--play-next-subtitle" }
elseif action_id == "shiftSubDelayPrevLine" then
return { "--shift-sub-delay-prev-line" }
elseif action_id == "shiftSubDelayNextLine" then
return { "--shift-sub-delay-next-line" }
end
return nil
end
local function invoke_cli_action(action_id, payload)
if not process.check_binary_available() then
show_osd("Error: binary not found")
return
end
local cli_args = build_cli_args(action_id, payload)
if not cli_args then
subminer_log("warn", "session-bindings", "No CLI mapping for action: " .. tostring(action_id))
return
end
local args = { state.binary_path }
for _, arg in ipairs(cli_args) do
args[#args + 1] = arg
end
process.run_binary_command_async(args, function(ok, result, error)
if ok then
return
end
local reason = error or (result and result.stderr) or "unknown error"
subminer_log("warn", "session-bindings", "Session action failed: " .. tostring(reason))
show_osd("Session action failed")
end)
end
local function clear_numeric_selection(show_cancelled)
if state.session_numeric_selection and state.session_numeric_selection.timeout then
state.session_numeric_selection.timeout:kill()
end
state.session_numeric_selection = nil
remove_binding_names(state.session_numeric_binding_names)
if show_cancelled then
show_osd("Cancelled")
end
end
local function start_numeric_selection(action_id, timeout_ms)
clear_numeric_selection(false)
for digit = 1, 9 do
local digit_string = tostring(digit)
local name = "subminer-session-digit-" .. digit_string
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name
mp.add_forced_key_binding(digit_string, name, function()
clear_numeric_selection(false)
invoke_cli_action(action_id, { count = digit })
end)
end
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] =
"subminer-session-digit-cancel"
mp.add_forced_key_binding("ESC", "subminer-session-digit-cancel", function()
clear_numeric_selection(true)
end)
state.session_numeric_selection = {
action_id = action_id,
timeout = mp.add_timeout((timeout_ms or 3000) / 1000, function()
clear_numeric_selection(false)
show_osd(action_id == "copySubtitleMultiple" and "Copy timeout" or "Mine timeout")
end),
}
show_osd(
action_id == "copySubtitleMultiple"
and "Copy how many lines? Press 1-9 (Esc to cancel)"
or "Mine how many lines? Press 1-9 (Esc to cancel)"
)
end
local function execute_mpv_command(command)
if type(command) ~= "table" or command[1] == nil then
return
end
mp.commandv(unpack_fn(command))
end
local function handle_binding(binding, numeric_selection_timeout_ms)
if binding.actionType == "mpv-command" then
execute_mpv_command(binding.command)
return
end
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms)
return
end
invoke_cli_action(binding.actionId, binding.payload)
end
local function load_artifact()
local artifact_path = environment.resolve_session_bindings_artifact_path()
local raw = read_file(artifact_path)
if not raw or raw == "" then
return nil, "Missing session binding artifact: " .. tostring(artifact_path)
end
local parsed, parse_error = utils.parse_json(raw)
if not parsed then
return nil, "Failed to parse session binding artifact: " .. tostring(parse_error)
end
if type(parsed) ~= "table" or type(parsed.bindings) ~= "table" then
return nil, "Invalid session binding artifact"
end
return parsed, nil
end
local function clear_bindings()
clear_numeric_selection(false)
remove_binding_names(state.session_binding_names)
end
local function register_bindings()
local artifact, load_error = load_artifact()
if not artifact then
subminer_log("warn", "session-bindings", load_error)
return false
end
clear_numeric_selection(false)
local previous_binding_names = state.session_binding_names
local next_binding_names = {}
state.session_binding_names = next_binding_names
local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000
for index, binding in ipairs(artifact.bindings) do
local key_name = key_spec_to_mpv_binding(binding.key)
if key_name then
local name = "subminer-session-binding-" .. tostring(index)
next_binding_names[#next_binding_names + 1] = name
mp.add_forced_key_binding(key_name, name, function()
handle_binding(binding, timeout_ms)
end)
else
subminer_log(
"warn",
"session-bindings",
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
)
end
end
remove_binding_names(previous_binding_names)
subminer_log(
"info",
"session-bindings",
"Registered " .. tostring(#next_binding_names) .. " shared session bindings"
)
return true
end
local function reload_bindings()
return register_bindings()
end
return {
register_bindings = register_bindings,
reload_bindings = reload_bindings,
clear_bindings = clear_bindings,
}
end
return M

View File

@@ -33,6 +33,9 @@ function M.new()
auto_play_ready_timeout = nil, auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil, auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false, suppress_ready_overlay_restore = false,
session_binding_names = {},
session_numeric_binding_names = {},
session_numeric_selection = nil,
} }
end end

View File

@@ -310,3 +310,186 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
}), }),
); );
}); });
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-beta-notes');
const projectRoot = path.join(workspace, 'SubMiner');
const changelogPath = path.join(projectRoot, 'CHANGELOG.md');
const docsChangelogPath = path.join(projectRoot, 'docs-site', 'changelog.md');
const existingChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable release.\n';
const existingDocsChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable docs release.\n';
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
'utf8',
);
fs.writeFileSync(changelogPath, existingChangelog, 'utf8');
fs.writeFileSync(docsChangelogPath, existingDocsChangelog, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Added prerelease coverage.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: fixed', 'area: launcher', '', '- Fixed prerelease packaging checks.'].join('\n'),
'utf8',
);
try {
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.1',
});
assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
assert.equal(
fs.readFileSync(changelogPath, 'utf8'),
existingChangelog,
'stable CHANGELOG.md should remain unchanged',
);
assert.equal(
fs.readFileSync(docsChangelogPath, 'utf8'),
existingDocsChangelog,
'docs-site changelog should remain unchanged',
);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true);
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Overlay: Added prerelease coverage\./);
assert.match(
prereleaseNotes,
/### Fixed\n- Launcher: Fixed prerelease packaging checks\./,
);
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-rc-notes');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-rc.1' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: changed', 'area: release', '', '- Prepared release candidate notes.'].join('\n'),
'utf8',
);
try {
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-rc.1',
});
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(
prereleaseNotes,
/## Highlights\n### Changed\n- Release: Prepared release candidate notes\./,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion rejects unsupported prerelease identifiers', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-alpha-reject');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-alpha.1' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Unsupported alpha prerelease.'].join('\n'),
'utf8',
);
try {
assert.throws(
() =>
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-alpha.1',
}),
/Unsupported prerelease version \(0\.11\.3-alpha\.1\)/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion rejects mismatched package versions', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-version-mismatch');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Mismatched prerelease.'].join('\n'),
'utf8',
);
try {
assert.throws(
() =>
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.2',
}),
/package\.json version \(0\.11\.3-beta\.1\) does not match requested release version \(0\.11\.3-beta\.2\)/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion rejects empty prerelease note generation when no fragments exist', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-no-fragments');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
'utf8',
);
try {
assert.throws(
() =>
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.1',
}),
/No changelog fragments found in changes\//,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});

View File

@@ -38,6 +38,7 @@ type PullRequestChangelogOptions = {
}; };
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md'); const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
const CHANGELOG_HEADER = '# Changelog'; const CHANGELOG_HEADER = '# Changelog';
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal']; const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = { const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
@@ -75,6 +76,10 @@ function resolveVersion(options: Pick<ChangelogOptions, 'cwd' | 'version' | 'dep
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync)); return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
} }
function isSupportedPrereleaseVersion(version: string): boolean {
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
}
function verifyRequestedVersionMatchesPackageVersion( function verifyRequestedVersionMatchesPackageVersion(
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>, options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
): void { ): void {
@@ -314,8 +319,15 @@ export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[
return [path.join(cwd, 'CHANGELOG.md')]; return [path.join(cwd, 'CHANGELOG.md')];
} }
function renderReleaseNotes(changes: string): string { function renderReleaseNotes(
changes: string,
options?: {
disclaimer?: string;
},
): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
return [ return [
...prefix,
'## Highlights', '## Highlights',
changes, changes,
'', '',
@@ -334,13 +346,21 @@ function renderReleaseNotes(changes: string): string {
].join('\n'); ].join('\n');
} }
function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string { function writeReleaseNotesFile(
cwd: string,
changes: string,
deps?: ChangelogFsDeps,
options?: {
disclaimer?: string;
outputPath?: string;
},
): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync; const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH); const releaseNotesPath = path.join(cwd, options?.outputPath ?? RELEASE_NOTES_PATH);
mkdirSync(path.dirname(releaseNotesPath), { recursive: true }); mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
writeFileSync(releaseNotesPath, renderReleaseNotes(changes), 'utf8'); writeFileSync(releaseNotesPath, renderReleaseNotes(changes, options), 'utf8');
return releaseNotesPath; return releaseNotesPath;
} }
@@ -613,6 +633,30 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
return writeReleaseNotesFile(cwd, changes, options?.deps); return writeReleaseNotesFile(cwd, changes, options?.deps);
} }
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
verifyRequestedVersionMatchesPackageVersion(options ?? {});
const cwd = options?.cwd ?? process.cwd();
const version = resolveVersion(options ?? {});
if (!isSupportedPrereleaseVersion(version)) {
throw new Error(
`Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`,
);
}
const fragments = readChangeFragments(cwd, options?.deps);
if (fragments.length === 0) {
throw new Error('No changelog fragments found in changes/.');
}
const changes = renderGroupedChanges(fragments);
return writeReleaseNotesFile(cwd, changes, options?.deps, {
disclaimer:
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
outputPath: PRERELEASE_NOTES_PATH,
});
}
function parseCliArgs(argv: string[]): { function parseCliArgs(argv: string[]): {
baseRef?: string; baseRef?: string;
cwd?: string; cwd?: string;
@@ -710,6 +754,11 @@ function main(): void {
return; return;
} }
if (command === 'prerelease-notes') {
writePrereleaseNotesForVersion(options);
return;
}
if (command === 'docs') { if (command === 'docs') {
generateDocsChangelog(options); generateDocsChangelog(options);
return; return;

View File

@@ -1,7 +1,8 @@
param( param(
[ValidateSet('geometry')] [ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner', 'target-hwnd')]
[string]$Mode = 'geometry', [string]$Mode = 'geometry',
[string]$SocketPath [string]$SocketPath,
[string]$OverlayWindowHandle
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -35,19 +36,126 @@ public static class SubMinerWindowsHelper {
[DllImport("user32.dll")] [DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow(); public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool SetWindowPos(
IntPtr hWnd,
IntPtr hWndInsertAfter,
int X,
int Y,
int cx,
int cy,
uint uFlags
);
[DllImport("user32.dll", SetLastError = true)] [DllImport("user32.dll", SetLastError = true)]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[DllImport("user32.dll", SetLastError = true)]
public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", SetLastError = true)] [DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
[DllImport("kernel32.dll")]
public static extern void SetLastError(uint dwErrCode);
[DllImport("dwmapi.dll")] [DllImport("dwmapi.dll")]
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute); public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
} }
"@ "@
$DWMWA_EXTENDED_FRAME_BOUNDS = 9 $DWMWA_EXTENDED_FRAME_BOUNDS = 9
$SWP_NOSIZE = 0x0001
$SWP_NOMOVE = 0x0002
$SWP_NOACTIVATE = 0x0010
$SWP_NOOWNERZORDER = 0x0200
$SWP_FLAGS = $SWP_NOSIZE -bor $SWP_NOMOVE -bor $SWP_NOACTIVATE -bor $SWP_NOOWNERZORDER
$GWL_EXSTYLE = -20
$WS_EX_TOPMOST = 0x00000008
$GWLP_HWNDPARENT = -8
$HWND_TOP = [IntPtr]::Zero
$HWND_BOTTOM = [IntPtr]::One
$HWND_TOPMOST = [IntPtr](-1)
$HWND_NOTOPMOST = [IntPtr](-2)
function Assert-SetWindowLongPtrSucceeded {
param(
[IntPtr]$Result,
[string]$Operation
)
if ($Result -ne [IntPtr]::Zero) {
return
}
if ([Runtime.InteropServices.Marshal]::GetLastWin32Error() -eq 0) {
return
}
$lastError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
throw "$Operation failed ($lastError)"
}
function Assert-SetWindowPosSucceeded {
param(
[bool]$Result,
[string]$Operation
)
if ($Result) {
return
}
$lastError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
throw "$Operation failed ($lastError)"
}
if ($Mode -eq 'foreground-process') {
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
if ($foregroundWindow -eq [IntPtr]::Zero) {
Write-Output 'not-found'
exit 0
}
[uint32]$foregroundProcessId = 0
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($foregroundWindow, [ref]$foregroundProcessId)
if ($foregroundProcessId -eq 0) {
Write-Output 'not-found'
exit 0
}
try {
$foregroundProcess = Get-Process -Id $foregroundProcessId -ErrorAction Stop
} catch {
Write-Output 'not-found'
exit 0
}
Write-Output "process=$($foregroundProcess.ProcessName)"
exit 0
}
if ($Mode -eq 'clear-owner') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, [IntPtr]::Zero)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'clear-owner'
Write-Output 'ok'
exit 0
}
function Get-WindowBounds { function Get-WindowBounds {
param([IntPtr]$hWnd) param([IntPtr]$hWnd)
@@ -90,6 +198,7 @@ public static class SubMinerWindowsHelper {
} }
$mpvMatches = New-Object System.Collections.Generic.List[object] $mpvMatches = New-Object System.Collections.Generic.List[object]
$targetWindowState = 'not-found'
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow() $foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
$callback = [SubMinerWindowsHelper+EnumWindowsProc]{ $callback = [SubMinerWindowsHelper+EnumWindowsProc]{
param([IntPtr]$hWnd, [IntPtr]$lParam) param([IntPtr]$hWnd, [IntPtr]$lParam)
@@ -98,10 +207,6 @@ public static class SubMinerWindowsHelper {
return $true return $true
} }
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
return $true
}
[uint32]$windowProcessId = 0 [uint32]$windowProcessId = 0
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId) [void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId)
if ($windowProcessId -eq 0) { if ($windowProcessId -eq 0) {
@@ -131,11 +236,22 @@ public static class SubMinerWindowsHelper {
} }
} }
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
if (-not [string]::IsNullOrWhiteSpace($SocketPath) -and $targetWindowState -ne 'visible') {
$targetWindowState = 'minimized'
}
return $true
}
$bounds = Get-WindowBounds -hWnd $hWnd $bounds = Get-WindowBounds -hWnd $hWnd
if ($null -eq $bounds) { if ($null -eq $bounds) {
return $true return $true
} }
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
$targetWindowState = 'visible'
}
$mpvMatches.Add([PSCustomObject]@{ $mpvMatches.Add([PSCustomObject]@{
HWnd = $hWnd HWnd = $hWnd
X = $bounds.X X = $bounds.X
@@ -151,12 +267,45 @@ public static class SubMinerWindowsHelper {
[void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero) [void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero)
if ($Mode -eq 'lower-overlay') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
[void][SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow,
$HWND_NOTOPMOST,
0,
0,
0,
0,
$SWP_FLAGS
)
[void][SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow,
$HWND_BOTTOM,
0,
0,
0,
0,
$SWP_FLAGS
)
Write-Output 'ok'
exit 0
}
$focusedMatch = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1 $focusedMatch = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1
if ($null -ne $focusedMatch) { if ($null -ne $focusedMatch) {
[Console]::Error.WriteLine('focus=focused') [Console]::Error.WriteLine('focus=focused')
} else { } else {
[Console]::Error.WriteLine('focus=not-focused') [Console]::Error.WriteLine('focus=not-focused')
} }
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
[Console]::Error.WriteLine("state=$targetWindowState")
}
if ($mpvMatches.Count -eq 0) { if ($mpvMatches.Count -eq 0) {
Write-Output 'not-found' Write-Output 'not-found'
@@ -168,6 +317,83 @@ public static class SubMinerWindowsHelper {
} else { } else {
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1 $mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1
} }
if ($Mode -eq 'target-hwnd') {
Write-Output "$($bestMatch.HWnd)"
exit 0
}
if ($Mode -eq 'set-owner') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'set-owner'
Write-Output 'ok'
exit 0
}
if ($Mode -eq 'bind-overlay') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'bind-overlay owner assignment'
$targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE)
$targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0
$overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE)
$overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0
if ($targetWindowIsTopmost -and -not $overlayIsTopmost) {
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS
)
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay topmost adjustment'
} elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) {
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS
)
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay notopmost adjustment'
}
$GW_HWNDPREV = 3
$windowAboveMpv = [SubMinerWindowsHelper]::GetWindow($targetWindow, $GW_HWNDPREV)
if ($windowAboveMpv -ne [IntPtr]::Zero -and $windowAboveMpv -eq $overlayWindow) {
Write-Output 'ok'
exit 0
}
$insertAfter = $HWND_TOP
if ($windowAboveMpv -ne [IntPtr]::Zero) {
$aboveExStyle = [SubMinerWindowsHelper]::GetWindowLong($windowAboveMpv, $GWL_EXSTYLE)
$aboveIsTopmost = ($aboveExStyle -band $WS_EX_TOPMOST) -ne 0
if ($aboveIsTopmost -eq $targetWindowIsTopmost) {
$insertAfter = $windowAboveMpv
}
}
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $insertAfter, 0, 0, 0, 0, $SWP_FLAGS
)
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay z-order adjustment'
Write-Output 'ok'
exit 0
}
Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)" Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)"
} catch { } catch {
[Console]::Error.WriteLine($_.Exception.Message) [Console]::Error.WriteLine($_.Exception.Message)

View File

@@ -28,6 +28,27 @@ USAGE
force=0 force=0
generate_webp=0 generate_webp=0
input="" input=""
ffmpeg_bin="${FFMPEG_BIN:-ffmpeg}"
normalize_path() {
local value="$1"
if command -v cygpath > /dev/null 2>&1; then
case "$value" in
[A-Za-z]:\\* | [A-Za-z]:/*)
cygpath -u "$value"
return 0
;;
esac
fi
if [[ "$value" =~ ^([A-Za-z]):[\\/](.*)$ ]]; then
local drive="${BASH_REMATCH[1],,}"
local rest="${BASH_REMATCH[2]}"
rest="${rest//\\//}"
printf '/mnt/%s/%s\n' "$drive" "$rest"
return 0
fi
printf '%s\n' "$value"
}
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@@ -63,10 +84,20 @@ if [[ -z "$input" ]]; then
exit 1 exit 1
fi fi
if ! command -v ffmpeg > /dev/null 2>&1; then input="$(normalize_path "$input")"
ffmpeg_bin="$(normalize_path "$ffmpeg_bin")"
if [[ "$ffmpeg_bin" == */* ]]; then
if [[ ! -x "$ffmpeg_bin" ]]; then
echo "Error: ffmpeg binary is not executable: $ffmpeg_bin" >&2
exit 1
fi
else
if ! command -v "$ffmpeg_bin" > /dev/null 2>&1; then
echo "Error: ffmpeg is not installed or not in PATH." >&2 echo "Error: ffmpeg is not installed or not in PATH." >&2
exit 1 exit 1
fi fi
fi
if [[ ! -f "$input" ]]; then if [[ ! -f "$input" ]]; then
echo "Error: input file not found: $input" >&2 echo "Error: input file not found: $input" >&2
@@ -102,7 +133,7 @@ fi
has_encoder() { has_encoder() {
local encoder="$1" local encoder="$1"
ffmpeg -hide_banner -encoders 2> /dev/null | awk -v encoder="$encoder" '$2 == encoder { found = 1 } END { exit(found ? 0 : 1) }' "$ffmpeg_bin" -hide_banner -encoders 2> /dev/null | awk -v encoder="$encoder" '$2 == encoder { found = 1 } END { exit(found ? 0 : 1) }'
} }
pick_webp_encoder() { pick_webp_encoder() {
@@ -123,7 +154,7 @@ webm_vf="${crop_vf},fps=30"
echo "Generating MP4: $mp4_out" echo "Generating MP4: $mp4_out"
if has_encoder "h264_nvenc"; then if has_encoder "h264_nvenc"; then
echo "Trying GPU encoder for MP4: h264_nvenc" echo "Trying GPU encoder for MP4: h264_nvenc"
if ffmpeg "$overwrite_flag" -i "$input" \ if "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$crop_vf" \ -vf "$crop_vf" \
-c:v h264_nvenc -preset p6 -rc:v vbr -cq:v 20 -b:v 0 \ -c:v h264_nvenc -preset p6 -rc:v vbr -cq:v 20 -b:v 0 \
-pix_fmt yuv420p -movflags +faststart \ -pix_fmt yuv420p -movflags +faststart \
@@ -132,7 +163,7 @@ if has_encoder "h264_nvenc"; then
: :
else else
echo "GPU MP4 encode failed; retrying with CPU encoder: libx264" echo "GPU MP4 encode failed; retrying with CPU encoder: libx264"
ffmpeg "$overwrite_flag" -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$crop_vf" \ -vf "$crop_vf" \
-c:v libx264 -preset slow -crf 20 \ -c:v libx264 -preset slow -crf 20 \
-profile:v high -level 4.1 -pix_fmt yuv420p \ -profile:v high -level 4.1 -pix_fmt yuv420p \
@@ -142,7 +173,7 @@ if has_encoder "h264_nvenc"; then
fi fi
else else
echo "Using CPU encoder for MP4: libx264" echo "Using CPU encoder for MP4: libx264"
ffmpeg "$overwrite_flag" -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$crop_vf" \ -vf "$crop_vf" \
-c:v libx264 -preset slow -crf 20 \ -c:v libx264 -preset slow -crf 20 \
-profile:v high -level 4.1 -pix_fmt yuv420p \ -profile:v high -level 4.1 -pix_fmt yuv420p \
@@ -154,7 +185,7 @@ fi
echo "Generating WebM: $webm_out" echo "Generating WebM: $webm_out"
if has_encoder "av1_nvenc"; then if has_encoder "av1_nvenc"; then
echo "Trying GPU encoder for WebM: av1_nvenc" echo "Trying GPU encoder for WebM: av1_nvenc"
if ffmpeg "$overwrite_flag" -i "$input" \ if "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$webm_vf" \ -vf "$webm_vf" \
-c:v av1_nvenc -preset p6 -cq:v 34 -b:v 0 \ -c:v av1_nvenc -preset p6 -cq:v 34 -b:v 0 \
-c:a libopus -b:a 96k \ -c:a libopus -b:a 96k \
@@ -162,7 +193,7 @@ if has_encoder "av1_nvenc"; then
: :
else else
echo "GPU WebM encode failed; retrying with CPU encoder: libvpx-vp9" echo "GPU WebM encode failed; retrying with CPU encoder: libvpx-vp9"
ffmpeg "$overwrite_flag" -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$webm_vf" \ -vf "$webm_vf" \
-c:v libvpx-vp9 -crf 34 -b:v 0 \ -c:v libvpx-vp9 -crf 34 -b:v 0 \
-row-mt 1 -threads 8 \ -row-mt 1 -threads 8 \
@@ -171,7 +202,7 @@ if has_encoder "av1_nvenc"; then
fi fi
else else
echo "Using CPU encoder for WebM: libvpx-vp9" echo "Using CPU encoder for WebM: libvpx-vp9"
ffmpeg "$overwrite_flag" -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$webm_vf" \ -vf "$webm_vf" \
-c:v libvpx-vp9 -crf 34 -b:v 0 \ -c:v libvpx-vp9 -crf 34 -b:v 0 \
-row-mt 1 -threads 8 \ -row-mt 1 -threads 8 \
@@ -185,7 +216,7 @@ if [[ "$generate_webp" -eq 1 ]]; then
exit 1 exit 1
fi fi
echo "Generating animated WebP with $webp_encoder: $webp_out" echo "Generating animated WebP with $webp_encoder: $webp_out"
ffmpeg "$overwrite_flag" -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "${crop_vf},fps=24,scale=960:-1:flags=lanczos" \ -vf "${crop_vf},fps=24,scale=960:-1:flags=lanczos" \
-c:v "$webp_encoder" \ -c:v "$webp_encoder" \
-q:v 80 \ -q:v 80 \
@@ -195,7 +226,7 @@ if [[ "$generate_webp" -eq 1 ]]; then
fi fi
echo "Generating poster: $poster_out" echo "Generating poster: $poster_out"
ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -ss 00:00:05 -i "$input" \
-vf "$crop_vf" \ -vf "$crop_vf" \
-vframes 1 \ -vframes 1 \
-q:v 2 \ -q:v 2 \

View File

@@ -19,11 +19,33 @@ function writeExecutable(filePath: string, contents: string): void {
fs.chmodSync(filePath, 0o755); fs.chmodSync(filePath, 0o755);
} }
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
function toBashPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
const normalized = filePath.replace(/\\/g, '/');
const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
if (!match) return normalized;
const drive = match[1]!;
const rest = match[2]!;
const probe = spawnSync('bash', ['-c', 'uname -s'], { encoding: 'utf8' });
if (probe.status === 0 && /linux/i.test(probe.stdout)) {
return `/mnt/${drive.toLowerCase()}/${rest}`;
}
return `${drive.toUpperCase()}:/${rest}`;
}
test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => { test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => {
withTempDir((root) => { withTempDir((root) => {
const binDir = path.join(root, 'bin'); const binDir = path.join(root, 'bin');
const inputPath = path.join(root, 'sample.mkv'); const inputPath = path.join(root, 'sample.mkv');
const ffmpegLogPath = path.join(root, 'ffmpeg-args.log'); const ffmpegLogPath = path.join(root, 'ffmpeg-args.log');
const ffmpegLogPathBash = toBashPath(ffmpegLogPath);
fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(inputPath, 'fake-video', 'utf8'); fs.writeFileSync(inputPath, 'fake-video', 'utf8');
@@ -44,22 +66,33 @@ EOF
exit 0 exit 0
fi fi
printf '%s\\n' "$*" >> "${ffmpegLogPath}" if [[ "$#" -eq 0 ]]; then
exit 0
fi
printf '%s\\n' "$*" >> "${ffmpegLogPathBash}"
output="" output=""
for arg in "$@"; do for arg in "$@"; do
output="$arg" output="$arg"
done done
if [[ -z "$output" ]]; then
exit 0
fi
mkdir -p "$(dirname "$output")" mkdir -p "$(dirname "$output")"
touch "$output" touch "$output"
`, `,
); );
const result = spawnSync('bash', ['scripts/mkv-to-readme-video.sh', '--webp', inputPath], { const ffmpegShimPath = toBashPath(path.join(binDir, 'ffmpeg'));
const ffmpegShimDir = toBashPath(binDir);
const inputBashPath = toBashPath(inputPath);
const command = [
`chmod +x ${shellQuote(ffmpegShimPath)}`,
`PATH=${shellQuote(`${ffmpegShimDir}:`)}"$PATH"`,
`scripts/mkv-to-readme-video.sh --webp ${shellQuote(inputBashPath)}`,
].join('; ');
const result = spawnSync('bash', ['-lc', command], {
cwd: process.cwd(), cwd: process.cwd(),
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ''}`,
},
encoding: 'utf8', encoding: 'utf8',
}); });

View File

@@ -13,6 +13,26 @@ appimage=
wrapper= wrapper=
assets= assets=
normalize_path() {
local value="$1"
if command -v cygpath >/dev/null 2>&1; then
case "$value" in
[A-Za-z]:\\* | [A-Za-z]:/*)
cygpath -u "$value"
return 0
;;
esac
fi
if [[ "$value" =~ ^([A-Za-z]):[\\/](.*)$ ]]; then
local drive="${BASH_REMATCH[1],,}"
local rest="${BASH_REMATCH[2]}"
rest="${rest//\\//}"
printf '/mnt/%s/%s\n' "$drive" "$rest"
return 0
fi
printf '%s\n' "$value"
}
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--pkg-dir) --pkg-dir)
@@ -53,6 +73,10 @@ if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$
fi fi
version="${version#v}" version="${version#v}"
pkg_dir="$(normalize_path "$pkg_dir")"
appimage="$(normalize_path "$appimage")"
wrapper="$(normalize_path "$wrapper")"
assets="$(normalize_path "$assets")"
pkgbuild="${pkg_dir}/PKGBUILD" pkgbuild="${pkg_dir}/PKGBUILD"
srcinfo="${pkg_dir}/.SRCINFO" srcinfo="${pkg_dir}/.SRCINFO"
@@ -82,6 +106,9 @@ awk \
found_pkgver = 0 found_pkgver = 0
found_sha_block = 0 found_sha_block = 0
} }
{
sub(/\r$/, "")
}
/^pkgver=/ { /^pkgver=/ {
print "pkgver=" version print "pkgver=" version
found_pkgver = 1 found_pkgver = 1
@@ -140,6 +167,9 @@ awk \
found_source_wrapper = 0 found_source_wrapper = 0
found_source_assets = 0 found_source_assets = 0
} }
{
sub(/\r$/, "")
}
/^\tpkgver = / { /^\tpkgver = / {
print "\tpkgver = " version print "\tpkgver = " version
found_pkgver = 1 found_pkgver = 1

View File

@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process'; import { execFileSync, spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -9,6 +10,23 @@ function createWorkspace(name: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
} }
function toBashPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
const normalized = filePath.replace(/\\/g, '/');
const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
if (!match) return normalized;
const drive = match[1]!;
const rest = match[2]!;
const probe = spawnSync('bash', ['-c', 'uname -s'], { encoding: 'utf8' });
if (probe.status === 0 && /linux/i.test(probe.stdout)) {
return `/mnt/${drive.toLowerCase()}/${rest}`;
}
return `${drive.toUpperCase()}:/${rest}`;
}
test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => { test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
const workspace = createWorkspace('subminer-aur-package'); const workspace = createWorkspace('subminer-aur-package');
const pkgDir = path.join(workspace, 'aur-subminer-bin'); const pkgDir = path.join(workspace, 'aur-subminer-bin');
@@ -29,15 +47,15 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
[ [
'scripts/update-aur-package.sh', 'scripts/update-aur-package.sh',
'--pkg-dir', '--pkg-dir',
pkgDir, toBashPath(pkgDir),
'--version', '--version',
'v0.6.3', 'v0.6.3',
'--appimage', '--appimage',
appImagePath, toBashPath(appImagePath),
'--wrapper', '--wrapper',
wrapperPath, toBashPath(wrapperPath),
'--assets', '--assets',
assetsPath, toBashPath(assetsPath),
], ],
{ {
cwd: process.cwd(), cwd: process.cwd(),
@@ -47,8 +65,8 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8'); const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8'); const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
const expectedSums = [appImagePath, wrapperPath, assetsPath].map( const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) =>
(filePath) => execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0], crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
); );
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m); assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);

View File

@@ -73,6 +73,33 @@ test('parseArgs captures youtube startup forwarding flags', () => {
assert.equal(shouldStartApp(args), true); assert.equal(shouldStartApp(args), true);
}); });
test('parseArgs captures session action forwarding flags', () => {
const args = parseArgs([
'--open-jimaku',
'--open-youtube-picker',
'--open-playlist-browser',
'--replay-current-subtitle',
'--play-next-subtitle',
'--shift-sub-delay-prev-line',
'--shift-sub-delay-next-line',
'--copy-subtitle-count',
'3',
'--mine-sentence-count=2',
]);
assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
assert.equal(args.replayCurrentSubtitle, true);
assert.equal(args.playNextSubtitle, true);
assert.equal(args.shiftSubDelayPrevLine, true);
assert.equal(args.shiftSubDelayNextLine, true);
assert.equal(args.copySubtitleCount, 3);
assert.equal(args.mineSentenceCount, 2);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
});
test('youtube playback does not use generic overlay-runtime bootstrap classification', () => { test('youtube playback does not use generic overlay-runtime bootstrap classification', () => {
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']); const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);

View File

@@ -25,6 +25,15 @@ export interface CliArgs {
triggerSubsync: boolean; triggerSubsync: boolean;
markAudioCard: boolean; markAudioCard: boolean;
openRuntimeOptions: boolean; openRuntimeOptions: boolean;
openJimaku: boolean;
openYoutubePicker: boolean;
openPlaylistBrowser: boolean;
replayCurrentSubtitle: boolean;
playNextSubtitle: boolean;
shiftSubDelayPrevLine: boolean;
shiftSubDelayNextLine: boolean;
copySubtitleCount?: number;
mineSentenceCount?: number;
anilistStatus: boolean; anilistStatus: boolean;
anilistLogout: boolean; anilistLogout: boolean;
anilistSetup: boolean; anilistSetup: boolean;
@@ -103,6 +112,13 @@ export function parseArgs(argv: string[]): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,
@@ -180,6 +196,26 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--trigger-subsync') args.triggerSubsync = true; else if (arg === '--trigger-subsync') args.triggerSubsync = true;
else if (arg === '--mark-audio-card') args.markAudioCard = true; else if (arg === '--mark-audio-card') args.markAudioCard = true;
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true; else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
else if (arg === '--open-jimaku') args.openJimaku = true;
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
else if (arg.startsWith('--copy-subtitle-count=')) {
const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value)) args.copySubtitleCount = value;
} else if (arg === '--copy-subtitle-count') {
const value = Number(readValue(argv[i + 1]));
if (Number.isInteger(value)) args.copySubtitleCount = value;
} else if (arg.startsWith('--mine-sentence-count=')) {
const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value)) args.mineSentenceCount = value;
} else if (arg === '--mine-sentence-count') {
const value = Number(readValue(argv[i + 1]));
if (Number.isInteger(value)) args.mineSentenceCount = value;
}
else if (arg === '--anilist-status') args.anilistStatus = true; else if (arg === '--anilist-status') args.anilistStatus = true;
else if (arg === '--anilist-logout') args.anilistLogout = true; else if (arg === '--anilist-logout') args.anilistLogout = true;
else if (arg === '--anilist-setup') args.anilistSetup = true; else if (arg === '--anilist-setup') args.anilistSetup = true;
@@ -372,6 +408,15 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.anilistStatus || args.anilistStatus ||
args.anilistLogout || args.anilistLogout ||
args.anilistSetup || args.anilistSetup ||
@@ -424,6 +469,15 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.triggerSubsync && !args.triggerSubsync &&
!args.markAudioCard && !args.markAudioCard &&
!args.openRuntimeOptions && !args.openRuntimeOptions &&
!args.openJimaku &&
!args.openYoutubePicker &&
!args.openPlaylistBrowser &&
!args.replayCurrentSubtitle &&
!args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
!args.anilistLogout && !args.anilistLogout &&
!args.anilistSetup && !args.anilistSetup &&
@@ -467,6 +521,15 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.dictionary || args.dictionary ||
args.stats || args.stats ||
args.jellyfin || args.jellyfin ||
@@ -505,6 +568,15 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.triggerSubsync && !args.triggerSubsync &&
!args.markAudioCard && !args.markAudioCard &&
!args.openRuntimeOptions && !args.openRuntimeOptions &&
!args.openJimaku &&
!args.openYoutubePicker &&
!args.openPlaylistBrowser &&
!args.replayCurrentSubtitle &&
!args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
!args.anilistLogout && !args.anilistLogout &&
!args.anilistSetup && !args.anilistSetup &&
@@ -547,7 +619,16 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.triggerFieldGrouping || args.triggerFieldGrouping ||
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions args.openRuntimeOptions ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined
); );
} }

View File

@@ -166,20 +166,14 @@ const TRENDS_DASHBOARD = {
ratios: { ratios: {
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }], lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
}, },
librarySummary: [ animePerDay: {
{ episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
title: 'Little Witch Academia', watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
watchTimeMin: 25, cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
videos: 1, words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
sessions: 1, lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }],
cards: 5, lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
words: 300,
lookups: 15,
lookupsPerHundred: 5,
firstWatched: 20_000,
lastWatched: 20_000,
}, },
],
animeCumulative: { animeCumulative: {
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }], watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }], episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
@@ -604,23 +598,7 @@ describe('stats server API routes', () => {
const body = await res.json(); const body = await res.json();
assert.deepEqual(seenArgs, ['90d', 'month']); assert.deepEqual(seenArgs, ['90d', 'month']);
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime); assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary); assert.deepEqual(body.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
});
it('GET /api/stats/trends/dashboard accepts 365d range', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
createMockTracker({
getTrendsDashboard: async (...args: unknown[]) => {
seenArgs = args;
return TRENDS_DASHBOARD;
},
}),
);
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['365d', 'month']);
}); });
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => { it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {

View File

@@ -29,6 +29,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,

View File

@@ -31,6 +31,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
markAudioCard: false, markAudioCard: false,
refreshKnownWords: false, refreshKnownWords: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,
@@ -143,6 +150,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette: () => {
calls.push('openRuntimeOptionsPalette'); calls.push('openRuntimeOptionsPalette');
}, },
dispatchSessionAction: async () => {
calls.push('dispatchSessionAction');
},
getAnilistStatus: () => ({ getAnilistStatus: () => ({
tokenStatus: 'resolved', tokenStatus: 'resolved',
tokenSource: 'stored', tokenSource: 'stored',

View File

@@ -1,4 +1,5 @@
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args'; import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
import type { SessionActionDispatchRequest } from '../../types/runtime';
export interface CliCommandServiceDeps { export interface CliCommandServiceDeps {
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void; setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
@@ -32,6 +33,7 @@ export interface CliCommandServiceDeps {
triggerSubsyncFromConfig: () => Promise<void>; triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
getAnilistStatus: () => { getAnilistStatus: () => {
tokenStatus: 'not_checked' | 'resolved' | 'error'; tokenStatus: 'not_checked' | 'resolved' | 'error';
tokenSource: 'none' | 'literal' | 'stored'; tokenSource: 'none' | 'literal' | 'stored';
@@ -168,6 +170,7 @@ export interface CliCommandDepsRuntimeOptions {
}; };
ui: UiCliRuntime; ui: UiCliRuntime;
app: AppCliRuntime; app: AppCliRuntime;
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => unknown; schedule: (fn: () => void, delayMs: number) => unknown;
log: (message: string) => void; log: (message: string) => void;
@@ -226,6 +229,7 @@ export function createCliCommandDepsRuntime(
triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig, triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig,
markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard, markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard,
openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette, openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette,
dispatchSessionAction: options.dispatchSessionAction,
getAnilistStatus: options.anilist.getStatus, getAnilistStatus: options.anilist.getStatus,
clearAnilistToken: options.anilist.clearToken, clearAnilistToken: options.anilist.clearToken,
openAnilistSetup: options.anilist.openSetup, openAnilistSetup: options.anilist.openSetup,
@@ -268,6 +272,19 @@ export function handleCliCommand(
source: CliCommandSource = 'initial', source: CliCommandSource = 'initial',
deps: CliCommandServiceDeps, deps: CliCommandServiceDeps,
): void { ): void {
const dispatchCliSessionAction = (
request: SessionActionDispatchRequest,
logLabel: string,
osdLabel: string,
): void => {
runAsyncWithOsd(
() => deps.dispatchSessionAction?.(request) ?? Promise.resolve(),
deps,
logLabel,
osdLabel,
);
};
if (args.logLevel) { if (args.logLevel) {
deps.setLogLevel?.(args.logLevel); deps.setLogLevel?.(args.logLevel);
} }
@@ -381,6 +398,56 @@ export function handleCliCommand(
); );
} else if (args.openRuntimeOptions) { } else if (args.openRuntimeOptions) {
deps.openRuntimeOptionsPalette(); deps.openRuntimeOptionsPalette();
} else if (args.openJimaku) {
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
} else if (args.openYoutubePicker) {
dispatchCliSessionAction(
{ actionId: 'openYoutubePicker' },
'openYoutubePicker',
'Open YouTube picker failed',
);
} else if (args.openPlaylistBrowser) {
dispatchCliSessionAction(
{ actionId: 'openPlaylistBrowser' },
'openPlaylistBrowser',
'Open playlist browser failed',
);
} else if (args.replayCurrentSubtitle) {
dispatchCliSessionAction(
{ actionId: 'replayCurrentSubtitle' },
'replayCurrentSubtitle',
'Replay subtitle failed',
);
} else if (args.playNextSubtitle) {
dispatchCliSessionAction(
{ actionId: 'playNextSubtitle' },
'playNextSubtitle',
'Play next subtitle failed',
);
} else if (args.shiftSubDelayPrevLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayPrevLine' },
'shiftSubDelayPrevLine',
'Shift subtitle delay failed',
);
} else if (args.shiftSubDelayNextLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayNextLine' },
'shiftSubDelayNextLine',
'Shift subtitle delay failed',
);
} else if (args.copySubtitleCount !== undefined) {
dispatchCliSessionAction(
{ actionId: 'copySubtitleMultiple', payload: { count: args.copySubtitleCount } },
'copySubtitleMultiple',
'Copy failed',
);
} else if (args.mineSentenceCount !== undefined) {
dispatchCliSessionAction(
{ actionId: 'mineSentenceMultiple', payload: { count: args.mineSentenceCount } },
'mineSentenceMultiple',
'Mine sentence failed',
);
} else if (args.anilistStatus) { } else if (args.anilistStatus) {
const status = deps.getAnilistStatus(); const status = deps.getAnilistStatus();
deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`); deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`);

View File

@@ -488,7 +488,7 @@ export class ImmersionTrackerService {
} }
async getTrendsDashboard( async getTrendsDashboard(
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d', range: '7d' | '30d' | '90d' | 'all' = '30d',
groupBy: 'day' | 'month' = 'day', groupBy: 'day' | 'month' = 'day',
): Promise<unknown> { ): Promise<unknown> {
return getTrendsDashboard(this.db, range, groupBy); return getTrendsDashboard(this.db, range, groupBy);

View File

@@ -687,7 +687,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
assert.equal(dashboard.progress.watchTime[1]?.value, 75); assert.equal(dashboard.progress.watchTime[1]?.value, 75);
assert.equal(dashboard.progress.lookups[1]?.value, 18); assert.equal(dashboard.progress.lookups[1]?.value, 18);
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1)); assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime'); assert.equal(dashboard.animePerDay.watchTime[0]?.animeTitle, 'Trend Dashboard Anime');
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75); assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
assert.equal( assert.equal(
dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0), dashboard.patterns.watchTimeByDayOfWeek.reduce((sum, point) => sum + point.value, 0),
@@ -835,65 +835,6 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
} }
}); });
test('getTrendsDashboard supports 365d range and caps day buckets at 365', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/365d-trends.mkv', {
canonicalTitle: '365d Trends',
sourcePath: '/tmp/365d-trends.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: '365d Trends',
canonicalTitle: '365d Trends',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: '365d-trends.mkv',
parsedTitle: '365d Trends',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
// Seed 400 distinct rollup days so we can prove the 365d range caps at 365.
const latestRollupDay = 20513;
const createdAtMs = '1772395200000';
for (let offset = 0; offset < 400; offset += 1) {
const rollupDay = latestRollupDay - offset;
insertDailyRollup.run(rollupDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
}
const dashboard = getTrendsDashboard(db, '365d', 'day');
assert.equal(dashboard.activity.watchTime.length, 365);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
});
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => { test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
@@ -3725,224 +3666,3 @@ test('deleteSession removes zero-session media from library and trends', () => {
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
} }
}); });
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/library-summary-test.mkv', {
canonicalTitle: 'Library Summary Test',
sourcePath: '/tmp/library-summary-test.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Summary Anime',
canonicalTitle: 'Summary Anime',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: 'library-summary-test.mkv',
parsedTitle: 'Summary Anime',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
const dayOneStart = 1_700_000_000_000;
const dayTwoStart = dayOneStart + 86_400_000;
const sessionOne = startSessionRecord(db, videoId, dayOneStart);
const sessionTwo = startSessionRecord(db, videoId, dayTwoStart);
for (const [sessionId, startedAtMs, activeMs, cards, tokens, lookups] of [
[sessionOne.sessionId, dayOneStart, 30 * 60_000, 2, 120, 8],
[sessionTwo.sessionId, dayTwoStart, 45 * 60_000, 3, 140, 10],
] as const) {
stmts.telemetryInsertStmt.run(
sessionId,
`${startedAtMs + 60_000}`,
activeMs,
activeMs,
10,
tokens,
cards,
0,
0,
lookups,
0,
0,
0,
0,
`${startedAtMs + 60_000}`,
`${startedAtMs + 60_000}`,
);
db.prepare(
`
UPDATE imm_sessions
SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?,
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
).run(
`${startedAtMs + activeMs}`,
activeMs,
activeMs,
10,
tokens,
cards,
lookups,
sessionId,
);
}
for (const [day, active, tokens, cards] of [
[Math.floor(dayOneStart / 86_400_000), 30, 120, 2],
[Math.floor(dayTwoStart / 86_400_000), 45, 140, 3],
] as const) {
db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards
) VALUES (?, ?, ?, ?, ?, ?, ?)
`,
).run(day, videoId, 1, active, 10, tokens, cards);
}
const dashboard = getTrendsDashboard(db, 'all', 'day');
assert.equal(dashboard.librarySummary.length, 1);
const row = dashboard.librarySummary[0]!;
assert.equal(row.title, 'Summary Anime');
assert.equal(row.watchTimeMin, 75);
assert.equal(row.videos, 1);
assert.equal(row.sessions, 2);
assert.equal(row.cards, 5);
assert.equal(row.words, 260);
assert.equal(row.lookups, 18);
assert.equal(row.lookupsPerHundred, +((18 / 260) * 100).toFixed(1));
assert.equal(row.firstWatched, Math.floor(dayOneStart / 86_400_000));
assert.equal(row.lastWatched, Math.floor(dayTwoStart / 86_400_000));
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/lib-summary-null.mkv', {
canonicalTitle: 'Null Lookups Title',
sourcePath: '/tmp/lib-summary-null.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Null Lookups Anime',
canonicalTitle: 'Null Lookups Anime',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: 'lib-summary-null.mkv',
parsedTitle: 'Null Lookups Anime',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
const startMs = 1_700_000_000_000;
const session = startSessionRecord(db, videoId, startMs);
stmts.telemetryInsertStmt.run(
session.sessionId,
`${startMs + 60_000}`,
20 * 60_000,
20 * 60_000,
5,
0,
0,
0,
0,
0,
0,
0,
0,
0,
`${startMs + 60_000}`,
`${startMs + 60_000}`,
);
db.prepare(
`
UPDATE imm_sessions
SET ended_at_ms = ?, total_watched_ms = ?, active_watched_ms = ?,
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
).run(
`${startMs + 20 * 60_000}`,
20 * 60_000,
20 * 60_000,
5,
0,
0,
0,
session.sessionId,
);
db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards
) VALUES (?, ?, ?, ?, ?, ?, ?)
`,
).run(Math.floor(startMs / 86_400_000), videoId, 1, 20, 5, 0, 0);
const dashboard = getTrendsDashboard(db, 'all', 'day');
assert.equal(dashboard.librarySummary.length, 1);
assert.equal(dashboard.librarySummary[0]!.lookupsPerHundred, null);
assert.equal(dashboard.librarySummary[0]!.words, 0);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const dashboard = getTrendsDashboard(db, 'all', 'day');
assert.deepEqual(dashboard.librarySummary, []);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});

View File

@@ -13,7 +13,7 @@ import {
} from './query-shared'; } from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions'; import { getDailyRollups, getMonthlyRollups } from './query-sessions';
type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all'; type TrendRange = '7d' | '30d' | '90d' | 'all';
type TrendGroupBy = 'day' | 'month'; type TrendGroupBy = 'day' | 'month';
interface TrendChartPoint { interface TrendChartPoint {
@@ -27,19 +27,6 @@ interface TrendPerAnimePoint {
value: number; value: number;
} }
export interface LibrarySummaryRow {
title: string;
watchTimeMin: number;
videos: number;
sessions: number;
cards: number;
words: number;
lookups: number;
lookupsPerHundred: number | null;
firstWatched: number;
lastWatched: number;
}
interface TrendSessionMetricRow { interface TrendSessionMetricRow {
startedAtMs: number; startedAtMs: number;
epochDay: number; epochDay: number;
@@ -74,6 +61,14 @@ export interface TrendsDashboardQueryResult {
ratios: { ratios: {
lookupsPerHundred: TrendChartPoint[]; lookupsPerHundred: TrendChartPoint[];
}; };
animePerDay: {
episodes: TrendPerAnimePoint[];
watchTime: TrendPerAnimePoint[];
cards: TrendPerAnimePoint[];
words: TrendPerAnimePoint[];
lookups: TrendPerAnimePoint[];
lookupsPerHundred: TrendPerAnimePoint[];
};
animeCumulative: { animeCumulative: {
watchTime: TrendPerAnimePoint[]; watchTime: TrendPerAnimePoint[];
episodes: TrendPerAnimePoint[]; episodes: TrendPerAnimePoint[];
@@ -84,14 +79,12 @@ export interface TrendsDashboardQueryResult {
watchTimeByDayOfWeek: TrendChartPoint[]; watchTimeByDayOfWeek: TrendChartPoint[];
watchTimeByHour: TrendChartPoint[]; watchTimeByHour: TrendChartPoint[];
}; };
librarySummary: LibrarySummaryRow[];
} }
const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = { const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
'7d': 7, '7d': 7,
'30d': 30, '30d': 30,
'90d': 90, '90d': 90,
'365d': 365,
}; };
const MONTH_NAMES = [ const MONTH_NAMES = [
@@ -307,6 +300,61 @@ function buildLookupsPerHundredWords(
}); });
} }
function buildPerAnimeFromSessions(
sessions: TrendSessionMetricRow[],
getValue: (session: TrendSessionMetricRow) => number,
): TrendPerAnimePoint[] {
const byAnime = new Map<string, Map<number, number>>();
for (const session of sessions) {
const animeTitle = resolveTrendAnimeTitle(session);
const epochDay = session.epochDay;
const dayMap = byAnime.get(animeTitle) ?? new Map();
dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session));
byAnime.set(animeTitle, dayMap);
}
const result: TrendPerAnimePoint[] = [];
for (const [animeTitle, dayMap] of byAnime) {
for (const [epochDay, value] of dayMap) {
result.push({ epochDay, animeTitle, value });
}
}
return result;
}
function buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): TrendPerAnimePoint[] {
const lookups = new Map<string, Map<number, number>>();
const words = new Map<string, Map<number, number>>();
for (const session of sessions) {
const animeTitle = resolveTrendAnimeTitle(session);
const epochDay = session.epochDay;
const lookupMap = lookups.get(animeTitle) ?? new Map();
lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount);
lookups.set(animeTitle, lookupMap);
const wordMap = words.get(animeTitle) ?? new Map();
wordMap.set(epochDay, (wordMap.get(epochDay) ?? 0) + getTrendSessionWordCount(session));
words.set(animeTitle, wordMap);
}
const result: TrendPerAnimePoint[] = [];
for (const [animeTitle, dayMap] of lookups) {
const wordMap = words.get(animeTitle) ?? new Map();
for (const [epochDay, lookupCount] of dayMap) {
const wordCount = wordMap.get(epochDay) ?? 0;
result.push({
epochDay,
animeTitle,
value: wordCount > 0 ? +((lookupCount / wordCount) * 100).toFixed(1) : 0,
});
}
}
return result;
}
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] { function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
const byAnime = new Map<string, Map<number, number>>(); const byAnime = new Map<string, Map<number, number>>();
const allDays = new Set<number>(); const allDays = new Set<number>();
@@ -342,89 +390,6 @@ function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoi
return result; return result;
} }
function buildLibrarySummary(
rollups: ImmersionSessionRollupRow[],
sessions: TrendSessionMetricRow[],
titlesByVideoId: Map<number, string>,
): LibrarySummaryRow[] {
type Accum = {
watchTimeMin: number;
videos: Set<number>;
cards: number;
words: number;
firstWatched: number;
lastWatched: number;
sessions: number;
lookups: number;
};
const byTitle = new Map<string, Accum>();
const ensure = (title: string): Accum => {
const existing = byTitle.get(title);
if (existing) return existing;
const created: Accum = {
watchTimeMin: 0,
videos: new Set<number>(),
cards: 0,
words: 0,
firstWatched: Number.POSITIVE_INFINITY,
lastWatched: Number.NEGATIVE_INFINITY,
sessions: 0,
lookups: 0,
};
byTitle.set(title, created);
return created;
};
for (const rollup of rollups) {
if (rollup.videoId === null) continue;
const title = resolveVideoAnimeTitle(rollup.videoId, titlesByVideoId);
const acc = ensure(title);
acc.watchTimeMin += rollup.totalActiveMin;
acc.cards += rollup.totalCards;
acc.words += rollup.totalTokensSeen;
acc.videos.add(rollup.videoId);
if (rollup.rollupDayOrMonth < acc.firstWatched) {
acc.firstWatched = rollup.rollupDayOrMonth;
}
if (rollup.rollupDayOrMonth > acc.lastWatched) {
acc.lastWatched = rollup.rollupDayOrMonth;
}
}
for (const session of sessions) {
const title = resolveTrendAnimeTitle(session);
if (!byTitle.has(title)) continue;
const acc = byTitle.get(title)!;
acc.sessions += 1;
acc.lookups += session.yomitanLookupCount;
}
const rows: LibrarySummaryRow[] = [];
for (const [title, acc] of byTitle) {
if (!Number.isFinite(acc.firstWatched) || !Number.isFinite(acc.lastWatched)) {
continue;
}
rows.push({
title,
watchTimeMin: Math.round(acc.watchTimeMin),
videos: acc.videos.size,
sessions: acc.sessions,
cards: acc.cards,
words: acc.words,
lookups: acc.lookups,
lookupsPerHundred:
acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
firstWatched: acc.firstWatched,
lastWatched: acc.lastWatched,
});
}
rows.sort((a, b) => b.watchTimeMin - a.watchTimeMin || a.title.localeCompare(b.title));
return rows;
}
function getVideoAnimeTitleMap( function getVideoAnimeTitleMap(
db: DatabaseSync, db: DatabaseSync,
videoIds: Array<number | null>, videoIds: Array<number | null>,
@@ -697,6 +662,8 @@ export function getTrendsDashboard(
titlesByVideoId, titlesByVideoId,
(rollup) => rollup.totalTokensSeen, (rollup) => rollup.totalTokensSeen,
), ),
lookups: buildPerAnimeFromSessions(sessions, (session) => session.yomitanLookupCount),
lookupsPerHundred: buildLookupsPerHundredPerAnime(sessions),
}; };
return { return {
@@ -723,6 +690,7 @@ export function getTrendsDashboard(
ratios: { ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy), lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
}, },
animePerDay,
animeCumulative: { animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime), watchTime: buildCumulativePerAnime(animePerDay.watchTime),
episodes: buildCumulativePerAnime(animePerDay.episodes), episodes: buildCumulativePerAnime(animePerDay.episodes),
@@ -733,6 +701,5 @@ export function getTrendsDashboard(
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions), watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
watchTimeByHour: buildWatchTimeByHour(sessions), watchTimeByHour: buildWatchTimeByHour(sessions),
}, },
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
}; };
} }

View File

@@ -72,6 +72,7 @@ export {
createOverlayWindow, createOverlayWindow,
enforceOverlayLayerOrder, enforceOverlayLayerOrder,
ensureOverlayWindowLevel, ensureOverlayWindowLevel,
isOverlayWindowContentReady,
syncOverlayWindowLayer, syncOverlayWindowLayer,
updateOverlayWindowBounds, updateOverlayWindowBounds,
} from './overlay-window'; } from './overlay-window';

View File

@@ -127,7 +127,9 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
setMecabEnabled: () => {}, setMecabEnabled: () => {},
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
@@ -226,7 +228,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getMecabTokenizer: () => null, getMecabTokenizer: () => null,
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
@@ -382,7 +386,9 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
setMecabEnabled: () => {}, setMecabEnabled: () => {},
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
@@ -707,7 +713,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
setMecabEnabled: () => {}, setMecabEnabled: () => {},
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
@@ -786,7 +794,9 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
setMecabEnabled: () => {}, setMecabEnabled: () => {},
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
@@ -872,7 +882,9 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
setMecabEnabled: () => {}, setMecabEnabled: () => {},
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),

View File

@@ -1,6 +1,7 @@
import electron from 'electron'; import electron from 'electron';
import type { IpcMainEvent } from 'electron'; import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
import type { import type {
CompiledSessionBinding,
ControllerConfigUpdate, ControllerConfigUpdate,
PlaylistBrowserMutationResult, PlaylistBrowserMutationResult,
PlaylistBrowserSnapshot, PlaylistBrowserSnapshot,
@@ -12,6 +13,7 @@ import type {
SubtitlePosition, SubtitlePosition,
SubsyncManualRunRequest, SubsyncManualRunRequest,
SubsyncResult, SubsyncResult,
SessionActionDispatchRequest,
YoutubePickerResolveRequest, YoutubePickerResolveRequest,
YoutubePickerResolveResult, YoutubePickerResolveResult,
} from '../../types'; } from '../../types';
@@ -30,11 +32,14 @@ import {
parseYoutubePickerResolveRequest, parseYoutubePickerResolveRequest,
} from '../../shared/ipc/validators'; } from '../../shared/ipc/validators';
const { BrowserWindow, ipcMain } = electron; const { ipcMain } = electron;
export interface IpcServiceDeps { export interface IpcServiceDeps {
onOverlayModalClosed: (modal: OverlayHostedModal) => void; onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalOpened?: (modal: OverlayHostedModal) => void; onOverlayModalOpened?: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
toggleDevTools: () => void; toggleDevTools: () => void;
@@ -56,7 +61,9 @@ export interface IpcServiceDeps {
setMecabEnabled: (enabled: boolean) => void; setMecabEnabled: (enabled: boolean) => void;
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string; getStatsToggleKey: () => string;
getMarkWatchedKey: () => string; getMarkWatchedKey: () => string;
getControllerConfig: () => ResolvedControllerConfig; getControllerConfig: () => ResolvedControllerConfig;
@@ -154,7 +161,10 @@ export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null; getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean; getVisibleOverlayVisibility: () => boolean;
onOverlayModalClosed: (modal: OverlayHostedModal) => void; onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalOpened?: (modal: OverlayHostedModal) => void; onOverlayModalOpened?: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
@@ -169,7 +179,9 @@ export interface IpcDepsRuntimeOptions {
getMecabTokenizer: () => MecabTokenizerLike | null; getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string; getStatsToggleKey: () => string;
getMarkWatchedKey: () => string; getMarkWatchedKey: () => string;
getControllerConfig: () => ResolvedControllerConfig; getControllerConfig: () => ResolvedControllerConfig;
@@ -238,7 +250,9 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
}, },
handleMpvCommand: options.handleMpvCommand, handleMpvCommand: options.handleMpvCommand,
getKeybindings: options.getKeybindings, getKeybindings: options.getKeybindings,
getSessionBindings: options.getSessionBindings ?? (() => []),
getConfiguredShortcuts: options.getConfiguredShortcuts, getConfiguredShortcuts: options.getConfiguredShortcuts,
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
getStatsToggleKey: options.getStatsToggleKey, getStatsToggleKey: options.getStatsToggleKey,
getMarkWatchedKey: options.getMarkWatchedKey, getMarkWatchedKey: options.getMarkWatchedKey,
getControllerConfig: options.getControllerConfig, getControllerConfig: options.getControllerConfig,
@@ -299,7 +313,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
(event: unknown, ignore: unknown, options: unknown = {}) => { (event: unknown, ignore: unknown, options: unknown = {}) => {
if (typeof ignore !== 'boolean') return; if (typeof ignore !== 'boolean') return;
const parsedOptions = parseOptionalForwardingOptions(options); const parsedOptions = parseOptionalForwardingOptions(options);
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender); const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
if (senderWindow && !senderWindow.isDestroyed()) { if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions); senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
} }
@@ -311,11 +326,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
if (!parsedModal) return; if (!parsedModal) return;
deps.onOverlayModalClosed(parsedModal); deps.onOverlayModalClosed(parsedModal);
}); });
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (_event: unknown, modal: unknown) => { ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal); const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return; if (!parsedModal) return;
if (!deps.onOverlayModalOpened) return; if (!deps.onOverlayModalOpened) return;
deps.onOverlayModalOpened(parsedModal); const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalOpened(parsedModal, senderWindow);
}); });
ipc.handle( ipc.handle(
@@ -431,10 +448,36 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.handleMpvCommand(parsedCommand); deps.handleMpvCommand(parsedCommand);
}); });
ipc.handle(
IPC_CHANNELS.command.dispatchSessionAction,
async (_event: unknown, request: unknown) => {
if (!request || typeof request !== 'object') {
throw new Error('Invalid session action payload');
}
const actionId =
typeof (request as Record<string, unknown>).actionId === 'string'
? ((request as Record<string, unknown>).actionId as SessionActionDispatchRequest['actionId'])
: null;
if (!actionId) {
throw new Error('Invalid session action id');
}
const payload =
(request as Record<string, unknown>).payload &&
typeof (request as Record<string, unknown>).payload === 'object'
? ((request as Record<string, unknown>).payload as SessionActionDispatchRequest['payload'])
: undefined;
await deps.dispatchSessionAction?.({ actionId, payload });
},
);
ipc.handle(IPC_CHANNELS.request.getKeybindings, () => { ipc.handle(IPC_CHANNELS.request.getKeybindings, () => {
return deps.getKeybindings(); return deps.getKeybindings();
}); });
ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => {
return deps.getSessionBindings?.() ?? [];
});
ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => { ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => {
return deps.getConfiguredShortcuts(); return deps.getConfiguredShortcuts();
}); });

View File

@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
buildMpvLoadfileCommands, buildMpvLoadfileCommands,
buildMpvSubtitleAddCommands,
collectDroppedSubtitlePaths,
collectDroppedVideoPaths, collectDroppedVideoPaths,
parseClipboardVideoPath, parseClipboardVideoPath,
type DropDataTransferLike, type DropDataTransferLike,
@@ -41,6 +43,33 @@ test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates',
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']); assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
}); });
test('collectDroppedSubtitlePaths keeps supported dropped subtitle paths in order', () => {
const transfer = makeTransfer({
files: [
{ path: '/subs/ep02.ass' },
{ path: '/subs/readme.txt' },
{ path: '/subs/ep03.SRT' },
],
});
const result = collectDroppedSubtitlePaths(transfer);
assert.deepEqual(result, ['/subs/ep02.ass', '/subs/ep03.SRT']);
});
test('collectDroppedSubtitlePaths parses text/uri-list entries and de-duplicates', () => {
const transfer = makeTransfer({
getData: (format: string) =>
format === 'text/uri-list'
? '#comment\nfile:///tmp/ep01.ass\nfile:///tmp/ep01.ass\nfile:///tmp/ep02.vtt\nfile:///tmp/readme.md\n'
: '',
});
const result = collectDroppedSubtitlePaths(transfer);
assert.deepEqual(result, ['/tmp/ep01.ass', '/tmp/ep02.vtt']);
});
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => { test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false); const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
@@ -59,6 +88,15 @@ test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () =>
]); ]);
}); });
test('buildMpvSubtitleAddCommands selects first subtitle and adds remainder', () => {
const commands = buildMpvSubtitleAddCommands(['/tmp/ep01.ass', '/tmp/ep02.srt']);
assert.deepEqual(commands, [
['sub-add', '/tmp/ep01.ass', 'select'],
['sub-add', '/tmp/ep02.srt'],
]);
});
test('parseClipboardVideoPath accepts quoted local paths', () => { test('parseClipboardVideoPath accepts quoted local paths', () => {
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv'); assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
}); });

View File

@@ -22,6 +22,8 @@ const VIDEO_EXTENSIONS = new Set([
'.wmv', '.wmv',
]); ]);
const SUBTITLE_EXTENSIONS = new Set(['.ass', '.srt', '.ssa', '.sub', '.vtt']);
function getPathExtension(pathValue: string): string { function getPathExtension(pathValue: string): string {
const normalized = pathValue.split(/[?#]/, 1)[0] ?? ''; const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
const dot = normalized.lastIndexOf('.'); const dot = normalized.lastIndexOf('.');
@@ -32,7 +34,11 @@ function isSupportedVideoPath(pathValue: string): boolean {
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue)); return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
} }
function parseUriList(data: string): string[] { function isSupportedSubtitlePath(pathValue: string): boolean {
return SUBTITLE_EXTENSIONS.has(getPathExtension(pathValue));
}
function parseUriList(data: string, isSupportedPath: (pathValue: string) => boolean): string[] {
if (!data.trim()) return []; if (!data.trim()) return [];
const out: string[] = []; const out: string[] = [];
@@ -47,7 +53,7 @@ function parseUriList(data: string): string[] {
if (/^\/[A-Za-z]:\//.test(filePath)) { if (/^\/[A-Za-z]:\//.test(filePath)) {
filePath = filePath.slice(1); filePath = filePath.slice(1);
} }
if (filePath && isSupportedVideoPath(filePath)) { if (filePath && isSupportedPath(filePath)) {
out.push(filePath); out.push(filePath);
} }
} catch { } catch {
@@ -87,6 +93,19 @@ export function parseClipboardVideoPath(text: string): string | null {
export function collectDroppedVideoPaths( export function collectDroppedVideoPaths(
dataTransfer: DropDataTransferLike | null | undefined, dataTransfer: DropDataTransferLike | null | undefined,
): string[] {
return collectDroppedPaths(dataTransfer, isSupportedVideoPath);
}
export function collectDroppedSubtitlePaths(
dataTransfer: DropDataTransferLike | null | undefined,
): string[] {
return collectDroppedPaths(dataTransfer, isSupportedSubtitlePath);
}
function collectDroppedPaths(
dataTransfer: DropDataTransferLike | null | undefined,
isSupportedPath: (pathValue: string) => boolean,
): string[] { ): string[] {
if (!dataTransfer) return []; if (!dataTransfer) return [];
@@ -96,7 +115,7 @@ export function collectDroppedVideoPaths(
const addPath = (candidate: string | null | undefined): void => { const addPath = (candidate: string | null | undefined): void => {
if (!candidate) return; if (!candidate) return;
const trimmed = candidate.trim(); const trimmed = candidate.trim();
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return; if (!trimmed || !isSupportedPath(trimmed) || seen.has(trimmed)) return;
seen.add(trimmed); seen.add(trimmed);
out.push(trimmed); out.push(trimmed);
}; };
@@ -109,7 +128,7 @@ export function collectDroppedVideoPaths(
} }
if (typeof dataTransfer.getData === 'function') { if (typeof dataTransfer.getData === 'function') {
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) { for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'), isSupportedPath)) {
addPath(pathValue); addPath(pathValue);
} }
} }
@@ -130,3 +149,9 @@ export function buildMpvLoadfileCommands(
index === 0 ? 'replace' : 'append', index === 0 ? 'replace' : 'append',
]); ]);
} }
export function buildMpvSubtitleAddCommands(paths: string[]): Array<(string | number)[]> {
return paths.map((pathValue, index) =>
index === 0 ? ['sub-add', pathValue, 'select'] : ['sub-add', pathValue],
);
}

View File

@@ -443,3 +443,214 @@ test('initializeOverlayRuntime refreshes visible overlay when tracker focus chan
assert.equal(visibilityRefreshCalls, 2); assert.equal(visibilityRefreshCalls, 2);
}); });
test('initializeOverlayRuntime refreshes the current subtitle when tracker finds the target window again', () => {
let subtitleRefreshCalls = 0;
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
start: () => {},
};
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {},
refreshCurrentSubtitle: () => {
subtitleRefreshCalls += 1;
},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 });
assert.equal(subtitleRefreshCalls, 1);
});
test('initializeOverlayRuntime hides overlay windows when tracker loses the target window', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
isTargetWindowMinimized: () => true,
start: () => {},
};
const overlayWindows = [
{
hide: () => calls.push('hide-visible'),
},
{
hide: () => calls.push('hide-modal'),
},
];
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {},
refreshCurrentSubtitle: () => {},
getOverlayWindows: () => overlayWindows as never,
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
tracker.onWindowLost?.();
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
});
test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
isTargetWindowMinimized: () => false,
start: () => {},
};
const overlayWindows = [
{
hide: () => calls.push('hide-visible'),
},
];
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {
calls.push('update-visible');
},
refreshCurrentSubtitle: () => {},
getOverlayWindows: () => overlayWindows as never,
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
calls.length = 0;
tracker.onWindowLost?.();
assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']);
});
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
const bounds: Array<{ x: number; y: number; width: number; height: number }> = [];
let visibilityRefreshCalls = 0;
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
start: () => {},
};
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: (geometry) => {
bounds.push(geometry);
},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {
visibilityRefreshCalls += 1;
},
refreshCurrentSubtitle: () => {},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
const restoredGeometry = { x: 100, y: 200, width: 1280, height: 720 };
tracker.onWindowFound?.(restoredGeometry);
assert.deepEqual(bounds, [restoredGeometry]);
assert.equal(visibilityRefreshCalls, 2);
});

View File

@@ -71,6 +71,7 @@ export function initializeOverlayRuntime(options: {
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void; updateVisibleOverlayVisibility: () => void;
refreshCurrentSubtitle?: () => void;
getOverlayWindows: () => BrowserWindow[]; getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -78,6 +79,8 @@ export function initializeOverlayRuntime(options: {
override?: string | null, override?: string | null,
targetMpvSocketPath?: string | null, targetMpvSocketPath?: string | null,
) => BaseWindowTracker | null; ) => BaseWindowTracker | null;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}): void { }): void {
options.createMainWindow(); options.createMainWindow();
options.registerGlobalShortcuts(); options.registerGlobalShortcuts();
@@ -94,11 +97,14 @@ export function initializeOverlayRuntime(options: {
}; };
windowTracker.onWindowFound = (geometry: WindowGeometry) => { windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry); options.updateVisibleOverlayBounds(geometry);
options.bindOverlayOwner?.();
if (options.isVisibleOverlayVisible()) { if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility(); options.updateVisibleOverlayVisibility();
options.refreshCurrentSubtitle?.();
} }
}; };
windowTracker.onWindowLost = () => { windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.();
for (const window of options.getOverlayWindows()) { for (const window of options.getOverlayWindows()) {
window.hide(); window.hide();
} }

View File

@@ -6,7 +6,11 @@ import {
OverlayShortcutRuntimeDeps, OverlayShortcutRuntimeDeps,
runOverlayShortcutLocalFallback, runOverlayShortcutLocalFallback,
} from './overlay-shortcut-handler'; } from './overlay-shortcut-handler';
import { shouldActivateOverlayShortcuts } from './overlay-shortcut'; import {
registerOverlayShortcutsRuntime,
shouldActivateOverlayShortcuts,
unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut';
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts { function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return { return {
@@ -313,3 +317,85 @@ test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => {
true, true,
); );
}); });
test('registerOverlayShortcutsRuntime reports active shortcuts when configured', () => {
const result = registerOverlayShortcutsRuntime({
getConfiguredShortcuts: () =>
({
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: 'Ctrl+J',
}) as never,
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
cancelPendingMultiCopy: () => {},
cancelPendingMineSentenceMultiple: () => {},
});
assert.equal(result, true);
});
test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active', () => {
const calls: string[] = [];
const result = unregisterOverlayShortcutsRuntime(true, {
getConfiguredShortcuts: () =>
({
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
}) as never,
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy');
},
cancelPendingMineSentenceMultiple: () => {
calls.push('cancel-mine-sentence-multiple');
},
});
assert.equal(result, false);
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
});

View File

@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import {
registerOverlayShortcuts,
syncOverlayShortcutsRuntime,
unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
...overrides,
};
}
test('registerOverlayShortcuts reports active overlay shortcuts when configured', () => {
assert.equal(
registerOverlayShortcuts(createShortcuts({ openJimaku: 'Ctrl+J' }), {
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
true,
);
});
test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent', () => {
assert.equal(
registerOverlayShortcuts(createShortcuts(), {
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
false,
);
});
test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active', () => {
const calls: string[] = [];
const result = syncOverlayShortcutsRuntime(false, true, {
getConfiguredShortcuts: () => createShortcuts(),
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy');
},
cancelPendingMineSentenceMultiple: () => {
calls.push('cancel-mine-sentence-multiple');
},
});
assert.equal(result, false);
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
});

View File

@@ -1,10 +1,4 @@
import electron from 'electron';
import { ConfiguredShortcuts } from '../utils/shortcut-config'; import { ConfiguredShortcuts } from '../utils/shortcut-config';
import { isGlobalShortcutRegisteredSafe } from './shortcut-fallback';
import { createLogger } from '../../logger';
const { globalShortcut } = electron;
const logger = createLogger('main:overlay-shortcut-service');
export interface OverlayShortcutHandlers { export interface OverlayShortcutHandlers {
copySubtitle: () => void; copySubtitle: () => void;
@@ -27,6 +21,27 @@ export interface OverlayShortcutLifecycleDeps {
cancelPendingMineSentenceMultiple: () => void; cancelPendingMineSentenceMultiple: () => void;
} }
const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>> = [
'copySubtitle',
'copySubtitleMultiple',
'updateLastCardFromClipboard',
'triggerFieldGrouping',
'triggerSubsync',
'mineSentence',
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openRuntimeOptions',
'openJimaku',
];
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
return OVERLAY_SHORTCUT_KEYS.some((key) => {
const shortcut = shortcuts[key];
return typeof shortcut === 'string' && shortcut.trim().length > 0;
});
}
export function shouldActivateOverlayShortcuts(args: { export function shouldActivateOverlayShortcuts(args: {
overlayRuntimeInitialized: boolean; overlayRuntimeInitialized: boolean;
isMacOSPlatform: boolean; isMacOSPlatform: boolean;
@@ -43,139 +58,12 @@ export function shouldActivateOverlayShortcuts(args: {
export function registerOverlayShortcuts( export function registerOverlayShortcuts(
shortcuts: ConfiguredShortcuts, shortcuts: ConfiguredShortcuts,
handlers: OverlayShortcutHandlers, _handlers: OverlayShortcutHandlers,
): boolean { ): boolean {
let registeredAny = false; return hasConfiguredOverlayShortcuts(shortcuts);
const registerOverlayShortcut = (
accelerator: string,
handler: () => void,
label: string,
): void => {
if (isGlobalShortcutRegisteredSafe(accelerator)) {
registeredAny = true;
return;
}
const ok = globalShortcut.register(accelerator, handler);
if (!ok) {
logger.warn(`Failed to register overlay shortcut ${label}: ${accelerator}`);
return;
}
registeredAny = true;
};
if (shortcuts.copySubtitleMultiple) {
registerOverlayShortcut(
shortcuts.copySubtitleMultiple,
() => handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs),
'copySubtitleMultiple',
);
} }
if (shortcuts.copySubtitle) { export function unregisterOverlayShortcuts(_shortcuts: ConfiguredShortcuts): void {}
registerOverlayShortcut(shortcuts.copySubtitle, () => handlers.copySubtitle(), 'copySubtitle');
}
if (shortcuts.triggerFieldGrouping) {
registerOverlayShortcut(
shortcuts.triggerFieldGrouping,
() => handlers.triggerFieldGrouping(),
'triggerFieldGrouping',
);
}
if (shortcuts.triggerSubsync) {
registerOverlayShortcut(
shortcuts.triggerSubsync,
() => handlers.triggerSubsync(),
'triggerSubsync',
);
}
if (shortcuts.mineSentence) {
registerOverlayShortcut(shortcuts.mineSentence, () => handlers.mineSentence(), 'mineSentence');
}
if (shortcuts.mineSentenceMultiple) {
registerOverlayShortcut(
shortcuts.mineSentenceMultiple,
() => handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs),
'mineSentenceMultiple',
);
}
if (shortcuts.toggleSecondarySub) {
registerOverlayShortcut(
shortcuts.toggleSecondarySub,
() => handlers.toggleSecondarySub(),
'toggleSecondarySub',
);
}
if (shortcuts.updateLastCardFromClipboard) {
registerOverlayShortcut(
shortcuts.updateLastCardFromClipboard,
() => handlers.updateLastCardFromClipboard(),
'updateLastCardFromClipboard',
);
}
if (shortcuts.markAudioCard) {
registerOverlayShortcut(
shortcuts.markAudioCard,
() => handlers.markAudioCard(),
'markAudioCard',
);
}
if (shortcuts.openRuntimeOptions) {
registerOverlayShortcut(
shortcuts.openRuntimeOptions,
() => handlers.openRuntimeOptions(),
'openRuntimeOptions',
);
}
if (shortcuts.openJimaku) {
registerOverlayShortcut(shortcuts.openJimaku, () => handlers.openJimaku(), 'openJimaku');
}
return registeredAny;
}
export function unregisterOverlayShortcuts(shortcuts: ConfiguredShortcuts): void {
if (shortcuts.copySubtitle) {
globalShortcut.unregister(shortcuts.copySubtitle);
}
if (shortcuts.copySubtitleMultiple) {
globalShortcut.unregister(shortcuts.copySubtitleMultiple);
}
if (shortcuts.updateLastCardFromClipboard) {
globalShortcut.unregister(shortcuts.updateLastCardFromClipboard);
}
if (shortcuts.triggerFieldGrouping) {
globalShortcut.unregister(shortcuts.triggerFieldGrouping);
}
if (shortcuts.triggerSubsync) {
globalShortcut.unregister(shortcuts.triggerSubsync);
}
if (shortcuts.mineSentence) {
globalShortcut.unregister(shortcuts.mineSentence);
}
if (shortcuts.mineSentenceMultiple) {
globalShortcut.unregister(shortcuts.mineSentenceMultiple);
}
if (shortcuts.toggleSecondarySub) {
globalShortcut.unregister(shortcuts.toggleSecondarySub);
}
if (shortcuts.markAudioCard) {
globalShortcut.unregister(shortcuts.markAudioCard);
}
if (shortcuts.openRuntimeOptions) {
globalShortcut.unregister(shortcuts.openRuntimeOptions);
}
if (shortcuts.openJimaku) {
globalShortcut.unregister(shortcuts.openJimaku);
}
}
export function registerOverlayShortcutsRuntime(deps: OverlayShortcutLifecycleDeps): boolean { export function registerOverlayShortcutsRuntime(deps: OverlayShortcutLifecycleDeps): boolean {
return registerOverlayShortcuts(deps.getConfiguredShortcuts(), deps.getOverlayHandlers()); return registerOverlayShortcuts(deps.getConfiguredShortcuts(), deps.getOverlayHandlers());

View File

@@ -6,27 +6,59 @@ import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './over
type WindowTrackerStub = { type WindowTrackerStub = {
isTracking: () => boolean; isTracking: () => boolean;
getGeometry: () => { x: number; y: number; width: number; height: number } | null; getGeometry: () => { x: number; y: number; width: number; height: number } | null;
isTargetWindowFocused?: () => boolean;
isTargetWindowMinimized?: () => boolean;
}; };
function createMainWindowRecorder() { function createMainWindowRecorder() {
const calls: string[] = []; const calls: string[] = [];
let visible = false;
let focused = false;
let opacity = 1;
const window = { const window = {
isDestroyed: () => false, isDestroyed: () => false,
isVisible: () => visible,
isFocused: () => focused,
hide: () => { hide: () => {
visible = false;
focused = false;
calls.push('hide'); calls.push('hide');
}, },
show: () => { show: () => {
visible = true;
calls.push('show'); calls.push('show');
}, },
showInactive: () => {
visible = true;
calls.push('show-inactive');
},
focus: () => { focus: () => {
focused = true;
calls.push('focus'); calls.push('focus');
}, },
setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`);
},
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'}`);
}, },
setOpacity: (nextOpacity: number) => {
opacity = nextOpacity;
calls.push(`opacity:${nextOpacity}`);
},
moveTop: () => {
calls.push('move-top');
},
}; };
return { window, calls }; return {
window,
calls,
getOpacity: () => opacity,
setFocused: (nextFocused: boolean) => {
focused = nextFocused;
},
};
} }
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => { test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
@@ -163,7 +195,286 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
assert.ok(!calls.includes('osd')); assert.ok(!calls.includes('osd'));
}); });
test('Windows visible overlay stays click-through and does not steal focus while tracked', () => { test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('opacity:0'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('focus'));
});
test('Windows visible overlay restores opacity after the deferred reveal delay', async () => {
const { window, calls, getOpacity } = createMainWindowRecorder();
let syncWindowsZOrderCalls = 0;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
syncWindowsZOrderCalls += 1;
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.equal(getOpacity(), 0);
assert.equal(syncWindowsZOrderCalls, 1);
await new Promise<void>((resolve) => setTimeout(resolve, 60));
assert.equal(getOpacity(), 1);
assert.equal(syncWindowsZOrderCalls, 2);
assert.ok(calls.includes('opacity:1'));
});
test('tracked Windows overlay refresh rebinds while already visible', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
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');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts'));
});
test('forced passthrough still reapplies while visible on Windows', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
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');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('forced passthrough still shows tracked overlay while bound to mpv on Windows', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('sync-windows-z-order'));
});
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
isTracking: () => true, isTracking: () => true,
@@ -191,13 +502,209 @@ test('Windows visible overlay stays click-through and does not steal focus while
syncOverlayShortcuts: () => { syncOverlayShortcuts: () => {
calls.push('sync-shortcuts'); calls.push('sync-shortcuts');
}, },
isMacOSPlatform: true,
isWindowsPlatform: false,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('tracked Windows overlay rebinds without hiding when tracker focus changes', () => {
const { window, calls } = createMainWindowRecorder();
let focused = true;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => focused,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false, isMacOSPlatform: false,
isWindowsPlatform: true, isWindowsPlatform: true,
} as never); } as never);
calls.length = 0;
focused = false;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
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('sync-windows-z-order'));
assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('show'));
});
test('tracked Windows overlay stays interactive while the overlay window itself is focused', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
setFocused(true);
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('move-top'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('tracked Windows overlay binds above mpv even when tracker focus lags', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
}); });
test('visible overlay stays hidden while a modal window is active', () => { test('visible overlay stays hidden while a modal window is active', () => {
@@ -355,6 +862,157 @@ test('Windows keeps visible overlay hidden while tracker is not ready', () => {
assert.ok(!calls.includes('update-bounds')); assert.ok(!calls.includes('update-bounds'));
}); });
test('Windows preserves visible overlay and rebinds to mpv while tracker transiently loses a non-minimized window', () => {
const { window, calls } = createMainWindowRecorder();
let tracking = true;
const tracker: WindowTrackerStub = {
isTracking: () => tracking,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
tracking = false;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('move-top'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('sync-windows-z-order'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts'));
});
test('Windows hides the visible overlay when the tracked window is minimized', () => {
const { window, calls } = createMainWindowRecorder();
let tracking = true;
const tracker: WindowTrackerStub = {
isTracking: () => tracking,
getGeometry: () => (tracking ? { x: 0, y: 0, width: 1280, height: 720 } : null),
isTargetWindowMinimized: () => !tracking,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
tracking = false;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('sync-windows-z-order'));
});
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => { test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
let trackerWarning = false; let trackerWarning = false;

View File

@@ -2,16 +2,67 @@ import type { BrowserWindow } from 'electron';
import { BaseWindowTracker } from '../../window-trackers'; import { BaseWindowTracker } from '../../window-trackers';
import { WindowGeometry } from '../../types'; import { WindowGeometry } from '../../types';
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
BrowserWindow,
ReturnType<typeof setTimeout>
>();
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
const opacityCapableWindow = window as BrowserWindow & {
setOpacity?: (opacity: number) => void;
};
opacityCapableWindow.setOpacity?.(opacity);
}
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
if (!pendingTimeout) {
return;
}
clearTimeout(pendingTimeout);
pendingWindowsOverlayRevealTimeoutByWindow.delete(window);
}
function scheduleWindowsOverlayReveal(
window: BrowserWindow,
onReveal?: (window: BrowserWindow) => void,
): void {
clearPendingWindowsOverlayReveal(window);
const timeout = setTimeout(() => {
pendingWindowsOverlayRevealTimeoutByWindow.delete(window);
if (window.isDestroyed() || !window.isVisible()) {
return;
}
setOverlayWindowOpacity(window, 1);
onReveal?.(window);
}, WINDOWS_OVERLAY_REVEAL_DELAY_MS);
pendingWindowsOverlayRevealTimeoutByWindow.set(window, timeout);
}
function isOverlayWindowContentReady(window: BrowserWindow): boolean {
return (
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] === true
);
}
export function updateVisibleOverlayVisibility(args: { export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean; visibleOverlayVisible: boolean;
modalActive?: boolean; modalActive?: boolean;
forceMousePassthrough?: boolean; forceMousePassthrough?: boolean;
mainWindow: BrowserWindow | null; mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null; windowTracker: BaseWindowTracker | null;
lastKnownWindowsForegroundProcessName?: string | null;
windowsOverlayProcessName?: string | null;
windowsFocusHandoffGraceActive?: boolean;
trackerNotReadyWarningShown: boolean; trackerNotReadyWarningShown: boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void; setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void;
syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void; syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void; enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
@@ -30,6 +81,10 @@ export function updateVisibleOverlayVisibility(args: {
const mainWindow = args.mainWindow; const mainWindow = args.mainWindow;
if (args.modalActive) { if (args.modalActive) {
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide(); mainWindow.hide();
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
return; return;
@@ -37,13 +92,92 @@ export function updateVisibleOverlayVisibility(args: {
const showPassiveVisibleOverlay = (): void => { const showPassiveVisibleOverlay = (): void => {
const forceMousePassthrough = args.forceMousePassthrough === true; const forceMousePassthrough = args.forceMousePassthrough === true;
if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) { const shouldDefaultToPassthrough =
args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough;
const isVisibleOverlayFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
const windowsForegroundProcessName =
args.lastKnownWindowsForegroundProcessName?.trim().toLowerCase() ?? null;
const windowsOverlayProcessName = args.windowsOverlayProcessName?.trim().toLowerCase() ?? null;
const hasWindowsForegroundProcessSignal =
args.isWindowsPlatform && windowsForegroundProcessName !== null;
const isTrackedWindowsTargetFocused = args.windowTracker?.isTargetWindowFocused?.() ?? true;
const isTrackedWindowsTargetMinimized =
args.isWindowsPlatform &&
typeof args.windowTracker?.isTargetWindowMinimized === 'function' &&
args.windowTracker.isTargetWindowMinimized();
const shouldPreserveWindowsOverlayDuringFocusHandoff =
args.isWindowsPlatform &&
args.windowsFocusHandoffGraceActive === true &&
!!args.windowTracker &&
(!hasWindowsForegroundProcessSignal ||
windowsForegroundProcessName === 'mpv' ||
(windowsOverlayProcessName !== null &&
windowsForegroundProcessName === windowsOverlayProcessName)) &&
!isTrackedWindowsTargetMinimized &&
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldIgnoreMouseEvents =
forceMousePassthrough || (shouldDefaultToPassthrough && !isVisibleOverlayFocused);
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost =
!args.isWindowsPlatform ||
!args.windowTracker ||
isVisibleOverlayFocused ||
isTrackedWindowsTargetFocused ||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
const wasVisible = mainWindow.isVisible();
if (shouldIgnoreMouseEvents) {
mainWindow.setIgnoreMouseEvents(true, { forward: true }); mainWindow.setIgnoreMouseEvents(true, { forward: true });
} else { } else {
mainWindow.setIgnoreMouseEvents(false); mainWindow.setIgnoreMouseEvents(false);
} }
if (shouldBindTrackedWindowsOverlay) {
// On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management.
} else if (!forceMousePassthrough) {
args.ensureOverlayWindowLevel(mainWindow); args.ensureOverlayWindowLevel(mainWindow);
} else {
mainWindow.setAlwaysOnTop(false);
}
if (!wasVisible) {
const hasWebContents =
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
if (
args.isWindowsPlatform &&
hasWebContents &&
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
) {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer
// has painted its first frame.
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
setOverlayWindowOpacity(mainWindow, 0);
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined);
} else {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.show(); mainWindow.show();
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined);
}
}
}
if (shouldBindTrackedWindowsOverlay) {
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
}
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
mainWindow.focus(); mainWindow.focus();
} }
@@ -63,12 +197,27 @@ export function updateVisibleOverlayVisibility(args: {
if (!args.visibleOverlayVisible) { if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false); args.setTrackerNotReadyWarningShown(false);
args.resetOverlayLoadingOsdSuppression?.(); args.resetOverlayLoadingOsdSuppression?.();
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide(); mainWindow.hide();
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
return; return;
} }
if (args.windowTracker && args.windowTracker.isTracking()) { if (args.windowTracker && args.windowTracker.isTracking()) {
if (
args.isWindowsPlatform &&
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
args.windowTracker.isTargetWindowMinimized()
) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
args.setTrackerNotReadyWarningShown(false); args.setTrackerNotReadyWarningShown(false);
const geometry = args.windowTracker.getGeometry(); const geometry = args.windowTracker.getGeometry();
if (geometry) { if (geometry) {
@@ -76,7 +225,9 @@ export function updateVisibleOverlayVisibility(args: {
} }
args.syncPrimaryOverlayWindowLayer('visible'); args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay(); showPassiveVisibleOverlay();
if (!args.forceMousePassthrough && !args.isWindowsPlatform) {
args.enforceOverlayLayerOrder(); args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
return; return;
} }
@@ -87,6 +238,10 @@ export function updateVisibleOverlayVisibility(args: {
args.setTrackerNotReadyWarningShown(true); args.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd(); maybeShowOverlayLoadingOsd();
} }
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide(); mainWindow.hide();
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
return; return;
@@ -99,11 +254,32 @@ export function updateVisibleOverlayVisibility(args: {
return; return;
} }
if (
args.isWindowsPlatform &&
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
!args.windowTracker.isTargetWindowMinimized() &&
(mainWindow.isVisible() || args.windowTracker.getGeometry() !== null)
) {
args.setTrackerNotReadyWarningShown(false);
const geometry = args.windowTracker.getGeometry();
if (geometry) {
args.updateVisibleOverlayBounds(geometry);
}
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
args.syncOverlayShortcuts();
return;
}
if (!args.trackerNotReadyWarningShown) { if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true); args.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd(); maybeShowOverlayLoadingOsd();
} }
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.hide(); mainWindow.hide();
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
} }

View File

@@ -8,7 +8,31 @@ test('overlay window config explicitly disables renderer sandbox for preload com
yomitanSession: null, yomitanSession: null,
}); });
assert.equal(options.backgroundColor, '#00000000');
assert.equal(options.webPreferences?.sandbox, false); assert.equal(options.webPreferences?.sandbox, false);
assert.equal(options.webPreferences?.backgroundThrottling, false);
});
test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const options = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
assert.equal(options.alwaysOnTop, false);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
}); });
test('overlay window config uses the provided Yomitan session when available', () => { test('overlay window config uses the provided Yomitan session when available', () => {

View File

@@ -66,7 +66,14 @@ export function handleOverlayWindowBlurred(options: {
isOverlayVisible: (kind: OverlayWindowKind) => boolean; isOverlayVisible: (kind: OverlayWindowKind) => boolean;
ensureOverlayWindowLevel: () => void; ensureOverlayWindowLevel: () => void;
moveWindowTop: () => void; moveWindowTop: () => void;
onWindowsVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean { }): boolean {
if ((options.platform ?? process.platform) === 'win32' && options.kind === 'visible') {
options.onWindowsVisibleOverlayBlur?.();
return false;
}
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) { if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
return false; return false;
} }

View File

@@ -10,6 +10,7 @@ export function buildOverlayWindowOptions(
}, },
): BrowserWindowConstructorOptions { ): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev; const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
return { return {
show: false, show: false,
@@ -18,8 +19,9 @@ export function buildOverlayWindowOptions(
x: 0, x: 0,
y: 0, y: 0,
transparent: true, transparent: true,
backgroundColor: '#00000000',
frame: false, frame: false,
alwaysOnTop: true, alwaysOnTop: shouldStartAlwaysOnTop,
skipTaskbar: true, skipTaskbar: true,
resizable: false, resizable: false,
hasShadow: false, hasShadow: false,
@@ -31,6 +33,7 @@ export function buildOverlayWindowOptions(
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: false, sandbox: false,
backgroundThrottling: false,
webSecurity: true, webSecurity: true,
session: options.yomitanSession ?? undefined, session: options.yomitanSession ?? undefined,
additionalArguments: [`--overlay-layer=${kind}`], additionalArguments: [`--overlay-layer=${kind}`],

View File

@@ -103,6 +103,49 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
test('handleOverlayWindowBlurred skips Windows visible overlay restacking after focus loss', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
platform: 'win32',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback without restacking', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
},
platform: 'win32',
});
assert.equal(handled, false);
assert.deepEqual(calls, ['windows-visible-blur']);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => { test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
const calls: string[] = []; const calls: string[] = [];
@@ -117,6 +160,7 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking'
moveWindowTop: () => { moveWindowTop: () => {
calls.push('move-visible'); calls.push('move-visible');
}, },
platform: 'linux',
}), }),
true, true,
); );

View File

@@ -13,6 +13,20 @@ import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds
const logger = createLogger('main:overlay-window'); const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>(); const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
const overlayWindowContentReady = new WeakSet<BrowserWindow>();
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
export function isOverlayWindowContentReady(window: BrowserWindow): boolean {
if (window.isDestroyed()) {
return false;
}
return (
overlayWindowContentReady.has(window) ||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] === true
);
}
function getOverlayWindowHtmlPath(): string { function getOverlayWindowHtmlPath(): string {
return path.join(__dirname, '..', '..', 'renderer', 'index.html'); return path.join(__dirname, '..', '..', 'renderer', 'index.html');
@@ -76,13 +90,17 @@ export function createOverlayWindow(
isOverlayVisible: (kind: OverlayWindowKind) => boolean; isOverlayVisible: (kind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void; forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (kind: OverlayWindowKind) => void; onWindowClosed: (kind: OverlayWindowKind) => void;
yomitanSession?: Session | null; yomitanSession?: Session | null;
}, },
): BrowserWindow { ): BrowserWindow {
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options)); const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
if (!(process.platform === 'win32' && kind === 'visible')) {
options.ensureOverlayWindowLevel(window); options.ensureOverlayWindowLevel(window);
}
loadOverlayWindowLayer(window, kind); loadOverlayWindowLayer(window, kind);
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
@@ -93,6 +111,14 @@ export function createOverlayWindow(
options.onRuntimeOptionsChanged(); options.onRuntimeOptionsChanged();
}); });
window.once('ready-to-show', () => {
overlayWindowContentReady.add(window);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = true;
options.onWindowContentReady?.();
});
if (kind === 'visible') { if (kind === 'visible') {
window.webContents.on('devtools-opened', () => { window.webContents.on('devtools-opened', () => {
options.setOverlayDebugVisualizationEnabled(true); options.setOverlayDebugVisualizationEnabled(true);
@@ -136,6 +162,8 @@ export function createOverlayWindow(
moveWindowTop: () => { moveWindowTop: () => {
window.moveTop(); window.moveTop();
}, },
onWindowsVisibleOverlayBlur:
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
}); });
}); });

View File

@@ -0,0 +1,109 @@
import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../../types';
import type { SessionActionId } from '../../types/session-bindings';
import type { SessionActionDispatchRequest } from '../../types/runtime';
export interface SessionActionExecutorDeps {
toggleStatsOverlay: () => void;
toggleVisibleOverlay: () => void;
copyCurrentSubtitle: () => void;
copySubtitleCount: (count: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
mineSentenceCard: () => Promise<void>;
mineSentenceCount: (count: number) => void;
toggleSecondarySub: () => void;
markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
replayCurrentSubtitle: () => void;
playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
}
function resolveCount(count: number | undefined): number {
const normalized = typeof count === 'number' && Number.isInteger(count) ? count : 1;
return Math.min(9, Math.max(1, normalized));
}
export async function dispatchSessionAction(
request: SessionActionDispatchRequest,
deps: SessionActionExecutorDeps,
): Promise<void> {
switch (request.actionId) {
case 'toggleStatsOverlay':
deps.toggleStatsOverlay();
return;
case 'toggleVisibleOverlay':
deps.toggleVisibleOverlay();
return;
case 'copySubtitle':
deps.copyCurrentSubtitle();
return;
case 'copySubtitleMultiple':
deps.copySubtitleCount(resolveCount(request.payload?.count));
return;
case 'updateLastCardFromClipboard':
await deps.updateLastCardFromClipboard();
return;
case 'triggerFieldGrouping':
await deps.triggerFieldGrouping();
return;
case 'triggerSubsync':
await deps.triggerSubsyncFromConfig();
return;
case 'mineSentence':
await deps.mineSentenceCard();
return;
case 'mineSentenceMultiple':
deps.mineSentenceCount(resolveCount(request.payload?.count));
return;
case 'toggleSecondarySub':
deps.toggleSecondarySub();
return;
case 'markAudioCard':
await deps.markLastCardAsAudioCard();
return;
case 'openRuntimeOptions':
deps.openRuntimeOptionsPalette();
return;
case 'openJimaku':
deps.openJimaku();
return;
case 'openYoutubePicker':
await deps.openYoutubeTrackPicker();
return;
case 'openPlaylistBrowser':
await deps.openPlaylistBrowser();
return;
case 'replayCurrentSubtitle':
deps.replayCurrentSubtitle();
return;
case 'playNextSubtitle':
deps.playNextSubtitle();
return;
case 'shiftSubDelayPrevLine':
await deps.shiftSubDelayToAdjacentSubtitle('previous');
return;
case 'shiftSubDelayNextLine':
await deps.shiftSubDelayToAdjacentSubtitle('next');
return;
case 'cycleRuntimeOption': {
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
if (!runtimeOptionId) {
deps.showMpvOsd('Runtime option id is required.');
return;
}
const direction = request.payload?.direction === -1 ? -1 : 1;
const result = deps.cycleRuntimeOption(runtimeOptionId, direction);
if (!result.ok && result.error) {
deps.showMpvOsd(result.error);
}
return;
}
}
}

View File

@@ -0,0 +1,247 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Keybinding } from '../../types';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import { compileSessionBindings } from './session-bindings';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
...overrides,
};
}
function createKeybinding(key: string, command: Keybinding['command']): Keybinding {
return { key, command };
}
test('compileSessionBindings merges shortcuts and keybindings into one canonical list', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
openJimaku: 'Ctrl+Shift+J',
}),
keybindings: [
createKeybinding('KeyF', ['cycle', 'fullscreen']),
createKeybinding('Ctrl+Shift+Y', [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]),
],
platform: 'linux',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(
result.bindings.map((binding) => ({
actionType: binding.actionType,
sourcePath: binding.sourcePath,
code: binding.key.code,
modifiers: binding.key.modifiers,
target:
binding.actionType === 'session-action'
? binding.actionId
: binding.command.join(' '),
})),
[
{
actionType: 'mpv-command',
sourcePath: 'keybindings[0].key',
code: 'KeyF',
modifiers: [],
target: 'cycle fullscreen',
},
{
actionType: 'session-action',
sourcePath: 'keybindings[1].key',
code: 'KeyY',
modifiers: ['ctrl', 'shift'],
target: 'openYoutubePicker',
},
{
actionType: 'session-action',
sourcePath: 'shortcuts.openJimaku',
code: 'KeyJ',
modifiers: ['ctrl', 'shift'],
target: 'openJimaku',
},
{
actionType: 'session-action',
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
code: 'KeyO',
modifiers: ['alt', 'shift'],
target: 'toggleVisibleOverlay',
},
],
);
});
test('compileSessionBindings resolves CommandOrControl per platform', () => {
const input = {
shortcuts: createShortcuts({
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
}),
keybindings: [],
};
const windows = compileSessionBindings({ ...input, platform: 'win32' });
const mac = compileSessionBindings({ ...input, platform: 'darwin' });
assert.deepEqual(windows.bindings[0]?.key.modifiers, ['ctrl', 'shift']);
assert.deepEqual(mac.bindings[0]?.key.modifiers, ['shift', 'meta']);
});
test('compileSessionBindings resolves CommandOrControl in DOM key strings per platform', () => {
const input = {
shortcuts: createShortcuts(),
keybindings: [createKeybinding('CommandOrControl+Shift+J', ['cycle', 'fullscreen'])],
statsToggleKey: 'CommandOrControl+Backquote',
};
const windows = compileSessionBindings({ ...input, platform: 'win32' });
const mac = compileSessionBindings({ ...input, platform: 'darwin' });
assert.deepEqual(
windows.bindings
.map((binding) => ({
sourcePath: binding.sourcePath,
modifiers: binding.key.modifiers,
}))
.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)),
[
{
sourcePath: 'keybindings[0].key',
modifiers: ['ctrl', 'shift'],
},
{
sourcePath: 'stats.toggleKey',
modifiers: ['ctrl'],
},
],
);
assert.deepEqual(
mac.bindings
.map((binding) => ({
sourcePath: binding.sourcePath,
modifiers: binding.key.modifiers,
}))
.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)),
[
{
sourcePath: 'keybindings[0].key',
modifiers: ['shift', 'meta'],
},
{
sourcePath: 'stats.toggleKey',
modifiers: ['meta'],
},
],
);
});
test('compileSessionBindings drops conflicting bindings that canonicalize to the same key', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openJimaku: 'Ctrl+Shift+J',
}),
keybindings: [createKeybinding('Ctrl+Shift+J', [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN])],
platform: 'linux',
});
assert.deepEqual(result.bindings, []);
assert.equal(result.warnings.length, 1);
assert.equal(result.warnings[0]?.kind, 'conflict');
assert.deepEqual(result.warnings[0]?.conflictingPaths, [
'shortcuts.openJimaku',
'keybindings[0].key',
]);
});
test('compileSessionBindings omits disabled bindings', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openJimaku: null,
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
}),
keybindings: [createKeybinding('Ctrl+Shift+J', null)],
platform: 'linux',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), [
'shortcuts.toggleVisibleOverlayGlobal',
]);
});
test('compileSessionBindings warns on unsupported shortcut and keybinding syntax', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openJimaku: 'Hyper+J',
}),
keybindings: [createKeybinding('Ctrl+ß', ['cycle', 'fullscreen'])],
platform: 'linux',
});
assert.deepEqual(result.bindings, []);
assert.deepEqual(
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
['unsupported:shortcuts.openJimaku', 'unsupported:keybindings[0].key'],
);
});
test('compileSessionBindings warns on deprecated toggleVisibleOverlayGlobal config', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [],
platform: 'linux',
rawConfig: {
shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
},
} as never,
});
assert.equal(result.bindings.length, 0);
assert.deepEqual(result.warnings, [
{
kind: 'deprecated-config',
path: 'shortcuts.toggleVisibleOverlayGlobal',
value: 'Alt+Shift+O',
message: 'Rename shortcuts.toggleVisibleOverlayGlobal to shortcuts.toggleVisibleOverlay.',
},
]);
});
test('compileSessionBindings includes stats toggle in the shared session binding artifact', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [],
statsToggleKey: 'Backquote',
platform: 'win32',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(result.bindings, [
{
sourcePath: 'stats.toggleKey',
originalKey: 'Backquote',
key: {
code: 'Backquote',
modifiers: [],
},
actionType: 'session-action',
actionId: 'toggleStatsOverlay',
},
]);
});

View File

@@ -0,0 +1,431 @@
import type { Keybinding, ResolvedConfig } from '../../types';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import type {
CompiledMpvCommandBinding,
CompiledSessionActionBinding,
CompiledSessionBinding,
PluginSessionBindingsArtifact,
SessionActionId,
SessionBindingWarning,
SessionKeyModifier,
SessionKeySpec,
} from '../../types/session-bindings';
import { SPECIAL_COMMANDS } from '../../config';
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
type CompileSessionBindingsInput = {
keybindings: Keybinding[];
shortcuts: ConfiguredShortcuts;
statsToggleKey?: string | null;
platform: PlatformKeyModel;
rawConfig?: ResolvedConfig | null;
};
type DraftBinding = {
binding: CompiledSessionBinding;
actionFingerprint: string;
};
const MODIFIER_ORDER: SessionKeyModifier[] = ['ctrl', 'alt', 'shift', 'meta'];
const SESSION_SHORTCUT_ACTIONS: Array<{
key: keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>;
actionId: SessionActionId;
}> = [
{ key: 'toggleVisibleOverlayGlobal', actionId: 'toggleVisibleOverlay' },
{ key: 'copySubtitle', actionId: 'copySubtitle' },
{ key: 'copySubtitleMultiple', actionId: 'copySubtitleMultiple' },
{ key: 'updateLastCardFromClipboard', actionId: 'updateLastCardFromClipboard' },
{ key: 'triggerFieldGrouping', actionId: 'triggerFieldGrouping' },
{ key: 'triggerSubsync', actionId: 'triggerSubsync' },
{ key: 'mineSentence', actionId: 'mineSentence' },
{ key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' },
{ key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' },
{ key: 'markAudioCard', actionId: 'markAudioCard' },
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
];
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
return [...new Set(modifiers)].sort(
(left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right),
);
}
function normalizeCodeToken(token: string): string | null {
const normalized = token.trim();
if (!normalized) return null;
if (/^[a-z]$/i.test(normalized)) {
return `Key${normalized.toUpperCase()}`;
}
if (/^[0-9]$/.test(normalized)) {
return `Digit${normalized}`;
}
const exactMap: Record<string, string> = {
space: 'Space',
tab: 'Tab',
enter: 'Enter',
return: 'Enter',
esc: 'Escape',
escape: 'Escape',
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
backspace: 'Backspace',
delete: 'Delete',
slash: 'Slash',
backslash: 'Backslash',
minus: 'Minus',
plus: 'Equal',
equal: 'Equal',
comma: 'Comma',
period: 'Period',
quote: 'Quote',
semicolon: 'Semicolon',
bracketleft: 'BracketLeft',
bracketright: 'BracketRight',
backquote: 'Backquote',
};
const lower = normalized.toLowerCase();
if (exactMap[lower]) return exactMap[lower];
if (
/^key[a-z]$/i.test(normalized) ||
/^digit[0-9]$/i.test(normalized) ||
/^arrow(?:up|down|left|right)$/i.test(normalized) ||
/^f\d{1,2}$/i.test(normalized)
) {
return normalized[0]!.toUpperCase() + normalized.slice(1);
}
return null;
}
function parseAccelerator(
accelerator: string,
platform: PlatformKeyModel,
): { key: SessionKeySpec | null; message?: string } {
const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl');
if (!normalized) {
return { key: null, message: 'Empty accelerator is not supported.' };
}
const parts = normalized.split('+').filter(Boolean);
const keyToken = parts.pop();
if (!keyToken) {
return { key: null, message: 'Missing accelerator key token.' };
}
const modifiers: SessionKeyModifier[] = [];
for (const modifier of parts) {
const lower = modifier.toLowerCase();
if (lower === 'ctrl' || lower === 'control') {
modifiers.push('ctrl');
continue;
}
if (lower === 'alt' || lower === 'option') {
modifiers.push('alt');
continue;
}
if (lower === 'shift') {
modifiers.push('shift');
continue;
}
if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') {
modifiers.push('meta');
continue;
}
if (lower === 'commandorcontrol') {
modifiers.push(platform === 'darwin' ? 'meta' : 'ctrl');
continue;
}
return {
key: null,
message: `Unsupported accelerator modifier: ${modifier}`,
};
}
const code = normalizeCodeToken(keyToken);
if (!code) {
return {
key: null,
message: `Unsupported accelerator key token: ${keyToken}`,
};
}
return {
key: {
code,
modifiers: normalizeModifiers(modifiers),
},
};
}
function parseDomKeyString(
key: string,
platform: PlatformKeyModel,
): { key: SessionKeySpec | null; message?: string } {
const parts = key
.split('+')
.map((part) => part.trim())
.filter(Boolean);
const keyToken = parts.pop();
if (!keyToken) {
return { key: null, message: 'Missing keybinding key token.' };
}
const modifiers: SessionKeyModifier[] = [];
for (const modifier of parts) {
const lower = modifier.toLowerCase();
if (lower === 'ctrl' || lower === 'control') {
modifiers.push('ctrl');
continue;
}
if (lower === 'alt' || lower === 'option') {
modifiers.push('alt');
continue;
}
if (lower === 'shift') {
modifiers.push('shift');
continue;
}
if (
lower === 'meta' ||
lower === 'super' ||
lower === 'command' ||
lower === 'cmd' ||
lower === 'commandorcontrol'
) {
modifiers.push(
lower === 'commandorcontrol' ? (platform === 'darwin' ? 'meta' : 'ctrl') : 'meta',
);
continue;
}
return {
key: null,
message: `Unsupported keybinding modifier: ${modifier}`,
};
}
const code = normalizeCodeToken(keyToken);
if (!code) {
return {
key: null,
message: `Unsupported keybinding token: ${keyToken}`,
};
}
return {
key: {
code,
modifiers: normalizeModifiers(modifiers),
},
};
}
export function getSessionKeySpecSignature(key: SessionKeySpec): string {
return [...key.modifiers, key.code].join('+');
}
function resolveCommandBinding(
binding: Keybinding,
):
| Omit<CompiledMpvCommandBinding, 'key' | 'sourcePath' | 'originalKey'>
| Omit<CompiledSessionActionBinding, 'key' | 'sourcePath' | 'originalKey'>
| null {
const command = binding.command ?? [];
const first = typeof command[0] === 'string' ? command[0] : '';
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
return { actionType: 'session-action', actionId: 'triggerSubsync' };
}
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) {
return { actionType: 'session-action', actionId: 'openRuntimeOptions' };
}
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) {
return { actionType: 'session-action', actionId: 'openJimaku' };
}
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
return { actionType: 'session-action', actionId: 'openYoutubePicker' };
}
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) {
return { actionType: 'session-action', actionId: 'openPlaylistBrowser' };
}
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) {
return { actionType: 'session-action', actionId: 'replayCurrentSubtitle' };
}
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) {
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' };
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, runtimeOptionId, rawDirection] = first.split(':');
return {
actionType: 'session-action',
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId,
direction: rawDirection === 'prev' ? -1 : 1,
},
};
}
return {
actionType: 'mpv-command',
command,
};
}
function getBindingFingerprint(binding: CompiledSessionBinding): string {
if (binding.actionType === 'mpv-command') {
return `mpv:${JSON.stringify(binding.command)}`;
}
return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`;
}
export function compileSessionBindings(
input: CompileSessionBindingsInput,
): {
bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[];
} {
const warnings: SessionBindingWarning[] = [];
const candidates = new Map<string, DraftBinding[]>();
const legacyToggleVisibleOverlayGlobal = (
input.rawConfig?.shortcuts as Record<string, unknown> | undefined
)?.toggleVisibleOverlayGlobal;
const statsToggleKey = input.statsToggleKey ?? input.rawConfig?.stats?.toggleKey ?? null;
if (legacyToggleVisibleOverlayGlobal !== undefined) {
warnings.push({
kind: 'deprecated-config',
path: 'shortcuts.toggleVisibleOverlayGlobal',
value: legacyToggleVisibleOverlayGlobal,
message: 'Rename shortcuts.toggleVisibleOverlayGlobal to shortcuts.toggleVisibleOverlay.',
});
}
for (const shortcut of SESSION_SHORTCUT_ACTIONS) {
const accelerator = input.shortcuts[shortcut.key];
if (!accelerator) continue;
const parsed = parseAccelerator(accelerator, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: `shortcuts.${shortcut.key}`,
value: accelerator,
message: parsed.message ?? 'Unsupported accelerator syntax.',
});
continue;
}
const binding: CompiledSessionActionBinding = {
sourcePath: `shortcuts.${shortcut.key}`,
originalKey: accelerator,
key: parsed.key,
actionType: 'session-action',
actionId: shortcut.actionId,
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding,
actionFingerprint: getBindingFingerprint(binding),
});
candidates.set(signature, draft);
}
if (statsToggleKey) {
const parsed = parseDomKeyString(statsToggleKey, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: 'stats.toggleKey',
value: statsToggleKey,
message: parsed.message ?? 'Unsupported stats toggle key syntax.',
});
} else {
const binding: CompiledSessionActionBinding = {
sourcePath: 'stats.toggleKey',
originalKey: statsToggleKey,
key: parsed.key,
actionType: 'session-action',
actionId: 'toggleStatsOverlay',
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding,
actionFingerprint: getBindingFingerprint(binding),
});
candidates.set(signature, draft);
}
}
input.keybindings.forEach((binding, index) => {
if (!binding.command) return;
const parsed = parseDomKeyString(binding.key, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: `keybindings[${index}].key`,
value: binding.key,
message: parsed.message ?? 'Unsupported keybinding syntax.',
});
return;
}
const resolved = resolveCommandBinding(binding);
if (!resolved) return;
const compiled: CompiledSessionBinding = {
sourcePath: `keybindings[${index}].key`,
originalKey: binding.key,
key: parsed.key,
...resolved,
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding: compiled,
actionFingerprint: getBindingFingerprint(compiled),
});
candidates.set(signature, draft);
});
const bindings: CompiledSessionBinding[] = [];
for (const [signature, draftBindings] of candidates.entries()) {
const uniqueFingerprints = new Set(draftBindings.map((entry) => entry.actionFingerprint));
if (uniqueFingerprints.size > 1) {
warnings.push({
kind: 'conflict',
path: draftBindings[0]!.binding.sourcePath,
value: signature,
conflictingPaths: draftBindings.map((entry) => entry.binding.sourcePath),
message: `Conflicting session bindings compile to ${signature}; SubMiner will bind neither action.`,
});
continue;
}
bindings.push(draftBindings[0]!.binding);
}
bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
return { bindings, warnings };
}
export function buildPluginSessionBindingsArtifact(input: {
bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[];
numericSelectionTimeoutMs: number;
now?: Date;
}): PluginSessionBindingsArtifact {
return {
version: 1,
generatedAt: (input.now ?? new Date()).toISOString(),
numericSelectionTimeoutMs: input.numericSelectionTimeoutMs,
bindings: input.bindings,
warnings: input.warnings,
};
}

View File

@@ -20,42 +20,6 @@ export interface RegisterGlobalShortcutsServiceOptions {
} }
export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void { export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void {
const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal;
const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase();
const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase();
const normalizedSettings = 'alt+shift+y';
if (visibleShortcut) {
const toggleVisibleRegistered = globalShortcut.register(visibleShortcut, () => {
options.onToggleVisibleOverlay();
});
if (!toggleVisibleRegistered) {
logger.warn(
`Failed to register global shortcut toggleVisibleOverlayGlobal: ${visibleShortcut}`,
);
}
}
if (options.shortcuts.openJimaku && options.onOpenJimaku) {
if (
normalizedJimaku &&
(normalizedJimaku === normalizedVisible || normalizedJimaku === normalizedSettings)
) {
logger.warn(
'Skipped registering openJimaku because it collides with another global shortcut',
);
} else {
const openJimakuRegistered = globalShortcut.register(options.shortcuts.openJimaku, () => {
options.onOpenJimaku?.();
});
if (!openJimakuRegistered) {
logger.warn(
`Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`,
);
}
}
}
const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => { const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => {
options.onOpenYomitanSettings(); options.onOpenYomitanSettings();
}); });

View File

@@ -29,6 +29,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,

View File

@@ -30,10 +30,8 @@ function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: num
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
} }
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' { function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | 'all' {
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all' return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d';
? raw
: '30d';
} }
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' { function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {

View File

@@ -109,11 +109,13 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer'; import { MecabTokenizer } from './mecab-tokenizer';
import type { import type {
CompiledSessionBinding,
JimakuApiResponse, JimakuApiResponse,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
ResolvedConfig, ResolvedConfig,
RuntimeOptionState, RuntimeOptionState,
SessionActionDispatchRequest,
SecondarySubMode, SecondarySubMode,
SubtitleData, SubtitleData,
SubtitlePosition, SubtitlePosition,
@@ -130,6 +132,14 @@ import {
type LogLevelSource, type LogLevelSource,
} from './logger'; } from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import {
bindWindowsOverlayAboveMpvNative,
clearWindowsOverlayOwnerNative,
ensureWindowsOverlayTransparencyNative,
getWindowsForegroundProcessNameNative,
queryWindowsForegroundProcessName,
setWindowsOverlayOwnerNative,
} from './window-trackers/windows-helper';
import { import {
commandNeedsOverlayStartupPrereqs, commandNeedsOverlayStartupPrereqs,
commandNeedsOverlayRuntime, commandNeedsOverlayRuntime,
@@ -342,6 +352,7 @@ import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-reso
import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { startStatsServer } from './core/services/stats-server'; import { startStatsServer } from './core/services/stats-server';
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/stats-window.js';
import { import {
createFirstRunSetupService, createFirstRunSetupService,
getFirstRunSetupCompletionMessage, getFirstRunSetupCompletionMessage,
@@ -404,6 +415,8 @@ import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store'; import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './core/services/session-bindings';
import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts'; import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
import { createMainRuntimeRegistry } from './main/runtime/registry'; import { createMainRuntimeRegistry } from './main/runtime/registry';
import { import {
@@ -440,6 +453,7 @@ import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import { import {
createFrequencyDictionaryRuntimeService, createFrequencyDictionaryRuntimeService,
@@ -1526,6 +1540,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
setKeybindings: (keybindings) => { setKeybindings: (keybindings) => {
appState.keybindings = keybindings; appState.keybindings = keybindings;
}, },
setSessionBindings: (sessionBindings) => {
persistSessionBindings(sessionBindings);
},
refreshGlobalAndOverlayShortcuts: () => { refreshGlobalAndOverlayShortcuts: () => {
refreshGlobalAndOverlayShortcuts(); refreshGlobalAndOverlayShortcuts();
}, },
@@ -1835,6 +1852,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible, getForceMousePassthrough: () => appState.statsOverlayVisible,
getWindowTracker: () => appState.windowTracker, getWindowTracker: () => appState.windowTracker,
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => { setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown; appState.trackerNotReadyWarningShown = shown;
@@ -1843,6 +1863,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
ensureOverlayWindowLevel: (window) => { ensureOverlayWindowLevel: (window) => {
ensureOverlayWindowLevel(window); ensureOverlayWindowLevel(window);
}, },
syncWindowsOverlayToMpvZOrder: (_window) => {
requestWindowsVisibleOverlayZOrderSync();
},
syncPrimaryOverlayWindowLayer: (layer) => { syncPrimaryOverlayWindowLayer: (layer) => {
syncPrimaryOverlayWindowLayer(layer); syncPrimaryOverlayWindowLayer(layer);
}, },
@@ -1870,6 +1893,231 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
}, },
})(), })(),
); );
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let windowsVisibleOverlayForegroundPollInFlight = false;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout);
}
windowsVisibleOverlayBlurRefreshTimeouts = [];
}
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
clearTimeout(timeout);
}
windowsVisibleOverlayZOrderRetryTimeouts = [];
}
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
const handle = window.getNativeWindowHandle();
return handle.length >= 8
? handle.readBigUInt64LE(0).toString()
: BigInt(handle.readUInt32LE(0)).toString();
}
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
const handle = window.getNativeWindowHandle();
return handle.length >= 8
? Number(handle.readBigUInt64LE(0))
: handle.readUInt32LE(0);
}
function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | null): number | null {
if (process.platform !== 'win32') {
return null;
}
try {
const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused?.hwnd ?? poll.matches.sort((a, b) => b.area - a.area)[0]?.hwnd ?? null;
} catch {
return null;
}
}
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
if (process.platform !== 'win32') {
return false;
}
const mainWindow = overlayManager.getMainWindow();
if (
!mainWindow ||
mainWindow.isDestroyed() ||
!mainWindow.isVisible() ||
!overlayManager.getVisibleOverlayVisible()
) {
return false;
}
const windowTracker = appState.windowTracker;
if (!windowTracker) {
return false;
}
if (
typeof windowTracker.isTargetWindowMinimized === 'function' &&
windowTracker.isTargetWindowMinimized()
) {
return false;
}
if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) {
return false;
}
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
return true;
}
return false;
}
function requestWindowsVisibleOverlayZOrderSync(): void {
if (process.platform !== 'win32') {
return;
}
if (windowsVisibleOverlayZOrderSyncInFlight) {
windowsVisibleOverlayZOrderSyncQueued = true;
return;
}
windowsVisibleOverlayZOrderSyncInFlight = true;
void syncWindowsVisibleOverlayToMpvZOrder()
.catch((error) => {
logger.warn('Failed to bind Windows overlay z-order to mpv', error);
})
.finally(() => {
windowsVisibleOverlayZOrderSyncInFlight = false;
if (!windowsVisibleOverlayZOrderSyncQueued) {
return;
}
windowsVisibleOverlayZOrderSyncQueued = false;
requestWindowsVisibleOverlayZOrderSync();
});
}
function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void {
if (process.platform !== 'win32') {
return;
}
clearWindowsVisibleOverlayZOrderRetryTimeouts();
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) {
const retryTimeout = setTimeout(() => {
windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter(
(timeout) => timeout !== retryTimeout,
);
requestWindowsVisibleOverlayZOrderSync();
}, delayMs);
windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout);
}
}
function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean {
return (
process.platform === 'win32' &&
lastWindowsVisibleOverlayBlurredAtMs > 0 &&
Date.now() - lastWindowsVisibleOverlayBlurredAtMs <=
WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
);
}
function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean {
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
return false;
}
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return false;
}
const windowTracker = appState.windowTracker;
if (!windowTracker) {
return false;
}
if (
typeof windowTracker.isTargetWindowMinimized === 'function' &&
windowTracker.isTargetWindowMinimized()
) {
return false;
}
const overlayFocused = mainWindow.isFocused();
const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false;
return !overlayFocused && !trackerFocused;
}
function maybePollWindowsVisibleOverlayForegroundProcess(): void {
if (!shouldPollWindowsVisibleOverlayForegroundProcess()) {
lastWindowsVisibleOverlayForegroundProcessName = null;
return;
}
const processName = getWindowsForegroundProcessNameNative();
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
if (normalizedProcessName !== previousProcessName) {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') {
requestWindowsVisibleOverlayZOrderSync();
}
}
function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) {
return;
}
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
maybePollWindowsVisibleOverlayForegroundProcess();
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
}
function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32') {
return;
}
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
clearWindowsVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, delayMs);
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
}
}
ensureWindowsVisibleOverlayForegroundPollLoop();
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler( const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
{ {
getRuntimeOptionsManager: () => appState.runtimeOptionsManager, getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
@@ -3146,6 +3394,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
loadSubtitlePosition: () => loadSubtitlePosition(), loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => { resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
refreshCurrentSessionBindings();
}, },
createMpvClient: () => { createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService(); appState.mpvClient = createMpvClientRuntimeService();
@@ -3288,6 +3537,9 @@ function ensureOverlayStartupPrereqs(): void {
} }
if (appState.keybindings.length === 0) { if (appState.keybindings.length === 0) {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
refreshCurrentSessionBindings();
} else if (appState.sessionBindings.length === 0) {
refreshCurrentSessionBindings();
} }
if (!appState.mpvClient) { if (!appState.mpvClient) {
appState.mpvClient = createMpvClientRuntimeService(); appState.mpvClient = createMpvClientRuntimeService();
@@ -3674,6 +3926,12 @@ function applyOverlayRegions(geometry: WindowGeometry): void {
const buildUpdateVisibleOverlayBoundsMainDepsHandler = const buildUpdateVisibleOverlayBoundsMainDepsHandler =
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
afterSetOverlayWindowBounds: () => {
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
return;
}
scheduleWindowsVisibleOverlayZOrderSyncBurst();
},
}); });
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
@@ -3796,7 +4054,14 @@ function createModalWindow(): BrowserWindow {
} }
function createMainWindow(): BrowserWindow { function createMainWindow(): BrowserWindow {
return createMainWindowHandler(); const window = createMainWindowHandler();
if (process.platform === 'win32') {
const overlayHwnd = getWindowsNativeWindowHandleNumber(window);
if (!ensureWindowsOverlayTransparencyNative(overlayHwnd)) {
logger.warn('Failed to eagerly extend Windows overlay transparency via koffi');
}
}
return window;
} }
function ensureTray(): void { function ensureTray(): void {
@@ -3873,6 +4138,54 @@ const {
}, },
}); });
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32';
return 'linux';
}
function compileCurrentSessionBindings(): {
bindings: CompiledSessionBinding[];
warnings: ReturnType<typeof compileSessionBindings>['warnings'];
} {
return compileSessionBindings({
keybindings: appState.keybindings,
shortcuts: getConfiguredShortcuts(),
statsToggleKey: getResolvedConfig().stats.toggleKey,
platform: resolveSessionBindingPlatform(),
rawConfig: getResolvedConfig(),
});
}
function persistSessionBindings(
bindings: CompiledSessionBinding[],
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
): void {
appState.sessionBindings = bindings;
writeSessionBindingsArtifact(
CONFIG_DIR,
buildPluginSessionBindingsArtifact({
bindings,
warnings,
numericSelectionTimeoutMs: getConfiguredShortcuts().multiCopyTimeoutMs,
}),
);
if (appState.mpvClient?.connected) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
'subminer-reload-session-bindings',
]);
}
}
function refreshCurrentSessionBindings(): void {
const compiled = compileCurrentSessionBindings();
for (const warning of compiled.warnings) {
logger.warn(`[session-bindings] ${warning.message}`);
}
persistSessionBindings(compiled.bindings, compiled.warnings);
}
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: { appendToMpvLogMainDeps: {
logPath: DEFAULT_MPV_LOG_PATH, logPath: DEFAULT_MPV_LOG_PATH,
@@ -4184,6 +4497,51 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text) => showMpvOsd(text),
}); });
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
await dispatchSessionActionCore(request, {
toggleStatsOverlay: () =>
toggleStatsOverlayWindow({
staticDir: statsDistPath,
preloadPath: statsPreloadPath,
getApiBaseUrl: () => ensureStatsServerStarted(),
getToggleKey: () => getResolvedConfig().stats.toggleKey,
resolveBounds: () => getCurrentOverlayGeometry(),
onVisibilityChanged: (visible) => {
appState.statsOverlayVisible = visible;
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
},
}),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
copyCurrentSubtitle: () => copyCurrentSubtitle(),
copySubtitleCount: (count) => handleMultiCopyDigit(count),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
mineSentenceCard: () => mineSentenceCard(),
mineSentenceCount: (count) => handleMineSentenceDigit(count),
toggleSecondarySub: () => handleCycleSecondarySubMode(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => overlayModalRuntime.openJimaku(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
shiftSubDelayToAdjacentSubtitle: (direction) =>
shiftSubtitleDelayToAdjacentCueHandler(direction),
cycleRuntimeOption: (id, direction) => {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' };
}
return applyRuntimeOptionResultRuntime(
appState.runtimeOptionsManager.cycleOption(id, direction),
(text) => showMpvOsd(text),
);
},
showMpvOsd: (text) => showMpvOsd(text),
});
}
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, { const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, {
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
@@ -4341,7 +4699,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position), saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings, getKeybindings: () => appState.keybindings,
getSessionBindings: () => appState.sessionBindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request),
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey, getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
getControllerConfig: () => getResolvedConfig().controller, getControllerConfig: () => getResolvedConfig().controller,
@@ -4462,6 +4822,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
stopApp: () => requestAppQuit(), stopApp: () => requestAppQuit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()), hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
dispatchSessionAction: (request: SessionActionDispatchRequest) => dispatchSessionAction(request),
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
logInfo: (message: string) => logger.info(message), logInfo: (message: string) => logger.info(message),
@@ -4595,6 +4956,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
tryHandleOverlayShortcutLocalFallback: (input) => tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
onWindowClosed: (windowKind) => { onWindowClosed: (windowKind) => {
if (windowKind === 'visible') { if (windowKind === 'visible') {
overlayManager.setMainWindow(null); overlayManager.setMainWindow(null);
@@ -4696,6 +5059,9 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateVisibleOverlayVisibility(), overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
}, },
refreshCurrentSubtitle: () => {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
overlayShortcutsRuntime: { overlayShortcutsRuntime: {
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
}, },
@@ -4719,6 +5085,40 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
}, },
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
updateVisibleOverlayBounds(geometry), updateVisibleOverlayBounds(geometry),
bindOverlayOwner: () => {
const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) {
return;
}
const tracker = appState.windowTracker;
const mpvResult = tracker
? (() => {
try {
const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused ?? poll.matches.sort((a, b) => b.area - a.area)[0] ?? null;
} catch {
return null;
}
})()
: null;
if (!mpvResult) return;
if (!setWindowsOverlayOwnerNative(overlayHwnd, mpvResult.hwnd)) {
logger.warn('Failed to set overlay owner via koffi');
}
},
releaseOverlayOwner: () => {
const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (!clearWindowsOverlayOwnerNative(overlayHwnd)) {
logger.warn('Failed to clear overlay owner via koffi');
}
},
getOverlayWindows: () => getOverlayWindows(), getOverlayWindows: () => getOverlayWindows(),
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
showDesktopNotification, showDesktopNotification,

View File

@@ -30,6 +30,7 @@ export interface CliCommandRuntimeServiceContext {
triggerFieldGrouping: () => Promise<void>; triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>; triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandRuntimeServiceDepsParams['dispatchSessionAction'];
getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus']; getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus'];
clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken']; clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken'];
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup']; openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
@@ -113,6 +114,7 @@ function createCliCommandDepsFromContext(
hasMainWindow: context.hasMainWindow, hasMainWindow: context.hasMainWindow,
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow, runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
}, },
dispatchSessionAction: context.dispatchSessionAction,
ui: { ui: {
openFirstRunSetup: context.openFirstRunSetup, openFirstRunSetup: context.openFirstRunSetup,
openYomitanSettings: context.openYomitanSettings, openYomitanSettings: context.openYomitanSettings,

View File

@@ -73,7 +73,9 @@ export interface MainIpcRuntimeServiceDepsParams {
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer']; getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey']; getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey']; getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
@@ -178,6 +180,7 @@ export interface CliCommandRuntimeServiceDepsParams {
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow']; hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow']; runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
}; };
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
ui: { ui: {
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
@@ -233,7 +236,9 @@ export function createMainIpcRuntimeServiceDeps(
getMecabTokenizer: params.getMecabTokenizer, getMecabTokenizer: params.getMecabTokenizer,
handleMpvCommand: params.handleMpvCommand, handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings, getKeybindings: params.getKeybindings,
getSessionBindings: params.getSessionBindings,
getConfiguredShortcuts: params.getConfiguredShortcuts, getConfiguredShortcuts: params.getConfiguredShortcuts,
dispatchSessionAction: params.dispatchSessionAction,
getStatsToggleKey: params.getStatsToggleKey, getStatsToggleKey: params.getStatsToggleKey,
getMarkWatchedKey: params.getMarkWatchedKey, getMarkWatchedKey: params.getMarkWatchedKey,
getControllerConfig: params.getControllerConfig, getControllerConfig: params.getControllerConfig,
@@ -347,6 +352,7 @@ export function createCliCommandRuntimeServiceDeps(
hasMainWindow: params.app.hasMainWindow, hasMainWindow: params.app.hasMainWindow,
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow, runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
}, },
dispatchSessionAction: params.dispatchSessionAction,
ui: { ui: {
openFirstRunSetup: params.ui.openFirstRunSetup, openFirstRunSetup: params.ui.openFirstRunSetup,
openYomitanSettings: params.ui.openYomitanSettings, openYomitanSettings: params.ui.openYomitanSettings,

View File

@@ -12,10 +12,14 @@ export interface OverlayVisibilityRuntimeDeps {
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean; getForceMousePassthrough: () => boolean;
getWindowTracker: () => BaseWindowTracker | null; getWindowTracker: () => BaseWindowTracker | null;
getLastKnownWindowsForegroundProcessName?: () => string | null;
getWindowsOverlayProcessName?: () => string | null;
getWindowsFocusHandoffGraceActive?: () => boolean;
getTrackerNotReadyWarningShown: () => boolean; getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void; setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void;
syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void; syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void; enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
@@ -36,12 +40,20 @@ export function createOverlayVisibilityRuntimeService(
return { return {
updateVisibleOverlayVisibility(): void { updateVisibleOverlayVisibility(): void {
const visibleOverlayVisible = deps.getVisibleOverlayVisible();
const forceMousePassthrough = deps.getForceMousePassthrough();
const windowTracker = deps.getWindowTracker();
const mainWindow = deps.getMainWindow();
updateVisibleOverlayVisibility({ updateVisibleOverlayVisibility({
visibleOverlayVisible: deps.getVisibleOverlayVisible(), visibleOverlayVisible,
modalActive: deps.getModalActive(), modalActive: deps.getModalActive(),
forceMousePassthrough: deps.getForceMousePassthrough(), forceMousePassthrough,
mainWindow: deps.getMainWindow(), mainWindow,
windowTracker: deps.getWindowTracker(), windowTracker,
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(), trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => { setTrackerNotReadyWarningShown: (shown: boolean) => {
deps.setTrackerNotReadyWarningShown(shown); deps.setTrackerNotReadyWarningShown(shown);
@@ -49,6 +61,8 @@ export function createOverlayVisibilityRuntimeService(
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry), deps.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
deps.syncWindowsOverlayToMpvZOrder?.(window),
syncPrimaryOverlayWindowLayer: (layer: 'visible') => syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
deps.syncPrimaryOverlayWindowLayer(layer), deps.syncPrimaryOverlayWindowLayer(layer),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),

View File

@@ -42,6 +42,7 @@ test('build cli command context deps maps handlers and values', () => {
markLastCardAsAudioCard: async () => { markLastCardAsAudioCard: async () => {
calls.push('mark'); calls.push('mark');
}, },
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({}) as never, getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => calls.push('clear-token'), clearAnilistToken: () => calls.push('clear-token'),
openAnilistSetup: () => calls.push('anilist'), openAnilistSetup: () => calls.push('anilist'),

View File

@@ -28,6 +28,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
triggerFieldGrouping: () => Promise<void>; triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>; triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken']; clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup']; openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
@@ -77,6 +78,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
triggerFieldGrouping: deps.triggerFieldGrouping, triggerFieldGrouping: deps.triggerFieldGrouping,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
markLastCardAsAudioCard: deps.markLastCardAsAudioCard, markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
dispatchSessionAction: deps.dispatchSessionAction,
getAnilistStatus: deps.getAnilistStatus, getAnilistStatus: deps.getAnilistStatus,
clearAnilistToken: deps.clearAnilistToken, clearAnilistToken: deps.clearAnilistToken,
openAnilistSetup: deps.openAnilistSetup, openAnilistSetup: deps.openAnilistSetup,

View File

@@ -37,6 +37,7 @@ test('cli command context factory composes main deps and context handlers', () =
triggerFieldGrouping: async () => {}, triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {}, markLastCardAsAudioCard: async () => {},
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({ getAnilistStatus: () => ({
tokenStatus: 'resolved', tokenStatus: 'resolved',
tokenSource: 'literal', tokenSource: 'literal',

View File

@@ -53,6 +53,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
markLastCardAsAudioCard: async () => { markLastCardAsAudioCard: async () => {
calls.push('mark-audio'); calls.push('mark-audio');
}, },
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({ getAnilistStatus: () => ({
tokenStatus: 'resolved', tokenStatus: 'resolved',

View File

@@ -39,6 +39,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
triggerFieldGrouping: () => Promise<void>; triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>; triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: () => void; clearAnilistToken: () => void;
@@ -103,6 +104,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
triggerFieldGrouping: () => deps.triggerFieldGrouping(), triggerFieldGrouping: () => deps.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(), markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
dispatchSessionAction: (request) => deps.dispatchSessionAction(request),
getAnilistStatus: () => deps.getAnilistStatus(), getAnilistStatus: () => deps.getAnilistStatus(),
clearAnilistToken: () => deps.clearAnilistToken(), clearAnilistToken: () => deps.clearAnilistToken(),
openAnilistSetup: () => deps.openAnilistSetupWindow(), openAnilistSetup: () => deps.openAnilistSetupWindow(),

View File

@@ -36,6 +36,7 @@ function createDeps() {
triggerFieldGrouping: async () => {}, triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {}, markLastCardAsAudioCard: async () => {},
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({}) as never, getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {}, clearAnilistToken: () => {},
openAnilistSetup: () => {}, openAnilistSetup: () => {},

View File

@@ -33,6 +33,7 @@ export type CliCommandContextFactoryDeps = {
triggerFieldGrouping: () => Promise<void>; triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>; triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandRuntimeServiceContext['dispatchSessionAction'];
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus']; getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken']; clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup']; openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
@@ -89,6 +90,7 @@ export function createCliCommandContext(
triggerFieldGrouping: deps.triggerFieldGrouping, triggerFieldGrouping: deps.triggerFieldGrouping,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
markLastCardAsAudioCard: deps.markLastCardAsAudioCard, markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
dispatchSessionAction: deps.dispatchSessionAction,
getAnilistStatus: deps.getAnilistStatus, getAnilistStatus: deps.getAnilistStatus,
clearAnilistToken: deps.clearAnilistToken, clearAnilistToken: deps.clearAnilistToken,
openAnilistSetup: deps.openAnilistSetup, openAnilistSetup: deps.openAnilistSetup,

View File

@@ -30,6 +30,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
triggerFieldGrouping: async () => {}, triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {}, markLastCardAsAudioCard: async () => {},
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({}) as never, getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {}, clearAnilistToken: () => {},
openAnilistSetupWindow: () => {}, openAnilistSetupWindow: () => {},

View File

@@ -53,7 +53,9 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
saveSubtitlePosition: () => {}, saveSubtitlePosition: () => {},
getMecabTokenizer: () => null, getMecabTokenizer: () => null,
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}) as never, getConfiguredShortcuts: () => ({}) as never,
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => ({}) as never, getControllerConfig: () => ({}) as never,

View File

@@ -14,6 +14,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
const applyHotReload = createConfigHotReloadAppliedHandler({ const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'), setKeybindings: () => calls.push('set:keybindings'),
setSessionBindings: () => calls.push('set:session-bindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`), setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
broadcastToOverlayWindows: (channel, payload) => broadcastToOverlayWindows: (channel, payload) =>
@@ -37,6 +38,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
); );
assert.ok(calls.includes('set:keybindings')); assert.ok(calls.includes('set:keybindings'));
assert.ok(calls.includes('set:session-bindings'));
assert.ok(calls.includes('refresh:shortcuts')); assert.ok(calls.includes('refresh:shortcuts'));
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`)); assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:'))); assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
@@ -50,6 +52,7 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
const applyHotReload = createConfigHotReloadAppliedHandler({ const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'), setKeybindings: () => calls.push('set:keybindings'),
setSessionBindings: () => calls.push('set:session-bindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: () => calls.push('set:secondary'), setSecondarySubMode: () => calls.push('set:secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
@@ -64,7 +67,7 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
config, config,
); );
assert.deepEqual(calls, ['set:keybindings']); assert.deepEqual(calls, ['set:keybindings', 'set:session-bindings']);
}); });
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => { test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {

View File

@@ -1,10 +1,13 @@
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload'; import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { compileSessionBindings } from '../../core/services/session-bindings';
import { resolveKeybindings } from '../../core/utils/keybindings'; import { resolveKeybindings } from '../../core/utils/keybindings';
import { DEFAULT_KEYBINDINGS } from '../../config'; import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types'; import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
type ConfigHotReloadAppliedDeps = { type ConfigHotReloadAppliedDeps = {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void;
refreshGlobalAndOverlayShortcuts: () => void; refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
@@ -33,8 +36,21 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
} }
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload { export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS);
const { bindings: sessionBindings } = compileSessionBindings({
keybindings,
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
platform:
process.platform === 'darwin'
? 'darwin'
: process.platform === 'win32'
? 'win32'
: 'linux',
rawConfig: config,
});
return { return {
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS), keybindings,
sessionBindings,
subtitleStyle: resolveSubtitleStyleForRenderer(config), subtitleStyle: resolveSubtitleStyleForRenderer(config),
subtitleSidebar: config.subtitleSidebar, subtitleSidebar: config.subtitleSidebar,
secondarySubMode: config.secondarySub.defaultMode, secondarySubMode: config.secondarySub.defaultMode,
@@ -45,6 +61,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => { return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
const payload = buildConfigHotReloadPayload(config); const payload = buildConfigHotReloadPayload(config);
deps.setKeybindings(payload.keybindings); deps.setKeybindings(payload.keybindings);
deps.setSessionBindings(payload.sessionBindings);
if (diff.hotReloadFields.includes('shortcuts')) { if (diff.hotReloadFields.includes('shortcuts')) {
deps.refreshGlobalAndOverlayShortcuts(); deps.refreshGlobalAndOverlayShortcuts();

View File

@@ -86,21 +86,25 @@ test('config hot reload message main deps builder maps notifications', () => {
test('config hot reload applied main deps builder maps callbacks', () => { test('config hot reload applied main deps builder maps callbacks', () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createBuildConfigHotReloadAppliedMainDepsHandler({ const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({
setKeybindings: () => calls.push('keybindings'), setKeybindings: () => calls.push('keybindings'),
setSessionBindings: () => calls.push('session-bindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
setSecondarySubMode: () => calls.push('set-secondary'), setSecondarySubMode: () => calls.push('set-secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'), applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'),
})(); });
const deps = buildDeps();
deps.setKeybindings([]); deps.setKeybindings([]);
deps.setSessionBindings([]);
deps.refreshGlobalAndOverlayShortcuts(); deps.refreshGlobalAndOverlayShortcuts();
deps.setSecondarySubMode('hover'); deps.setSecondarySubMode('hover');
deps.broadcastToOverlayWindows('config:hot-reload', {}); deps.broadcastToOverlayWindows('config:hot-reload', {});
deps.applyAnkiRuntimeConfigPatch({ ai: true }); deps.applyAnkiRuntimeConfigPatch({ ai: true });
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'keybindings', 'keybindings',
'session-bindings',
'refresh-shortcuts', 'refresh-shortcuts',
'set-secondary', 'set-secondary',
'broadcast:config:hot-reload', 'broadcast:config:hot-reload',

View File

@@ -62,6 +62,7 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void;
refreshGlobalAndOverlayShortcuts: () => void; refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
@@ -72,6 +73,8 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
return () => ({ return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
deps.setKeybindings(keybindings), deps.setKeybindings(keybindings),
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) =>
deps.setSessionBindings(sessionBindings),
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(), refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
broadcastToOverlayWindows: (channel: string, payload: unknown) => broadcastToOverlayWindows: (channel: string, payload: unknown) =>

View File

@@ -43,6 +43,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,

View File

@@ -31,6 +31,8 @@ type InitializeOverlayRuntimeCore = (options: {
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean; shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}) => void; }) => void;
export function createInitializeOverlayRuntimeHandler(deps: { export function createInitializeOverlayRuntimeHandler(deps: {

View File

@@ -23,6 +23,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
overlayVisibilityRuntime: { overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('update-visible'), updateVisibleOverlayVisibility: () => calls.push('update-visible'),
}, },
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
overlayShortcutsRuntime: { overlayShortcutsRuntime: {
syncOverlayShortcuts: () => calls.push('sync-shortcuts'), syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
}, },
@@ -53,6 +54,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
deps.registerGlobalShortcuts(); deps.registerGlobalShortcuts();
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.updateVisibleOverlayVisibility(); deps.updateVisibleOverlayVisibility();
deps.refreshCurrentSubtitle?.();
deps.syncOverlayShortcuts(); deps.syncOverlayShortcuts();
deps.showDesktopNotification('title', {}); deps.showDesktopNotification('title', {});
@@ -68,6 +70,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
'register-shortcuts', 'register-shortcuts',
'visible-bounds', 'visible-bounds',
'update-visible', 'update-visible',
'refresh-subtitle',
'sync-shortcuts', 'sync-shortcuts',
'notify', 'notify',
]); ]);

View File

@@ -21,6 +21,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
overlayVisibilityRuntime: { overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => void; updateVisibleOverlayVisibility: () => void;
}; };
refreshCurrentSubtitle?: () => void;
overlayShortcutsRuntime: { overlayShortcutsRuntime: {
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
}; };
@@ -39,6 +40,8 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean; shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}) { }) {
return (): OverlayRuntimeOptionsMainDeps => ({ return (): OverlayRuntimeOptionsMainDeps => ({
getBackendOverride: () => deps.appState.backendOverride, getBackendOverride: () => deps.appState.backendOverride,
@@ -53,6 +56,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(), isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility: () =>
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(), deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
getOverlayWindows: () => deps.getOverlayWindows(), getOverlayWindows: () => deps.getOverlayWindows(),
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(), syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
setWindowTracker: (tracker) => { setWindowTracker: (tracker) => {
@@ -71,5 +75,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(), shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
bindOverlayOwner: deps.bindOverlayOwner,
releaseOverlayOwner: deps.releaseOverlayOwner,
}); });
} }

View File

@@ -11,6 +11,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'), updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'),
isVisibleOverlayVisible: () => true, isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => calls.push('update-visible'), updateVisibleOverlayVisibility: () => calls.push('update-visible'),
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
getOverlayWindows: () => [], getOverlayWindows: () => [],
syncOverlayShortcuts: () => calls.push('sync-shortcuts'), syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
setWindowTracker: () => calls.push('set-tracker'), setWindowTracker: () => calls.push('set-tracker'),
@@ -41,6 +42,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
options.registerGlobalShortcuts(); options.registerGlobalShortcuts();
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
options.updateVisibleOverlayVisibility(); options.updateVisibleOverlayVisibility();
options.refreshCurrentSubtitle?.();
options.syncOverlayShortcuts(); options.syncOverlayShortcuts();
options.setWindowTracker(null); options.setWindowTracker(null);
options.setAnkiIntegration(null); options.setAnkiIntegration(null);
@@ -51,6 +53,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
'register-shortcuts', 'register-shortcuts',
'update-visible-bounds', 'update-visible-bounds',
'update-visible', 'update-visible',
'refresh-subtitle',
'sync-shortcuts', 'sync-shortcuts',
'set-tracker', 'set-tracker',
'set-anki', 'set-anki',

View File

@@ -14,6 +14,7 @@ type OverlayRuntimeOptions = {
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void; updateVisibleOverlayVisibility: () => void;
refreshCurrentSubtitle?: () => void;
getOverlayWindows: () => BrowserWindow[]; getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -35,6 +36,8 @@ type OverlayRuntimeOptions = {
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean; shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}; };
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
@@ -44,6 +47,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void; updateVisibleOverlayVisibility: () => void;
refreshCurrentSubtitle?: () => void;
getOverlayWindows: () => BrowserWindow[]; getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void;
@@ -65,6 +69,8 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean; shouldStartAnkiIntegration: () => boolean;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}) { }) {
return (): OverlayRuntimeOptions => ({ return (): OverlayRuntimeOptions => ({
backendOverride: deps.getBackendOverride(), backendOverride: deps.getBackendOverride(),
@@ -73,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds, updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
isVisibleOverlayVisible: deps.isVisibleOverlayVisible, isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility, updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
refreshCurrentSubtitle: deps.refreshCurrentSubtitle,
getOverlayWindows: deps.getOverlayWindows, getOverlayWindows: deps.getOverlayWindows,
syncOverlayShortcuts: deps.syncOverlayShortcuts, syncOverlayShortcuts: deps.syncOverlayShortcuts,
setWindowTracker: deps.setWindowTracker, setWindowTracker: deps.setWindowTracker,
@@ -87,5 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
createFieldGroupingCallback: deps.createFieldGroupingCallback, createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration, shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
bindOverlayOwner: deps.bindOverlayOwner,
releaseOverlayOwner: deps.releaseOverlayOwner,
}); });
} }

View File

@@ -16,6 +16,9 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
getVisibleOverlayVisible: () => true, getVisibleOverlayVisible: () => true,
getForceMousePassthrough: () => true, getForceMousePassthrough: () => true,
getWindowTracker: () => tracker, getWindowTracker: () => tracker,
getLastKnownWindowsForegroundProcessName: () => 'mpv',
getWindowsOverlayProcessName: () => 'subminer',
getWindowsFocusHandoffGraceActive: () => true,
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown, getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown) => { setTrackerNotReadyWarningShown: (shown) => {
trackerNotReadyWarningShown = shown; trackerNotReadyWarningShown = shown;
@@ -23,6 +26,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
}, },
updateVisibleOverlayBounds: () => calls.push('visible-bounds'), updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
ensureOverlayWindowLevel: () => calls.push('ensure-level'), ensureOverlayWindowLevel: () => calls.push('ensure-level'),
syncWindowsOverlayToMpvZOrder: () => calls.push('sync-windows-z-order'),
syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`), syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`),
enforceOverlayLayerOrder: () => calls.push('enforce-order'), enforceOverlayLayerOrder: () => calls.push('enforce-order'),
syncOverlayShortcuts: () => calls.push('sync-shortcuts'), syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
@@ -36,10 +40,14 @@ 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.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
assert.equal(deps.getTrackerNotReadyWarningShown(), false); assert.equal(deps.getTrackerNotReadyWarningShown(), false);
deps.setTrackerNotReadyWarningShown(true); deps.setTrackerNotReadyWarningShown(true);
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.ensureOverlayWindowLevel(mainWindow); deps.ensureOverlayWindowLevel(mainWindow);
deps.syncWindowsOverlayToMpvZOrder?.(mainWindow);
deps.syncPrimaryOverlayWindowLayer('visible'); deps.syncPrimaryOverlayWindowLayer('visible');
deps.enforceOverlayLayerOrder(); deps.enforceOverlayLayerOrder();
deps.syncOverlayShortcuts(); deps.syncOverlayShortcuts();
@@ -52,6 +60,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
'tracker-warning:true', 'tracker-warning:true',
'visible-bounds', 'visible-bounds',
'ensure-level', 'ensure-level',
'sync-windows-z-order',
'primary-layer:visible', 'primary-layer:visible',
'enforce-order', 'enforce-order',
'sync-shortcuts', 'sync-shortcuts',

View File

@@ -11,11 +11,17 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(), getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getWindowTracker: () => deps.getWindowTracker(), getWindowTracker: () => deps.getWindowTracker(),
getLastKnownWindowsForegroundProcessName: () =>
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null,
getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false,
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(), getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown), setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry), deps.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
deps.syncWindowsOverlayToMpvZOrder?.(window),
syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer), syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),

View File

@@ -11,6 +11,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void; forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void;
yomitanSession?: Session | null; yomitanSession?: Session | null;
}, },
@@ -22,6 +24,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void; forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void;
getYomitanSession?: () => Session | null; getYomitanSession?: () => Session | null;
}) { }) {
@@ -34,6 +38,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
isOverlayVisible: deps.isOverlayVisible, isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv, forwardTabToMpv: deps.forwardTabToMpv,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed, onWindowClosed: deps.onWindowClosed,
getYomitanSession: () => deps.getYomitanSession?.() ?? null, getYomitanSession: () => deps.getYomitanSession?.() ?? null,
}); });

View File

@@ -13,6 +13,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void; forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void; onWindowClosed: (windowKind: OverlayWindowKind) => void;
yomitanSession?: Session | null; yomitanSession?: Session | null;
}, },
@@ -24,6 +26,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void; forwardTabToMpv: () => void;
onVisibleWindowBlurred?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void; onWindowClosed: (windowKind: OverlayWindowKind) => void;
getYomitanSession?: () => Session | null; getYomitanSession?: () => Session | null;
}) { }) {
@@ -36,6 +40,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
isOverlayVisible: deps.isOverlayVisible, isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv, forwardTabToMpv: deps.forwardTabToMpv,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed, onWindowClosed: deps.onWindowClosed,
yomitanSession: deps.getYomitanSession?.() ?? null, yomitanSession: deps.getYomitanSession?.() ?? null,
}); });

View File

@@ -15,6 +15,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
) { ) {
return (): UpdateVisibleOverlayBoundsMainDeps => ({ return (): UpdateVisibleOverlayBoundsMainDeps => ({
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry), setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
}); });
} }

View File

@@ -16,6 +16,22 @@ test('visible bounds handler writes visible layer geometry', () => {
assert.deepEqual(calls, [geometry]); assert.deepEqual(calls, [geometry]);
}); });
test('visible bounds handler runs follow-up callback after applying geometry', () => {
const calls: string[] = [];
const geometry = { x: 0, y: 0, width: 100, height: 50 };
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
setOverlayWindowBounds: () => calls.push('set-bounds'),
afterSetOverlayWindowBounds: (nextGeometry) => {
assert.deepEqual(nextGeometry, geometry);
calls.push('after-bounds');
},
});
handleVisible(geometry);
assert.deepEqual(calls, ['set-bounds', 'after-bounds']);
});
test('ensure overlay window level handler delegates to core', () => { test('ensure overlay window level handler delegates to core', () => {
const calls: string[] = []; const calls: string[] = [];
const ensureLevel = createEnsureOverlayWindowLevelHandler({ const ensureLevel = createEnsureOverlayWindowLevelHandler({

View File

@@ -2,9 +2,11 @@ import type { WindowGeometry } from '../../types';
export function createUpdateVisibleOverlayBoundsHandler(deps: { export function createUpdateVisibleOverlayBoundsHandler(deps: {
setOverlayWindowBounds: (geometry: WindowGeometry) => void; setOverlayWindowBounds: (geometry: WindowGeometry) => void;
afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void;
}) { }) {
return (geometry: WindowGeometry): void => { return (geometry: WindowGeometry): void => {
deps.setOverlayWindowBounds(geometry); deps.setOverlayWindowBounds(geometry);
deps.afterSetOverlayWindowBounds?.(geometry);
}; };
} }

View File

@@ -0,0 +1,17 @@
import fs from 'node:fs';
import path from 'node:path';
import type { PluginSessionBindingsArtifact } from '../../types';
export function getSessionBindingsArtifactPath(configDir: string): string {
return path.join(configDir, 'session-bindings.json');
}
export function writeSessionBindingsArtifact(
configDir: string,
artifact: PluginSessionBindingsArtifact,
): string {
const artifactPath = getSessionBindingsArtifactPath(configDir);
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
return artifactPath;
}

View File

@@ -1,6 +1,7 @@
import type { BrowserWindow, Extension, Session } from 'electron'; import type { BrowserWindow, Extension, Session } from 'electron';
import type { import type {
CompiledSessionBinding,
Keybinding, Keybinding,
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
SecondarySubMode, SecondarySubMode,
@@ -170,6 +171,7 @@ export interface AppState {
anilistClientSecretState: AnilistSecretResolutionState; anilistClientSecretState: AnilistSecretResolutionState;
mecabTokenizer: MecabTokenizer | null; mecabTokenizer: MecabTokenizer | null;
keybindings: Keybinding[]; keybindings: Keybinding[];
sessionBindings: CompiledSessionBinding[];
subtitleTimingTracker: SubtitleTimingTracker | null; subtitleTimingTracker: SubtitleTimingTracker | null;
immersionTracker: ImmersionTrackerService | null; immersionTracker: ImmersionTrackerService | null;
ankiIntegration: AnkiIntegration | null; ankiIntegration: AnkiIntegration | null;
@@ -252,6 +254,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
anilistClientSecretState: createInitialAnilistSecretResolutionState(), anilistClientSecretState: createInitialAnilistSecretResolutionState(),
mecabTokenizer: null, mecabTokenizer: null,
keybindings: [], keybindings: [],
sessionBindings: [],
subtitleTimingTracker: null, subtitleTimingTracker: null,
immersionTracker: null, immersionTracker: null,
ankiIntegration: null, ankiIntegration: null,

View File

@@ -223,8 +223,11 @@ const electronAPI: ElectronAPI = {
getKeybindings: (): Promise<Keybinding[]> => getKeybindings: (): Promise<Keybinding[]> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings),
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> => getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
dispatchSessionAction: (actionId, payload) =>
ipcRenderer.invoke(IPC_CHANNELS.command.dispatchSessionAction, { actionId, payload }),
getStatsToggleKey: (): Promise<string> => getStatsToggleKey: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey), ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey),
getMarkWatchedKey: (): Promise<string> => getMarkWatchedKey: (): Promise<string> =>

View File

@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml');
const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8');
const packageJsonPath = resolve(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>;
};
test('prerelease workflow triggers on beta and rc tags only', () => {
assert.match(prereleaseWorkflow, /name: Prerelease/);
assert.match(prereleaseWorkflow, /tags:\s*\n\s*-\s*'v\*-beta\.\*'/);
assert.match(prereleaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'v\*-rc\.\*'/);
});
test('package scripts expose prerelease notes generation separately from stable changelog build', () => {
assert.equal(
packageJson.scripts['changelog:prerelease-notes'],
'bun run scripts/build-changelog.ts prerelease-notes',
);
});
test('prerelease workflow generates prerelease notes from pending fragments', () => {
assert.match(prereleaseWorkflow, /bun run changelog:prerelease-notes --version/);
assert.doesNotMatch(prereleaseWorkflow, /bun run changelog:build --version/);
});
test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => {
assert.match(prereleaseWorkflow, /gh release edit[\s\S]*--prerelease/);
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--prerelease/);
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--latest=false/);
});
test('prerelease workflow builds and uploads all release platforms', () => {
assert.match(prereleaseWorkflow, /build-linux:/);
assert.match(prereleaseWorkflow, /build-macos:/);
assert.match(prereleaseWorkflow, /build-windows:/);
assert.match(prereleaseWorkflow, /name: appimage/);
assert.match(prereleaseWorkflow, /name: macos/);
assert.match(prereleaseWorkflow, /name: windows/);
});
test('prerelease workflow publishes the same release assets as the stable workflow', () => {
assert.match(
prereleaseWorkflow,
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/,
);
assert.match(
prereleaseWorkflow,
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
);
});
test('prerelease workflow does not publish to AUR', () => {
assert.doesNotMatch(prereleaseWorkflow, /aur-publish:/);
assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/);
});

View File

@@ -22,6 +22,12 @@ test('publish release leaves prerelease unset so gh creates a normal release', (
assert.ok(!releaseWorkflow.includes('--prerelease')); assert.ok(!releaseWorkflow.includes('--prerelease'));
}); });
test('stable release workflow excludes prerelease beta and rc tags', () => {
assert.match(releaseWorkflow, /tags:\s*\n\s*-\s*'v\*'/);
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-beta\.\*'/);
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-rc\.\*'/);
});
test('publish release forces an existing draft tag release to become public', () => { test('publish release forces an existing draft tag release to become public', () => {
assert.ok(releaseWorkflow.includes('--draft=false')); assert.ok(releaseWorkflow.includes('--draft=false'));
}); });

View File

@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
import { createRendererRecoveryController } from './error-recovery.js'; import { createRendererRecoveryController } from './error-recovery.js';
import { import {
YOMITAN_POPUP_HOST_SELECTOR,
YOMITAN_POPUP_IFRAME_SELECTOR, YOMITAN_POPUP_IFRAME_SELECTOR,
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
hasYomitanPopupIframe, hasYomitanPopupIframe,
isYomitanPopupIframe, isYomitanPopupIframe,
isYomitanPopupVisible, isYomitanPopupVisible,
@@ -228,6 +230,42 @@ test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles
} }
}); });
test('resolvePlatformInfo flags Windows platforms', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'visible',
},
location: { search: '' },
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
platform: 'Win32',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
},
});
try {
const info = resolvePlatformInfo();
assert.equal(info.isWindowsPlatform, true);
assert.equal(info.isMacOSPlatform, false);
assert.equal(info.isLinuxPlatform, false);
assert.equal(info.shouldToggleMouseIgnore, true);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => { test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
const createElement = (options: { const createElement = (options: {
tagName: string; tagName: string;
@@ -284,9 +322,25 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR); assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
}); });
test('hasYomitanPopupIframe falls back to popup host selector for shadow-hosted popups', () => {
const selectors: string[] = [];
const root = {
querySelector: (value: string) => {
selectors.push(value);
if (value === YOMITAN_POPUP_HOST_SELECTOR) {
return {};
}
return null;
},
} as unknown as ParentNode;
assert.equal(hasYomitanPopupIframe(root), true);
assert.deepEqual(selectors, [YOMITAN_POPUP_IFRAME_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]);
});
test('isYomitanPopupVisible requires visible iframe geometry', () => { test('isYomitanPopupVisible requires visible iframe geometry', () => {
const previousWindow = (globalThis as { window?: unknown }).window; const previousWindow = (globalThis as { window?: unknown }).window;
let selector = ''; const selectors: string[] = [];
const visibleFrame = { const visibleFrame = {
getBoundingClientRect: () => ({ width: 320, height: 180 }), getBoundingClientRect: () => ({ width: 320, height: 180 }),
} as unknown as HTMLIFrameElement; } as unknown as HTMLIFrameElement;
@@ -309,18 +363,40 @@ test('isYomitanPopupVisible requires visible iframe geometry', () => {
try { try {
const root = { const root = {
querySelectorAll: (value: string) => { querySelectorAll: (value: string) => {
selector = value; selectors.push(value);
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || value === YOMITAN_POPUP_HOST_SELECTOR) {
return [];
}
return [hiddenFrame, visibleFrame]; return [hiddenFrame, visibleFrame];
}, },
} as unknown as ParentNode; } as unknown as ParentNode;
assert.equal(isYomitanPopupVisible(root), true); assert.equal(isYomitanPopupVisible(root), true);
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR); assert.deepEqual(selectors, [
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
YOMITAN_POPUP_IFRAME_SELECTOR,
]);
} finally { } finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
} }
}); });
test('isYomitanPopupVisible detects visible shadow-hosted popup marker without iframe access', () => {
let selector = '';
const root = {
querySelectorAll: (value: string) => {
selector = value;
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
return [{ getAttribute: () => 'true' }];
}
return [];
},
} as unknown as ParentNode;
assert.equal(isYomitanPopupVisible(root), true);
assert.equal(selector, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
});
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => { test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
const calls: Array<{ block?: ScrollLogicalPosition }> = []; const calls: Array<{ block?: ScrollLogicalPosition }> = [];
const activeItem = { const activeItem = {

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