mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-14 16:19:29 -07:00
Compare commits
30 Commits
v0.12.0-be
...
stats-upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
0942e8fd45
|
|||
|
233591435d
|
|||
|
7e355dbac6
|
|||
|
73c33930a9
|
|||
|
0294671de1
|
|||
|
8d45102848
|
|||
|
51b38f615d
|
|||
|
8751ffd6c8
|
|||
|
f91c600ed0
|
|||
|
6977c59691
|
|||
|
8e77e422e8
|
|||
|
928a0d6b61
|
|||
|
70d52248f8
|
|||
|
f4c7923f2b
|
|||
|
42cc35dcd6
|
|||
|
c5e778d7d2
|
|||
|
b1acbae580
|
|||
|
45d30ea66c
|
|||
|
b080add6ce
|
|||
|
b4aea0f77e
|
|||
|
6dcf7d9234
|
|||
|
cfb2396791
|
|||
|
8e25e19cac
|
|||
|
20976d63f0
|
|||
|
c1bc92f254
|
|||
|
364f7aacb7
|
|||
|
76547bb96e
|
|||
|
409a3964d2
|
|||
|
8874e2e1c6
|
|||
|
82d58a57c6
|
389
.github/workflows/prerelease.yml
vendored
389
.github/workflows/prerelease.yml
vendored
@@ -1,389 +0,0 @@
|
|||||||
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
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -4,8 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
- '!v*-beta.*'
|
|
||||||
- '!v*-rc.*'
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
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 -->
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
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 -->
|
|
||||||
3
bun.lock
3
bun.lock
@@ -12,7 +12,6 @@
|
|||||||
"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",
|
||||||
},
|
},
|
||||||
@@ -479,8 +478,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -30,9 +30,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Added configurable overlay shortcuts for session help, controller select, and controller debug actions.
|
|
||||||
- Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path.
|
|
||||||
- Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser.
|
|
||||||
11
changes/stats-dashboard-feedback-pass.md
Normal file
11
changes/stats-dashboard-feedback-pass.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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.
|
||||||
4
changes/stats-library-summary.md
Normal file
4
changes/stats-library-summary.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
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.
|
||||||
@@ -173,11 +173,7 @@
|
|||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
||||||
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
|
|
||||||
"openControllerSelect": "Alt+C", // Open controller select setting.
|
|
||||||
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
|
|
||||||
"toggleSubtitleSidebar": "\\" // Toggle subtitle sidebar setting.
|
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -536,11 +536,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
||||||
"markAudioCard": "CommandOrControl+Shift+A",
|
"markAudioCard": "CommandOrControl+Shift+A",
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
||||||
"openSessionHelp": "CommandOrControl+Shift+H",
|
|
||||||
"openControllerSelect": "Alt+C",
|
|
||||||
"openControllerDebug": "Alt+Shift+C",
|
|
||||||
"openJimaku": "Ctrl+Shift+J",
|
"openJimaku": "Ctrl+Shift+J",
|
||||||
"toggleSubtitleSidebar": "\\",
|
|
||||||
"multiCopyTimeoutMs": 3000
|
"multiCopyTimeoutMs": 3000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,11 +556,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||||
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Shift+H"`) |
|
|
||||||
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
|
||||||
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
|
|
||||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"\\"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
|
||||||
|
|
||||||
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||||
|
|
||||||
@@ -581,10 +573,9 @@ Important behavior:
|
|||||||
- Controller input is only active while keyboard-only mode is enabled.
|
- Controller input is only active while keyboard-only mode is enabled.
|
||||||
- Keyboard-only mode continues to work normally without a controller.
|
- Keyboard-only mode continues to work normally without a controller.
|
||||||
- By default SubMiner uses the first connected controller.
|
- By default SubMiner uses the first connected controller.
|
||||||
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
|
- `Alt+C` opens the controller config modal, where you can save the selected controller and remap actions inline.
|
||||||
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
||||||
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
|
- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
||||||
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
|
||||||
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
||||||
- Turning keyboard-only mode off clears the keyboard-only token highlight state.
|
- Turning keyboard-only mode off clears the keyboard-only token highlight state.
|
||||||
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
|
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
|
||||||
@@ -703,7 +694,7 @@ These shortcuts are only active when the overlay window is visible and automatic
|
|||||||
|
|
||||||
### Session Help Modal
|
### Session Help Modal
|
||||||
|
|
||||||
The session help modal opens from the overlay with `Ctrl/Cmd+Shift+H` by default. The mpv plugin also exposes it through the `Y-H` chord (falling back to `Y-K` if needed). It shows the current session keybindings and color legend.
|
The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend.
|
||||||
|
|
||||||
You can filter the modal quickly with `/`:
|
You can filter the modal quickly with `/`:
|
||||||
|
|
||||||
|
|||||||
@@ -173,11 +173,7 @@
|
|||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
||||||
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
|
|
||||||
"openControllerSelect": "Alt+C", // Open controller select setting.
|
|
||||||
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
|
|
||||||
"toggleSubtitleSidebar": "\\" // Toggle subtitle sidebar setting.
|
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
|||||||
| ------------------ | -------------------------------------------------------- | ------------------------------ |
|
| ------------------ | -------------------------------------------------------- | ------------------------------ |
|
||||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||||
| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` |
|
|
||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
@@ -80,12 +79,12 @@ The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a
|
|||||||
|
|
||||||
## Controller Shortcuts
|
## Controller Shortcuts
|
||||||
|
|
||||||
These overlay-local shortcuts open controller utilities for the Chrome Gamepad API integration.
|
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration.
|
||||||
|
|
||||||
| Shortcut | Action | Configurable |
|
| Shortcut | Action | Configurable |
|
||||||
| ------------- | ------------------------------------ | -------------------------------- |
|
| ------------- | ------------------------------ | ------------ |
|
||||||
| `Alt+C` | Open controller config + remap modal | `shortcuts.openControllerSelect` |
|
| `Alt+C` | Open controller config + remap modal | Fixed |
|
||||||
| `Alt+Shift+C` | Open controller debug modal | `shortcuts.openControllerDebug` |
|
| `Alt+Shift+C` | Open controller debug modal | Fixed |
|
||||||
|
|
||||||
Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller.
|
Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller.
|
||||||
|
|
||||||
@@ -102,7 +101,6 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press
|
|||||||
| `y-o` | Open Yomitan settings |
|
| `y-o` | Open Yomitan settings |
|
||||||
| `y-r` | Restart overlay |
|
| `y-r` | Restart overlay |
|
||||||
| `y-c` | Check overlay status |
|
| `y-c` | Check overlay status |
|
||||||
| `y-h` | Open session help |
|
|
||||||
|
|
||||||
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
|
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
|
||||||
|
|
||||||
|
|||||||
@@ -272,12 +272,12 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
|
|||||||
|
|
||||||
1. Connect a controller before or after launching SubMiner.
|
1. Connect a controller before or after launching SubMiner.
|
||||||
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
||||||
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
|
3. Press `Alt+C` in the overlay to pick the controller you want to save and remap any action inline.
|
||||||
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
||||||
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
||||||
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
||||||
|
|
||||||
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
|
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline. `Alt+Shift+C` still opens the live debug modal with raw axes/button values for non-standard pads.
|
||||||
|
|
||||||
### Default Button Mapping
|
### Default Button Mapping
|
||||||
|
|
||||||
@@ -321,8 +321,6 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
|||||||
|
|
||||||
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
|
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
|
||||||
|
|
||||||
`Ctrl/Cmd+Shift+H` opens the session help modal with the current overlay and mpv keybindings. If you use the mpv plugin, the same help view is also available through the `y-h` chord.
|
|
||||||
|
|
||||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||||
|
|
||||||
### Drag-and-Drop
|
### Drag-and-Drop
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
# 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`.
|
||||||
@@ -26,37 +24,15 @@
|
|||||||
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 version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||||
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
|
||||||
- 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.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1609
docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md
Normal file
1609
docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# Stats Dashboard Feedback Pass — Design
|
||||||
|
|
||||||
|
Date: 2026-04-09
|
||||||
|
Scope: Stats dashboard UX follow-ups from user feedback (items 1–7).
|
||||||
|
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.`
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.12.0-beta.3",
|
"version": "0.11.2",
|
||||||
"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,7 +26,6 @@
|
|||||||
"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",
|
||||||
@@ -70,7 +69,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/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",
|
"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",
|
||||||
"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",
|
||||||
@@ -113,7 +112,6 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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, utils = utils })
|
local environment = require("environment").create({ mp = mp })
|
||||||
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,9 +61,6 @@ 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)
|
||||||
@@ -75,7 +72,6 @@ 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")
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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
|
||||||
@@ -32,57 +30,6 @@ 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
|
||||||
@@ -251,10 +198,7 @@ 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,
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ 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 {
|
||||||
|
|||||||
@@ -229,22 +229,6 @@ function M.create(ctx)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function run_binary_command_async(args, callback)
|
|
||||||
subminer_log("debug", "process", "Binary command: " .. table.concat(args, " "))
|
|
||||||
mp.command_native_async({
|
|
||||||
name = "subprocess",
|
|
||||||
args = args,
|
|
||||||
playback_only = false,
|
|
||||||
capture_stdout = true,
|
|
||||||
capture_stderr = true,
|
|
||||||
}, function(success, result, error)
|
|
||||||
local ok = success and (result == nil or result.status == 0)
|
|
||||||
if callback then
|
|
||||||
callback(ok, result, error)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function parse_start_script_message_overrides(...)
|
local function parse_start_script_message_overrides(...)
|
||||||
local overrides = {}
|
local overrides = {}
|
||||||
for i = 1, select("#", ...) do
|
for i = 1, select("#", ...) do
|
||||||
@@ -544,7 +528,6 @@ function M.create(ctx)
|
|||||||
build_command_args = build_command_args,
|
build_command_args = build_command_args,
|
||||||
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
||||||
run_control_command_async = run_control_command_async,
|
run_control_command_async = run_control_command_async,
|
||||||
run_binary_command_async = run_binary_command_async,
|
|
||||||
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
||||||
ensure_texthooker_running = ensure_texthooker_running,
|
ensure_texthooker_running = ensure_texthooker_running,
|
||||||
start_overlay = start_overlay,
|
start_overlay = start_overlay,
|
||||||
|
|||||||
@@ -1,357 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
if type(key.code) ~= "string" then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
if type(key.modifiers) ~= "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) 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 == "toggleStatsOverlay" then
|
|
||||||
return { "--toggle-stats-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 == "toggleSubtitleSidebar" then
|
|
||||||
return { "--toggle-subtitle-sidebar" }
|
|
||||||
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 == "openSessionHelp" then
|
|
||||||
return { "--open-session-help" }
|
|
||||||
elseif action_id == "openControllerSelect" then
|
|
||||||
return { "--open-controller-select" }
|
|
||||||
elseif action_id == "openControllerDebug" then
|
|
||||||
return { "--open-controller-debug" }
|
|
||||||
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" }
|
|
||||||
elseif action_id == "cycleRuntimeOption" then
|
|
||||||
local runtime_option_id = payload and payload.runtimeOptionId or nil
|
|
||||||
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local direction = payload and payload.direction == -1 and "prev" or "next"
|
|
||||||
return { "--cycle-runtime-option", runtime_option_id .. ":" .. direction }
|
|
||||||
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
|
|
||||||
local runner = process.run_binary_command_async
|
|
||||||
if type(runner) ~= "function" then
|
|
||||||
runner = function(binary_args, callback)
|
|
||||||
mp.command_native_async({
|
|
||||||
name = "subprocess",
|
|
||||||
args = binary_args,
|
|
||||||
playback_only = false,
|
|
||||||
capture_stdout = true,
|
|
||||||
capture_stderr = true,
|
|
||||||
}, function(success, result, error)
|
|
||||||
local ok = success and (result == nil or result.status == 0)
|
|
||||||
if callback then
|
|
||||||
callback(ok, result, error)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
runner(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 = {}
|
|
||||||
|
|
||||||
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)
|
|
||||||
state.session_binding_names = next_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
|
|
||||||
@@ -33,9 +33,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -90,12 +90,6 @@ function M.create(ctx)
|
|||||||
mp.add_key_binding("y-c", "subminer-status", function()
|
mp.add_key_binding("y-c", "subminer-status", function()
|
||||||
process.check_status()
|
process.check_status()
|
||||||
end)
|
end)
|
||||||
mp.add_key_binding("y-h", "subminer-session-help", function()
|
|
||||||
if not ensure_binary_for_menu() then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
process.run_control_command_async("open-session-help")
|
|
||||||
end)
|
|
||||||
if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
|
if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
|
||||||
mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function()
|
mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function()
|
||||||
aniskip.skip_intro_now()
|
aniskip.skip_intro_now()
|
||||||
|
|||||||
@@ -310,186 +310,3 @@ 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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ 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> = {
|
||||||
@@ -76,10 +75,6 @@ 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 {
|
||||||
@@ -319,15 +314,8 @@ export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[
|
|||||||
return [path.join(cwd, 'CHANGELOG.md')];
|
return [path.join(cwd, 'CHANGELOG.md')];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReleaseNotes(
|
function renderReleaseNotes(changes: string): string {
|
||||||
changes: string,
|
|
||||||
options?: {
|
|
||||||
disclaimer?: string;
|
|
||||||
},
|
|
||||||
): string {
|
|
||||||
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
|
||||||
return [
|
return [
|
||||||
...prefix,
|
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
changes,
|
changes,
|
||||||
'',
|
'',
|
||||||
@@ -346,21 +334,13 @@ function renderReleaseNotes(
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeReleaseNotesFile(
|
function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string {
|
||||||
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, options?.outputPath ?? RELEASE_NOTES_PATH);
|
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
|
||||||
|
|
||||||
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
|
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
|
||||||
writeFileSync(releaseNotesPath, renderReleaseNotes(changes, options), 'utf8');
|
writeFileSync(releaseNotesPath, renderReleaseNotes(changes), 'utf8');
|
||||||
return releaseNotesPath;
|
return releaseNotesPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,30 +613,6 @@ 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;
|
||||||
@@ -754,11 +710,6 @@ function main(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'prerelease-notes') {
|
|
||||||
writePrereleaseNotesForVersion(options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command === 'docs') {
|
if (command === 'docs') {
|
||||||
generateDocsChangelog(options);
|
generateDocsChangelog(options);
|
||||||
return;
|
return;
|
||||||
|
|||||||
175
scripts/get-mpv-window-windows.ps1
Normal file
175
scripts/get-mpv-window-windows.ps1
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
param(
|
||||||
|
[ValidateSet('geometry')]
|
||||||
|
[string]$Mode = 'geometry',
|
||||||
|
[string]$SocketPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
try {
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
public static class SubMinerWindowsHelper {
|
||||||
|
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct RECT {
|
||||||
|
public int Left;
|
||||||
|
public int Top;
|
||||||
|
public int Right;
|
||||||
|
public int Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
public static extern bool IsWindowVisible(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool IsIconic(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern IntPtr GetForegroundWindow();
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||||
|
|
||||||
|
[DllImport("dwmapi.dll")]
|
||||||
|
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
|
||||||
|
$DWMWA_EXTENDED_FRAME_BOUNDS = 9
|
||||||
|
|
||||||
|
function Get-WindowBounds {
|
||||||
|
param([IntPtr]$hWnd)
|
||||||
|
|
||||||
|
$rect = New-Object SubMinerWindowsHelper+RECT
|
||||||
|
$size = [System.Runtime.InteropServices.Marshal]::SizeOf($rect)
|
||||||
|
$dwmResult = [SubMinerWindowsHelper]::DwmGetWindowAttribute(
|
||||||
|
$hWnd,
|
||||||
|
$DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||||
|
[ref]$rect,
|
||||||
|
$size
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($dwmResult -ne 0) {
|
||||||
|
if (-not [SubMinerWindowsHelper]::GetWindowRect($hWnd, [ref]$rect)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = $rect.Right - $rect.Left
|
||||||
|
$height = $rect.Bottom - $rect.Top
|
||||||
|
if ($width -le 0 -or $height -le 0) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
X = $rect.Left
|
||||||
|
Y = $rect.Top
|
||||||
|
Width = $width
|
||||||
|
Height = $height
|
||||||
|
Area = $width * $height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$commandLineByPid = @{}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||||
|
foreach ($process in Get-CimInstance Win32_Process) {
|
||||||
|
$commandLineByPid[[uint32]$process.ProcessId] = $process.CommandLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$mpvMatches = New-Object System.Collections.Generic.List[object]
|
||||||
|
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
|
||||||
|
$callback = [SubMinerWindowsHelper+EnumWindowsProc]{
|
||||||
|
param([IntPtr]$hWnd, [IntPtr]$lParam)
|
||||||
|
|
||||||
|
if (-not [SubMinerWindowsHelper]::IsWindowVisible($hWnd)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[uint32]$windowProcessId = 0
|
||||||
|
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId)
|
||||||
|
if ($windowProcessId -eq 0) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$process = Get-Process -Id $windowProcessId -ErrorAction Stop
|
||||||
|
} catch {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($process.ProcessName -ine 'mpv') {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||||
|
$commandLine = $commandLineByPid[[uint32]$windowProcessId]
|
||||||
|
if ([string]::IsNullOrWhiteSpace($commandLine)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
($commandLine -notlike "*--input-ipc-server=$SocketPath*") -and
|
||||||
|
($commandLine -notlike "*--input-ipc-server $SocketPath*")
|
||||||
|
) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bounds = Get-WindowBounds -hWnd $hWnd
|
||||||
|
if ($null -eq $bounds) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$mpvMatches.Add([PSCustomObject]@{
|
||||||
|
HWnd = $hWnd
|
||||||
|
X = $bounds.X
|
||||||
|
Y = $bounds.Y
|
||||||
|
Width = $bounds.Width
|
||||||
|
Height = $bounds.Height
|
||||||
|
Area = $bounds.Area
|
||||||
|
IsForeground = ($foregroundWindow -ne [IntPtr]::Zero -and $hWnd -eq $foregroundWindow)
|
||||||
|
})
|
||||||
|
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
[void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero)
|
||||||
|
|
||||||
|
$focusedMatch = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1
|
||||||
|
if ($null -ne $focusedMatch) {
|
||||||
|
[Console]::Error.WriteLine('focus=focused')
|
||||||
|
} else {
|
||||||
|
[Console]::Error.WriteLine('focus=not-focused')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mpvMatches.Count -eq 0) {
|
||||||
|
Write-Output 'not-found'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$bestMatch = if ($null -ne $focusedMatch) {
|
||||||
|
$focusedMatch
|
||||||
|
} else {
|
||||||
|
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1
|
||||||
|
}
|
||||||
|
Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)"
|
||||||
|
} catch {
|
||||||
|
[Console]::Error.WriteLine($_.Exception.Message)
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -28,27 +28,6 @@ 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
|
||||||
@@ -84,19 +63,9 @@ if [[ -z "$input" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
input="$(normalize_path "$input")"
|
if ! command -v ffmpeg > /dev/null 2>&1; then
|
||||||
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
|
||||||
@@ -133,7 +102,7 @@ fi
|
|||||||
|
|
||||||
has_encoder() {
|
has_encoder() {
|
||||||
local encoder="$1"
|
local encoder="$1"
|
||||||
"$ffmpeg_bin" -hide_banner -encoders 2> /dev/null | awk -v encoder="$encoder" '$2 == encoder { found = 1 } END { exit(found ? 0 : 1) }'
|
ffmpeg -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() {
|
||||||
@@ -154,7 +123,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_bin" "$overwrite_flag" -i "$input" \
|
if ffmpeg "$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 \
|
||||||
@@ -163,7 +132,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_bin" "$overwrite_flag" -i "$input" \
|
ffmpeg "$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 \
|
||||||
@@ -173,7 +142,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_bin" "$overwrite_flag" -i "$input" \
|
ffmpeg "$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 \
|
||||||
@@ -185,7 +154,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_bin" "$overwrite_flag" -i "$input" \
|
if ffmpeg "$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 \
|
||||||
@@ -193,7 +162,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_bin" "$overwrite_flag" -i "$input" \
|
ffmpeg "$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 \
|
||||||
@@ -202,7 +171,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_bin" "$overwrite_flag" -i "$input" \
|
ffmpeg "$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 \
|
||||||
@@ -216,7 +185,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_bin" "$overwrite_flag" -i "$input" \
|
ffmpeg "$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 \
|
||||||
@@ -226,7 +195,7 @@ if [[ "$generate_webp" -eq 1 ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Generating poster: $poster_out"
|
echo "Generating poster: $poster_out"
|
||||||
"$ffmpeg_bin" "$overwrite_flag" -ss 00:00:05 -i "$input" \
|
ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \
|
||||||
-vf "$crop_vf" \
|
-vf "$crop_vf" \
|
||||||
-vframes 1 \
|
-vframes 1 \
|
||||||
-q:v 2 \
|
-q:v 2 \
|
||||||
|
|||||||
@@ -19,33 +19,11 @@ 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');
|
||||||
@@ -66,33 +44,22 @@ EOF
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$#" -eq 0 ]]; then
|
printf '%s\\n' "$*" >> "${ffmpegLogPath}"
|
||||||
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 ffmpegShimPath = toBashPath(path.join(binDir, 'ffmpeg'));
|
const result = spawnSync('bash', ['scripts/mkv-to-readme-video.sh', '--webp', inputPath], {
|
||||||
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',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const repoRoot = path.resolve(scriptDir, '..');
|
|||||||
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||||
|
const windowsHelperSourcePath = path.join(scriptDir, 'get-mpv-window-windows.ps1');
|
||||||
|
const windowsHelperOutputPath = path.join(scriptsOutputDir, 'get-mpv-window-windows.ps1');
|
||||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||||
const macosHelperSourceCopyPath = path.join(scriptsOutputDir, 'get-mpv-window-macos.swift');
|
const macosHelperSourceCopyPath = path.join(scriptsOutputDir, 'get-mpv-window-macos.swift');
|
||||||
@@ -31,6 +33,11 @@ function copyRendererAssets() {
|
|||||||
process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`);
|
process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stageWindowsHelper() {
|
||||||
|
copyFile(windowsHelperSourcePath, windowsHelperOutputPath);
|
||||||
|
process.stdout.write(`Staged Windows helper: ${windowsHelperOutputPath}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
function fallbackToMacosSource() {
|
function fallbackToMacosSource() {
|
||||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
||||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||||
@@ -70,6 +77,7 @@ function buildMacosHelper() {
|
|||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
copyRendererAssets();
|
copyRendererAssets();
|
||||||
|
stageWindowsHelper();
|
||||||
buildMacosHelper();
|
buildMacosHelper();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,26 +13,6 @@ 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)
|
||||||
@@ -73,10 +53,6 @@ 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"
|
||||||
|
|
||||||
@@ -106,9 +82,6 @@ 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
|
||||||
@@ -167,9 +140,6 @@ 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
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { execFileSync, spawnSync } from 'node:child_process';
|
import { execFileSync } 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';
|
||||||
@@ -10,23 +9,6 @@ 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');
|
||||||
@@ -47,15 +29,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',
|
||||||
toBashPath(pkgDir),
|
pkgDir,
|
||||||
'--version',
|
'--version',
|
||||||
'v0.6.3',
|
'v0.6.3',
|
||||||
'--appimage',
|
'--appimage',
|
||||||
toBashPath(appImagePath),
|
appImagePath,
|
||||||
'--wrapper',
|
'--wrapper',
|
||||||
toBashPath(wrapperPath),
|
wrapperPath,
|
||||||
'--assets',
|
'--assets',
|
||||||
toBashPath(assetsPath),
|
assetsPath,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -65,8 +47,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((filePath) =>
|
const expectedSums = [appImagePath, wrapperPath, assetsPath].map(
|
||||||
crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
|
(filePath) => execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);
|
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);
|
||||||
|
|||||||
@@ -73,50 +73,6 @@ 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([
|
|
||||||
'--toggle-stats-overlay',
|
|
||||||
'--open-jimaku',
|
|
||||||
'--open-youtube-picker',
|
|
||||||
'--open-playlist-browser',
|
|
||||||
'--replay-current-subtitle',
|
|
||||||
'--play-next-subtitle',
|
|
||||||
'--shift-sub-delay-prev-line',
|
|
||||||
'--shift-sub-delay-next-line',
|
|
||||||
'--cycle-runtime-option',
|
|
||||||
'anki.autoUpdateNewCards:prev',
|
|
||||||
'--copy-subtitle-count',
|
|
||||||
'3',
|
|
||||||
'--mine-sentence-count=2',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.toggleStatsOverlay, true);
|
|
||||||
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.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
|
|
||||||
assert.equal(args.cycleRuntimeOptionDirection, -1);
|
|
||||||
assert.equal(args.copySubtitleCount, 3);
|
|
||||||
assert.equal(args.mineSentenceCount, 2);
|
|
||||||
assert.equal(hasExplicitCommand(args), true);
|
|
||||||
assert.equal(shouldStartApp(args), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseArgs ignores non-positive numeric session action counts', () => {
|
|
||||||
const args = parseArgs([
|
|
||||||
'--copy-subtitle-count=0',
|
|
||||||
'--mine-sentence-count',
|
|
||||||
'-1',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(args.copySubtitleCount, undefined);
|
|
||||||
assert.equal(args.mineSentenceCount, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
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']);
|
||||||
|
|
||||||
@@ -216,21 +172,6 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
|
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
|
||||||
assert.equal(shouldStartApp(anilistRetryQueue), false);
|
assert.equal(shouldStartApp(anilistRetryQueue), false);
|
||||||
|
|
||||||
const toggleStatsOverlay = parseArgs(['--toggle-stats-overlay']);
|
|
||||||
assert.equal(toggleStatsOverlay.toggleStatsOverlay, true);
|
|
||||||
assert.equal(hasExplicitCommand(toggleStatsOverlay), true);
|
|
||||||
assert.equal(shouldStartApp(toggleStatsOverlay), true);
|
|
||||||
|
|
||||||
const cycleRuntimeOption = parseArgs([
|
|
||||||
'--cycle-runtime-option',
|
|
||||||
'anki.autoUpdateNewCards:next',
|
|
||||||
]);
|
|
||||||
assert.equal(cycleRuntimeOption.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
|
|
||||||
assert.equal(cycleRuntimeOption.cycleRuntimeOptionDirection, 1);
|
|
||||||
assert.equal(hasExplicitCommand(cycleRuntimeOption), true);
|
|
||||||
assert.equal(shouldStartApp(cycleRuntimeOption), true);
|
|
||||||
assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true);
|
|
||||||
|
|
||||||
const dictionary = parseArgs(['--dictionary']);
|
const dictionary = parseArgs(['--dictionary']);
|
||||||
assert.equal(dictionary.dictionary, true);
|
assert.equal(dictionary.dictionary, true);
|
||||||
assert.equal(hasExplicitCommand(dictionary), true);
|
assert.equal(hasExplicitCommand(dictionary), true);
|
||||||
|
|||||||
160
src/cli/args.ts
160
src/cli/args.ts
@@ -24,23 +24,7 @@ export interface CliArgs {
|
|||||||
triggerFieldGrouping: boolean;
|
triggerFieldGrouping: boolean;
|
||||||
triggerSubsync: boolean;
|
triggerSubsync: boolean;
|
||||||
markAudioCard: boolean;
|
markAudioCard: boolean;
|
||||||
toggleStatsOverlay: boolean;
|
|
||||||
toggleSubtitleSidebar: boolean;
|
|
||||||
openRuntimeOptions: boolean;
|
openRuntimeOptions: boolean;
|
||||||
openSessionHelp: boolean;
|
|
||||||
openControllerSelect: boolean;
|
|
||||||
openControllerDebug: boolean;
|
|
||||||
openJimaku: boolean;
|
|
||||||
openYoutubePicker: boolean;
|
|
||||||
openPlaylistBrowser: boolean;
|
|
||||||
replayCurrentSubtitle: boolean;
|
|
||||||
playNextSubtitle: boolean;
|
|
||||||
shiftSubDelayPrevLine: boolean;
|
|
||||||
shiftSubDelayNextLine: boolean;
|
|
||||||
cycleRuntimeOptionId?: string;
|
|
||||||
cycleRuntimeOptionDirection?: 1 | -1;
|
|
||||||
copySubtitleCount?: number;
|
|
||||||
mineSentenceCount?: number;
|
|
||||||
anilistStatus: boolean;
|
anilistStatus: boolean;
|
||||||
anilistLogout: boolean;
|
anilistLogout: boolean;
|
||||||
anilistSetup: boolean;
|
anilistSetup: boolean;
|
||||||
@@ -118,19 +102,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
triggerFieldGrouping: false,
|
triggerFieldGrouping: false,
|
||||||
triggerSubsync: false,
|
triggerSubsync: false,
|
||||||
markAudioCard: false,
|
markAudioCard: false,
|
||||||
toggleStatsOverlay: false,
|
|
||||||
toggleSubtitleSidebar: false,
|
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
openSessionHelp: false,
|
|
||||||
openControllerSelect: false,
|
|
||||||
openControllerDebug: 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,
|
||||||
@@ -166,24 +138,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseCycleRuntimeOption = (
|
|
||||||
value: string | undefined,
|
|
||||||
): { id: string; direction: 1 | -1 } | null => {
|
|
||||||
if (!value) return null;
|
|
||||||
const separatorIndex = value.lastIndexOf(':');
|
|
||||||
if (separatorIndex <= 0 || separatorIndex === value.length - 1) return null;
|
|
||||||
const id = value.slice(0, separatorIndex).trim();
|
|
||||||
const rawDirection = value.slice(separatorIndex + 1).trim().toLowerCase();
|
|
||||||
if (!id) return null;
|
|
||||||
if (rawDirection === 'next' || rawDirection === '1') {
|
|
||||||
return { id, direction: 1 };
|
|
||||||
}
|
|
||||||
if (rawDirection === 'prev' || rawDirection === '-1') {
|
|
||||||
return { id, direction: -1 };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
const arg = argv[i];
|
const arg = argv[i];
|
||||||
if (!arg || !arg.startsWith('--')) continue;
|
if (!arg || !arg.startsWith('--')) continue;
|
||||||
@@ -225,44 +179,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (arg === '--trigger-field-grouping') args.triggerFieldGrouping = true;
|
else if (arg === '--trigger-field-grouping') args.triggerFieldGrouping = true;
|
||||||
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 === '--toggle-stats-overlay') args.toggleStatsOverlay = true;
|
|
||||||
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
|
|
||||||
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
|
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
|
||||||
else if (arg === '--open-session-help') args.openSessionHelp = true;
|
else if (arg === '--anilist-status') args.anilistStatus = true;
|
||||||
else if (arg === '--open-controller-select') args.openControllerSelect = true;
|
|
||||||
else if (arg === '--open-controller-debug') args.openControllerDebug = 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('--cycle-runtime-option=')) {
|
|
||||||
const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]);
|
|
||||||
if (parsed) {
|
|
||||||
args.cycleRuntimeOptionId = parsed.id;
|
|
||||||
args.cycleRuntimeOptionDirection = parsed.direction;
|
|
||||||
}
|
|
||||||
} else if (arg === '--cycle-runtime-option') {
|
|
||||||
const parsed = parseCycleRuntimeOption(readValue(argv[i + 1]));
|
|
||||||
if (parsed) {
|
|
||||||
args.cycleRuntimeOptionId = parsed.id;
|
|
||||||
args.cycleRuntimeOptionDirection = parsed.direction;
|
|
||||||
}
|
|
||||||
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
|
||||||
const value = Number(arg.split('=', 2)[1]);
|
|
||||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
|
||||||
} else if (arg === '--copy-subtitle-count') {
|
|
||||||
const value = Number(readValue(argv[i + 1]));
|
|
||||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
|
||||||
} else if (arg.startsWith('--mine-sentence-count=')) {
|
|
||||||
const value = Number(arg.split('=', 2)[1]);
|
|
||||||
if (Number.isInteger(value) && value > 0) args.mineSentenceCount = value;
|
|
||||||
} else if (arg === '--mine-sentence-count') {
|
|
||||||
const value = Number(readValue(argv[i + 1]));
|
|
||||||
if (Number.isInteger(value) && value > 0) args.mineSentenceCount = value;
|
|
||||||
} 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;
|
||||||
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
|
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
|
||||||
@@ -453,22 +371,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.triggerFieldGrouping ||
|
args.triggerFieldGrouping ||
|
||||||
args.triggerSubsync ||
|
args.triggerSubsync ||
|
||||||
args.markAudioCard ||
|
args.markAudioCard ||
|
||||||
args.toggleStatsOverlay ||
|
|
||||||
args.toggleSubtitleSidebar ||
|
|
||||||
args.openRuntimeOptions ||
|
args.openRuntimeOptions ||
|
||||||
args.openSessionHelp ||
|
|
||||||
args.openControllerSelect ||
|
|
||||||
args.openControllerDebug ||
|
|
||||||
args.openJimaku ||
|
|
||||||
args.openYoutubePicker ||
|
|
||||||
args.openPlaylistBrowser ||
|
|
||||||
args.replayCurrentSubtitle ||
|
|
||||||
args.playNextSubtitle ||
|
|
||||||
args.shiftSubDelayPrevLine ||
|
|
||||||
args.shiftSubDelayNextLine ||
|
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
|
||||||
args.copySubtitleCount !== undefined ||
|
|
||||||
args.mineSentenceCount !== undefined ||
|
|
||||||
args.anilistStatus ||
|
args.anilistStatus ||
|
||||||
args.anilistLogout ||
|
args.anilistLogout ||
|
||||||
args.anilistSetup ||
|
args.anilistSetup ||
|
||||||
@@ -520,22 +423,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.triggerFieldGrouping &&
|
!args.triggerFieldGrouping &&
|
||||||
!args.triggerSubsync &&
|
!args.triggerSubsync &&
|
||||||
!args.markAudioCard &&
|
!args.markAudioCard &&
|
||||||
!args.toggleStatsOverlay &&
|
|
||||||
!args.toggleSubtitleSidebar &&
|
|
||||||
!args.openRuntimeOptions &&
|
!args.openRuntimeOptions &&
|
||||||
!args.openSessionHelp &&
|
|
||||||
!args.openControllerSelect &&
|
|
||||||
!args.openControllerDebug &&
|
|
||||||
!args.openJimaku &&
|
|
||||||
!args.openYoutubePicker &&
|
|
||||||
!args.openPlaylistBrowser &&
|
|
||||||
!args.replayCurrentSubtitle &&
|
|
||||||
!args.playNextSubtitle &&
|
|
||||||
!args.shiftSubDelayPrevLine &&
|
|
||||||
!args.shiftSubDelayNextLine &&
|
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
|
||||||
args.copySubtitleCount === undefined &&
|
|
||||||
args.mineSentenceCount === undefined &&
|
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
!args.anilistLogout &&
|
!args.anilistLogout &&
|
||||||
!args.anilistSetup &&
|
!args.anilistSetup &&
|
||||||
@@ -578,22 +466,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.triggerFieldGrouping ||
|
args.triggerFieldGrouping ||
|
||||||
args.triggerSubsync ||
|
args.triggerSubsync ||
|
||||||
args.markAudioCard ||
|
args.markAudioCard ||
|
||||||
args.toggleStatsOverlay ||
|
|
||||||
args.toggleSubtitleSidebar ||
|
|
||||||
args.openRuntimeOptions ||
|
args.openRuntimeOptions ||
|
||||||
args.openSessionHelp ||
|
|
||||||
args.openControllerSelect ||
|
|
||||||
args.openControllerDebug ||
|
|
||||||
args.openJimaku ||
|
|
||||||
args.openYoutubePicker ||
|
|
||||||
args.openPlaylistBrowser ||
|
|
||||||
args.replayCurrentSubtitle ||
|
|
||||||
args.playNextSubtitle ||
|
|
||||||
args.shiftSubDelayPrevLine ||
|
|
||||||
args.shiftSubDelayNextLine ||
|
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
|
||||||
args.copySubtitleCount !== undefined ||
|
|
||||||
args.mineSentenceCount !== undefined ||
|
|
||||||
args.dictionary ||
|
args.dictionary ||
|
||||||
args.stats ||
|
args.stats ||
|
||||||
args.jellyfin ||
|
args.jellyfin ||
|
||||||
@@ -631,22 +504,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.triggerFieldGrouping &&
|
!args.triggerFieldGrouping &&
|
||||||
!args.triggerSubsync &&
|
!args.triggerSubsync &&
|
||||||
!args.markAudioCard &&
|
!args.markAudioCard &&
|
||||||
!args.toggleStatsOverlay &&
|
|
||||||
!args.toggleSubtitleSidebar &&
|
|
||||||
!args.openRuntimeOptions &&
|
!args.openRuntimeOptions &&
|
||||||
!args.openSessionHelp &&
|
|
||||||
!args.openControllerSelect &&
|
|
||||||
!args.openControllerDebug &&
|
|
||||||
!args.openJimaku &&
|
|
||||||
!args.openYoutubePicker &&
|
|
||||||
!args.openPlaylistBrowser &&
|
|
||||||
!args.replayCurrentSubtitle &&
|
|
||||||
!args.playNextSubtitle &&
|
|
||||||
!args.shiftSubDelayPrevLine &&
|
|
||||||
!args.shiftSubDelayNextLine &&
|
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
|
||||||
args.copySubtitleCount === undefined &&
|
|
||||||
args.mineSentenceCount === undefined &&
|
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
!args.anilistLogout &&
|
!args.anilistLogout &&
|
||||||
!args.anilistSetup &&
|
!args.anilistSetup &&
|
||||||
@@ -686,24 +544,10 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
|||||||
args.mineSentenceMultiple ||
|
args.mineSentenceMultiple ||
|
||||||
args.updateLastCardFromClipboard ||
|
args.updateLastCardFromClipboard ||
|
||||||
args.toggleSecondarySub ||
|
args.toggleSecondarySub ||
|
||||||
args.toggleSubtitleSidebar ||
|
|
||||||
args.triggerFieldGrouping ||
|
args.triggerFieldGrouping ||
|
||||||
args.triggerSubsync ||
|
args.triggerSubsync ||
|
||||||
args.markAudioCard ||
|
args.markAudioCard ||
|
||||||
args.openRuntimeOptions ||
|
args.openRuntimeOptions
|
||||||
args.openSessionHelp ||
|
|
||||||
args.openControllerSelect ||
|
|
||||||
args.openControllerDebug ||
|
|
||||||
args.openJimaku ||
|
|
||||||
args.openYoutubePicker ||
|
|
||||||
args.openPlaylistBrowser ||
|
|
||||||
args.replayCurrentSubtitle ||
|
|
||||||
args.playNextSubtitle ||
|
|
||||||
args.shiftSubDelayPrevLine ||
|
|
||||||
args.shiftSubDelayNextLine ||
|
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
|
||||||
args.copySubtitleCount !== undefined ||
|
|
||||||
args.mineSentenceCount !== undefined
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,7 @@ ${B}Mining${R}
|
|||||||
--trigger-field-grouping Run Kiku field grouping
|
--trigger-field-grouping Run Kiku field grouping
|
||||||
--trigger-subsync Run subtitle sync
|
--trigger-subsync Run subtitle sync
|
||||||
--toggle-secondary-sub Cycle secondary subtitle mode
|
--toggle-secondary-sub Cycle secondary subtitle mode
|
||||||
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
|
|
||||||
--open-runtime-options Open runtime options palette
|
--open-runtime-options Open runtime options palette
|
||||||
--open-session-help Open session help modal
|
|
||||||
--open-controller-select Open controller select modal
|
|
||||||
--open-controller-debug Open controller debug modal
|
|
||||||
|
|
||||||
${B}AniList${R}
|
${B}AniList${R}
|
||||||
--anilist-setup Open AniList authentication flow
|
--anilist-setup Open AniList authentication flow
|
||||||
|
|||||||
@@ -88,10 +88,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
markAudioCard: 'CommandOrControl+Shift+A',
|
markAudioCard: 'CommandOrControl+Shift+A',
|
||||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||||
openJimaku: 'Ctrl+Shift+J',
|
openJimaku: 'Ctrl+Shift+J',
|
||||||
openSessionHelp: 'CommandOrControl+Shift+H',
|
|
||||||
openControllerSelect: 'Alt+C',
|
|
||||||
openControllerDebug: 'Alt+Shift+C',
|
|
||||||
toggleSubtitleSidebar: '\\',
|
|
||||||
},
|
},
|
||||||
secondarySub: {
|
secondarySub: {
|
||||||
secondarySubLanguages: [],
|
secondarySubLanguages: [],
|
||||||
|
|||||||
@@ -166,14 +166,20 @@ const TRENDS_DASHBOARD = {
|
|||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
||||||
},
|
},
|
||||||
animePerDay: {
|
librarySummary: [
|
||||||
episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
|
{
|
||||||
watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
|
title: 'Little Witch Academia',
|
||||||
cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
|
watchTimeMin: 25,
|
||||||
words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
|
videos: 1,
|
||||||
lookups: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 15 }],
|
sessions: 1,
|
||||||
lookupsPerHundred: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
|
cards: 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 }],
|
||||||
@@ -598,7 +604,23 @@ 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.animePerDay.watchTime, TRENDS_DASHBOARD.animePerDay.watchTime);
|
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
|||||||
@@ -28,21 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
triggerFieldGrouping: false,
|
triggerFieldGrouping: false,
|
||||||
triggerSubsync: false,
|
triggerSubsync: false,
|
||||||
markAudioCard: false,
|
markAudioCard: false,
|
||||||
toggleStatsOverlay: false,
|
|
||||||
toggleSubtitleSidebar: false,
|
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
openSessionHelp: false,
|
|
||||||
openControllerSelect: false,
|
|
||||||
openControllerDebug: false,
|
|
||||||
openJimaku: false,
|
|
||||||
openYoutubePicker: false,
|
|
||||||
openPlaylistBrowser: false,
|
|
||||||
replayCurrentSubtitle: false,
|
|
||||||
playNextSubtitle: false,
|
|
||||||
shiftSubDelayPrevLine: false,
|
|
||||||
shiftSubDelayNextLine: false,
|
|
||||||
cycleRuntimeOptionId: undefined,
|
|
||||||
cycleRuntimeOptionDirection: undefined,
|
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
|
|||||||
);
|
);
|
||||||
assert.ok(calls.includes('startBackgroundWarmups'));
|
assert.ok(calls.includes('startBackgroundWarmups'));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
calls.includes('log:Runtime ready: immersion tracker startup requested.'),
|
calls.includes(
|
||||||
|
'log:Runtime ready: immersion tracker startup deferred until first media activity.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,17 +103,6 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => {
|
|
||||||
const { deps, calls } = makeDeps({
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runAppReadyRuntime(deps);
|
|
||||||
|
|
||||||
assert.ok(calls.includes('createImmersionTracker'));
|
|
||||||
assert.ok(calls.indexOf('createImmersionTracker') < calls.indexOf('handleInitialArgs'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
|
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
|
||||||
const { deps, calls } = makeDeps({
|
const { deps, calls } = makeDeps({
|
||||||
getResolvedConfig: () => ({
|
getResolvedConfig: () => ({
|
||||||
|
|||||||
@@ -29,22 +29,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
triggerFieldGrouping: false,
|
triggerFieldGrouping: false,
|
||||||
triggerSubsync: false,
|
triggerSubsync: false,
|
||||||
markAudioCard: false,
|
markAudioCard: false,
|
||||||
toggleStatsOverlay: false,
|
|
||||||
toggleSubtitleSidebar: false,
|
|
||||||
refreshKnownWords: false,
|
refreshKnownWords: false,
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
openSessionHelp: false,
|
|
||||||
openControllerSelect: false,
|
|
||||||
openControllerDebug: false,
|
|
||||||
openJimaku: false,
|
|
||||||
openYoutubePicker: false,
|
|
||||||
openPlaylistBrowser: false,
|
|
||||||
replayCurrentSubtitle: false,
|
|
||||||
playNextSubtitle: false,
|
|
||||||
shiftSubDelayPrevLine: false,
|
|
||||||
shiftSubDelayNextLine: false,
|
|
||||||
cycleRuntimeOptionId: undefined,
|
|
||||||
cycleRuntimeOptionDirection: undefined,
|
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
@@ -157,9 +143,6 @@ 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',
|
||||||
@@ -516,7 +499,6 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
|||||||
expected: 'startPendingMineSentenceMultiple:2500',
|
expected: 'startPendingMineSentenceMultiple:2500',
|
||||||
},
|
},
|
||||||
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
|
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
|
||||||
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
|
|
||||||
{
|
{
|
||||||
args: { openRuntimeOptions: true },
|
args: { openRuntimeOptions: true },
|
||||||
expected: 'openRuntimeOptionsPalette',
|
expected: 'openRuntimeOptionsPalette',
|
||||||
@@ -536,33 +518,6 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleCliCommand dispatches cycle-runtime-option session action', async () => {
|
|
||||||
let request: unknown = null;
|
|
||||||
const { deps } = createDeps({
|
|
||||||
dispatchSessionAction: async (nextRequest) => {
|
|
||||||
request = nextRequest;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
handleCliCommand(
|
|
||||||
makeArgs({
|
|
||||||
cycleRuntimeOptionId: 'anki.autoUpdateNewCards',
|
|
||||||
cycleRuntimeOptionDirection: -1,
|
|
||||||
}),
|
|
||||||
'initial',
|
|
||||||
deps,
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
assert.deepEqual(request, {
|
|
||||||
actionId: 'cycleRuntimeOption',
|
|
||||||
payload: {
|
|
||||||
runtimeOptionId: 'anki.autoUpdateNewCards',
|
|
||||||
direction: -1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleCliCommand logs AniList status details', () => {
|
test('handleCliCommand logs AniList status details', () => {
|
||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
|
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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;
|
||||||
@@ -33,7 +32,6 @@ 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';
|
||||||
@@ -170,7 +168,6 @@ 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;
|
||||||
@@ -229,7 +226,6 @@ 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,
|
||||||
@@ -272,19 +268,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -396,100 +379,8 @@ export function handleCliCommand(
|
|||||||
'markLastCardAsAudioCard',
|
'markLastCardAsAudioCard',
|
||||||
'Audio card failed',
|
'Audio card failed',
|
||||||
);
|
);
|
||||||
} else if (args.toggleStatsOverlay) {
|
|
||||||
dispatchCliSessionAction(
|
|
||||||
{ actionId: 'toggleStatsOverlay' },
|
|
||||||
'toggleStatsOverlay',
|
|
||||||
'Stats toggle failed',
|
|
||||||
);
|
|
||||||
} else if (args.toggleSubtitleSidebar) {
|
|
||||||
dispatchCliSessionAction(
|
|
||||||
{ actionId: 'toggleSubtitleSidebar' },
|
|
||||||
'toggleSubtitleSidebar',
|
|
||||||
'Subtitle sidebar toggle failed',
|
|
||||||
);
|
|
||||||
} else if (args.openRuntimeOptions) {
|
} else if (args.openRuntimeOptions) {
|
||||||
deps.openRuntimeOptionsPalette();
|
deps.openRuntimeOptionsPalette();
|
||||||
} else if (args.openSessionHelp) {
|
|
||||||
dispatchCliSessionAction(
|
|
||||||
{ actionId: 'openSessionHelp' },
|
|
||||||
'openSessionHelp',
|
|
||||||
'Open session help failed',
|
|
||||||
);
|
|
||||||
} else if (args.openControllerSelect) {
|
|
||||||
dispatchCliSessionAction(
|
|
||||||
{ actionId: 'openControllerSelect' },
|
|
||||||
'openControllerSelect',
|
|
||||||
'Open controller select failed',
|
|
||||||
);
|
|
||||||
} else if (args.openControllerDebug) {
|
|
||||||
dispatchCliSessionAction(
|
|
||||||
{ actionId: 'openControllerDebug' },
|
|
||||||
'openControllerDebug',
|
|
||||||
'Open controller debug failed',
|
|
||||||
);
|
|
||||||
} 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.cycleRuntimeOptionId !== undefined) {
|
|
||||||
dispatchCliSessionAction(
|
|
||||||
{
|
|
||||||
actionId: 'cycleRuntimeOption',
|
|
||||||
payload: {
|
|
||||||
runtimeOptionId: args.cycleRuntimeOptionId,
|
|
||||||
direction: args.cycleRuntimeOptionDirection ?? 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'cycleRuntimeOption',
|
|
||||||
'Runtime option change 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})`);
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTrendsDashboard(
|
async getTrendsDashboard(
|
||||||
range: '7d' | '30d' | '90d' | 'all' = '30d',
|
range: '7d' | '30d' | '90d' | '365d' | '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);
|
||||||
|
|||||||
@@ -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.animePerDay.watchTime[0]?.animeTitle, 'Trend Dashboard Anime');
|
assert.equal(dashboard.librarySummary[0]?.title, '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,6 +835,65 @@ 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);
|
||||||
@@ -3666,3 +3725,224 @@ 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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' | 'all';
|
type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||||
type TrendGroupBy = 'day' | 'month';
|
type TrendGroupBy = 'day' | 'month';
|
||||||
|
|
||||||
interface TrendChartPoint {
|
interface TrendChartPoint {
|
||||||
@@ -27,6 +27,19 @@ 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;
|
||||||
@@ -61,14 +74,6 @@ 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[];
|
||||||
@@ -79,12 +84,14 @@ 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 = [
|
||||||
@@ -300,61 +307,6 @@ 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>();
|
||||||
@@ -390,6 +342,89 @@ 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>,
|
||||||
@@ -662,8 +697,6 @@ export function getTrendsDashboard(
|
|||||||
titlesByVideoId,
|
titlesByVideoId,
|
||||||
(rollup) => rollup.totalTokensSeen,
|
(rollup) => rollup.totalTokensSeen,
|
||||||
),
|
),
|
||||||
lookups: buildPerAnimeFromSessions(sessions, (session) => session.yomitanLookupCount),
|
|
||||||
lookupsPerHundred: buildLookupsPerHundredPerAnime(sessions),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -690,7 +723,6 @@ 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),
|
||||||
@@ -701,5 +733,6 @@ export function getTrendsDashboard(
|
|||||||
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
|
||||||
watchTimeByHour: buildWatchTimeByHour(sessions),
|
watchTimeByHour: buildWatchTimeByHour(sessions),
|
||||||
},
|
},
|
||||||
|
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export {
|
|||||||
createOverlayWindow,
|
createOverlayWindow,
|
||||||
enforceOverlayLayerOrder,
|
enforceOverlayLayerOrder,
|
||||||
ensureOverlayWindowLevel,
|
ensureOverlayWindowLevel,
|
||||||
isOverlayWindowContentReady,
|
|
||||||
syncOverlayWindowLayer,
|
syncOverlayWindowLayer,
|
||||||
updateOverlayWindowBounds,
|
updateOverlayWindowBounds,
|
||||||
} from './overlay-window';
|
} from './overlay-window';
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
import type {
|
import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types';
|
||||||
PlaylistBrowserSnapshot,
|
|
||||||
SessionActionDispatchRequest,
|
|
||||||
SubtitleSidebarSnapshot,
|
|
||||||
} from '../../types';
|
|
||||||
|
|
||||||
interface FakeIpcRegistrar {
|
interface FakeIpcRegistrar {
|
||||||
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
||||||
@@ -131,9 +127,7 @@ 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(),
|
||||||
@@ -232,9 +226,7 @@ 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(),
|
||||||
@@ -390,9 +382,7 @@ 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(),
|
||||||
@@ -717,9 +707,7 @@ 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(),
|
||||||
@@ -798,9 +786,7 @@ 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(),
|
||||||
@@ -864,55 +850,6 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
const dispatched: SessionActionDispatchRequest[] = [];
|
|
||||||
registerIpcHandlers(
|
|
||||||
createRegisterIpcDeps({
|
|
||||||
dispatchSessionAction: async (request) => {
|
|
||||||
dispatched.push(request);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
registrar,
|
|
||||||
);
|
|
||||||
|
|
||||||
const dispatchHandler = handlers.handle.get(IPC_CHANNELS.command.dispatchSessionAction);
|
|
||||||
assert.ok(dispatchHandler);
|
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
|
||||||
await dispatchHandler!({}, { actionId: 'cycleRuntimeOption', payload: { direction: 1 } });
|
|
||||||
}, /Invalid session action payload/);
|
|
||||||
await assert.rejects(async () => {
|
|
||||||
await dispatchHandler!({}, { actionId: 'unknown-action' });
|
|
||||||
}, /Invalid session action payload/);
|
|
||||||
|
|
||||||
await dispatchHandler!({}, {
|
|
||||||
actionId: 'copySubtitleMultiple',
|
|
||||||
payload: { count: 3 },
|
|
||||||
});
|
|
||||||
await dispatchHandler!({}, {
|
|
||||||
actionId: 'cycleRuntimeOption',
|
|
||||||
payload: {
|
|
||||||
runtimeOptionId: 'anki.autoUpdateNewCards',
|
|
||||||
direction: -1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(dispatched, [
|
|
||||||
{
|
|
||||||
actionId: 'copySubtitleMultiple',
|
|
||||||
payload: { count: 3 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
actionId: 'cycleRuntimeOption',
|
|
||||||
payload: {
|
|
||||||
runtimeOptionId: 'anki.autoUpdateNewCards',
|
|
||||||
direction: -1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
registerIpcHandlers(
|
registerIpcHandlers(
|
||||||
@@ -935,9 +872,7 @@ 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(),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
|
import type { IpcMainEvent } from 'electron';
|
||||||
import type {
|
import type {
|
||||||
CompiledSessionBinding,
|
|
||||||
ControllerConfigUpdate,
|
ControllerConfigUpdate,
|
||||||
PlaylistBrowserMutationResult,
|
PlaylistBrowserMutationResult,
|
||||||
PlaylistBrowserSnapshot,
|
PlaylistBrowserSnapshot,
|
||||||
@@ -13,7 +12,6 @@ import type {
|
|||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
SubsyncManualRunRequest,
|
SubsyncManualRunRequest,
|
||||||
SubsyncResult,
|
SubsyncResult,
|
||||||
SessionActionDispatchRequest,
|
|
||||||
YoutubePickerResolveRequest,
|
YoutubePickerResolveRequest,
|
||||||
YoutubePickerResolveResult,
|
YoutubePickerResolveResult,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
@@ -27,23 +25,16 @@ import {
|
|||||||
parseRuntimeOptionDirection,
|
parseRuntimeOptionDirection,
|
||||||
parseRuntimeOptionId,
|
parseRuntimeOptionId,
|
||||||
parseRuntimeOptionValue,
|
parseRuntimeOptionValue,
|
||||||
parseSessionActionDispatchRequest,
|
|
||||||
parseSubtitlePosition,
|
parseSubtitlePosition,
|
||||||
parseSubsyncManualRunRequest,
|
parseSubsyncManualRunRequest,
|
||||||
parseYoutubePickerResolveRequest,
|
parseYoutubePickerResolveRequest,
|
||||||
} from '../../shared/ipc/validators';
|
} from '../../shared/ipc/validators';
|
||||||
|
|
||||||
const { ipcMain } = electron;
|
const { BrowserWindow, ipcMain } = electron;
|
||||||
|
|
||||||
export interface IpcServiceDeps {
|
export interface IpcServiceDeps {
|
||||||
onOverlayModalClosed: (
|
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||||
modal: OverlayHostedModal,
|
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
|
||||||
) => void;
|
|
||||||
onOverlayModalOpened?: (
|
|
||||||
modal: OverlayHostedModal,
|
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
|
||||||
) => void;
|
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleDevTools: () => void;
|
toggleDevTools: () => void;
|
||||||
@@ -65,9 +56,7 @@ 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;
|
||||||
@@ -164,14 +153,8 @@ interface IpcMainRegistrar {
|
|||||||
export interface IpcDepsRuntimeOptions {
|
export interface IpcDepsRuntimeOptions {
|
||||||
getMainWindow: () => WindowLike | null;
|
getMainWindow: () => WindowLike | null;
|
||||||
getVisibleOverlayVisibility: () => boolean;
|
getVisibleOverlayVisibility: () => boolean;
|
||||||
onOverlayModalClosed: (
|
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||||
modal: OverlayHostedModal,
|
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
|
||||||
) => void;
|
|
||||||
onOverlayModalOpened?: (
|
|
||||||
modal: OverlayHostedModal,
|
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
|
||||||
) => void;
|
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
@@ -186,9 +169,7 @@ 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;
|
||||||
@@ -257,9 +238,7 @@ 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,
|
||||||
@@ -320,28 +299,23 @@ 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 =
|
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender);
|
||||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
|
||||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => {
|
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (_event: unknown, modal: unknown) => {
|
||||||
const parsedModal = parseOverlayHostedModal(modal);
|
const parsedModal = parseOverlayHostedModal(modal);
|
||||||
if (!parsedModal) return;
|
if (!parsedModal) return;
|
||||||
const senderWindow =
|
deps.onOverlayModalClosed(parsedModal);
|
||||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
|
||||||
deps.onOverlayModalClosed(parsedModal, senderWindow);
|
|
||||||
});
|
});
|
||||||
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;
|
||||||
const senderWindow =
|
deps.onOverlayModalOpened(parsedModal);
|
||||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
|
||||||
deps.onOverlayModalOpened(parsedModal, senderWindow);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.handle(
|
ipc.handle(
|
||||||
@@ -457,25 +431,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
deps.handleMpvCommand(parsedCommand);
|
deps.handleMpvCommand(parsedCommand);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.handle(
|
|
||||||
IPC_CHANNELS.command.dispatchSessionAction,
|
|
||||||
async (_event: unknown, request: unknown) => {
|
|
||||||
const parsedRequest = parseSessionActionDispatchRequest(request);
|
|
||||||
if (!parsedRequest) {
|
|
||||||
throw new Error('Invalid session action payload');
|
|
||||||
}
|
|
||||||
await deps.dispatchSessionAction?.(parsedRequest);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ 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,
|
||||||
@@ -43,33 +41,6 @@ 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);
|
||||||
|
|
||||||
@@ -88,15 +59,6 @@ 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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ 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('.');
|
||||||
@@ -34,11 +32,7 @@ function isSupportedVideoPath(pathValue: string): boolean {
|
|||||||
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportedSubtitlePath(pathValue: string): boolean {
|
function parseUriList(data: string): string[] {
|
||||||
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[] = [];
|
||||||
|
|
||||||
@@ -53,7 +47,7 @@ function parseUriList(data: string, isSupportedPath: (pathValue: string) => bool
|
|||||||
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
||||||
filePath = filePath.slice(1);
|
filePath = filePath.slice(1);
|
||||||
}
|
}
|
||||||
if (filePath && isSupportedPath(filePath)) {
|
if (filePath && isSupportedVideoPath(filePath)) {
|
||||||
out.push(filePath);
|
out.push(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -93,19 +87,6 @@ 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 [];
|
||||||
|
|
||||||
@@ -115,7 +96,7 @@ function collectDroppedPaths(
|
|||||||
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 || !isSupportedPath(trimmed) || seen.has(trimmed)) return;
|
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return;
|
||||||
seen.add(trimmed);
|
seen.add(trimmed);
|
||||||
out.push(trimmed);
|
out.push(trimmed);
|
||||||
};
|
};
|
||||||
@@ -128,7 +109,7 @@ function collectDroppedPaths(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof dataTransfer.getData === 'function') {
|
if (typeof dataTransfer.getData === 'function') {
|
||||||
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'), isSupportedPath)) {
|
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) {
|
||||||
addPath(pathValue);
|
addPath(pathValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,9 +130,3 @@ 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],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -443,214 +443,3 @@ 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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ 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;
|
||||||
@@ -79,8 +78,6 @@ 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();
|
||||||
@@ -97,14 +94,11 @@ 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ import {
|
|||||||
OverlayShortcutRuntimeDeps,
|
OverlayShortcutRuntimeDeps,
|
||||||
runOverlayShortcutLocalFallback,
|
runOverlayShortcutLocalFallback,
|
||||||
} from './overlay-shortcut-handler';
|
} from './overlay-shortcut-handler';
|
||||||
import {
|
import { shouldActivateOverlayShortcuts } from './overlay-shortcut';
|
||||||
registerOverlayShortcutsRuntime,
|
|
||||||
shouldActivateOverlayShortcuts,
|
|
||||||
unregisterOverlayShortcutsRuntime,
|
|
||||||
} from './overlay-shortcut';
|
|
||||||
|
|
||||||
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||||
return {
|
return {
|
||||||
@@ -27,10 +23,6 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
|||||||
markAudioCard: null,
|
markAudioCard: null,
|
||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openSessionHelp: null,
|
|
||||||
openControllerSelect: null,
|
|
||||||
openControllerDebug: null,
|
|
||||||
toggleSubtitleSidebar: null,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -321,59 +313,3 @@ test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerOverlayShortcutsRuntime reports active shortcuts when configured', () => {
|
|
||||||
const deps = {
|
|
||||||
getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }),
|
|
||||||
getOverlayHandlers: () => ({
|
|
||||||
copySubtitle: () => {},
|
|
||||||
copySubtitleMultiple: () => {},
|
|
||||||
updateLastCardFromClipboard: () => {},
|
|
||||||
triggerFieldGrouping: () => {},
|
|
||||||
triggerSubsync: () => {},
|
|
||||||
mineSentence: () => {},
|
|
||||||
mineSentenceMultiple: () => {},
|
|
||||||
toggleSecondarySub: () => {},
|
|
||||||
markAudioCard: () => {},
|
|
||||||
openRuntimeOptions: () => {},
|
|
||||||
openJimaku: () => {},
|
|
||||||
}),
|
|
||||||
cancelPendingMultiCopy: () => {},
|
|
||||||
cancelPendingMineSentenceMultiple: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = registerOverlayShortcutsRuntime(deps);
|
|
||||||
assert.equal(result, true);
|
|
||||||
assert.equal(unregisterOverlayShortcutsRuntime(result, deps), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const deps = {
|
|
||||||
getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }),
|
|
||||||
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(registerOverlayShortcutsRuntime(deps), true);
|
|
||||||
const result = unregisterOverlayShortcutsRuntime(true, deps);
|
|
||||||
assert.equal(result, false);
|
|
||||||
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
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,
|
|
||||||
openSessionHelp: null,
|
|
||||||
openControllerSelect: null,
|
|
||||||
openControllerDebug: null,
|
|
||||||
toggleSubtitleSidebar: 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']);
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
|
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;
|
||||||
@@ -21,27 +27,6 @@ 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;
|
||||||
@@ -58,12 +43,139 @@ export function shouldActivateOverlayShortcuts(args: {
|
|||||||
|
|
||||||
export function registerOverlayShortcuts(
|
export function registerOverlayShortcuts(
|
||||||
shortcuts: ConfiguredShortcuts,
|
shortcuts: ConfiguredShortcuts,
|
||||||
_handlers: OverlayShortcutHandlers,
|
handlers: OverlayShortcutHandlers,
|
||||||
): boolean {
|
): boolean {
|
||||||
return hasConfiguredOverlayShortcuts(shortcuts);
|
let registeredAny = false;
|
||||||
|
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) {
|
||||||
|
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 {}
|
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());
|
||||||
|
|||||||
@@ -6,59 +6,27 @@ 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 {
|
return { window, calls };
|
||||||
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', () => {
|
||||||
@@ -195,7 +163,7 @@ 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 binds to mpv while tracked', () => {
|
test('Windows visible overlay stays click-through and does not steal focus while tracked', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
isTracking: () => true,
|
isTracking: () => true,
|
||||||
@@ -214,9 +182,6 @@ test('Windows visible overlay stays click-through and binds to mpv while tracked
|
|||||||
ensureOverlayWindowLevel: () => {
|
ensureOverlayWindowLevel: () => {
|
||||||
calls.push('ensure-level');
|
calls.push('ensure-level');
|
||||||
},
|
},
|
||||||
syncWindowsOverlayToMpvZOrder: () => {
|
|
||||||
calls.push('sync-windows-z-order');
|
|
||||||
},
|
|
||||||
syncPrimaryOverlayWindowLayer: () => {
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
calls.push('sync-layer');
|
calls.push('sync-layer');
|
||||||
},
|
},
|
||||||
@@ -230,557 +195,11 @@ test('Windows visible overlay stays click-through and binds to mpv while tracked
|
|||||||
isWindowsPlatform: true,
|
isWindowsPlatform: true,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('opacity:0'));
|
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('show-inactive'));
|
assert.ok(calls.includes('show'));
|
||||||
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'));
|
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 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');
|
|
||||||
},
|
|
||||||
syncPrimaryOverlayWindowLayer: () => {
|
|
||||||
calls.push('sync-layer');
|
|
||||||
},
|
|
||||||
enforceOverlayLayerOrder: () => {
|
|
||||||
calls.push('enforce-order');
|
|
||||||
},
|
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
calls.push('sync-shortcuts');
|
|
||||||
},
|
|
||||||
isMacOSPlatform: true,
|
|
||||||
isWindowsPlatform: false,
|
|
||||||
forceMousePassthrough: true,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
|
||||||
assert.ok(calls.includes('always-on-top:false'));
|
|
||||||
assert.ok(!calls.includes('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,
|
|
||||||
isWindowsPlatform: true,
|
|
||||||
} 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('sync-windows-z-order'));
|
|
||||||
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 reshows click-through even if focus state is stale after a modal closes', () => {
|
|
||||||
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;
|
|
||||||
window.hide();
|
|
||||||
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:true:forward'));
|
|
||||||
assert.ok(calls.includes('show-inactive'));
|
|
||||||
assert.ok(!calls.includes('show'));
|
|
||||||
});
|
|
||||||
|
|
||||||
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', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
@@ -936,157 +355,6 @@ 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;
|
||||||
|
|||||||
@@ -1,52 +1,6 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import { BaseWindowTracker } from '../../window-trackers';
|
import { BaseWindowTracker } from '../../window-trackers';
|
||||||
import { WindowGeometry } from '../../types';
|
import { WindowGeometry } from '../../types';
|
||||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
|
||||||
|
|
||||||
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
|
||||||
const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
|
|
||||||
BrowserWindow,
|
|
||||||
ReturnType<typeof setTimeout>
|
|
||||||
>();
|
|
||||||
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;
|
||||||
@@ -54,14 +8,10 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
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;
|
||||||
@@ -80,10 +30,6 @@ 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;
|
||||||
@@ -91,93 +37,13 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
|
|
||||||
const showPassiveVisibleOverlay = (): void => {
|
const showPassiveVisibleOverlay = (): void => {
|
||||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||||
const wasVisible = mainWindow.isVisible();
|
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 shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
|
|
||||||
const shouldIgnoreMouseEvents =
|
|
||||||
forceMousePassthrough ||
|
|
||||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
|
||||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
|
||||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
|
||||||
!args.isWindowsPlatform ||
|
|
||||||
!args.windowTracker ||
|
|
||||||
isVisibleOverlayFocused ||
|
|
||||||
isTrackedWindowsTargetFocused ||
|
|
||||||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
|
|
||||||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
@@ -197,27 +63,12 @@ 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) {
|
||||||
@@ -225,9 +76,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -238,10 +87,6 @@ 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;
|
||||||
@@ -254,32 +99,11 @@ 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,31 +8,7 @@ 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', () => {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
|
||||||
@@ -66,14 +66,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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,
|
||||||
@@ -19,9 +18,8 @@ export function buildOverlayWindowOptions(
|
|||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
backgroundColor: '#00000000',
|
|
||||||
frame: false,
|
frame: false,
|
||||||
alwaysOnTop: shouldStartAlwaysOnTop,
|
alwaysOnTop: true,
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
hasShadow: false,
|
hasShadow: false,
|
||||||
@@ -33,7 +31,6 @@ 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}`],
|
||||||
|
|||||||
@@ -103,49 +103,6 @@ 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[] = [];
|
||||||
|
|
||||||
@@ -160,7 +117,6 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking'
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
calls.push('move-visible');
|
calls.push('move-visible');
|
||||||
},
|
},
|
||||||
platform: 'linux',
|
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,24 +10,9 @@ import {
|
|||||||
} from './overlay-window-input';
|
} from './overlay-window-input';
|
||||||
import { buildOverlayWindowOptions } from './overlay-window-options';
|
import { buildOverlayWindowOptions } from './overlay-window-options';
|
||||||
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
|
||||||
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
|
||||||
|
|
||||||
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>();
|
|
||||||
|
|
||||||
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');
|
||||||
@@ -91,20 +76,13 @@ 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));
|
||||||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
|
||||||
OVERLAY_WINDOW_CONTENT_READY_FLAG
|
|
||||||
] = false;
|
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -115,14 +93,6 @@ 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);
|
||||||
@@ -166,8 +136,6 @@ export function createOverlayWindow(
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
window.moveTop();
|
window.moveTop();
|
||||||
},
|
},
|
||||||
onWindowsVisibleOverlayBlur:
|
|
||||||
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
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;
|
|
||||||
toggleSubtitleSidebar: () => void;
|
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
|
||||||
openRuntimeOptionsPalette: () => void;
|
|
||||||
openSessionHelp: () => void;
|
|
||||||
openControllerSelect: () => void;
|
|
||||||
openControllerDebug: () => 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 'toggleSubtitleSidebar':
|
|
||||||
deps.toggleSubtitleSidebar();
|
|
||||||
return;
|
|
||||||
case 'markAudioCard':
|
|
||||||
await deps.markLastCardAsAudioCard();
|
|
||||||
return;
|
|
||||||
case 'openRuntimeOptions':
|
|
||||||
deps.openRuntimeOptionsPalette();
|
|
||||||
return;
|
|
||||||
case 'openSessionHelp':
|
|
||||||
deps.openSessionHelp();
|
|
||||||
return;
|
|
||||||
case 'openControllerSelect':
|
|
||||||
deps.openControllerSelect();
|
|
||||||
return;
|
|
||||||
case 'openControllerDebug':
|
|
||||||
deps.openControllerDebug();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
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,
|
|
||||||
openSessionHelp: null,
|
|
||||||
openControllerSelect: null,
|
|
||||||
openControllerDebug: null,
|
|
||||||
toggleSubtitleSidebar: 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',
|
|
||||||
openControllerSelect: 'Alt+C',
|
|
||||||
}),
|
|
||||||
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.openControllerSelect',
|
|
||||||
code: 'KeyC',
|
|
||||||
modifiers: ['alt'],
|
|
||||||
target: 'openControllerSelect',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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 rejects malformed command arrays', () => {
|
|
||||||
const result = compileSessionBindings({
|
|
||||||
shortcuts: createShortcuts(),
|
|
||||||
keybindings: [
|
|
||||||
createKeybinding('Ctrl+J', ['show-text', 3000]),
|
|
||||||
createKeybinding('Ctrl+K', ['show-text', { bad: true } as never] as never),
|
|
||||||
],
|
|
||||||
platform: 'linux',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
|
|
||||||
assert.equal(result.bindings[0]?.actionType, 'mpv-command');
|
|
||||||
assert.deepEqual(result.bindings[0]?.command, ['show-text', 3000]);
|
|
||||||
assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
|
|
||||||
'unsupported:keybindings[1].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',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
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' },
|
|
||||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
|
||||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
|
||||||
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
|
|
||||||
{ key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
|
|
||||||
return [...new Set(modifiers)].sort(
|
|
||||||
(left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidCommandEntry(value: unknown): value is string | number {
|
|
||||||
return typeof value === 'string' || typeof value === 'number';
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
) {
|
|
||||||
const keyMatch = normalized.match(/^key([a-z])$/i);
|
|
||||||
if (keyMatch) {
|
|
||||||
return `Key${keyMatch[1]!.toUpperCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const digitMatch = normalized.match(/^digit([0-9])$/i);
|
|
||||||
if (digitMatch) {
|
|
||||||
return `Digit${digitMatch[1]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrowMatch = normalized.match(/^arrow(up|down|left|right)$/i);
|
|
||||||
if (arrowMatch) {
|
|
||||||
const direction = arrowMatch[1]!;
|
|
||||||
return `Arrow${direction[0]!.toUpperCase()}${direction.slice(1).toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const functionKeyMatch = normalized.match(/^f(\d{1,2})$/i);
|
|
||||||
if (functionKeyMatch) {
|
|
||||||
return `F${functionKeyMatch[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;
|
|
||||||
if (!Array.isArray(command) || command.length === 0 || !command.every(isValidCommandEntry)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = command[0];
|
|
||||||
if (typeof first !== 'string') {
|
|
||||||
return {
|
|
||||||
actionType: 'mpv-command',
|
|
||||||
command,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 parts = first.split(':');
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const [, runtimeOptionId, rawDirection] = parts;
|
|
||||||
if (!runtimeOptionId || (rawDirection !== 'prev' && rawDirection !== 'next')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
warnings.push({
|
|
||||||
kind: 'unsupported',
|
|
||||||
path: `keybindings[${index}].key`,
|
|
||||||
value: binding.command,
|
|
||||||
message: 'Unsupported keybinding command syntax.',
|
|
||||||
});
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,42 @@ 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,21 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
triggerFieldGrouping: false,
|
triggerFieldGrouping: false,
|
||||||
triggerSubsync: false,
|
triggerSubsync: false,
|
||||||
markAudioCard: false,
|
markAudioCard: false,
|
||||||
toggleStatsOverlay: false,
|
|
||||||
toggleSubtitleSidebar: false,
|
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
openSessionHelp: false,
|
|
||||||
openControllerSelect: false,
|
|
||||||
openControllerDebug: false,
|
|
||||||
openJimaku: false,
|
|
||||||
openYoutubePicker: false,
|
|
||||||
openPlaylistBrowser: false,
|
|
||||||
replayCurrentSubtitle: false,
|
|
||||||
playNextSubtitle: false,
|
|
||||||
shiftSubDelayPrevLine: false,
|
|
||||||
shiftSubDelayNextLine: false,
|
|
||||||
cycleRuntimeOptionId: undefined,
|
|
||||||
cycleRuntimeOptionDirection: undefined,
|
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
|
|||||||
@@ -311,8 +311,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
|
|
||||||
deps.createSubtitleTimingTracker();
|
deps.createSubtitleTimingTracker();
|
||||||
if (deps.createImmersionTracker) {
|
if (deps.createImmersionTracker) {
|
||||||
deps.createImmersionTracker();
|
deps.log('Runtime ready: immersion tracker startup deferred until first media activity.');
|
||||||
deps.log('Runtime ready: immersion tracker startup requested.');
|
|
||||||
} else {
|
} else {
|
||||||
deps.log('Runtime ready: immersion tracker dependency is missing.');
|
deps.log('Runtime ready: immersion tracker dependency is missing.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ 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' | 'all' {
|
function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
|
||||||
return raw === '7d' || raw === '30d' || raw === '90d' || raw === 'all' ? raw : '30d';
|
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
|
||||||
|
? raw
|
||||||
|
: '30d';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ export interface ConfiguredShortcuts {
|
|||||||
markAudioCard: string | null | undefined;
|
markAudioCard: string | null | undefined;
|
||||||
openRuntimeOptions: string | null | undefined;
|
openRuntimeOptions: string | null | undefined;
|
||||||
openJimaku: string | null | undefined;
|
openJimaku: string | null | undefined;
|
||||||
openSessionHelp: string | null | undefined;
|
|
||||||
openControllerSelect: string | null | undefined;
|
|
||||||
openControllerDebug: string | null | undefined;
|
|
||||||
toggleSubtitleSidebar: string | null | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConfiguredShortcuts(
|
export function resolveConfiguredShortcuts(
|
||||||
@@ -82,17 +78,5 @@ export function resolveConfiguredShortcuts(
|
|||||||
openJimaku: normalizeShortcut(
|
openJimaku: normalizeShortcut(
|
||||||
config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku,
|
config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku,
|
||||||
),
|
),
|
||||||
openSessionHelp: normalizeShortcut(
|
|
||||||
config.shortcuts?.openSessionHelp ?? defaultConfig.shortcuts?.openSessionHelp,
|
|
||||||
),
|
|
||||||
openControllerSelect: normalizeShortcut(
|
|
||||||
config.shortcuts?.openControllerSelect ?? defaultConfig.shortcuts?.openControllerSelect,
|
|
||||||
),
|
|
||||||
openControllerDebug: normalizeShortcut(
|
|
||||||
config.shortcuts?.openControllerDebug ?? defaultConfig.shortcuts?.openControllerDebug,
|
|
||||||
),
|
|
||||||
toggleSubtitleSidebar: normalizeShortcut(
|
|
||||||
config.shortcuts?.toggleSubtitleSidebar ?? defaultConfig.shortcuts?.toggleSubtitleSidebar,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
535
src/main.ts
535
src/main.ts
@@ -109,13 +109,11 @@ 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,
|
||||||
@@ -132,14 +130,6 @@ import {
|
|||||||
type LogLevelSource,
|
type LogLevelSource,
|
||||||
} from './logger';
|
} from './logger';
|
||||||
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
||||||
import {
|
|
||||||
bindWindowsOverlayAboveMpv,
|
|
||||||
clearWindowsOverlayOwner,
|
|
||||||
ensureWindowsOverlayTransparency,
|
|
||||||
findWindowsMpvTargetWindowHandle,
|
|
||||||
getWindowsForegroundProcessName,
|
|
||||||
setWindowsOverlayOwner,
|
|
||||||
} from './window-trackers/windows-helper';
|
|
||||||
import {
|
import {
|
||||||
commandNeedsOverlayStartupPrereqs,
|
commandNeedsOverlayStartupPrereqs,
|
||||||
commandNeedsOverlayRuntime,
|
commandNeedsOverlayRuntime,
|
||||||
@@ -352,7 +342,6 @@ 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,
|
||||||
@@ -415,8 +404,6 @@ 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 {
|
||||||
@@ -452,14 +439,7 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
|||||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
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 { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
|
||||||
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
|
||||||
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
|
||||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
|
||||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-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 { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open';
|
|
||||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||||
import {
|
import {
|
||||||
createFrequencyDictionaryRuntimeService,
|
createFrequencyDictionaryRuntimeService,
|
||||||
@@ -1490,7 +1470,9 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
|||||||
openRuntimeOptionsPalette();
|
openRuntimeOptionsPalette();
|
||||||
},
|
},
|
||||||
openJimaku: () => {
|
openJimaku: () => {
|
||||||
openJimakuOverlay();
|
sendToActiveOverlayWindow('jimaku:open', undefined, {
|
||||||
|
restoreOnModalClose: 'jimaku',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
markAudioCard: () => markLastCardAsAudioCard(),
|
markAudioCard: () => markLastCardAsAudioCard(),
|
||||||
copySubtitleMultiple: (timeoutMs: number) => {
|
copySubtitleMultiple: (timeoutMs: number) => {
|
||||||
@@ -1544,9 +1526,6 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
|||||||
setKeybindings: (keybindings) => {
|
setKeybindings: (keybindings) => {
|
||||||
appState.keybindings = keybindings;
|
appState.keybindings = keybindings;
|
||||||
},
|
},
|
||||||
setSessionBindings: (sessionBindings) => {
|
|
||||||
persistSessionBindings(sessionBindings);
|
|
||||||
},
|
|
||||||
refreshGlobalAndOverlayShortcuts: () => {
|
refreshGlobalAndOverlayShortcuts: () => {
|
||||||
refreshGlobalAndOverlayShortcuts();
|
refreshGlobalAndOverlayShortcuts();
|
||||||
},
|
},
|
||||||
@@ -1856,9 +1835,6 @@ 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;
|
||||||
@@ -1867,9 +1843,6 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
ensureOverlayWindowLevel: (window) => {
|
ensureOverlayWindowLevel: (window) => {
|
||||||
ensureOverlayWindowLevel(window);
|
ensureOverlayWindowLevel(window);
|
||||||
},
|
},
|
||||||
syncWindowsOverlayToMpvZOrder: (_window) => {
|
|
||||||
requestWindowsVisibleOverlayZOrderSync();
|
|
||||||
},
|
|
||||||
syncPrimaryOverlayWindowLayer: (layer) => {
|
syncPrimaryOverlayWindowLayer: (layer) => {
|
||||||
syncPrimaryOverlayWindowLayer(layer);
|
syncPrimaryOverlayWindowLayer(layer);
|
||||||
},
|
},
|
||||||
@@ -1897,237 +1870,6 @@ 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 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 {
|
|
||||||
void targetMpvSocketPath;
|
|
||||||
return findWindowsMpvTargetWindowHandle();
|
|
||||||
} 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 && bindWindowsOverlayAboveMpv(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 = getWindowsForegroundProcessName();
|
|
||||||
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 clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
|
||||||
if (windowsVisibleOverlayForegroundPollInterval === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearInterval(windowsVisibleOverlayForegroundPollInterval);
|
|
||||||
windowsVisibleOverlayForegroundPollInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -2215,84 +1957,8 @@ function setOverlayDebugVisualizationEnabled(enabled: boolean): void {
|
|||||||
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled);
|
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOverlayHostedModalOpenDeps(): {
|
|
||||||
ensureOverlayStartupPrereqs: () => void;
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
|
||||||
sendToActiveOverlayWindow: (
|
|
||||||
channel: string,
|
|
||||||
payload?: unknown,
|
|
||||||
runtimeOptions?: {
|
|
||||||
restoreOnModalClose?: OverlayHostedModal;
|
|
||||||
preferModalWindow?: boolean;
|
|
||||||
},
|
|
||||||
) => boolean;
|
|
||||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
|
||||||
logWarn: (message: string) => void;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions(),
|
|
||||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
|
||||||
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
|
||||||
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
|
||||||
logWarn: (message) => logger.warn(message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function openOverlayHostedModalWithOsd(
|
|
||||||
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
|
||||||
unavailableMessage: string,
|
|
||||||
failureLogMessage: string,
|
|
||||||
): void {
|
|
||||||
void openModal(createOverlayHostedModalOpenDeps()).then((opened) => {
|
|
||||||
if (!opened) {
|
|
||||||
showMpvOsd(unavailableMessage);
|
|
||||||
}
|
|
||||||
}).catch((error) => {
|
|
||||||
logger.error(failureLogMessage, error);
|
|
||||||
showMpvOsd(unavailableMessage);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openRuntimeOptionsPalette(): void {
|
function openRuntimeOptionsPalette(): void {
|
||||||
openOverlayHostedModalWithOsd(
|
overlayVisibilityComposer.openRuntimeOptionsPalette();
|
||||||
openRuntimeOptionsModalRuntime,
|
|
||||||
'Runtime options overlay unavailable.',
|
|
||||||
'Failed to open runtime options overlay.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openJimakuOverlay(): void {
|
|
||||||
openOverlayHostedModalWithOsd(
|
|
||||||
openJimakuModalRuntime,
|
|
||||||
'Jimaku overlay unavailable.',
|
|
||||||
'Failed to open Jimaku overlay.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSessionHelpOverlay(): void {
|
|
||||||
openOverlayHostedModalWithOsd(
|
|
||||||
openSessionHelpModalRuntime,
|
|
||||||
'Session help overlay unavailable.',
|
|
||||||
'Failed to open session help overlay.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openControllerSelectOverlay(): void {
|
|
||||||
openOverlayHostedModalWithOsd(
|
|
||||||
openControllerSelectModalRuntime,
|
|
||||||
'Controller select overlay unavailable.',
|
|
||||||
'Failed to open controller select overlay.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openControllerDebugOverlay(): void {
|
|
||||||
openOverlayHostedModalWithOsd(
|
|
||||||
openControllerDebugModalRuntime,
|
|
||||||
'Controller debug overlay unavailable.',
|
|
||||||
'Failed to open controller debug overlay.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPlaylistBrowser(): void {
|
function openPlaylistBrowser(): void {
|
||||||
@@ -2300,11 +1966,16 @@ function openPlaylistBrowser(): void {
|
|||||||
showMpvOsd('Playlist browser requires active playback.');
|
showMpvOsd('Playlist browser requires active playback.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openOverlayHostedModalWithOsd(
|
const opened = openPlaylistBrowserRuntime({
|
||||||
openPlaylistBrowserRuntime,
|
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
||||||
'Playlist browser overlay unavailable.',
|
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||||
'Failed to open playlist browser overlay.',
|
ensureOverlayWindowsReadyForVisibilityActions(),
|
||||||
);
|
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||||
|
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||||
|
});
|
||||||
|
if (!opened) {
|
||||||
|
showMpvOsd('Playlist browser overlay unavailable.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResolvedConfig() {
|
function getResolvedConfig() {
|
||||||
@@ -3075,8 +2746,6 @@ const {
|
|||||||
annotationSubtitleWsService.stop();
|
annotationSubtitleWsService.stop();
|
||||||
},
|
},
|
||||||
stopTexthookerService: () => texthookerService.stop(),
|
stopTexthookerService: () => texthookerService.stop(),
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop(),
|
|
||||||
getMainOverlayWindow: () => overlayManager.getMainWindow(),
|
getMainOverlayWindow: () => overlayManager.getMainWindow(),
|
||||||
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
|
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
|
||||||
getModalOverlayWindow: () => overlayManager.getModalWindow(),
|
getModalOverlayWindow: () => overlayManager.getModalWindow(),
|
||||||
@@ -3477,7 +3146,6 @@ 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();
|
||||||
@@ -3620,9 +3288,6 @@ 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();
|
||||||
@@ -4009,12 +3674,6 @@ 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(
|
||||||
@@ -4137,14 +3796,7 @@ function createModalWindow(): BrowserWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMainWindow(): BrowserWindow {
|
function createMainWindow(): BrowserWindow {
|
||||||
const window = createMainWindowHandler();
|
return createMainWindowHandler();
|
||||||
if (process.platform === 'win32') {
|
|
||||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(window);
|
|
||||||
if (!ensureWindowsOverlayTransparency(overlayHwnd)) {
|
|
||||||
logger.warn('Failed to eagerly extend Windows overlay transparency via koffi');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return window;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureTray(): void {
|
function ensureTray(): void {
|
||||||
@@ -4221,54 +3873,6 @@ 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,
|
||||||
@@ -4319,10 +3923,6 @@ function handleCycleSecondarySubMode(): void {
|
|||||||
cycleSecondarySubMode();
|
cycleSecondarySubMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSubtitleSidebar(): void {
|
|
||||||
broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triggerSubsyncFromConfig(): Promise<void> {
|
async function triggerSubsyncFromConfig(): Promise<void> {
|
||||||
await subsyncRuntime.triggerFromConfig();
|
await subsyncRuntime.triggerFromConfig();
|
||||||
}
|
}
|
||||||
@@ -4584,55 +4184,6 @@ 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(),
|
|
||||||
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
|
|
||||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
|
||||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
|
||||||
openJimaku: () => openJimakuOverlay(),
|
|
||||||
openSessionHelp: () => openSessionHelpOverlay(),
|
|
||||||
openControllerSelect: () => openControllerSelectOverlay(),
|
|
||||||
openControllerDebug: () => openControllerDebugOverlay(),
|
|
||||||
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,
|
||||||
@@ -4642,7 +4193,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
mpvCommandMainDeps: {
|
mpvCommandMainDeps: {
|
||||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||||
openJimaku: () => openJimakuOverlay(),
|
openJimaku: () => overlayModalRuntime.openJimaku(),
|
||||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||||
cycleRuntimeOption: (id, direction) => {
|
cycleRuntimeOption: (id, direction) => {
|
||||||
@@ -4682,17 +4233,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOverlayModalClosed: (modal, senderWindow) => {
|
onOverlayModalClosed: (modal) => {
|
||||||
const modalWindow = overlayManager.getModalWindow();
|
|
||||||
if (
|
|
||||||
senderWindow &&
|
|
||||||
modalWindow &&
|
|
||||||
senderWindow === modalWindow &&
|
|
||||||
!senderWindow.isDestroyed()
|
|
||||||
) {
|
|
||||||
senderWindow.setIgnoreMouseEvents(true, { forward: true });
|
|
||||||
senderWindow.hide();
|
|
||||||
}
|
|
||||||
handleOverlayModalClosed(modal);
|
handleOverlayModalClosed(modal);
|
||||||
},
|
},
|
||||||
onOverlayModalOpened: (modal) => {
|
onOverlayModalOpened: (modal) => {
|
||||||
@@ -4800,9 +4341,7 @@ 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,
|
||||||
@@ -4923,7 +4462,6 @@ 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),
|
||||||
@@ -5057,8 +4595,6 @@ 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);
|
||||||
@@ -5160,9 +4696,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
updateVisibleOverlayVisibility: () =>
|
updateVisibleOverlayVisibility: () =>
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||||
},
|
},
|
||||||
refreshCurrentSubtitle: () => {
|
|
||||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
|
||||||
},
|
|
||||||
overlayShortcutsRuntime: {
|
overlayShortcutsRuntime: {
|
||||||
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||||
},
|
},
|
||||||
@@ -5186,40 +4719,6 @@ 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 && bindWindowsOverlayAboveMpv(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 (!setWindowsOverlayOwner(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 (!clearWindowsOverlayOwner(overlayHwnd)) {
|
|
||||||
logger.warn('Failed to clear overlay owner via koffi');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getOverlayWindows: () => getOverlayWindows(),
|
getOverlayWindows: () => getOverlayWindows(),
|
||||||
getResolvedConfig: () => getResolvedConfig(),
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
showDesktopNotification,
|
showDesktopNotification,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
{ kind: string },
|
{ kind: string },
|
||||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||||
{ registry: boolean },
|
{ registry: boolean },
|
||||||
{ getMainWindow: () => null; getModalWindow: () => null },
|
{ getModalWindow: () => null },
|
||||||
{
|
{
|
||||||
inputState: boolean;
|
inputState: boolean;
|
||||||
getModalInputExclusive: () => boolean;
|
getModalInputExclusive: () => boolean;
|
||||||
@@ -82,7 +82,6 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
}) as const,
|
}) as const,
|
||||||
createMainRuntimeRegistry: () => ({ registry: true }),
|
createMainRuntimeRegistry: () => ({ registry: true }),
|
||||||
createOverlayManager: () => ({
|
createOverlayManager: () => ({
|
||||||
getMainWindow: () => null,
|
|
||||||
getModalWindow: () => null,
|
getModalWindow: () => null,
|
||||||
}),
|
}),
|
||||||
createOverlayModalInputState: () => ({
|
createOverlayModalInputState: () => ({
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ export interface MainBootServicesParams<
|
|||||||
getModalWindow: () => BrowserWindow | null;
|
getModalWindow: () => BrowserWindow | null;
|
||||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||||
syncOverlayVisibilityForModal: () => void;
|
syncOverlayVisibilityForModal: () => void;
|
||||||
restoreMainWindowFocus?: () => void;
|
|
||||||
}) => TOverlayModalInputState;
|
}) => TOverlayModalInputState;
|
||||||
createOverlayContentMeasurementStore: (params: {
|
createOverlayContentMeasurementStore: (params: {
|
||||||
logger: TLogger;
|
logger: TLogger;
|
||||||
@@ -132,7 +131,7 @@ export function createMainBootServices<
|
|||||||
TSubtitleWebSocket,
|
TSubtitleWebSocket,
|
||||||
TLogger,
|
TLogger,
|
||||||
TRuntimeRegistry,
|
TRuntimeRegistry,
|
||||||
TOverlayManager extends { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null },
|
TOverlayManager extends { getModalWindow: () => BrowserWindow | null },
|
||||||
TOverlayModalInputState extends OverlayModalInputStateShape,
|
TOverlayModalInputState extends OverlayModalInputStateShape,
|
||||||
TOverlayContentMeasurementStore,
|
TOverlayContentMeasurementStore,
|
||||||
TOverlayModalRuntime,
|
TOverlayModalRuntime,
|
||||||
@@ -213,26 +212,6 @@ export function createMainBootServices<
|
|||||||
syncOverlayVisibilityForModal: () => {
|
syncOverlayVisibilityForModal: () => {
|
||||||
params.getSyncOverlayVisibilityForModal()();
|
params.getSyncOverlayVisibilityForModal()();
|
||||||
},
|
},
|
||||||
restoreMainWindowFocus: () => {
|
|
||||||
const mainWindow = overlayManager.getMainWindow();
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return;
|
|
||||||
try {
|
|
||||||
const electron = require('electron') as {
|
|
||||||
app?: { focus?: (options?: { steal?: boolean }) => void };
|
|
||||||
};
|
|
||||||
electron.app?.focus?.({ steal: true });
|
|
||||||
} catch {
|
|
||||||
// Ignore in non-Electron environments.
|
|
||||||
}
|
|
||||||
const maybeFocusable = mainWindow as typeof mainWindow & {
|
|
||||||
setFocusable?: (focusable: boolean) => void;
|
|
||||||
};
|
|
||||||
maybeFocusable.setFocusable?.(true);
|
|
||||||
mainWindow.focus();
|
|
||||||
if (!mainWindow.webContents.isFocused()) {
|
|
||||||
mainWindow.webContents.focus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({
|
const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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'];
|
||||||
@@ -114,7 +113,6 @@ 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,
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ 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'];
|
||||||
@@ -180,7 +178,6 @@ 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'];
|
||||||
@@ -236,9 +233,7 @@ 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,
|
||||||
@@ -352,7 +347,6 @@ 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,
|
||||||
|
|||||||
@@ -7,16 +7,13 @@ type MockWindow = {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
ignoreMouseEvents: boolean;
|
ignoreMouseEvents: boolean;
|
||||||
forwardedIgnoreMouseEvents: boolean;
|
|
||||||
webContentsFocused: boolean;
|
webContentsFocused: boolean;
|
||||||
showCount: number;
|
showCount: number;
|
||||||
hideCount: number;
|
hideCount: number;
|
||||||
sent: unknown[][];
|
sent: unknown[][];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
contentReady: boolean;
|
|
||||||
loadCallbacks: Array<() => void>;
|
loadCallbacks: Array<() => void>;
|
||||||
readyToShowCallbacks: Array<() => void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMockWindow(): MockWindow & {
|
function createMockWindow(): MockWindow & {
|
||||||
@@ -31,9 +28,7 @@ function createMockWindow(): MockWindow & {
|
|||||||
getHideCount: () => number;
|
getHideCount: () => number;
|
||||||
show: () => void;
|
show: () => void;
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
destroy: () => void;
|
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
once: (event: 'ready-to-show', cb: () => void) => void;
|
|
||||||
webContents: {
|
webContents: {
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
isLoading: () => boolean;
|
isLoading: () => boolean;
|
||||||
@@ -49,16 +44,13 @@ function createMockWindow(): MockWindow & {
|
|||||||
visible: false,
|
visible: false,
|
||||||
focused: false,
|
focused: false,
|
||||||
ignoreMouseEvents: false,
|
ignoreMouseEvents: false,
|
||||||
forwardedIgnoreMouseEvents: false,
|
|
||||||
webContentsFocused: false,
|
webContentsFocused: false,
|
||||||
showCount: 0,
|
showCount: 0,
|
||||||
hideCount: 0,
|
hideCount: 0,
|
||||||
sent: [],
|
sent: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
url: 'file:///overlay/index.html?layer=modal',
|
url: 'file:///overlay/index.html?layer=modal',
|
||||||
contentReady: true,
|
|
||||||
loadCallbacks: [],
|
loadCallbacks: [],
|
||||||
readyToShowCallbacks: [],
|
|
||||||
};
|
};
|
||||||
const window = {
|
const window = {
|
||||||
...state,
|
...state,
|
||||||
@@ -66,9 +58,8 @@ function createMockWindow(): MockWindow & {
|
|||||||
isVisible: () => state.visible,
|
isVisible: () => state.visible,
|
||||||
isFocused: () => state.focused,
|
isFocused: () => state.focused,
|
||||||
getURL: () => state.url,
|
getURL: () => state.url,
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => {
|
||||||
state.ignoreMouseEvents = ignore;
|
state.ignoreMouseEvents = ignore;
|
||||||
state.forwardedIgnoreMouseEvents = options?.forward === true;
|
|
||||||
},
|
},
|
||||||
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
|
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
|
||||||
moveTop: () => {},
|
moveTop: () => {},
|
||||||
@@ -82,16 +73,9 @@ function createMockWindow(): MockWindow & {
|
|||||||
state.visible = false;
|
state.visible = false;
|
||||||
state.hideCount += 1;
|
state.hideCount += 1;
|
||||||
},
|
},
|
||||||
destroy: () => {
|
|
||||||
state.destroyed = true;
|
|
||||||
state.visible = false;
|
|
||||||
},
|
|
||||||
focus: () => {
|
focus: () => {
|
||||||
state.focused = true;
|
state.focused = true;
|
||||||
},
|
},
|
||||||
once: (_event: 'ready-to-show', cb: () => void) => {
|
|
||||||
state.readyToShowCallbacks.push(cb);
|
|
||||||
},
|
|
||||||
webContents: {
|
webContents: {
|
||||||
isLoading: () => state.loading,
|
isLoading: () => state.loading,
|
||||||
getURL: () => state.url,
|
getURL: () => state.url,
|
||||||
@@ -155,25 +139,6 @@ function createMockWindow(): MockWindow & {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.defineProperty(window, 'forwardedIgnoreMouseEvents', {
|
|
||||||
get: () => state.forwardedIgnoreMouseEvents,
|
|
||||||
set: (value: boolean) => {
|
|
||||||
state.forwardedIgnoreMouseEvents = value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(window, 'contentReady', {
|
|
||||||
get: () => state.contentReady,
|
|
||||||
set: (value: boolean) => {
|
|
||||||
state.contentReady = value;
|
|
||||||
(window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
|
|
||||||
value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
(window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
|
|
||||||
state.contentReady;
|
|
||||||
|
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,29 +195,10 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
|
|||||||
assert.deepEqual(window.sent, [['jimaku:open']]);
|
assert.deepEqual(window.sent, [['jimaku:open']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => {
|
|
||||||
const runtime = createOverlayModalRuntimeService({
|
|
||||||
getMainWindow: () => null,
|
|
||||||
getModalWindow: () => null,
|
|
||||||
createModalWindow: () => null,
|
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
|
||||||
setModalWindowBounds: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
|
||||||
restoreOnModalClose: 'runtime-options',
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sendToActiveOverlayWindow waits for blank modal URL before sending open command', () => {
|
test('sendToActiveOverlayWindow waits for blank modal URL before sending open command', () => {
|
||||||
const window = createMockWindow();
|
const window = createMockWindow();
|
||||||
window.url = '';
|
window.url = '';
|
||||||
window.loading = true;
|
window.loading = true;
|
||||||
window.contentReady = false;
|
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const runtime = createOverlayModalRuntimeService({
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
getModalWindow: () => window as never,
|
getModalWindow: () => window as never,
|
||||||
@@ -271,14 +217,9 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
|||||||
assert.deepEqual(window.sent, []);
|
assert.deepEqual(window.sent, []);
|
||||||
|
|
||||||
assert.equal(window.loadCallbacks.length, 1);
|
assert.equal(window.loadCallbacks.length, 1);
|
||||||
assert.equal(window.readyToShowCallbacks.length, 1);
|
|
||||||
window.loading = false;
|
window.loading = false;
|
||||||
window.url = 'file:///overlay/index.html?layer=modal';
|
window.url = 'file:///overlay/index.html?layer=modal';
|
||||||
window.loadCallbacks[0]!();
|
window.loadCallbacks[0]!();
|
||||||
assert.deepEqual(window.sent, []);
|
|
||||||
|
|
||||||
window.contentReady = true;
|
|
||||||
window.readyToShowCallbacks[0]!();
|
|
||||||
|
|
||||||
runtime.notifyOverlayModalOpened('runtime-options');
|
runtime.notifyOverlayModalOpened('runtime-options');
|
||||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||||
@@ -307,10 +248,10 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
|||||||
);
|
);
|
||||||
|
|
||||||
runtime.handleOverlayModalClosed('runtime-options');
|
runtime.handleOverlayModalClosed('runtime-options');
|
||||||
assert.equal(window.isDestroyed(), false);
|
assert.equal(window.getHideCount(), 0);
|
||||||
|
|
||||||
runtime.handleOverlayModalClosed('subsync');
|
runtime.handleOverlayModalClosed('subsync');
|
||||||
assert.equal(window.isDestroyed(), true);
|
assert.equal(window.getHideCount(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => {
|
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => {
|
||||||
@@ -384,12 +325,11 @@ test('modal window path makes visible main overlay click-through until modal clo
|
|||||||
|
|
||||||
assert.equal(sent, true);
|
assert.equal(sent, true);
|
||||||
assert.equal(mainWindow.ignoreMouseEvents, true);
|
assert.equal(mainWindow.ignoreMouseEvents, true);
|
||||||
assert.equal(mainWindow.forwardedIgnoreMouseEvents, true);
|
|
||||||
assert.equal(modalWindow.ignoreMouseEvents, false);
|
assert.equal(modalWindow.ignoreMouseEvents, false);
|
||||||
|
|
||||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||||
|
|
||||||
assert.equal(mainWindow.ignoreMouseEvents, true);
|
assert.equal(mainWindow.ignoreMouseEvents, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('modal window path hides visible main overlay until modal closes', () => {
|
test('modal window path hides visible main overlay until modal closes', () => {
|
||||||
@@ -419,8 +359,8 @@ test('modal window path hides visible main overlay until modal closes', () => {
|
|||||||
|
|
||||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||||
|
|
||||||
assert.equal(mainWindow.getShowCount(), 0);
|
assert.equal(mainWindow.getShowCount(), 1);
|
||||||
assert.equal(mainWindow.isVisible(), false);
|
assert.equal(mainWindow.isVisible(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
||||||
@@ -497,7 +437,7 @@ test('notifyOverlayModalOpened enables input on visible main overlay window when
|
|||||||
assert.equal(mainWindow.webContentsFocused, true);
|
assert.equal(mainWindow.webContentsFocused, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleOverlayModalClosed is a no-op when no modal window can be targeted', () => {
|
test('handleOverlayModalClosed resets modal state even when modal window does not exist', () => {
|
||||||
const state: boolean[] = [];
|
const state: boolean[] = [];
|
||||||
const runtime = createOverlayModalRuntimeService(
|
const runtime = createOverlayModalRuntimeService(
|
||||||
{
|
{
|
||||||
@@ -514,17 +454,16 @@ test('handleOverlayModalClosed is a no-op when no modal window can be targeted',
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||||
restoreOnModalClose: 'runtime-options',
|
restoreOnModalClose: 'runtime-options',
|
||||||
});
|
});
|
||||||
assert.equal(sent, false);
|
|
||||||
runtime.notifyOverlayModalOpened('runtime-options');
|
runtime.notifyOverlayModalOpened('runtime-options');
|
||||||
runtime.handleOverlayModalClosed('runtime-options');
|
runtime.handleOverlayModalClosed('runtime-options');
|
||||||
|
|
||||||
assert.deepEqual(state, []);
|
assert.deepEqual(state, [true, false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleOverlayModalClosed destroys modal window for single kiku modal', () => {
|
test('handleOverlayModalClosed hides modal window for single kiku modal', () => {
|
||||||
const window = createMockWindow();
|
const window = createMockWindow();
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const runtime = createOverlayModalRuntimeService({
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
@@ -543,11 +482,11 @@ test('handleOverlayModalClosed destroys modal window for single kiku modal', ()
|
|||||||
);
|
);
|
||||||
runtime.handleOverlayModalClosed('kiku');
|
runtime.handleOverlayModalClosed('kiku');
|
||||||
|
|
||||||
assert.equal(window.isDestroyed(), true);
|
assert.equal(window.getHideCount(), 1);
|
||||||
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
|
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('modal fallback reveal skips showing window when content is not ready', async () => {
|
test('modal fallback reveal keeps mouse events ignored until modal confirms open', async () => {
|
||||||
const window = createMockWindow();
|
const window = createMockWindow();
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const runtime = createOverlayModalRuntimeService({
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
@@ -561,164 +500,30 @@ test('modal fallback reveal skips showing window when content is not ready', asy
|
|||||||
|
|
||||||
window.loading = true;
|
window.loading = true;
|
||||||
window.url = '';
|
window.url = '';
|
||||||
window.contentReady = false;
|
|
||||||
|
|
||||||
const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, {
|
const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, {
|
||||||
restoreOnModalClose: 'jimaku',
|
restoreOnModalClose: 'jimaku',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(sent, true);
|
assert.equal(sent, true);
|
||||||
|
assert.equal(window.ignoreMouseEvents, false);
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
setTimeout(resolve, 260);
|
setTimeout(resolve, 260);
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(window.getShowCount(), 0);
|
|
||||||
|
|
||||||
runtime.notifyOverlayModalOpened('jimaku');
|
|
||||||
assert.equal(window.getShowCount(), 1);
|
assert.equal(window.getShowCount(), 1);
|
||||||
assert.equal(window.ignoreMouseEvents, false);
|
assert.equal(window.ignoreMouseEvents, false);
|
||||||
});
|
|
||||||
|
|
||||||
test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => {
|
runtime.notifyOverlayModalOpened('jimaku');
|
||||||
const window = createMockWindow();
|
|
||||||
window.contentReady = false;
|
|
||||||
const runtime = createOverlayModalRuntimeService({
|
|
||||||
getMainWindow: () => null,
|
|
||||||
getModalWindow: () => window as never,
|
|
||||||
createModalWindow: () => {
|
|
||||||
throw new Error('modal window should not be created when already present');
|
|
||||||
},
|
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
|
||||||
setModalWindowBounds: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
|
||||||
restoreOnModalClose: 'runtime-options',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(sent, true);
|
|
||||||
assert.deepEqual(window.sent, []);
|
|
||||||
assert.equal(window.loadCallbacks.length, 1);
|
|
||||||
assert.equal(window.readyToShowCallbacks.length, 1);
|
|
||||||
|
|
||||||
window.loadCallbacks[0]!();
|
|
||||||
assert.deepEqual(window.sent, []);
|
|
||||||
|
|
||||||
window.contentReady = true;
|
|
||||||
window.readyToShowCallbacks[0]!();
|
|
||||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('modal reopen creates a fresh window after close destroys the previous one', () => {
|
|
||||||
const firstWindow = createMockWindow();
|
|
||||||
const secondWindow = createMockWindow();
|
|
||||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
|
||||||
|
|
||||||
const runtime = createOverlayModalRuntimeService({
|
|
||||||
getMainWindow: () => null,
|
|
||||||
getModalWindow: () =>
|
|
||||||
currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null,
|
|
||||||
createModalWindow: () => {
|
|
||||||
currentModal = secondWindow;
|
|
||||||
return secondWindow as never;
|
|
||||||
},
|
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
|
||||||
setModalWindowBounds: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
|
||||||
restoreOnModalClose: 'runtime-options',
|
|
||||||
});
|
|
||||||
runtime.notifyOverlayModalOpened('runtime-options');
|
|
||||||
runtime.handleOverlayModalClosed('runtime-options');
|
|
||||||
|
|
||||||
assert.equal(firstWindow.isDestroyed(), true);
|
|
||||||
|
|
||||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
|
||||||
restoreOnModalClose: 'runtime-options',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(sent, true);
|
|
||||||
assert.equal(currentModal, secondWindow);
|
|
||||||
assert.equal(secondWindow.getShowCount(), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => {
|
|
||||||
const firstWindow = createMockWindow();
|
|
||||||
const secondWindow = createMockWindow();
|
|
||||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
|
||||||
const state: boolean[] = [];
|
|
||||||
|
|
||||||
const runtime = createOverlayModalRuntimeService(
|
|
||||||
{
|
|
||||||
getMainWindow: () => null,
|
|
||||||
getModalWindow: () =>
|
|
||||||
currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null,
|
|
||||||
createModalWindow: () => {
|
|
||||||
currentModal = secondWindow;
|
|
||||||
return secondWindow as never;
|
|
||||||
},
|
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
|
||||||
setModalWindowBounds: () => {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onModalStateChange: (active: boolean): void => {
|
|
||||||
state.push(active);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
|
||||||
restoreOnModalClose: 'runtime-options',
|
|
||||||
});
|
|
||||||
runtime.notifyOverlayModalOpened('runtime-options');
|
|
||||||
runtime.handleOverlayModalClosed('runtime-options');
|
|
||||||
|
|
||||||
assert.deepEqual(state, [true, false]);
|
|
||||||
assert.equal(firstWindow.isDestroyed(), true);
|
|
||||||
|
|
||||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
|
||||||
restoreOnModalClose: 'runtime-options',
|
|
||||||
});
|
|
||||||
runtime.notifyOverlayModalOpened('runtime-options');
|
|
||||||
|
|
||||||
assert.deepEqual(state, [true, false, true]);
|
|
||||||
assert.equal(currentModal, secondWindow);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('visible stale modal window is made interactive again before reopening', () => {
|
|
||||||
const window = createMockWindow();
|
|
||||||
window.visible = true;
|
|
||||||
window.focused = true;
|
|
||||||
window.webContentsFocused = false;
|
|
||||||
window.ignoreMouseEvents = true;
|
|
||||||
|
|
||||||
const runtime = createOverlayModalRuntimeService({
|
|
||||||
getMainWindow: () => null,
|
|
||||||
getModalWindow: () => window as never,
|
|
||||||
createModalWindow: () => window as never,
|
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
|
||||||
setModalWindowBounds: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
|
||||||
restoreOnModalClose: 'runtime-options',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(sent, true);
|
|
||||||
assert.equal(window.ignoreMouseEvents, false);
|
assert.equal(window.ignoreMouseEvents, false);
|
||||||
assert.equal(window.isFocused(), true);
|
|
||||||
assert.equal(window.webContentsFocused, true);
|
|
||||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('waitForModalOpen resolves true after modal acknowledgement', async () => {
|
test('waitForModalOpen resolves true after modal acknowledgement', async () => {
|
||||||
const modalWindow = createMockWindow();
|
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const runtime = createOverlayModalRuntimeService({
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
getModalWindow: () => modalWindow as never,
|
getModalWindow: () => null,
|
||||||
createModalWindow: () => modalWindow as never,
|
createModalWindow: () => null,
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
setModalWindowBounds: () => {},
|
setModalWindowBounds: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,9 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
|
||||||
|
|
||||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||||
|
|
||||||
function requestOverlayApplicationFocus(): void {
|
|
||||||
try {
|
|
||||||
const electron = require('electron') as {
|
|
||||||
app?: {
|
|
||||||
focus?: (options?: { steal?: boolean }) => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
electron.app?.focus?.({ steal: true });
|
|
||||||
} catch {
|
|
||||||
// Ignore focus-steal failures in non-Electron test environments.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setWindowFocusable(window: BrowserWindow): void {
|
|
||||||
const maybeFocusableWindow = window as BrowserWindow & {
|
|
||||||
setFocusable?: (focusable: boolean) => void;
|
|
||||||
};
|
|
||||||
maybeFocusableWindow.setFocusable?.(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayWindowResolver {
|
export interface OverlayWindowResolver {
|
||||||
getMainWindow: () => BrowserWindow | null;
|
getMainWindow: () => BrowserWindow | null;
|
||||||
getModalWindow: () => BrowserWindow | null;
|
getModalWindow: () => BrowserWindow | null;
|
||||||
@@ -63,7 +42,6 @@ export function createOverlayModalRuntimeService(
|
|||||||
let modalActive = false;
|
let modalActive = false;
|
||||||
let mainWindowMousePassthroughForcedByModal = false;
|
let mainWindowMousePassthroughForcedByModal = false;
|
||||||
let mainWindowHiddenByModal = false;
|
let mainWindowHiddenByModal = false;
|
||||||
let modalWindowPrimedForImmediateShow = false;
|
|
||||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||||
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
|
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -109,21 +87,9 @@ export function createOverlayModalRuntimeService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isWindowReadyForIpc = (window: BrowserWindow): boolean => {
|
const isWindowReadyForIpc = (window: BrowserWindow): boolean => {
|
||||||
if (window.isDestroyed()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (window.webContents.isLoading()) {
|
if (window.webContents.isLoading()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const overlayWindow = window as BrowserWindow & {
|
|
||||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
|
||||||
};
|
|
||||||
if (
|
|
||||||
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
|
|
||||||
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const currentURL = window.webContents.getURL();
|
const currentURL = window.webContents.getURL();
|
||||||
return currentURL !== '' && currentURL !== 'about:blank';
|
return currentURL !== '' && currentURL !== 'about:blank';
|
||||||
};
|
};
|
||||||
@@ -143,17 +109,11 @@ export function createOverlayModalRuntimeService(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let delivered = false;
|
window.webContents.once('did-finish-load', () => {
|
||||||
const deliverWhenReady = (): void => {
|
if (!window.isDestroyed() && !window.webContents.isLoading()) {
|
||||||
if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
delivered = true;
|
|
||||||
sendNow(window);
|
sendNow(window);
|
||||||
};
|
}
|
||||||
|
});
|
||||||
window.webContents.once('did-finish-load', deliverWhenReady);
|
|
||||||
window.once('ready-to-show', deliverWhenReady);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showModalWindow = (
|
const showModalWindow = (
|
||||||
@@ -162,8 +122,6 @@ export function createOverlayModalRuntimeService(
|
|||||||
passThroughMouseEvents: boolean;
|
passThroughMouseEvents: boolean;
|
||||||
} = { passThroughMouseEvents: false },
|
} = { passThroughMouseEvents: false },
|
||||||
): void => {
|
): void => {
|
||||||
setWindowFocusable(window);
|
|
||||||
requestOverlayApplicationFocus();
|
|
||||||
if (!window.isVisible()) {
|
if (!window.isVisible()) {
|
||||||
window.show();
|
window.show();
|
||||||
}
|
}
|
||||||
@@ -180,14 +138,15 @@ export function createOverlayModalRuntimeService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||||
setWindowFocusable(window);
|
|
||||||
requestOverlayApplicationFocus();
|
|
||||||
window.setIgnoreMouseEvents(false);
|
|
||||||
elevateModalWindow(window);
|
|
||||||
|
|
||||||
if (window.isVisible()) {
|
if (window.isVisible()) {
|
||||||
|
window.setIgnoreMouseEvents(false);
|
||||||
|
if (!window.isFocused()) {
|
||||||
window.focus();
|
window.focus();
|
||||||
|
}
|
||||||
|
if (!window.webContents.isFocused()) {
|
||||||
window.webContents.focus();
|
window.webContents.focus();
|
||||||
|
}
|
||||||
|
elevateModalWindow(window);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,9 +231,6 @@ export function createOverlayModalRuntimeService(
|
|||||||
if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) {
|
if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isWindowReadyForIpc(targetWindow)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showModalWindow(targetWindow, { passThroughMouseEvents: false });
|
showModalWindow(targetWindow, { passThroughMouseEvents: false });
|
||||||
}, MODAL_REVEAL_FALLBACK_DELAY_MS);
|
}, MODAL_REVEAL_FALLBACK_DELAY_MS);
|
||||||
};
|
};
|
||||||
@@ -300,9 +256,9 @@ export function createOverlayModalRuntimeService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (restoreOnModalClose) {
|
if (restoreOnModalClose) {
|
||||||
|
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||||
const mainWindow = getTargetOverlayWindow();
|
const mainWindow = getTargetOverlayWindow();
|
||||||
if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
|
||||||
sendOrQueueForWindow(mainWindow, (window) => {
|
sendOrQueueForWindow(mainWindow, (window) => {
|
||||||
if (payload === undefined) {
|
if (payload === undefined) {
|
||||||
window.webContents.send(channel);
|
window.webContents.send(channel);
|
||||||
@@ -316,23 +272,15 @@ export function createOverlayModalRuntimeService(
|
|||||||
const modalWindow = resolveModalWindow();
|
const modalWindow = resolveModalWindow();
|
||||||
if (!modalWindow) return false;
|
if (!modalWindow) return false;
|
||||||
|
|
||||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
|
||||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
deps.setModalWindowBounds(deps.getModalGeometry());
|
||||||
const wasVisible = modalWindow.isVisible();
|
const wasVisible = modalWindow.isVisible();
|
||||||
if (!wasVisible) {
|
if (!wasVisible) {
|
||||||
if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
|
|
||||||
showModalWindow(modalWindow);
|
|
||||||
} else {
|
|
||||||
scheduleModalWindowReveal(modalWindow);
|
scheduleModalWindowReveal(modalWindow);
|
||||||
}
|
|
||||||
} else if (!modalWindow.isFocused()) {
|
} else if (!modalWindow.isFocused()) {
|
||||||
showModalWindow(modalWindow);
|
showModalWindow(modalWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendOrQueueForWindow(modalWindow, (window) => {
|
sendOrQueueForWindow(modalWindow, (window) => {
|
||||||
if (window.isVisible()) {
|
|
||||||
ensureModalWindowInteractive(window);
|
|
||||||
}
|
|
||||||
if (payload === undefined) {
|
if (payload === undefined) {
|
||||||
window.webContents.send(channel);
|
window.webContents.send(channel);
|
||||||
} else {
|
} else {
|
||||||
@@ -372,13 +320,12 @@ export function createOverlayModalRuntimeService(
|
|||||||
const modalWindow = deps.getModalWindow();
|
const modalWindow = deps.getModalWindow();
|
||||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||||
clearPendingModalWindowReveal();
|
clearPendingModalWindowReveal();
|
||||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
|
||||||
modalWindow.destroy();
|
|
||||||
}
|
|
||||||
modalWindowPrimedForImmediateShow = false;
|
|
||||||
mainWindowMousePassthroughForcedByModal = false;
|
|
||||||
mainWindowHiddenByModal = false;
|
|
||||||
notifyModalStateChange(false);
|
notifyModalStateChange(false);
|
||||||
|
setMainWindowMousePassthroughForModal(false);
|
||||||
|
setMainWindowVisibilityForModal(false);
|
||||||
|
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||||
|
modalWindow.hide();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -403,7 +350,14 @@ export function createOverlayModalRuntimeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetWindow.isVisible()) {
|
if (targetWindow.isVisible()) {
|
||||||
ensureModalWindowInteractive(targetWindow);
|
targetWindow.setIgnoreMouseEvents(false);
|
||||||
|
elevateModalWindow(targetWindow);
|
||||||
|
if (!targetWindow.isFocused()) {
|
||||||
|
targetWindow.focus();
|
||||||
|
}
|
||||||
|
if (!targetWindow.webContents.isFocused()) {
|
||||||
|
targetWindow.webContents.focus();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,10 @@ 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;
|
||||||
@@ -40,20 +36,12 @@ 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,
|
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
||||||
modalActive: deps.getModalActive(),
|
modalActive: deps.getModalActive(),
|
||||||
forceMousePassthrough,
|
forceMousePassthrough: deps.getForceMousePassthrough(),
|
||||||
mainWindow,
|
mainWindow: deps.getMainWindow(),
|
||||||
windowTracker,
|
windowTracker: deps.getWindowTracker(),
|
||||||
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);
|
||||||
@@ -61,8 +49,6 @@ 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(),
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => calls.push('clear-windows-visible-overlay-poll'),
|
|
||||||
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
|
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
|
||||||
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
|
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
|
||||||
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
||||||
@@ -41,10 +40,9 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
assert.equal(calls.length, 29);
|
assert.equal(calls.length, 28);
|
||||||
assert.equal(calls[0], 'destroy-tray');
|
assert.equal(calls[0], 'destroy-tray');
|
||||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
|
||||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
unregisterAllGlobalShortcuts: () => void;
|
unregisterAllGlobalShortcuts: () => void;
|
||||||
stopSubtitleWebsocket: () => void;
|
stopSubtitleWebsocket: () => void;
|
||||||
stopTexthookerService: () => void;
|
stopTexthookerService: () => void;
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
|
||||||
destroyMainOverlayWindow: () => void;
|
destroyMainOverlayWindow: () => void;
|
||||||
destroyModalOverlayWindow: () => void;
|
destroyModalOverlayWindow: () => void;
|
||||||
destroyYomitanParserWindow: () => void;
|
destroyYomitanParserWindow: () => void;
|
||||||
@@ -37,7 +36,6 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.unregisterAllGlobalShortcuts();
|
deps.unregisterAllGlobalShortcuts();
|
||||||
deps.stopSubtitleWebsocket();
|
deps.stopSubtitleWebsocket();
|
||||||
deps.stopTexthookerService();
|
deps.stopTexthookerService();
|
||||||
deps.clearWindowsVisibleOverlayForegroundPollLoop();
|
|
||||||
deps.destroyMainOverlayWindow();
|
deps.destroyMainOverlayWindow();
|
||||||
deps.destroyModalOverlayWindow();
|
deps.destroyModalOverlayWindow();
|
||||||
deps.destroyYomitanParserWindow();
|
deps.destroyYomitanParserWindow();
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
|
||||||
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
|
|
||||||
getMainOverlayWindow: () => ({
|
getMainOverlayWindow: () => ({
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||||
@@ -87,7 +85,6 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||||
assert.ok(calls.includes('stop-discord-presence'));
|
assert.ok(calls.includes('stop-discord-presence'));
|
||||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
|
||||||
assert.equal(reconnectTimer, null);
|
assert.equal(reconnectTimer, null);
|
||||||
assert.equal(immersionTracker, null);
|
assert.equal(immersionTracker, null);
|
||||||
});
|
});
|
||||||
@@ -102,7 +99,6 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
|||||||
unregisterAllGlobalShortcuts: () => {},
|
unregisterAllGlobalShortcuts: () => {},
|
||||||
stopSubtitleWebsocket: () => {},
|
stopSubtitleWebsocket: () => {},
|
||||||
stopTexthookerService: () => {},
|
stopTexthookerService: () => {},
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
|
||||||
getMainOverlayWindow: () => ({
|
getMainOverlayWindow: () => ({
|
||||||
isDestroyed: () => true,
|
isDestroyed: () => true,
|
||||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
unregisterAllGlobalShortcuts: () => void;
|
unregisterAllGlobalShortcuts: () => void;
|
||||||
stopSubtitleWebsocket: () => void;
|
stopSubtitleWebsocket: () => void;
|
||||||
stopTexthookerService: () => void;
|
stopTexthookerService: () => void;
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
|
||||||
getMainOverlayWindow: () => DestroyableWindow | null;
|
getMainOverlayWindow: () => DestroyableWindow | null;
|
||||||
clearMainOverlayWindow: () => void;
|
clearMainOverlayWindow: () => void;
|
||||||
getModalOverlayWindow: () => DestroyableWindow | null;
|
getModalOverlayWindow: () => DestroyableWindow | null;
|
||||||
@@ -65,8 +64,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
|
||||||
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
|
|
||||||
destroyMainOverlayWindow: () => {
|
destroyMainOverlayWindow: () => {
|
||||||
const window = deps.getMainOverlayWindow();
|
const window = deps.getMainOverlayWindow();
|
||||||
if (!window) return;
|
if (!window) return;
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ 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'),
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ 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'];
|
||||||
@@ -78,7 +77,6 @@ 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,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ 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',
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ 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',
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ 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;
|
||||||
@@ -104,7 +103,6 @@ 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(),
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ 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: () => {},
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ 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'];
|
||||||
@@ -90,7 +89,6 @@ 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,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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: () => {},
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ 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,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user