mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Compare commits
16 Commits
v0.11.2
...
windows-qo
| Author | SHA1 | Date | |
|---|---|---|---|
| 87fbe6c002 | |||
| e06f12634f | |||
| 48f74db239 | |||
| fd6dea9d33 | |||
| 0cdd79da9a | |||
| 3e7573c9fc | |||
| 20a0efe572 | |||
| 7698258f61 | |||
| ac25213255 | |||
| a5dbe055fc | |||
| 04742b1806 | |||
| f0e15c5dc4 | |||
| 9145c730b5 | |||
| cf86817cd8 | |||
| 3f7de73734 | |||
| de9b887798 |
389
.github/workflows/prerelease.yml
vendored
Normal file
389
.github/workflows/prerelease.yml
vendored
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
name: Prerelease
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*-beta.*'
|
||||||
|
- 'v*-rc.*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: prerelease-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.5
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
stats/node_modules
|
||||||
|
vendor/subminer-yomitan/node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
cd stats && bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint stats (formatting)
|
||||||
|
run: bun run lint:stats
|
||||||
|
|
||||||
|
- name: Build (TypeScript check)
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Test suite (source)
|
||||||
|
run: bun run test:fast
|
||||||
|
|
||||||
|
- name: Coverage suite (maintained source lane)
|
||||||
|
run: bun run test:coverage:src
|
||||||
|
|
||||||
|
- name: Upload coverage artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: coverage-test-src
|
||||||
|
path: coverage/test-src/lcov.info
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Launcher smoke suite (source)
|
||||||
|
run: bun run test:launcher:smoke:src
|
||||||
|
|
||||||
|
- name: Upload launcher smoke artifacts (on failure)
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: launcher-smoke
|
||||||
|
path: .tmp/launcher-smoke/**
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
- name: Build (bundle)
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Immersion SQLite verification
|
||||||
|
run: bun run test:immersion:sqlite:dist
|
||||||
|
|
||||||
|
- name: Dist smoke suite
|
||||||
|
run: bun run test:smoke:dist
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
needs: [quality-gate]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.5
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
stats/node_modules
|
||||||
|
vendor/texthooker-ui/node_modules
|
||||||
|
vendor/subminer-yomitan/node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
cd stats && bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build texthooker-ui
|
||||||
|
run: |
|
||||||
|
cd vendor/texthooker-ui
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
- name: Build AppImage
|
||||||
|
run: bun run build:appimage
|
||||||
|
|
||||||
|
- name: Build unversioned AppImage
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
appimages=(release/SubMiner-*.AppImage)
|
||||||
|
if [ "${#appimages[@]}" -eq 0 ]; then
|
||||||
|
echo "No versioned AppImage found to create unversioned artifact."
|
||||||
|
ls -la release
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "${appimages[0]}" release/SubMiner.AppImage
|
||||||
|
|
||||||
|
- name: Upload AppImage artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: appimage
|
||||||
|
path: release/*.AppImage
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
needs: [quality-gate]
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.5
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
stats/node_modules
|
||||||
|
vendor/texthooker-ui/node_modules
|
||||||
|
vendor/subminer-yomitan/node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Validate macOS signing/notarization secrets
|
||||||
|
run: |
|
||||||
|
missing=0
|
||||||
|
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
|
||||||
|
if [ -z "${!name}" ]; then
|
||||||
|
echo "Missing required secret: $name"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$missing" -ne 0 ]; then
|
||||||
|
echo "Set all required macOS signing/notarization secrets and rerun."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
cd stats && bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build texthooker-ui
|
||||||
|
run: |
|
||||||
|
cd vendor/texthooker-ui
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
- name: Build signed + notarized macOS artifacts
|
||||||
|
run: bun run build:mac
|
||||||
|
env:
|
||||||
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Upload macOS artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos
|
||||||
|
path: |
|
||||||
|
release/*.dmg
|
||||||
|
release/*.zip
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
needs: [quality-gate]
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.5
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
stats/node_modules
|
||||||
|
vendor/texthooker-ui/node_modules
|
||||||
|
vendor/subminer-yomitan/node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
cd stats && bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build texthooker-ui
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
Set-Location vendor/texthooker-ui
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
- name: Build unsigned Windows artifacts
|
||||||
|
run: bun run build:win:unsigned
|
||||||
|
|
||||||
|
- name: Upload Windows artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: |
|
||||||
|
release/*.exe
|
||||||
|
release/*.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [build-linux, build-macos, build-windows]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Download AppImage
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: appimage
|
||||||
|
path: release
|
||||||
|
|
||||||
|
- name: Download macOS artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos
|
||||||
|
path: release
|
||||||
|
|
||||||
|
- name: Download Windows artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: release
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.5
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build Bun subminer wrapper
|
||||||
|
run: make build-launcher
|
||||||
|
|
||||||
|
- name: Verify Bun subminer wrapper
|
||||||
|
run: dist/launcher/subminer --help >/dev/null
|
||||||
|
|
||||||
|
- name: Enforce generated launcher workflow
|
||||||
|
run: bash scripts/verify-generated-launcher.sh
|
||||||
|
|
||||||
|
- name: Verify generated config examples
|
||||||
|
run: bun run verify:config-example
|
||||||
|
|
||||||
|
- name: Package optional assets bundle
|
||||||
|
run: |
|
||||||
|
tar -czf "release/subminer-assets.tar.gz" \
|
||||||
|
config.example.jsonc \
|
||||||
|
plugin/subminer \
|
||||||
|
plugin/subminer.conf \
|
||||||
|
assets/themes/subminer.rasi
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer)
|
||||||
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
|
echo "No release artifacts found for checksum generation."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sha256sum "${files[@]}" > release/SHA256SUMS.txt
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Generate prerelease notes from pending fragments
|
||||||
|
run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
- name: Publish Prerelease
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
|
||||||
|
gh release edit "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--draft=false \
|
||||||
|
--prerelease \
|
||||||
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--notes-file release/prerelease-notes.md
|
||||||
|
else
|
||||||
|
gh release create "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--latest=false \
|
||||||
|
--prerelease \
|
||||||
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--notes-file release/prerelease-notes.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
artifacts=(
|
||||||
|
release/*.AppImage
|
||||||
|
release/*.dmg
|
||||||
|
release/*.exe
|
||||||
|
release/*.zip
|
||||||
|
release/*.tar.gz
|
||||||
|
release/SHA256SUMS.txt
|
||||||
|
dist/launcher/subminer
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#artifacts[@]}" -eq 0 ]; then
|
||||||
|
echo "No release artifacts found for upload."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for asset in "${artifacts[@]}"; do
|
||||||
|
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
|
||||||
|
done
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -4,6 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
- '!v*-beta.*'
|
||||||
|
- '!v*-rc.*'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
id: TASK-285
|
||||||
|
title: Investigate inconsistent mpv y-t overlay toggle after menu toggle
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-04-07 22:55'
|
||||||
|
updated_date: '2026-04-07 22:55'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- overlay
|
||||||
|
- keyboard
|
||||||
|
- mpv
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- plugin/subminer/process.lua
|
||||||
|
- plugin/subminer/ui.lua
|
||||||
|
- src/renderer/handlers/keyboard.ts
|
||||||
|
- src/main/runtime/autoplay-ready-gate.ts
|
||||||
|
- src/core/services/overlay-window-input.ts
|
||||||
|
- backlog/tasks/task-248 - Fix-macOS-visible-overlay-toggle-getting-immediately-restored.md
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
User report: toggling the visible overlay with mpv `y-t` is inconsistent. After manually toggling through the `y-y` menu, `y-t` may allow one hide, but after toggling back on it can stop hiding the overlay again, forcing the user back into the menu path.
|
||||||
|
|
||||||
|
Initial assessment:
|
||||||
|
|
||||||
|
- no active backlog item currently tracks this exact report
|
||||||
|
- nearest prior work is `TASK-248`, which fixed a macOS-specific visible-overlay restore bug and is marked done
|
||||||
|
- current targeted regressions for the old fix surface pass, including plugin ready-signal suppression, focused-overlay `y-t` proxy dispatch, autoplay-ready gate deduplication, and blur-path restacking guards
|
||||||
|
|
||||||
|
This should be treated as a fresh investigation unless reproduction proves it is the same closed macOS issue resurfacing on the current build.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Reproduce the reported `y-t` / `y-y` inconsistency on the affected platform and identify the exact event sequence
|
||||||
|
- [ ] #2 Determine whether the failure is in mpv plugin command dispatch, focused-overlay key forwarding, or main-process visible-overlay state transitions
|
||||||
|
- [ ] #3 Fix the inconsistency so repeated hide/show/hide cycles work from `y-t` without requiring menu recovery
|
||||||
|
- [ ] #4 Add regression coverage for the reproduced failing sequence
|
||||||
|
- [ ] #5 Record whether this is a regression of `TASK-248` or a distinct bug
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Reproduce the report with platform/build details and capture whether the failing `y-t` press originates in raw mpv or the focused overlay y-chord proxy path.
|
||||||
|
2. Trace visible-overlay state mutations across plugin toggle commands, autoplay-ready callbacks, and main-process visibility/window blur handling.
|
||||||
|
3. Patch the narrowest failing path and add regression coverage for the exact hide/show/hide sequence.
|
||||||
|
4. Re-run targeted plugin, overlay visibility, overlay window, and renderer keyboard suites before broader verification.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
3
bun.lock
3
bun.lock
@@ -12,6 +12,7 @@
|
|||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"hono": "^4.12.7",
|
"hono": "^4.12.7",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
|
"koffi": "^2.15.6",
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.22",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
},
|
},
|
||||||
@@ -478,6 +479,8 @@
|
|||||||
|
|
||||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
|
|
||||||
|
"koffi": ["koffi@2.15.6", "", {}, "sha512-WQBpM5uo74UQ17UpsFN+PUOrQQg4/nYdey4SGVluQun2drYYfePziLLWdSmFb4wSdWlJC1aimXQnjhPCheRKuw=="],
|
||||||
|
|
||||||
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
|
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
|
||||||
|
|
||||||
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
|
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
|
||||||
|
|||||||
5
changes/2026-04-09-prerelease-workflow.md
Normal file
5
changes/2026-04-09-prerelease-workflow.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: internal
|
||||||
|
area: release
|
||||||
|
|
||||||
|
- Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
|
||||||
|
- Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.
|
||||||
@@ -30,3 +30,9 @@ Rules:
|
|||||||
- each non-empty body line becomes a bullet
|
- each non-empty body line becomes a bullet
|
||||||
- `README.md` is ignored by the generator
|
- `README.md` is ignored by the generator
|
||||||
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
|
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
|
||||||
|
|
||||||
|
Prerelease notes:
|
||||||
|
|
||||||
|
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
||||||
|
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
||||||
|
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
|
||||||
|
|||||||
4
changes/fix-overlay-subtitle-drop-routing.md
Normal file
4
changes/fix-overlay-subtitle-drop-routing.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.
|
||||||
4
changes/fix-windows-coderabbit-review-follow-ups.md
Normal file
4
changes/fix-windows-coderabbit-review-follow-ups.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Addressed the latest CodeRabbit follow-ups on the Windows overlay flow, including exact mpv target resolution, lower-overlay helper arguments, Win32 failure detection, and overlay cleanup on tracker loss.
|
||||||
11
changes/fix-windows-overlay-z-order.md
Normal file
11
changes/fix-windows-overlay-z-order.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus.
|
||||||
|
- Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably.
|
||||||
|
- Fixed Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click.
|
||||||
|
- Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open.
|
||||||
|
- Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line.
|
||||||
|
- Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears.
|
||||||
|
- Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles.
|
||||||
|
- Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area.
|
||||||
4
changes/fix-windows-secondary-hover-titlebar.md
Normal file
4
changes/fix-windows-secondary-hover-titlebar.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed Windows secondary subtitle hover mode so the expanded hover hit area no longer blocks the native minimize, maximize, and close buttons.
|
||||||
4
changes/fix-yomitan-nested-popup-focus.md
Normal file
4
changes/fix-yomitan-nested-popup-focus.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
# Releasing
|
# Releasing
|
||||||
|
|
||||||
|
## Stable Release
|
||||||
|
|
||||||
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
|
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
|
||||||
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
|
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
|
||||||
3. Run `bun run changelog:lint`.
|
3. Run `bun run changelog:lint`.
|
||||||
@@ -24,15 +26,37 @@
|
|||||||
10. Tag the commit: `git tag v<version>`.
|
10. Tag the commit: `git tag v<version>`.
|
||||||
11. Push commit + tag.
|
11. Push commit + tag.
|
||||||
|
|
||||||
|
## Prerelease
|
||||||
|
|
||||||
|
1. Confirm release-facing docs and pending `changes/*.md` fragments are current.
|
||||||
|
2. Run `bun run changelog:lint`.
|
||||||
|
3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`.
|
||||||
|
4. Run the prerelease gate locally:
|
||||||
|
`bun run changelog:prerelease-notes --version <version>`
|
||||||
|
`bun run verify:config-example`
|
||||||
|
`bun run typecheck`
|
||||||
|
`bun run test:fast`
|
||||||
|
`bun run test:env`
|
||||||
|
`bun run build`
|
||||||
|
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
|
||||||
|
6. Tag the commit: `git tag v<version>`.
|
||||||
|
7. Push commit + tag.
|
||||||
|
|
||||||
|
Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`.
|
- Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`.
|
||||||
|
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
||||||
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
||||||
- `changelog:check` now rejects tag/package version mismatches.
|
- `changelog:check` now rejects tag/package version mismatches.
|
||||||
|
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
|
||||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
|
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
|
||||||
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
|
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
|
||||||
- Do not tag while `changes/*.md` fragments still exist.
|
- Do not tag while `changes/*.md` fragments still exist.
|
||||||
|
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
|
||||||
- If you need to repair a published release body (for example, a prior 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.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.11.2",
|
"version": "0.12.0-beta.1",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"changelog:lint": "bun run scripts/build-changelog.ts lint",
|
"changelog:lint": "bun run scripts/build-changelog.ts lint",
|
||||||
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
|
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
|
||||||
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
|
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
|
||||||
|
"changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"format:src": "bash scripts/prettier-scope.sh --write",
|
"format:src": "bash scripts/prettier-scope.sh --write",
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
"test:launcher": "bun run test:launcher:src",
|
"test:launcher": "bun run test:launcher:src",
|
||||||
"test:core": "bun run test:core:src",
|
"test:core": "bun run test:core:src",
|
||||||
"test:subtitle": "bun run test:subtitle:src",
|
"test:subtitle": "bun run test:subtitle:src",
|
||||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
@@ -112,6 +113,7 @@
|
|||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"hono": "^4.12.7",
|
"hono": "^4.12.7",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
|
"koffi": "^2.15.6",
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.22",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function M.init()
|
|||||||
local utils = require("mp.utils")
|
local utils = require("mp.utils")
|
||||||
|
|
||||||
local options_helper = require("options")
|
local options_helper = require("options")
|
||||||
local environment = require("environment").create({ mp = mp })
|
local environment = require("environment").create({ mp = mp, utils = utils })
|
||||||
local opts = options_helper.load(options_lib, environment.default_socket_path())
|
local opts = options_helper.load(options_lib, environment.default_socket_path())
|
||||||
local state = require("state").new()
|
local state = require("state").new()
|
||||||
|
|
||||||
@@ -61,6 +61,9 @@ function M.init()
|
|||||||
ctx.process = make_lazy_proxy("process", function()
|
ctx.process = make_lazy_proxy("process", function()
|
||||||
return require("process").create(ctx)
|
return require("process").create(ctx)
|
||||||
end)
|
end)
|
||||||
|
ctx.session_bindings = make_lazy_proxy("session_bindings", function()
|
||||||
|
return require("session_bindings").create(ctx)
|
||||||
|
end)
|
||||||
ctx.ui = make_lazy_proxy("ui", function()
|
ctx.ui = make_lazy_proxy("ui", function()
|
||||||
return require("ui").create(ctx)
|
return require("ui").create(ctx)
|
||||||
end)
|
end)
|
||||||
@@ -72,6 +75,7 @@ function M.init()
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
ctx.ui.register_keybindings()
|
ctx.ui.register_keybindings()
|
||||||
|
ctx.session_bindings.register_bindings()
|
||||||
ctx.messages.register_script_messages()
|
ctx.messages.register_script_messages()
|
||||||
ctx.lifecycle.register_lifecycle_hooks()
|
ctx.lifecycle.register_lifecycle_hooks()
|
||||||
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")
|
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
local M = {}
|
local M = {}
|
||||||
|
local unpack_fn = table.unpack or unpack
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
|
local utils = ctx.utils
|
||||||
|
|
||||||
local detected_backend = nil
|
local detected_backend = nil
|
||||||
local app_running_cache_value = nil
|
local app_running_cache_value = nil
|
||||||
@@ -30,6 +32,57 @@ function M.create(ctx)
|
|||||||
return "/tmp/subminer-socket"
|
return "/tmp/subminer-socket"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function path_separator()
|
||||||
|
return is_windows() and "\\" or "/"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function join_path(...)
|
||||||
|
local parts = { ... }
|
||||||
|
if utils and type(utils.join_path) == "function" then
|
||||||
|
return utils.join_path(unpack_fn(parts))
|
||||||
|
end
|
||||||
|
return table.concat(parts, path_separator())
|
||||||
|
end
|
||||||
|
|
||||||
|
local function file_exists(path)
|
||||||
|
if not utils or type(utils.file_info) ~= "function" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return utils.file_info(path) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_subminer_config_dir()
|
||||||
|
local home = os.getenv("HOME") or os.getenv("USERPROFILE") or ""
|
||||||
|
local candidates = {}
|
||||||
|
if is_windows() then
|
||||||
|
local app_data = os.getenv("APPDATA") or join_path(home, "AppData", "Roaming")
|
||||||
|
candidates = {
|
||||||
|
join_path(app_data, "SubMiner"),
|
||||||
|
}
|
||||||
|
else
|
||||||
|
local xdg_config_home = os.getenv("XDG_CONFIG_HOME")
|
||||||
|
local primary_base = (type(xdg_config_home) == "string" and xdg_config_home ~= "")
|
||||||
|
and xdg_config_home
|
||||||
|
or join_path(home, ".config")
|
||||||
|
candidates = {
|
||||||
|
join_path(primary_base, "SubMiner"),
|
||||||
|
join_path(home, ".config", "SubMiner"),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, dir in ipairs(candidates) do
|
||||||
|
if file_exists(join_path(dir, "config.jsonc")) or file_exists(join_path(dir, "config.json")) or file_exists(dir) then
|
||||||
|
return dir
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return candidates[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_session_bindings_artifact_path()
|
||||||
|
return join_path(resolve_subminer_config_dir(), "session-bindings.json")
|
||||||
|
end
|
||||||
|
|
||||||
local function is_linux()
|
local function is_linux()
|
||||||
return not is_windows() and not is_macos()
|
return not is_windows() and not is_macos()
|
||||||
end
|
end
|
||||||
@@ -198,7 +251,10 @@ function M.create(ctx)
|
|||||||
is_windows = is_windows,
|
is_windows = is_windows,
|
||||||
is_macos = is_macos,
|
is_macos = is_macos,
|
||||||
is_linux = is_linux,
|
is_linux = is_linux,
|
||||||
|
join_path = join_path,
|
||||||
default_socket_path = default_socket_path,
|
default_socket_path = default_socket_path,
|
||||||
|
resolve_subminer_config_dir = resolve_subminer_config_dir,
|
||||||
|
resolve_session_bindings_artifact_path = resolve_session_bindings_artifact_path,
|
||||||
is_subminer_process_running = is_subminer_process_running,
|
is_subminer_process_running = is_subminer_process_running,
|
||||||
is_subminer_app_running = is_subminer_app_running,
|
is_subminer_app_running = is_subminer_app_running,
|
||||||
is_subminer_app_running_async = is_subminer_app_running_async,
|
is_subminer_app_running_async = is_subminer_app_running_async,
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ function M.create(ctx)
|
|||||||
mp.register_script_message("subminer-stats-toggle", function()
|
mp.register_script_message("subminer-stats-toggle", function()
|
||||||
mp.osd_message("Stats: press ` (backtick) in overlay", 3)
|
mp.osd_message("Stats: press ` (backtick) in overlay", 3)
|
||||||
end)
|
end)
|
||||||
|
mp.register_script_message("subminer-reload-session-bindings", function()
|
||||||
|
ctx.session_bindings.reload_bindings()
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
316
plugin/subminer/session_bindings.lua
Normal file
316
plugin/subminer/session_bindings.lua
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local unpack_fn = table.unpack or unpack
|
||||||
|
|
||||||
|
local KEY_NAME_MAP = {
|
||||||
|
Space = "SPACE",
|
||||||
|
Tab = "TAB",
|
||||||
|
Enter = "ENTER",
|
||||||
|
Escape = "ESC",
|
||||||
|
Backspace = "BS",
|
||||||
|
Delete = "DEL",
|
||||||
|
ArrowUp = "UP",
|
||||||
|
ArrowDown = "DOWN",
|
||||||
|
ArrowLeft = "LEFT",
|
||||||
|
ArrowRight = "RIGHT",
|
||||||
|
Slash = "/",
|
||||||
|
Backslash = "\\",
|
||||||
|
Minus = "-",
|
||||||
|
Equal = "=",
|
||||||
|
Comma = ",",
|
||||||
|
Period = ".",
|
||||||
|
Quote = "'",
|
||||||
|
Semicolon = ";",
|
||||||
|
BracketLeft = "[",
|
||||||
|
BracketRight = "]",
|
||||||
|
Backquote = "`",
|
||||||
|
}
|
||||||
|
|
||||||
|
local MODIFIER_MAP = {
|
||||||
|
ctrl = "Ctrl",
|
||||||
|
alt = "Alt",
|
||||||
|
shift = "Shift",
|
||||||
|
meta = "Meta",
|
||||||
|
}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local utils = ctx.utils
|
||||||
|
local state = ctx.state
|
||||||
|
local process = ctx.process
|
||||||
|
local environment = ctx.environment
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
|
local function read_file(path)
|
||||||
|
local handle = io.open(path, "r")
|
||||||
|
if not handle then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local content = handle:read("*a")
|
||||||
|
handle:close()
|
||||||
|
return content
|
||||||
|
end
|
||||||
|
|
||||||
|
local function remove_binding_names(names)
|
||||||
|
for _, name in ipairs(names) do
|
||||||
|
mp.remove_key_binding(name)
|
||||||
|
end
|
||||||
|
for index = #names, 1, -1 do
|
||||||
|
names[index] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function key_code_to_mpv_name(code)
|
||||||
|
if KEY_NAME_MAP[code] then
|
||||||
|
return KEY_NAME_MAP[code]
|
||||||
|
end
|
||||||
|
|
||||||
|
local letter = code:match("^Key([A-Z])$")
|
||||||
|
if letter then
|
||||||
|
return string.lower(letter)
|
||||||
|
end
|
||||||
|
|
||||||
|
local digit = code:match("^Digit([0-9])$")
|
||||||
|
if digit then
|
||||||
|
return digit
|
||||||
|
end
|
||||||
|
|
||||||
|
local function_key = code:match("^(F%d+)$")
|
||||||
|
if function_key then
|
||||||
|
return function_key
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function key_spec_to_mpv_binding(key)
|
||||||
|
if type(key) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local key_name = key_code_to_mpv_name(key.code)
|
||||||
|
if not key_name then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local parts = {}
|
||||||
|
for _, modifier in ipairs(key.modifiers or {}) do
|
||||||
|
local mapped = MODIFIER_MAP[modifier]
|
||||||
|
if mapped then
|
||||||
|
parts[#parts + 1] = mapped
|
||||||
|
end
|
||||||
|
end
|
||||||
|
parts[#parts + 1] = key_name
|
||||||
|
return table.concat(parts, "+")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_cli_args(action_id, payload)
|
||||||
|
if action_id == "toggleVisibleOverlay" then
|
||||||
|
return { "--toggle-visible-overlay" }
|
||||||
|
elseif action_id == "copySubtitle" then
|
||||||
|
return { "--copy-subtitle" }
|
||||||
|
elseif action_id == "copySubtitleMultiple" then
|
||||||
|
return { "--copy-subtitle-count", tostring(payload and payload.count or 1) }
|
||||||
|
elseif action_id == "updateLastCardFromClipboard" then
|
||||||
|
return { "--update-last-card-from-clipboard" }
|
||||||
|
elseif action_id == "triggerFieldGrouping" then
|
||||||
|
return { "--trigger-field-grouping" }
|
||||||
|
elseif action_id == "triggerSubsync" then
|
||||||
|
return { "--trigger-subsync" }
|
||||||
|
elseif action_id == "mineSentence" then
|
||||||
|
return { "--mine-sentence" }
|
||||||
|
elseif action_id == "mineSentenceMultiple" then
|
||||||
|
return { "--mine-sentence-count", tostring(payload and payload.count or 1) }
|
||||||
|
elseif action_id == "toggleSecondarySub" then
|
||||||
|
return { "--toggle-secondary-sub" }
|
||||||
|
elseif action_id == "markAudioCard" then
|
||||||
|
return { "--mark-audio-card" }
|
||||||
|
elseif action_id == "openRuntimeOptions" then
|
||||||
|
return { "--open-runtime-options" }
|
||||||
|
elseif action_id == "openJimaku" then
|
||||||
|
return { "--open-jimaku" }
|
||||||
|
elseif action_id == "openYoutubePicker" then
|
||||||
|
return { "--open-youtube-picker" }
|
||||||
|
elseif action_id == "openPlaylistBrowser" then
|
||||||
|
return { "--open-playlist-browser" }
|
||||||
|
elseif action_id == "replayCurrentSubtitle" then
|
||||||
|
return { "--replay-current-subtitle" }
|
||||||
|
elseif action_id == "playNextSubtitle" then
|
||||||
|
return { "--play-next-subtitle" }
|
||||||
|
elseif action_id == "shiftSubDelayPrevLine" then
|
||||||
|
return { "--shift-sub-delay-prev-line" }
|
||||||
|
elseif action_id == "shiftSubDelayNextLine" then
|
||||||
|
return { "--shift-sub-delay-next-line" }
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function invoke_cli_action(action_id, payload)
|
||||||
|
if not process.check_binary_available() then
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local cli_args = build_cli_args(action_id, payload)
|
||||||
|
if not cli_args then
|
||||||
|
subminer_log("warn", "session-bindings", "No CLI mapping for action: " .. tostring(action_id))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local args = { state.binary_path }
|
||||||
|
for _, arg in ipairs(cli_args) do
|
||||||
|
args[#args + 1] = arg
|
||||||
|
end
|
||||||
|
process.run_binary_command_async(args, function(ok, result, error)
|
||||||
|
if ok then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local reason = error or (result and result.stderr) or "unknown error"
|
||||||
|
subminer_log("warn", "session-bindings", "Session action failed: " .. tostring(reason))
|
||||||
|
show_osd("Session action failed")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_numeric_selection(show_cancelled)
|
||||||
|
if state.session_numeric_selection and state.session_numeric_selection.timeout then
|
||||||
|
state.session_numeric_selection.timeout:kill()
|
||||||
|
end
|
||||||
|
state.session_numeric_selection = nil
|
||||||
|
remove_binding_names(state.session_numeric_binding_names)
|
||||||
|
if show_cancelled then
|
||||||
|
show_osd("Cancelled")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start_numeric_selection(action_id, timeout_ms)
|
||||||
|
clear_numeric_selection(false)
|
||||||
|
for digit = 1, 9 do
|
||||||
|
local digit_string = tostring(digit)
|
||||||
|
local name = "subminer-session-digit-" .. digit_string
|
||||||
|
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name
|
||||||
|
mp.add_forced_key_binding(digit_string, name, function()
|
||||||
|
clear_numeric_selection(false)
|
||||||
|
invoke_cli_action(action_id, { count = digit })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] =
|
||||||
|
"subminer-session-digit-cancel"
|
||||||
|
mp.add_forced_key_binding("ESC", "subminer-session-digit-cancel", function()
|
||||||
|
clear_numeric_selection(true)
|
||||||
|
end)
|
||||||
|
|
||||||
|
state.session_numeric_selection = {
|
||||||
|
action_id = action_id,
|
||||||
|
timeout = mp.add_timeout((timeout_ms or 3000) / 1000, function()
|
||||||
|
clear_numeric_selection(false)
|
||||||
|
show_osd(action_id == "copySubtitleMultiple" and "Copy timeout" or "Mine timeout")
|
||||||
|
end),
|
||||||
|
}
|
||||||
|
|
||||||
|
show_osd(
|
||||||
|
action_id == "copySubtitleMultiple"
|
||||||
|
and "Copy how many lines? Press 1-9 (Esc to cancel)"
|
||||||
|
or "Mine how many lines? Press 1-9 (Esc to cancel)"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function execute_mpv_command(command)
|
||||||
|
if type(command) ~= "table" or command[1] == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mp.commandv(unpack_fn(command))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handle_binding(binding, numeric_selection_timeout_ms)
|
||||||
|
if binding.actionType == "mpv-command" then
|
||||||
|
execute_mpv_command(binding.command)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
|
||||||
|
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
invoke_cli_action(binding.actionId, binding.payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_artifact()
|
||||||
|
local artifact_path = environment.resolve_session_bindings_artifact_path()
|
||||||
|
local raw = read_file(artifact_path)
|
||||||
|
if not raw or raw == "" then
|
||||||
|
return nil, "Missing session binding artifact: " .. tostring(artifact_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
local parsed, parse_error = utils.parse_json(raw)
|
||||||
|
if not parsed then
|
||||||
|
return nil, "Failed to parse session binding artifact: " .. tostring(parse_error)
|
||||||
|
end
|
||||||
|
if type(parsed) ~= "table" or type(parsed.bindings) ~= "table" then
|
||||||
|
return nil, "Invalid session binding artifact"
|
||||||
|
end
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_bindings()
|
||||||
|
clear_numeric_selection(false)
|
||||||
|
remove_binding_names(state.session_binding_names)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_bindings()
|
||||||
|
local artifact, load_error = load_artifact()
|
||||||
|
if not artifact then
|
||||||
|
subminer_log("warn", "session-bindings", load_error)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
clear_numeric_selection(false)
|
||||||
|
|
||||||
|
local previous_binding_names = state.session_binding_names
|
||||||
|
local next_binding_names = {}
|
||||||
|
state.session_binding_names = next_binding_names
|
||||||
|
|
||||||
|
local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000
|
||||||
|
for index, binding in ipairs(artifact.bindings) do
|
||||||
|
local key_name = key_spec_to_mpv_binding(binding.key)
|
||||||
|
if key_name then
|
||||||
|
local name = "subminer-session-binding-" .. tostring(index)
|
||||||
|
next_binding_names[#next_binding_names + 1] = name
|
||||||
|
mp.add_forced_key_binding(key_name, name, function()
|
||||||
|
handle_binding(binding, timeout_ms)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"session-bindings",
|
||||||
|
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
remove_binding_names(previous_binding_names)
|
||||||
|
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"session-bindings",
|
||||||
|
"Registered " .. tostring(#next_binding_names) .. " shared session bindings"
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function reload_bindings()
|
||||||
|
return register_bindings()
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
register_bindings = register_bindings,
|
||||||
|
reload_bindings = reload_bindings,
|
||||||
|
clear_bindings = clear_bindings,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -33,6 +33,9 @@ function M.new()
|
|||||||
auto_play_ready_timeout = nil,
|
auto_play_ready_timeout = nil,
|
||||||
auto_play_ready_osd_timer = nil,
|
auto_play_ready_osd_timer = nil,
|
||||||
suppress_ready_overlay_restore = false,
|
suppress_ready_overlay_restore = false,
|
||||||
|
session_binding_names = {},
|
||||||
|
session_numeric_binding_names = {},
|
||||||
|
session_numeric_selection = nil,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -310,3 +310,186 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-beta-notes');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
const changelogPath = path.join(projectRoot, 'CHANGELOG.md');
|
||||||
|
const docsChangelogPath = path.join(projectRoot, 'docs-site', 'changelog.md');
|
||||||
|
const existingChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable release.\n';
|
||||||
|
const existingDocsChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable docs release.\n';
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(changelogPath, existingChangelog, 'utf8');
|
||||||
|
fs.writeFileSync(docsChangelogPath, existingDocsChangelog, 'utf8');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
['type: added', 'area: overlay', '', '- Added prerelease coverage.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '002.md'),
|
||||||
|
['type: fixed', 'area: launcher', '', '- Fixed prerelease packaging checks.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outputPath = writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-beta.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(changelogPath, 'utf8'),
|
||||||
|
existingChangelog,
|
||||||
|
'stable CHANGELOG.md should remain unchanged',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(docsChangelogPath, 'utf8'),
|
||||||
|
existingDocsChangelog,
|
||||||
|
'docs-site changelog should remain unchanged',
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true);
|
||||||
|
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true);
|
||||||
|
|
||||||
|
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||||
|
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
|
||||||
|
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Overlay: Added prerelease coverage\./);
|
||||||
|
assert.match(
|
||||||
|
prereleaseNotes,
|
||||||
|
/### Fixed\n- Launcher: Fixed prerelease packaging checks\./,
|
||||||
|
);
|
||||||
|
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-rc-notes');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-rc.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
['type: changed', 'area: release', '', '- Prepared release candidate notes.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outputPath = writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-rc.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||||
|
assert.match(
|
||||||
|
prereleaseNotes,
|
||||||
|
/## Highlights\n### Changed\n- Release: Prepared release candidate notes\./,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion rejects unsupported prerelease identifiers', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-alpha-reject');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-alpha.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
['type: added', 'area: overlay', '', '- Unsupported alpha prerelease.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-alpha.1',
|
||||||
|
}),
|
||||||
|
/Unsupported prerelease version \(0\.11\.3-alpha\.1\)/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion rejects mismatched package versions', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-version-mismatch');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
['type: added', 'area: overlay', '', '- Mismatched prerelease.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-beta.2',
|
||||||
|
}),
|
||||||
|
/package\.json version \(0\.11\.3-beta\.1\) does not match requested release version \(0\.11\.3-beta\.2\)/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion rejects empty prerelease note generation when no fragments exist', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-no-fragments');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.11.3-beta.1',
|
||||||
|
}),
|
||||||
|
/No changelog fragments found in changes\//,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type PullRequestChangelogOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
|
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
|
||||||
|
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
|
||||||
const CHANGELOG_HEADER = '# Changelog';
|
const CHANGELOG_HEADER = '# Changelog';
|
||||||
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
||||||
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
|
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
|
||||||
@@ -75,6 +76,10 @@ function resolveVersion(options: Pick<ChangelogOptions, 'cwd' | 'version' | 'dep
|
|||||||
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
|
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSupportedPrereleaseVersion(version: string): boolean {
|
||||||
|
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
|
||||||
|
}
|
||||||
|
|
||||||
function verifyRequestedVersionMatchesPackageVersion(
|
function verifyRequestedVersionMatchesPackageVersion(
|
||||||
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
||||||
): void {
|
): void {
|
||||||
@@ -314,8 +319,15 @@ export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[
|
|||||||
return [path.join(cwd, 'CHANGELOG.md')];
|
return [path.join(cwd, 'CHANGELOG.md')];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReleaseNotes(changes: string): string {
|
function renderReleaseNotes(
|
||||||
|
changes: string,
|
||||||
|
options?: {
|
||||||
|
disclaimer?: string;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
||||||
return [
|
return [
|
||||||
|
...prefix,
|
||||||
'## Highlights',
|
'## Highlights',
|
||||||
changes,
|
changes,
|
||||||
'',
|
'',
|
||||||
@@ -334,13 +346,21 @@ function renderReleaseNotes(changes: string): string {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string {
|
function writeReleaseNotesFile(
|
||||||
|
cwd: string,
|
||||||
|
changes: string,
|
||||||
|
deps?: ChangelogFsDeps,
|
||||||
|
options?: {
|
||||||
|
disclaimer?: string;
|
||||||
|
outputPath?: string;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||||
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
||||||
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
|
const releaseNotesPath = path.join(cwd, options?.outputPath ?? RELEASE_NOTES_PATH);
|
||||||
|
|
||||||
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
|
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
|
||||||
writeFileSync(releaseNotesPath, renderReleaseNotes(changes), 'utf8');
|
writeFileSync(releaseNotesPath, renderReleaseNotes(changes, options), 'utf8');
|
||||||
return releaseNotesPath;
|
return releaseNotesPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +633,30 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string
|
|||||||
return writeReleaseNotesFile(cwd, changes, options?.deps);
|
return writeReleaseNotesFile(cwd, changes, options?.deps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||||
|
verifyRequestedVersionMatchesPackageVersion(options ?? {});
|
||||||
|
|
||||||
|
const cwd = options?.cwd ?? process.cwd();
|
||||||
|
const version = resolveVersion(options ?? {});
|
||||||
|
if (!isSupportedPrereleaseVersion(version)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragments = readChangeFragments(cwd, options?.deps);
|
||||||
|
if (fragments.length === 0) {
|
||||||
|
throw new Error('No changelog fragments found in changes/.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = renderGroupedChanges(fragments);
|
||||||
|
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
||||||
|
disclaimer:
|
||||||
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
|
outputPath: PRERELEASE_NOTES_PATH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function parseCliArgs(argv: string[]): {
|
function parseCliArgs(argv: string[]): {
|
||||||
baseRef?: string;
|
baseRef?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -710,6 +754,11 @@ function main(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === 'prerelease-notes') {
|
||||||
|
writePrereleaseNotesForVersion(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (command === 'docs') {
|
if (command === 'docs') {
|
||||||
generateDocsChangelog(options);
|
generateDocsChangelog(options);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
param(
|
param(
|
||||||
[ValidateSet('geometry')]
|
[ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner', 'target-hwnd')]
|
||||||
[string]$Mode = 'geometry',
|
[string]$Mode = 'geometry',
|
||||||
[string]$SocketPath
|
[string]$SocketPath,
|
||||||
|
[string]$OverlayWindowHandle
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
@@ -35,19 +36,126 @@ public static class SubMinerWindowsHelper {
|
|||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
public static extern IntPtr GetForegroundWindow();
|
public static extern IntPtr GetForegroundWindow();
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
public static extern bool SetWindowPos(
|
||||||
|
IntPtr hWnd,
|
||||||
|
IntPtr hWndInsertAfter,
|
||||||
|
int X,
|
||||||
|
int Y,
|
||||||
|
int cx,
|
||||||
|
int cy,
|
||||||
|
uint uFlags
|
||||||
|
);
|
||||||
|
|
||||||
[DllImport("user32.dll", SetLastError = true)]
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
||||||
|
|
||||||
[DllImport("user32.dll", SetLastError = true)]
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
public static extern void SetLastError(uint dwErrCode);
|
||||||
|
|
||||||
[DllImport("dwmapi.dll")]
|
[DllImport("dwmapi.dll")]
|
||||||
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
|
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
|
||||||
}
|
}
|
||||||
"@
|
"@
|
||||||
|
|
||||||
$DWMWA_EXTENDED_FRAME_BOUNDS = 9
|
$DWMWA_EXTENDED_FRAME_BOUNDS = 9
|
||||||
|
$SWP_NOSIZE = 0x0001
|
||||||
|
$SWP_NOMOVE = 0x0002
|
||||||
|
$SWP_NOACTIVATE = 0x0010
|
||||||
|
$SWP_NOOWNERZORDER = 0x0200
|
||||||
|
$SWP_FLAGS = $SWP_NOSIZE -bor $SWP_NOMOVE -bor $SWP_NOACTIVATE -bor $SWP_NOOWNERZORDER
|
||||||
|
$GWL_EXSTYLE = -20
|
||||||
|
$WS_EX_TOPMOST = 0x00000008
|
||||||
|
$GWLP_HWNDPARENT = -8
|
||||||
|
$HWND_TOP = [IntPtr]::Zero
|
||||||
|
$HWND_BOTTOM = [IntPtr]::One
|
||||||
|
$HWND_TOPMOST = [IntPtr](-1)
|
||||||
|
$HWND_NOTOPMOST = [IntPtr](-2)
|
||||||
|
|
||||||
|
function Assert-SetWindowLongPtrSucceeded {
|
||||||
|
param(
|
||||||
|
[IntPtr]$Result,
|
||||||
|
[string]$Operation
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Result -ne [IntPtr]::Zero) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([Runtime.InteropServices.Marshal]::GetLastWin32Error() -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
||||||
|
throw "$Operation failed ($lastError)"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-SetWindowPosSucceeded {
|
||||||
|
param(
|
||||||
|
[bool]$Result,
|
||||||
|
[string]$Operation
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Result) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
||||||
|
throw "$Operation failed ($lastError)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Mode -eq 'foreground-process') {
|
||||||
|
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
|
||||||
|
if ($foregroundWindow -eq [IntPtr]::Zero) {
|
||||||
|
Write-Output 'not-found'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
[uint32]$foregroundProcessId = 0
|
||||||
|
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($foregroundWindow, [ref]$foregroundProcessId)
|
||||||
|
if ($foregroundProcessId -eq 0) {
|
||||||
|
Write-Output 'not-found'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$foregroundProcess = Get-Process -Id $foregroundProcessId -ErrorAction Stop
|
||||||
|
} catch {
|
||||||
|
Write-Output 'not-found'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "process=$($foregroundProcess.ProcessName)"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Mode -eq 'clear-owner') {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
|
||||||
|
[Console]::Error.WriteLine('overlay-window-handle-required')
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
|
||||||
|
[SubMinerWindowsHelper]::SetLastError(0)
|
||||||
|
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, [IntPtr]::Zero)
|
||||||
|
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'clear-owner'
|
||||||
|
Write-Output 'ok'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
function Get-WindowBounds {
|
function Get-WindowBounds {
|
||||||
param([IntPtr]$hWnd)
|
param([IntPtr]$hWnd)
|
||||||
@@ -90,6 +198,7 @@ public static class SubMinerWindowsHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$mpvMatches = New-Object System.Collections.Generic.List[object]
|
$mpvMatches = New-Object System.Collections.Generic.List[object]
|
||||||
|
$targetWindowState = 'not-found'
|
||||||
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
|
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
|
||||||
$callback = [SubMinerWindowsHelper+EnumWindowsProc]{
|
$callback = [SubMinerWindowsHelper+EnumWindowsProc]{
|
||||||
param([IntPtr]$hWnd, [IntPtr]$lParam)
|
param([IntPtr]$hWnd, [IntPtr]$lParam)
|
||||||
@@ -98,10 +207,6 @@ public static class SubMinerWindowsHelper {
|
|||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
|
|
||||||
[uint32]$windowProcessId = 0
|
[uint32]$windowProcessId = 0
|
||||||
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId)
|
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId)
|
||||||
if ($windowProcessId -eq 0) {
|
if ($windowProcessId -eq 0) {
|
||||||
@@ -131,11 +236,22 @@ public static class SubMinerWindowsHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SocketPath) -and $targetWindowState -ne 'visible') {
|
||||||
|
$targetWindowState = 'minimized'
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
$bounds = Get-WindowBounds -hWnd $hWnd
|
$bounds = Get-WindowBounds -hWnd $hWnd
|
||||||
if ($null -eq $bounds) {
|
if ($null -eq $bounds) {
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||||
|
$targetWindowState = 'visible'
|
||||||
|
}
|
||||||
|
|
||||||
$mpvMatches.Add([PSCustomObject]@{
|
$mpvMatches.Add([PSCustomObject]@{
|
||||||
HWnd = $hWnd
|
HWnd = $hWnd
|
||||||
X = $bounds.X
|
X = $bounds.X
|
||||||
@@ -151,12 +267,45 @@ public static class SubMinerWindowsHelper {
|
|||||||
|
|
||||||
[void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero)
|
[void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero)
|
||||||
|
|
||||||
|
if ($Mode -eq 'lower-overlay') {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
|
||||||
|
[Console]::Error.WriteLine('overlay-window-handle-required')
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
|
||||||
|
|
||||||
|
[void][SubMinerWindowsHelper]::SetWindowPos(
|
||||||
|
$overlayWindow,
|
||||||
|
$HWND_NOTOPMOST,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
$SWP_FLAGS
|
||||||
|
)
|
||||||
|
[void][SubMinerWindowsHelper]::SetWindowPos(
|
||||||
|
$overlayWindow,
|
||||||
|
$HWND_BOTTOM,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
$SWP_FLAGS
|
||||||
|
)
|
||||||
|
Write-Output 'ok'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
$focusedMatch = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1
|
$focusedMatch = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1
|
||||||
if ($null -ne $focusedMatch) {
|
if ($null -ne $focusedMatch) {
|
||||||
[Console]::Error.WriteLine('focus=focused')
|
[Console]::Error.WriteLine('focus=focused')
|
||||||
} else {
|
} else {
|
||||||
[Console]::Error.WriteLine('focus=not-focused')
|
[Console]::Error.WriteLine('focus=not-focused')
|
||||||
}
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||||
|
[Console]::Error.WriteLine("state=$targetWindowState")
|
||||||
|
}
|
||||||
|
|
||||||
if ($mpvMatches.Count -eq 0) {
|
if ($mpvMatches.Count -eq 0) {
|
||||||
Write-Output 'not-found'
|
Write-Output 'not-found'
|
||||||
@@ -168,6 +317,83 @@ public static class SubMinerWindowsHelper {
|
|||||||
} else {
|
} else {
|
||||||
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1
|
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($Mode -eq 'target-hwnd') {
|
||||||
|
Write-Output "$($bestMatch.HWnd)"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Mode -eq 'set-owner') {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
|
||||||
|
[Console]::Error.WriteLine('overlay-window-handle-required')
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
|
||||||
|
$targetWindow = [IntPtr]$bestMatch.HWnd
|
||||||
|
[SubMinerWindowsHelper]::SetLastError(0)
|
||||||
|
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
|
||||||
|
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'set-owner'
|
||||||
|
Write-Output 'ok'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Mode -eq 'bind-overlay') {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
|
||||||
|
[Console]::Error.WriteLine('overlay-window-handle-required')
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
|
||||||
|
$targetWindow = [IntPtr]$bestMatch.HWnd
|
||||||
|
[SubMinerWindowsHelper]::SetLastError(0)
|
||||||
|
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
|
||||||
|
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'bind-overlay owner assignment'
|
||||||
|
$targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE)
|
||||||
|
$targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0
|
||||||
|
|
||||||
|
$overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE)
|
||||||
|
$overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0
|
||||||
|
if ($targetWindowIsTopmost -and -not $overlayIsTopmost) {
|
||||||
|
[SubMinerWindowsHelper]::SetLastError(0)
|
||||||
|
$result = [SubMinerWindowsHelper]::SetWindowPos(
|
||||||
|
$overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS
|
||||||
|
)
|
||||||
|
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay topmost adjustment'
|
||||||
|
} elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) {
|
||||||
|
[SubMinerWindowsHelper]::SetLastError(0)
|
||||||
|
$result = [SubMinerWindowsHelper]::SetWindowPos(
|
||||||
|
$overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS
|
||||||
|
)
|
||||||
|
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay notopmost adjustment'
|
||||||
|
}
|
||||||
|
|
||||||
|
$GW_HWNDPREV = 3
|
||||||
|
$windowAboveMpv = [SubMinerWindowsHelper]::GetWindow($targetWindow, $GW_HWNDPREV)
|
||||||
|
|
||||||
|
if ($windowAboveMpv -ne [IntPtr]::Zero -and $windowAboveMpv -eq $overlayWindow) {
|
||||||
|
Write-Output 'ok'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$insertAfter = $HWND_TOP
|
||||||
|
if ($windowAboveMpv -ne [IntPtr]::Zero) {
|
||||||
|
$aboveExStyle = [SubMinerWindowsHelper]::GetWindowLong($windowAboveMpv, $GWL_EXSTYLE)
|
||||||
|
$aboveIsTopmost = ($aboveExStyle -band $WS_EX_TOPMOST) -ne 0
|
||||||
|
if ($aboveIsTopmost -eq $targetWindowIsTopmost) {
|
||||||
|
$insertAfter = $windowAboveMpv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SubMinerWindowsHelper]::SetLastError(0)
|
||||||
|
$result = [SubMinerWindowsHelper]::SetWindowPos(
|
||||||
|
$overlayWindow, $insertAfter, 0, 0, 0, 0, $SWP_FLAGS
|
||||||
|
)
|
||||||
|
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay z-order adjustment'
|
||||||
|
Write-Output 'ok'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)"
|
Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)"
|
||||||
} catch {
|
} catch {
|
||||||
[Console]::Error.WriteLine($_.Exception.Message)
|
[Console]::Error.WriteLine($_.Exception.Message)
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ USAGE
|
|||||||
force=0
|
force=0
|
||||||
generate_webp=0
|
generate_webp=0
|
||||||
input=""
|
input=""
|
||||||
|
ffmpeg_bin="${FFMPEG_BIN:-ffmpeg}"
|
||||||
|
|
||||||
|
normalize_path() {
|
||||||
|
local value="$1"
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
case "$value" in
|
||||||
|
[A-Za-z]:\\* | [A-Za-z]:/*)
|
||||||
|
cygpath -u "$value"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
if [[ "$value" =~ ^([A-Za-z]):[\\/](.*)$ ]]; then
|
||||||
|
local drive="${BASH_REMATCH[1],,}"
|
||||||
|
local rest="${BASH_REMATCH[2]}"
|
||||||
|
rest="${rest//\\//}"
|
||||||
|
printf '/mnt/%s/%s\n' "$drive" "$rest"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -63,9 +84,19 @@ if [[ -z "$input" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v ffmpeg > /dev/null 2>&1; then
|
input="$(normalize_path "$input")"
|
||||||
echo "Error: ffmpeg is not installed or not in PATH." >&2
|
ffmpeg_bin="$(normalize_path "$ffmpeg_bin")"
|
||||||
exit 1
|
|
||||||
|
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
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$input" ]]; then
|
if [[ ! -f "$input" ]]; then
|
||||||
@@ -102,7 +133,7 @@ fi
|
|||||||
|
|
||||||
has_encoder() {
|
has_encoder() {
|
||||||
local encoder="$1"
|
local encoder="$1"
|
||||||
ffmpeg -hide_banner -encoders 2> /dev/null | awk -v encoder="$encoder" '$2 == encoder { found = 1 } END { exit(found ? 0 : 1) }'
|
"$ffmpeg_bin" -hide_banner -encoders 2> /dev/null | awk -v encoder="$encoder" '$2 == encoder { found = 1 } END { exit(found ? 0 : 1) }'
|
||||||
}
|
}
|
||||||
|
|
||||||
pick_webp_encoder() {
|
pick_webp_encoder() {
|
||||||
@@ -123,7 +154,7 @@ webm_vf="${crop_vf},fps=30"
|
|||||||
echo "Generating MP4: $mp4_out"
|
echo "Generating MP4: $mp4_out"
|
||||||
if has_encoder "h264_nvenc"; then
|
if has_encoder "h264_nvenc"; then
|
||||||
echo "Trying GPU encoder for MP4: h264_nvenc"
|
echo "Trying GPU encoder for MP4: h264_nvenc"
|
||||||
if ffmpeg "$overwrite_flag" -i "$input" \
|
if "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
|
||||||
-vf "$crop_vf" \
|
-vf "$crop_vf" \
|
||||||
-c:v h264_nvenc -preset p6 -rc:v vbr -cq:v 20 -b:v 0 \
|
-c:v h264_nvenc -preset p6 -rc:v vbr -cq:v 20 -b:v 0 \
|
||||||
-pix_fmt yuv420p -movflags +faststart \
|
-pix_fmt yuv420p -movflags +faststart \
|
||||||
@@ -132,7 +163,7 @@ if has_encoder "h264_nvenc"; then
|
|||||||
:
|
:
|
||||||
else
|
else
|
||||||
echo "GPU MP4 encode failed; retrying with CPU encoder: libx264"
|
echo "GPU MP4 encode failed; retrying with CPU encoder: libx264"
|
||||||
ffmpeg "$overwrite_flag" -i "$input" \
|
"$ffmpeg_bin" "$overwrite_flag" -i "$input" \
|
||||||
-vf "$crop_vf" \
|
-vf "$crop_vf" \
|
||||||
-c:v libx264 -preset slow -crf 20 \
|
-c:v libx264 -preset slow -crf 20 \
|
||||||
-profile:v high -level 4.1 -pix_fmt yuv420p \
|
-profile:v high -level 4.1 -pix_fmt yuv420p \
|
||||||
@@ -142,7 +173,7 @@ if has_encoder "h264_nvenc"; then
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Using CPU encoder for MP4: libx264"
|
echo "Using CPU encoder for MP4: libx264"
|
||||||
ffmpeg "$overwrite_flag" -i "$input" \
|
"$ffmpeg_bin" "$overwrite_flag" -i "$input" \
|
||||||
-vf "$crop_vf" \
|
-vf "$crop_vf" \
|
||||||
-c:v libx264 -preset slow -crf 20 \
|
-c:v libx264 -preset slow -crf 20 \
|
||||||
-profile:v high -level 4.1 -pix_fmt yuv420p \
|
-profile:v high -level 4.1 -pix_fmt yuv420p \
|
||||||
@@ -154,7 +185,7 @@ fi
|
|||||||
echo "Generating WebM: $webm_out"
|
echo "Generating WebM: $webm_out"
|
||||||
if has_encoder "av1_nvenc"; then
|
if has_encoder "av1_nvenc"; then
|
||||||
echo "Trying GPU encoder for WebM: av1_nvenc"
|
echo "Trying GPU encoder for WebM: av1_nvenc"
|
||||||
if ffmpeg "$overwrite_flag" -i "$input" \
|
if "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
|
||||||
-vf "$webm_vf" \
|
-vf "$webm_vf" \
|
||||||
-c:v av1_nvenc -preset p6 -cq:v 34 -b:v 0 \
|
-c:v av1_nvenc -preset p6 -cq:v 34 -b:v 0 \
|
||||||
-c:a libopus -b:a 96k \
|
-c:a libopus -b:a 96k \
|
||||||
@@ -162,7 +193,7 @@ if has_encoder "av1_nvenc"; then
|
|||||||
:
|
:
|
||||||
else
|
else
|
||||||
echo "GPU WebM encode failed; retrying with CPU encoder: libvpx-vp9"
|
echo "GPU WebM encode failed; retrying with CPU encoder: libvpx-vp9"
|
||||||
ffmpeg "$overwrite_flag" -i "$input" \
|
"$ffmpeg_bin" "$overwrite_flag" -i "$input" \
|
||||||
-vf "$webm_vf" \
|
-vf "$webm_vf" \
|
||||||
-c:v libvpx-vp9 -crf 34 -b:v 0 \
|
-c:v libvpx-vp9 -crf 34 -b:v 0 \
|
||||||
-row-mt 1 -threads 8 \
|
-row-mt 1 -threads 8 \
|
||||||
@@ -171,7 +202,7 @@ if has_encoder "av1_nvenc"; then
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Using CPU encoder for WebM: libvpx-vp9"
|
echo "Using CPU encoder for WebM: libvpx-vp9"
|
||||||
ffmpeg "$overwrite_flag" -i "$input" \
|
"$ffmpeg_bin" "$overwrite_flag" -i "$input" \
|
||||||
-vf "$webm_vf" \
|
-vf "$webm_vf" \
|
||||||
-c:v libvpx-vp9 -crf 34 -b:v 0 \
|
-c:v libvpx-vp9 -crf 34 -b:v 0 \
|
||||||
-row-mt 1 -threads 8 \
|
-row-mt 1 -threads 8 \
|
||||||
@@ -185,7 +216,7 @@ if [[ "$generate_webp" -eq 1 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Generating animated WebP with $webp_encoder: $webp_out"
|
echo "Generating animated WebP with $webp_encoder: $webp_out"
|
||||||
ffmpeg "$overwrite_flag" -i "$input" \
|
"$ffmpeg_bin" "$overwrite_flag" -i "$input" \
|
||||||
-vf "${crop_vf},fps=24,scale=960:-1:flags=lanczos" \
|
-vf "${crop_vf},fps=24,scale=960:-1:flags=lanczos" \
|
||||||
-c:v "$webp_encoder" \
|
-c:v "$webp_encoder" \
|
||||||
-q:v 80 \
|
-q:v 80 \
|
||||||
@@ -195,7 +226,7 @@ if [[ "$generate_webp" -eq 1 ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Generating poster: $poster_out"
|
echo "Generating poster: $poster_out"
|
||||||
ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \
|
"$ffmpeg_bin" "$overwrite_flag" -ss 00:00:05 -i "$input" \
|
||||||
-vf "$crop_vf" \
|
-vf "$crop_vf" \
|
||||||
-vframes 1 \
|
-vframes 1 \
|
||||||
-q:v 2 \
|
-q:v 2 \
|
||||||
|
|||||||
@@ -19,11 +19,33 @@ function writeExecutable(filePath: string, contents: string): void {
|
|||||||
fs.chmodSync(filePath, 0o755);
|
fs.chmodSync(filePath, 0o755);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBashPath(filePath: string): string {
|
||||||
|
if (process.platform !== 'win32') return filePath;
|
||||||
|
|
||||||
|
const normalized = filePath.replace(/\\/g, '/');
|
||||||
|
const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
||||||
|
if (!match) return normalized;
|
||||||
|
|
||||||
|
const drive = match[1]!;
|
||||||
|
const rest = match[2]!;
|
||||||
|
const probe = spawnSync('bash', ['-c', 'uname -s'], { encoding: 'utf8' });
|
||||||
|
if (probe.status === 0 && /linux/i.test(probe.stdout)) {
|
||||||
|
return `/mnt/${drive.toLowerCase()}/${rest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${drive.toUpperCase()}:/${rest}`;
|
||||||
|
}
|
||||||
|
|
||||||
test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => {
|
test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const binDir = path.join(root, 'bin');
|
const binDir = path.join(root, 'bin');
|
||||||
const inputPath = path.join(root, 'sample.mkv');
|
const inputPath = path.join(root, 'sample.mkv');
|
||||||
const ffmpegLogPath = path.join(root, 'ffmpeg-args.log');
|
const ffmpegLogPath = path.join(root, 'ffmpeg-args.log');
|
||||||
|
const ffmpegLogPathBash = toBashPath(ffmpegLogPath);
|
||||||
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
fs.writeFileSync(inputPath, 'fake-video', 'utf8');
|
fs.writeFileSync(inputPath, 'fake-video', 'utf8');
|
||||||
@@ -44,22 +66,33 @@ EOF
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '%s\\n' "$*" >> "${ffmpegLogPath}"
|
if [[ "$#" -eq 0 ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\\n' "$*" >> "${ffmpegLogPathBash}"
|
||||||
output=""
|
output=""
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
output="$arg"
|
output="$arg"
|
||||||
done
|
done
|
||||||
|
if [[ -z "$output" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
mkdir -p "$(dirname "$output")"
|
mkdir -p "$(dirname "$output")"
|
||||||
touch "$output"
|
touch "$output"
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = spawnSync('bash', ['scripts/mkv-to-readme-video.sh', '--webp', inputPath], {
|
const ffmpegShimPath = toBashPath(path.join(binDir, 'ffmpeg'));
|
||||||
|
const ffmpegShimDir = toBashPath(binDir);
|
||||||
|
const inputBashPath = toBashPath(inputPath);
|
||||||
|
const command = [
|
||||||
|
`chmod +x ${shellQuote(ffmpegShimPath)}`,
|
||||||
|
`PATH=${shellQuote(`${ffmpegShimDir}:`)}"$PATH"`,
|
||||||
|
`scripts/mkv-to-readme-video.sh --webp ${shellQuote(inputBashPath)}`,
|
||||||
|
].join('; ');
|
||||||
|
const result = spawnSync('bash', ['-lc', command], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PATH: `${binDir}:${process.env.PATH || ''}`,
|
|
||||||
},
|
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,26 @@ appimage=
|
|||||||
wrapper=
|
wrapper=
|
||||||
assets=
|
assets=
|
||||||
|
|
||||||
|
normalize_path() {
|
||||||
|
local value="$1"
|
||||||
|
if command -v cygpath >/dev/null 2>&1; then
|
||||||
|
case "$value" in
|
||||||
|
[A-Za-z]:\\* | [A-Za-z]:/*)
|
||||||
|
cygpath -u "$value"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
if [[ "$value" =~ ^([A-Za-z]):[\\/](.*)$ ]]; then
|
||||||
|
local drive="${BASH_REMATCH[1],,}"
|
||||||
|
local rest="${BASH_REMATCH[2]}"
|
||||||
|
rest="${rest//\\//}"
|
||||||
|
printf '/mnt/%s/%s\n' "$drive" "$rest"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--pkg-dir)
|
--pkg-dir)
|
||||||
@@ -53,6 +73,10 @@ if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
version="${version#v}"
|
version="${version#v}"
|
||||||
|
pkg_dir="$(normalize_path "$pkg_dir")"
|
||||||
|
appimage="$(normalize_path "$appimage")"
|
||||||
|
wrapper="$(normalize_path "$wrapper")"
|
||||||
|
assets="$(normalize_path "$assets")"
|
||||||
pkgbuild="${pkg_dir}/PKGBUILD"
|
pkgbuild="${pkg_dir}/PKGBUILD"
|
||||||
srcinfo="${pkg_dir}/.SRCINFO"
|
srcinfo="${pkg_dir}/.SRCINFO"
|
||||||
|
|
||||||
@@ -82,6 +106,9 @@ awk \
|
|||||||
found_pkgver = 0
|
found_pkgver = 0
|
||||||
found_sha_block = 0
|
found_sha_block = 0
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
sub(/\r$/, "")
|
||||||
|
}
|
||||||
/^pkgver=/ {
|
/^pkgver=/ {
|
||||||
print "pkgver=" version
|
print "pkgver=" version
|
||||||
found_pkgver = 1
|
found_pkgver = 1
|
||||||
@@ -140,6 +167,9 @@ awk \
|
|||||||
found_source_wrapper = 0
|
found_source_wrapper = 0
|
||||||
found_source_assets = 0
|
found_source_assets = 0
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
sub(/\r$/, "")
|
||||||
|
}
|
||||||
/^\tpkgver = / {
|
/^\tpkgver = / {
|
||||||
print "\tpkgver = " version
|
print "\tpkgver = " version
|
||||||
found_pkgver = 1
|
found_pkgver = 1
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -9,6 +10,23 @@ function createWorkspace(name: string): string {
|
|||||||
return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
|
return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toBashPath(filePath: string): string {
|
||||||
|
if (process.platform !== 'win32') return filePath;
|
||||||
|
|
||||||
|
const normalized = filePath.replace(/\\/g, '/');
|
||||||
|
const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
||||||
|
if (!match) return normalized;
|
||||||
|
|
||||||
|
const drive = match[1]!;
|
||||||
|
const rest = match[2]!;
|
||||||
|
const probe = spawnSync('bash', ['-c', 'uname -s'], { encoding: 'utf8' });
|
||||||
|
if (probe.status === 0 && /linux/i.test(probe.stdout)) {
|
||||||
|
return `/mnt/${drive.toLowerCase()}/${rest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${drive.toUpperCase()}:/${rest}`;
|
||||||
|
}
|
||||||
|
|
||||||
test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
||||||
const workspace = createWorkspace('subminer-aur-package');
|
const workspace = createWorkspace('subminer-aur-package');
|
||||||
const pkgDir = path.join(workspace, 'aur-subminer-bin');
|
const pkgDir = path.join(workspace, 'aur-subminer-bin');
|
||||||
@@ -29,15 +47,15 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
|||||||
[
|
[
|
||||||
'scripts/update-aur-package.sh',
|
'scripts/update-aur-package.sh',
|
||||||
'--pkg-dir',
|
'--pkg-dir',
|
||||||
pkgDir,
|
toBashPath(pkgDir),
|
||||||
'--version',
|
'--version',
|
||||||
'v0.6.3',
|
'v0.6.3',
|
||||||
'--appimage',
|
'--appimage',
|
||||||
appImagePath,
|
toBashPath(appImagePath),
|
||||||
'--wrapper',
|
'--wrapper',
|
||||||
wrapperPath,
|
toBashPath(wrapperPath),
|
||||||
'--assets',
|
'--assets',
|
||||||
assetsPath,
|
toBashPath(assetsPath),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
@@ -47,8 +65,8 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
|||||||
|
|
||||||
const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
|
const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
|
||||||
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
|
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
|
||||||
const expectedSums = [appImagePath, wrapperPath, assetsPath].map(
|
const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) =>
|
||||||
(filePath) => execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
|
crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);
|
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);
|
||||||
|
|||||||
@@ -73,6 +73,33 @@ test('parseArgs captures youtube startup forwarding flags', () => {
|
|||||||
assert.equal(shouldStartApp(args), true);
|
assert.equal(shouldStartApp(args), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs captures session action forwarding flags', () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
'--open-jimaku',
|
||||||
|
'--open-youtube-picker',
|
||||||
|
'--open-playlist-browser',
|
||||||
|
'--replay-current-subtitle',
|
||||||
|
'--play-next-subtitle',
|
||||||
|
'--shift-sub-delay-prev-line',
|
||||||
|
'--shift-sub-delay-next-line',
|
||||||
|
'--copy-subtitle-count',
|
||||||
|
'3',
|
||||||
|
'--mine-sentence-count=2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.openJimaku, true);
|
||||||
|
assert.equal(args.openYoutubePicker, true);
|
||||||
|
assert.equal(args.openPlaylistBrowser, true);
|
||||||
|
assert.equal(args.replayCurrentSubtitle, true);
|
||||||
|
assert.equal(args.playNextSubtitle, true);
|
||||||
|
assert.equal(args.shiftSubDelayPrevLine, true);
|
||||||
|
assert.equal(args.shiftSubDelayNextLine, true);
|
||||||
|
assert.equal(args.copySubtitleCount, 3);
|
||||||
|
assert.equal(args.mineSentenceCount, 2);
|
||||||
|
assert.equal(hasExplicitCommand(args), true);
|
||||||
|
assert.equal(shouldStartApp(args), true);
|
||||||
|
});
|
||||||
|
|
||||||
test('youtube playback does not use generic overlay-runtime bootstrap classification', () => {
|
test('youtube playback does not use generic overlay-runtime bootstrap classification', () => {
|
||||||
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
|
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ export interface CliArgs {
|
|||||||
triggerSubsync: boolean;
|
triggerSubsync: boolean;
|
||||||
markAudioCard: boolean;
|
markAudioCard: boolean;
|
||||||
openRuntimeOptions: boolean;
|
openRuntimeOptions: boolean;
|
||||||
|
openJimaku: boolean;
|
||||||
|
openYoutubePicker: boolean;
|
||||||
|
openPlaylistBrowser: boolean;
|
||||||
|
replayCurrentSubtitle: boolean;
|
||||||
|
playNextSubtitle: boolean;
|
||||||
|
shiftSubDelayPrevLine: boolean;
|
||||||
|
shiftSubDelayNextLine: boolean;
|
||||||
|
copySubtitleCount?: number;
|
||||||
|
mineSentenceCount?: number;
|
||||||
anilistStatus: boolean;
|
anilistStatus: boolean;
|
||||||
anilistLogout: boolean;
|
anilistLogout: boolean;
|
||||||
anilistSetup: boolean;
|
anilistSetup: boolean;
|
||||||
@@ -103,6 +112,13 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
triggerSubsync: false,
|
triggerSubsync: false,
|
||||||
markAudioCard: false,
|
markAudioCard: false,
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
|
openJimaku: false,
|
||||||
|
openYoutubePicker: false,
|
||||||
|
openPlaylistBrowser: false,
|
||||||
|
replayCurrentSubtitle: false,
|
||||||
|
playNextSubtitle: false,
|
||||||
|
shiftSubDelayPrevLine: false,
|
||||||
|
shiftSubDelayNextLine: false,
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
@@ -180,6 +196,26 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (arg === '--trigger-subsync') args.triggerSubsync = true;
|
else if (arg === '--trigger-subsync') args.triggerSubsync = true;
|
||||||
else if (arg === '--mark-audio-card') args.markAudioCard = true;
|
else if (arg === '--mark-audio-card') args.markAudioCard = true;
|
||||||
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
|
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
|
||||||
|
else if (arg === '--open-jimaku') args.openJimaku = true;
|
||||||
|
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
|
||||||
|
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
|
||||||
|
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
|
||||||
|
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
|
||||||
|
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
|
||||||
|
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
|
||||||
|
else if (arg.startsWith('--copy-subtitle-count=')) {
|
||||||
|
const value = Number(arg.split('=', 2)[1]);
|
||||||
|
if (Number.isInteger(value)) args.copySubtitleCount = value;
|
||||||
|
} else if (arg === '--copy-subtitle-count') {
|
||||||
|
const value = Number(readValue(argv[i + 1]));
|
||||||
|
if (Number.isInteger(value)) args.copySubtitleCount = value;
|
||||||
|
} else if (arg.startsWith('--mine-sentence-count=')) {
|
||||||
|
const value = Number(arg.split('=', 2)[1]);
|
||||||
|
if (Number.isInteger(value)) args.mineSentenceCount = value;
|
||||||
|
} else if (arg === '--mine-sentence-count') {
|
||||||
|
const value = Number(readValue(argv[i + 1]));
|
||||||
|
if (Number.isInteger(value)) args.mineSentenceCount = value;
|
||||||
|
}
|
||||||
else if (arg === '--anilist-status') args.anilistStatus = true;
|
else if (arg === '--anilist-status') args.anilistStatus = true;
|
||||||
else if (arg === '--anilist-logout') args.anilistLogout = true;
|
else if (arg === '--anilist-logout') args.anilistLogout = true;
|
||||||
else if (arg === '--anilist-setup') args.anilistSetup = true;
|
else if (arg === '--anilist-setup') args.anilistSetup = true;
|
||||||
@@ -372,6 +408,15 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.triggerSubsync ||
|
args.triggerSubsync ||
|
||||||
args.markAudioCard ||
|
args.markAudioCard ||
|
||||||
args.openRuntimeOptions ||
|
args.openRuntimeOptions ||
|
||||||
|
args.openJimaku ||
|
||||||
|
args.openYoutubePicker ||
|
||||||
|
args.openPlaylistBrowser ||
|
||||||
|
args.replayCurrentSubtitle ||
|
||||||
|
args.playNextSubtitle ||
|
||||||
|
args.shiftSubDelayPrevLine ||
|
||||||
|
args.shiftSubDelayNextLine ||
|
||||||
|
args.copySubtitleCount !== undefined ||
|
||||||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.anilistStatus ||
|
args.anilistStatus ||
|
||||||
args.anilistLogout ||
|
args.anilistLogout ||
|
||||||
args.anilistSetup ||
|
args.anilistSetup ||
|
||||||
@@ -424,6 +469,15 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.triggerSubsync &&
|
!args.triggerSubsync &&
|
||||||
!args.markAudioCard &&
|
!args.markAudioCard &&
|
||||||
!args.openRuntimeOptions &&
|
!args.openRuntimeOptions &&
|
||||||
|
!args.openJimaku &&
|
||||||
|
!args.openYoutubePicker &&
|
||||||
|
!args.openPlaylistBrowser &&
|
||||||
|
!args.replayCurrentSubtitle &&
|
||||||
|
!args.playNextSubtitle &&
|
||||||
|
!args.shiftSubDelayPrevLine &&
|
||||||
|
!args.shiftSubDelayNextLine &&
|
||||||
|
args.copySubtitleCount === undefined &&
|
||||||
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
!args.anilistLogout &&
|
!args.anilistLogout &&
|
||||||
!args.anilistSetup &&
|
!args.anilistSetup &&
|
||||||
@@ -467,6 +521,15 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.triggerSubsync ||
|
args.triggerSubsync ||
|
||||||
args.markAudioCard ||
|
args.markAudioCard ||
|
||||||
args.openRuntimeOptions ||
|
args.openRuntimeOptions ||
|
||||||
|
args.openJimaku ||
|
||||||
|
args.openYoutubePicker ||
|
||||||
|
args.openPlaylistBrowser ||
|
||||||
|
args.replayCurrentSubtitle ||
|
||||||
|
args.playNextSubtitle ||
|
||||||
|
args.shiftSubDelayPrevLine ||
|
||||||
|
args.shiftSubDelayNextLine ||
|
||||||
|
args.copySubtitleCount !== undefined ||
|
||||||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.dictionary ||
|
args.dictionary ||
|
||||||
args.stats ||
|
args.stats ||
|
||||||
args.jellyfin ||
|
args.jellyfin ||
|
||||||
@@ -505,6 +568,15 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.triggerSubsync &&
|
!args.triggerSubsync &&
|
||||||
!args.markAudioCard &&
|
!args.markAudioCard &&
|
||||||
!args.openRuntimeOptions &&
|
!args.openRuntimeOptions &&
|
||||||
|
!args.openJimaku &&
|
||||||
|
!args.openYoutubePicker &&
|
||||||
|
!args.openPlaylistBrowser &&
|
||||||
|
!args.replayCurrentSubtitle &&
|
||||||
|
!args.playNextSubtitle &&
|
||||||
|
!args.shiftSubDelayPrevLine &&
|
||||||
|
!args.shiftSubDelayNextLine &&
|
||||||
|
args.copySubtitleCount === undefined &&
|
||||||
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
!args.anilistLogout &&
|
!args.anilistLogout &&
|
||||||
!args.anilistSetup &&
|
!args.anilistSetup &&
|
||||||
@@ -547,7 +619,16 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
|||||||
args.triggerFieldGrouping ||
|
args.triggerFieldGrouping ||
|
||||||
args.triggerSubsync ||
|
args.triggerSubsync ||
|
||||||
args.markAudioCard ||
|
args.markAudioCard ||
|
||||||
args.openRuntimeOptions
|
args.openRuntimeOptions ||
|
||||||
|
args.openJimaku ||
|
||||||
|
args.openYoutubePicker ||
|
||||||
|
args.openPlaylistBrowser ||
|
||||||
|
args.replayCurrentSubtitle ||
|
||||||
|
args.playNextSubtitle ||
|
||||||
|
args.shiftSubDelayPrevLine ||
|
||||||
|
args.shiftSubDelayNextLine ||
|
||||||
|
args.copySubtitleCount !== undefined ||
|
||||||
|
args.mineSentenceCount !== undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
triggerSubsync: false,
|
triggerSubsync: false,
|
||||||
markAudioCard: false,
|
markAudioCard: false,
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
|
openJimaku: false,
|
||||||
|
openYoutubePicker: false,
|
||||||
|
openPlaylistBrowser: false,
|
||||||
|
replayCurrentSubtitle: false,
|
||||||
|
playNextSubtitle: false,
|
||||||
|
shiftSubDelayPrevLine: false,
|
||||||
|
shiftSubDelayNextLine: false,
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
markAudioCard: false,
|
markAudioCard: false,
|
||||||
refreshKnownWords: false,
|
refreshKnownWords: false,
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
|
openJimaku: false,
|
||||||
|
openYoutubePicker: false,
|
||||||
|
openPlaylistBrowser: false,
|
||||||
|
replayCurrentSubtitle: false,
|
||||||
|
playNextSubtitle: false,
|
||||||
|
shiftSubDelayPrevLine: false,
|
||||||
|
shiftSubDelayNextLine: false,
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
@@ -143,6 +150,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
openRuntimeOptionsPalette: () => {
|
openRuntimeOptionsPalette: () => {
|
||||||
calls.push('openRuntimeOptionsPalette');
|
calls.push('openRuntimeOptionsPalette');
|
||||||
},
|
},
|
||||||
|
dispatchSessionAction: async () => {
|
||||||
|
calls.push('dispatchSessionAction');
|
||||||
|
},
|
||||||
getAnilistStatus: () => ({
|
getAnilistStatus: () => ({
|
||||||
tokenStatus: 'resolved',
|
tokenStatus: 'resolved',
|
||||||
tokenSource: 'stored',
|
tokenSource: 'stored',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
|
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
|
||||||
|
import type { SessionActionDispatchRequest } from '../../types/runtime';
|
||||||
|
|
||||||
export interface CliCommandServiceDeps {
|
export interface CliCommandServiceDeps {
|
||||||
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
|
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
|
||||||
@@ -32,6 +33,7 @@ export interface CliCommandServiceDeps {
|
|||||||
triggerSubsyncFromConfig: () => Promise<void>;
|
triggerSubsyncFromConfig: () => Promise<void>;
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
markLastCardAsAudioCard: () => Promise<void>;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
|
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
|
||||||
getAnilistStatus: () => {
|
getAnilistStatus: () => {
|
||||||
tokenStatus: 'not_checked' | 'resolved' | 'error';
|
tokenStatus: 'not_checked' | 'resolved' | 'error';
|
||||||
tokenSource: 'none' | 'literal' | 'stored';
|
tokenSource: 'none' | 'literal' | 'stored';
|
||||||
@@ -168,6 +170,7 @@ export interface CliCommandDepsRuntimeOptions {
|
|||||||
};
|
};
|
||||||
ui: UiCliRuntime;
|
ui: UiCliRuntime;
|
||||||
app: AppCliRuntime;
|
app: AppCliRuntime;
|
||||||
|
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
|
||||||
getMultiCopyTimeoutMs: () => number;
|
getMultiCopyTimeoutMs: () => number;
|
||||||
schedule: (fn: () => void, delayMs: number) => unknown;
|
schedule: (fn: () => void, delayMs: number) => unknown;
|
||||||
log: (message: string) => void;
|
log: (message: string) => void;
|
||||||
@@ -226,6 +229,7 @@ export function createCliCommandDepsRuntime(
|
|||||||
triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig,
|
triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig,
|
||||||
markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard,
|
markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard,
|
||||||
openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette,
|
||||||
|
dispatchSessionAction: options.dispatchSessionAction,
|
||||||
getAnilistStatus: options.anilist.getStatus,
|
getAnilistStatus: options.anilist.getStatus,
|
||||||
clearAnilistToken: options.anilist.clearToken,
|
clearAnilistToken: options.anilist.clearToken,
|
||||||
openAnilistSetup: options.anilist.openSetup,
|
openAnilistSetup: options.anilist.openSetup,
|
||||||
@@ -268,6 +272,19 @@ export function handleCliCommand(
|
|||||||
source: CliCommandSource = 'initial',
|
source: CliCommandSource = 'initial',
|
||||||
deps: CliCommandServiceDeps,
|
deps: CliCommandServiceDeps,
|
||||||
): void {
|
): void {
|
||||||
|
const dispatchCliSessionAction = (
|
||||||
|
request: SessionActionDispatchRequest,
|
||||||
|
logLabel: string,
|
||||||
|
osdLabel: string,
|
||||||
|
): void => {
|
||||||
|
runAsyncWithOsd(
|
||||||
|
() => deps.dispatchSessionAction?.(request) ?? Promise.resolve(),
|
||||||
|
deps,
|
||||||
|
logLabel,
|
||||||
|
osdLabel,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (args.logLevel) {
|
if (args.logLevel) {
|
||||||
deps.setLogLevel?.(args.logLevel);
|
deps.setLogLevel?.(args.logLevel);
|
||||||
}
|
}
|
||||||
@@ -381,6 +398,56 @@ export function handleCliCommand(
|
|||||||
);
|
);
|
||||||
} else if (args.openRuntimeOptions) {
|
} else if (args.openRuntimeOptions) {
|
||||||
deps.openRuntimeOptionsPalette();
|
deps.openRuntimeOptionsPalette();
|
||||||
|
} else if (args.openJimaku) {
|
||||||
|
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
|
||||||
|
} else if (args.openYoutubePicker) {
|
||||||
|
dispatchCliSessionAction(
|
||||||
|
{ actionId: 'openYoutubePicker' },
|
||||||
|
'openYoutubePicker',
|
||||||
|
'Open YouTube picker failed',
|
||||||
|
);
|
||||||
|
} else if (args.openPlaylistBrowser) {
|
||||||
|
dispatchCliSessionAction(
|
||||||
|
{ actionId: 'openPlaylistBrowser' },
|
||||||
|
'openPlaylistBrowser',
|
||||||
|
'Open playlist browser failed',
|
||||||
|
);
|
||||||
|
} else if (args.replayCurrentSubtitle) {
|
||||||
|
dispatchCliSessionAction(
|
||||||
|
{ actionId: 'replayCurrentSubtitle' },
|
||||||
|
'replayCurrentSubtitle',
|
||||||
|
'Replay subtitle failed',
|
||||||
|
);
|
||||||
|
} else if (args.playNextSubtitle) {
|
||||||
|
dispatchCliSessionAction(
|
||||||
|
{ actionId: 'playNextSubtitle' },
|
||||||
|
'playNextSubtitle',
|
||||||
|
'Play next subtitle failed',
|
||||||
|
);
|
||||||
|
} else if (args.shiftSubDelayPrevLine) {
|
||||||
|
dispatchCliSessionAction(
|
||||||
|
{ actionId: 'shiftSubDelayPrevLine' },
|
||||||
|
'shiftSubDelayPrevLine',
|
||||||
|
'Shift subtitle delay failed',
|
||||||
|
);
|
||||||
|
} else if (args.shiftSubDelayNextLine) {
|
||||||
|
dispatchCliSessionAction(
|
||||||
|
{ actionId: 'shiftSubDelayNextLine' },
|
||||||
|
'shiftSubDelayNextLine',
|
||||||
|
'Shift subtitle delay failed',
|
||||||
|
);
|
||||||
|
} else if (args.copySubtitleCount !== undefined) {
|
||||||
|
dispatchCliSessionAction(
|
||||||
|
{ actionId: 'copySubtitleMultiple', payload: { count: args.copySubtitleCount } },
|
||||||
|
'copySubtitleMultiple',
|
||||||
|
'Copy failed',
|
||||||
|
);
|
||||||
|
} else if (args.mineSentenceCount !== undefined) {
|
||||||
|
dispatchCliSessionAction(
|
||||||
|
{ actionId: 'mineSentenceMultiple', payload: { count: args.mineSentenceCount } },
|
||||||
|
'mineSentenceMultiple',
|
||||||
|
'Mine sentence failed',
|
||||||
|
);
|
||||||
} else if (args.anilistStatus) {
|
} else if (args.anilistStatus) {
|
||||||
const status = deps.getAnilistStatus();
|
const status = deps.getAnilistStatus();
|
||||||
deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`);
|
deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`);
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export {
|
|||||||
createOverlayWindow,
|
createOverlayWindow,
|
||||||
enforceOverlayLayerOrder,
|
enforceOverlayLayerOrder,
|
||||||
ensureOverlayWindowLevel,
|
ensureOverlayWindowLevel,
|
||||||
|
isOverlayWindowContentReady,
|
||||||
syncOverlayWindowLayer,
|
syncOverlayWindowLayer,
|
||||||
updateOverlayWindowBounds,
|
updateOverlayWindowBounds,
|
||||||
} from './overlay-window';
|
} from './overlay-window';
|
||||||
|
|||||||
@@ -127,7 +127,9 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
|||||||
setMecabEnabled: () => {},
|
setMecabEnabled: () => {},
|
||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
|
getSessionBindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getStatsToggleKey: () => 'Backquote',
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getMarkWatchedKey: () => 'KeyW',
|
getMarkWatchedKey: () => 'KeyW',
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
getControllerConfig: () => createControllerConfigFixture(),
|
||||||
@@ -226,7 +228,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
getMecabTokenizer: () => null,
|
getMecabTokenizer: () => null,
|
||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
|
getSessionBindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getStatsToggleKey: () => 'Backquote',
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getMarkWatchedKey: () => 'KeyW',
|
getMarkWatchedKey: () => 'KeyW',
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
getControllerConfig: () => createControllerConfigFixture(),
|
||||||
@@ -382,7 +386,9 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
setMecabEnabled: () => {},
|
setMecabEnabled: () => {},
|
||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
|
getSessionBindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getStatsToggleKey: () => 'Backquote',
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getMarkWatchedKey: () => 'KeyW',
|
getMarkWatchedKey: () => 'KeyW',
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
getControllerConfig: () => createControllerConfigFixture(),
|
||||||
@@ -707,7 +713,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
setMecabEnabled: () => {},
|
setMecabEnabled: () => {},
|
||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
|
getSessionBindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getStatsToggleKey: () => 'Backquote',
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getMarkWatchedKey: () => 'KeyW',
|
getMarkWatchedKey: () => 'KeyW',
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
getControllerConfig: () => createControllerConfigFixture(),
|
||||||
@@ -786,7 +794,9 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
setMecabEnabled: () => {},
|
setMecabEnabled: () => {},
|
||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
|
getSessionBindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getStatsToggleKey: () => 'Backquote',
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getMarkWatchedKey: () => 'KeyW',
|
getMarkWatchedKey: () => 'KeyW',
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
getControllerConfig: () => createControllerConfigFixture(),
|
||||||
@@ -872,7 +882,9 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
|||||||
setMecabEnabled: () => {},
|
setMecabEnabled: () => {},
|
||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
|
getSessionBindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getStatsToggleKey: () => 'Backquote',
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getMarkWatchedKey: () => 'KeyW',
|
getMarkWatchedKey: () => 'KeyW',
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
getControllerConfig: () => createControllerConfigFixture(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import type { IpcMainEvent } from 'electron';
|
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
|
||||||
import type {
|
import type {
|
||||||
|
CompiledSessionBinding,
|
||||||
ControllerConfigUpdate,
|
ControllerConfigUpdate,
|
||||||
PlaylistBrowserMutationResult,
|
PlaylistBrowserMutationResult,
|
||||||
PlaylistBrowserSnapshot,
|
PlaylistBrowserSnapshot,
|
||||||
@@ -12,6 +13,7 @@ import type {
|
|||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
SubsyncManualRunRequest,
|
SubsyncManualRunRequest,
|
||||||
SubsyncResult,
|
SubsyncResult,
|
||||||
|
SessionActionDispatchRequest,
|
||||||
YoutubePickerResolveRequest,
|
YoutubePickerResolveRequest,
|
||||||
YoutubePickerResolveResult,
|
YoutubePickerResolveResult,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
@@ -30,11 +32,14 @@ import {
|
|||||||
parseYoutubePickerResolveRequest,
|
parseYoutubePickerResolveRequest,
|
||||||
} from '../../shared/ipc/validators';
|
} from '../../shared/ipc/validators';
|
||||||
|
|
||||||
const { BrowserWindow, ipcMain } = electron;
|
const { ipcMain } = electron;
|
||||||
|
|
||||||
export interface IpcServiceDeps {
|
export interface IpcServiceDeps {
|
||||||
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||||
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
|
onOverlayModalOpened?: (
|
||||||
|
modal: OverlayHostedModal,
|
||||||
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
|
) => void;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleDevTools: () => void;
|
toggleDevTools: () => void;
|
||||||
@@ -56,7 +61,9 @@ export interface IpcServiceDeps {
|
|||||||
setMecabEnabled: (enabled: boolean) => void;
|
setMecabEnabled: (enabled: boolean) => void;
|
||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
|
getSessionBindings?: () => CompiledSessionBinding[];
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
|
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||||
getStatsToggleKey: () => string;
|
getStatsToggleKey: () => string;
|
||||||
getMarkWatchedKey: () => string;
|
getMarkWatchedKey: () => string;
|
||||||
getControllerConfig: () => ResolvedControllerConfig;
|
getControllerConfig: () => ResolvedControllerConfig;
|
||||||
@@ -154,7 +161,10 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
getMainWindow: () => WindowLike | null;
|
getMainWindow: () => WindowLike | null;
|
||||||
getVisibleOverlayVisibility: () => boolean;
|
getVisibleOverlayVisibility: () => boolean;
|
||||||
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||||
onOverlayModalOpened?: (modal: OverlayHostedModal) => void;
|
onOverlayModalOpened?: (
|
||||||
|
modal: OverlayHostedModal,
|
||||||
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
|
) => void;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
@@ -169,7 +179,9 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
getMecabTokenizer: () => MecabTokenizerLike | null;
|
getMecabTokenizer: () => MecabTokenizerLike | null;
|
||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
|
getSessionBindings?: () => CompiledSessionBinding[];
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
|
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||||
getStatsToggleKey: () => string;
|
getStatsToggleKey: () => string;
|
||||||
getMarkWatchedKey: () => string;
|
getMarkWatchedKey: () => string;
|
||||||
getControllerConfig: () => ResolvedControllerConfig;
|
getControllerConfig: () => ResolvedControllerConfig;
|
||||||
@@ -238,7 +250,9 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
},
|
},
|
||||||
handleMpvCommand: options.handleMpvCommand,
|
handleMpvCommand: options.handleMpvCommand,
|
||||||
getKeybindings: options.getKeybindings,
|
getKeybindings: options.getKeybindings,
|
||||||
|
getSessionBindings: options.getSessionBindings ?? (() => []),
|
||||||
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
||||||
|
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
|
||||||
getStatsToggleKey: options.getStatsToggleKey,
|
getStatsToggleKey: options.getStatsToggleKey,
|
||||||
getMarkWatchedKey: options.getMarkWatchedKey,
|
getMarkWatchedKey: options.getMarkWatchedKey,
|
||||||
getControllerConfig: options.getControllerConfig,
|
getControllerConfig: options.getControllerConfig,
|
||||||
@@ -299,7 +313,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
||||||
if (typeof ignore !== 'boolean') return;
|
if (typeof ignore !== 'boolean') return;
|
||||||
const parsedOptions = parseOptionalForwardingOptions(options);
|
const parsedOptions = parseOptionalForwardingOptions(options);
|
||||||
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender);
|
const senderWindow =
|
||||||
|
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||||
}
|
}
|
||||||
@@ -311,11 +326,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
if (!parsedModal) return;
|
if (!parsedModal) return;
|
||||||
deps.onOverlayModalClosed(parsedModal);
|
deps.onOverlayModalClosed(parsedModal);
|
||||||
});
|
});
|
||||||
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (_event: unknown, modal: unknown) => {
|
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
|
||||||
const parsedModal = parseOverlayHostedModal(modal);
|
const parsedModal = parseOverlayHostedModal(modal);
|
||||||
if (!parsedModal) return;
|
if (!parsedModal) return;
|
||||||
if (!deps.onOverlayModalOpened) return;
|
if (!deps.onOverlayModalOpened) return;
|
||||||
deps.onOverlayModalOpened(parsedModal);
|
const senderWindow =
|
||||||
|
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||||
|
deps.onOverlayModalOpened(parsedModal, senderWindow);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.handle(
|
ipc.handle(
|
||||||
@@ -431,10 +448,36 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
deps.handleMpvCommand(parsedCommand);
|
deps.handleMpvCommand(parsedCommand);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(
|
||||||
|
IPC_CHANNELS.command.dispatchSessionAction,
|
||||||
|
async (_event: unknown, request: unknown) => {
|
||||||
|
if (!request || typeof request !== 'object') {
|
||||||
|
throw new Error('Invalid session action payload');
|
||||||
|
}
|
||||||
|
const actionId =
|
||||||
|
typeof (request as Record<string, unknown>).actionId === 'string'
|
||||||
|
? ((request as Record<string, unknown>).actionId as SessionActionDispatchRequest['actionId'])
|
||||||
|
: null;
|
||||||
|
if (!actionId) {
|
||||||
|
throw new Error('Invalid session action id');
|
||||||
|
}
|
||||||
|
const payload =
|
||||||
|
(request as Record<string, unknown>).payload &&
|
||||||
|
typeof (request as Record<string, unknown>).payload === 'object'
|
||||||
|
? ((request as Record<string, unknown>).payload as SessionActionDispatchRequest['payload'])
|
||||||
|
: undefined;
|
||||||
|
await deps.dispatchSessionAction?.({ actionId, payload });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getKeybindings, () => {
|
ipc.handle(IPC_CHANNELS.request.getKeybindings, () => {
|
||||||
return deps.getKeybindings();
|
return deps.getKeybindings();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => {
|
||||||
|
return deps.getSessionBindings?.() ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => {
|
ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => {
|
||||||
return deps.getConfiguredShortcuts();
|
return deps.getConfiguredShortcuts();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
buildMpvLoadfileCommands,
|
buildMpvLoadfileCommands,
|
||||||
|
buildMpvSubtitleAddCommands,
|
||||||
|
collectDroppedSubtitlePaths,
|
||||||
collectDroppedVideoPaths,
|
collectDroppedVideoPaths,
|
||||||
parseClipboardVideoPath,
|
parseClipboardVideoPath,
|
||||||
type DropDataTransferLike,
|
type DropDataTransferLike,
|
||||||
@@ -41,6 +43,33 @@ test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates',
|
|||||||
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
|
assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('collectDroppedSubtitlePaths keeps supported dropped subtitle paths in order', () => {
|
||||||
|
const transfer = makeTransfer({
|
||||||
|
files: [
|
||||||
|
{ path: '/subs/ep02.ass' },
|
||||||
|
{ path: '/subs/readme.txt' },
|
||||||
|
{ path: '/subs/ep03.SRT' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = collectDroppedSubtitlePaths(transfer);
|
||||||
|
|
||||||
|
assert.deepEqual(result, ['/subs/ep02.ass', '/subs/ep03.SRT']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectDroppedSubtitlePaths parses text/uri-list entries and de-duplicates', () => {
|
||||||
|
const transfer = makeTransfer({
|
||||||
|
getData: (format: string) =>
|
||||||
|
format === 'text/uri-list'
|
||||||
|
? '#comment\nfile:///tmp/ep01.ass\nfile:///tmp/ep01.ass\nfile:///tmp/ep02.vtt\nfile:///tmp/readme.md\n'
|
||||||
|
: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = collectDroppedSubtitlePaths(transfer);
|
||||||
|
|
||||||
|
assert.deepEqual(result, ['/tmp/ep01.ass', '/tmp/ep02.vtt']);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
|
test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => {
|
||||||
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
|
const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false);
|
||||||
|
|
||||||
@@ -59,6 +88,15 @@ test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () =>
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildMpvSubtitleAddCommands selects first subtitle and adds remainder', () => {
|
||||||
|
const commands = buildMpvSubtitleAddCommands(['/tmp/ep01.ass', '/tmp/ep02.srt']);
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['sub-add', '/tmp/ep01.ass', 'select'],
|
||||||
|
['sub-add', '/tmp/ep02.srt'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('parseClipboardVideoPath accepts quoted local paths', () => {
|
test('parseClipboardVideoPath accepts quoted local paths', () => {
|
||||||
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
|
assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const VIDEO_EXTENSIONS = new Set([
|
|||||||
'.wmv',
|
'.wmv',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const SUBTITLE_EXTENSIONS = new Set(['.ass', '.srt', '.ssa', '.sub', '.vtt']);
|
||||||
|
|
||||||
function getPathExtension(pathValue: string): string {
|
function getPathExtension(pathValue: string): string {
|
||||||
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
|
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
|
||||||
const dot = normalized.lastIndexOf('.');
|
const dot = normalized.lastIndexOf('.');
|
||||||
@@ -32,7 +34,11 @@ function isSupportedVideoPath(pathValue: string): boolean {
|
|||||||
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
return VIDEO_EXTENSIONS.has(getPathExtension(pathValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUriList(data: string): string[] {
|
function isSupportedSubtitlePath(pathValue: string): boolean {
|
||||||
|
return SUBTITLE_EXTENSIONS.has(getPathExtension(pathValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUriList(data: string, isSupportedPath: (pathValue: string) => boolean): string[] {
|
||||||
if (!data.trim()) return [];
|
if (!data.trim()) return [];
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
||||||
@@ -47,7 +53,7 @@ function parseUriList(data: string): string[] {
|
|||||||
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
if (/^\/[A-Za-z]:\//.test(filePath)) {
|
||||||
filePath = filePath.slice(1);
|
filePath = filePath.slice(1);
|
||||||
}
|
}
|
||||||
if (filePath && isSupportedVideoPath(filePath)) {
|
if (filePath && isSupportedPath(filePath)) {
|
||||||
out.push(filePath);
|
out.push(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -87,6 +93,19 @@ export function parseClipboardVideoPath(text: string): string | null {
|
|||||||
|
|
||||||
export function collectDroppedVideoPaths(
|
export function collectDroppedVideoPaths(
|
||||||
dataTransfer: DropDataTransferLike | null | undefined,
|
dataTransfer: DropDataTransferLike | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
return collectDroppedPaths(dataTransfer, isSupportedVideoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectDroppedSubtitlePaths(
|
||||||
|
dataTransfer: DropDataTransferLike | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
return collectDroppedPaths(dataTransfer, isSupportedSubtitlePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDroppedPaths(
|
||||||
|
dataTransfer: DropDataTransferLike | null | undefined,
|
||||||
|
isSupportedPath: (pathValue: string) => boolean,
|
||||||
): string[] {
|
): string[] {
|
||||||
if (!dataTransfer) return [];
|
if (!dataTransfer) return [];
|
||||||
|
|
||||||
@@ -96,7 +115,7 @@ export function collectDroppedVideoPaths(
|
|||||||
const addPath = (candidate: string | null | undefined): void => {
|
const addPath = (candidate: string | null | undefined): void => {
|
||||||
if (!candidate) return;
|
if (!candidate) return;
|
||||||
const trimmed = candidate.trim();
|
const trimmed = candidate.trim();
|
||||||
if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return;
|
if (!trimmed || !isSupportedPath(trimmed) || seen.has(trimmed)) return;
|
||||||
seen.add(trimmed);
|
seen.add(trimmed);
|
||||||
out.push(trimmed);
|
out.push(trimmed);
|
||||||
};
|
};
|
||||||
@@ -109,7 +128,7 @@ export function collectDroppedVideoPaths(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof dataTransfer.getData === 'function') {
|
if (typeof dataTransfer.getData === 'function') {
|
||||||
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) {
|
for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'), isSupportedPath)) {
|
||||||
addPath(pathValue);
|
addPath(pathValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,3 +149,9 @@ export function buildMpvLoadfileCommands(
|
|||||||
index === 0 ? 'replace' : 'append',
|
index === 0 ? 'replace' : 'append',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildMpvSubtitleAddCommands(paths: string[]): Array<(string | number)[]> {
|
||||||
|
return paths.map((pathValue, index) =>
|
||||||
|
index === 0 ? ['sub-add', pathValue, 'select'] : ['sub-add', pathValue],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -443,3 +443,214 @@ test('initializeOverlayRuntime refreshes visible overlay when tracker focus chan
|
|||||||
|
|
||||||
assert.equal(visibilityRefreshCalls, 2);
|
assert.equal(visibilityRefreshCalls, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayRuntime refreshes the current subtitle when tracker finds the target window again', () => {
|
||||||
|
let subtitleRefreshCalls = 0;
|
||||||
|
const tracker = {
|
||||||
|
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||||
|
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||||
|
onWindowLost: null as (() => void) | null,
|
||||||
|
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||||
|
start: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeOverlayRuntime({
|
||||||
|
backendOverride: null,
|
||||||
|
createMainWindow: () => {},
|
||||||
|
registerGlobalShortcuts: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {},
|
||||||
|
isVisibleOverlayVisible: () => true,
|
||||||
|
updateVisibleOverlayVisibility: () => {},
|
||||||
|
refreshCurrentSubtitle: () => {
|
||||||
|
subtitleRefreshCalls += 1;
|
||||||
|
},
|
||||||
|
getOverlayWindows: () => [],
|
||||||
|
syncOverlayShortcuts: () => {},
|
||||||
|
setWindowTracker: () => {},
|
||||||
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||||
|
createWindowTracker: () => tracker as never,
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
ankiConnect: { enabled: false } as never,
|
||||||
|
}),
|
||||||
|
getSubtitleTimingTracker: () => null,
|
||||||
|
getMpvClient: () => null,
|
||||||
|
getRuntimeOptionsManager: () => null,
|
||||||
|
setAnkiIntegration: () => {},
|
||||||
|
showDesktopNotification: () => {},
|
||||||
|
createFieldGroupingCallback: () => async () => ({
|
||||||
|
keepNoteId: 1,
|
||||||
|
deleteNoteId: 2,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
}),
|
||||||
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 });
|
||||||
|
|
||||||
|
assert.equal(subtitleRefreshCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayRuntime hides overlay windows when tracker loses the target window', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const tracker = {
|
||||||
|
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||||
|
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||||
|
onWindowLost: null as (() => void) | null,
|
||||||
|
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||||
|
isTargetWindowMinimized: () => true,
|
||||||
|
start: () => {},
|
||||||
|
};
|
||||||
|
const overlayWindows = [
|
||||||
|
{
|
||||||
|
hide: () => calls.push('hide-visible'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hide: () => calls.push('hide-modal'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
initializeOverlayRuntime({
|
||||||
|
backendOverride: null,
|
||||||
|
createMainWindow: () => {},
|
||||||
|
registerGlobalShortcuts: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {},
|
||||||
|
isVisibleOverlayVisible: () => true,
|
||||||
|
updateVisibleOverlayVisibility: () => {},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
getOverlayWindows: () => overlayWindows as never,
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
setWindowTracker: () => {},
|
||||||
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||||
|
createWindowTracker: () => tracker as never,
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
ankiConnect: { enabled: false } as never,
|
||||||
|
}),
|
||||||
|
getSubtitleTimingTracker: () => null,
|
||||||
|
getMpvClient: () => null,
|
||||||
|
getRuntimeOptionsManager: () => null,
|
||||||
|
setAnkiIntegration: () => {},
|
||||||
|
showDesktopNotification: () => {},
|
||||||
|
createFieldGroupingCallback: () => async () => ({
|
||||||
|
keepNoteId: 1,
|
||||||
|
deleteNoteId: 2,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
}),
|
||||||
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.onWindowLost?.();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const tracker = {
|
||||||
|
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||||
|
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||||
|
onWindowLost: null as (() => void) | null,
|
||||||
|
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||||
|
isTargetWindowMinimized: () => false,
|
||||||
|
start: () => {},
|
||||||
|
};
|
||||||
|
const overlayWindows = [
|
||||||
|
{
|
||||||
|
hide: () => calls.push('hide-visible'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
initializeOverlayRuntime({
|
||||||
|
backendOverride: null,
|
||||||
|
createMainWindow: () => {},
|
||||||
|
registerGlobalShortcuts: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {},
|
||||||
|
isVisibleOverlayVisible: () => true,
|
||||||
|
updateVisibleOverlayVisibility: () => {
|
||||||
|
calls.push('update-visible');
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
getOverlayWindows: () => overlayWindows as never,
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
setWindowTracker: () => {},
|
||||||
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||||
|
createWindowTracker: () => tracker as never,
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
ankiConnect: { enabled: false } as never,
|
||||||
|
}),
|
||||||
|
getSubtitleTimingTracker: () => null,
|
||||||
|
getMpvClient: () => null,
|
||||||
|
getRuntimeOptionsManager: () => null,
|
||||||
|
setAnkiIntegration: () => {},
|
||||||
|
showDesktopNotification: () => {},
|
||||||
|
createFieldGroupingCallback: () => async () => ({
|
||||||
|
keepNoteId: 1,
|
||||||
|
deleteNoteId: 2,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
}),
|
||||||
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
tracker.onWindowLost?.();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
|
||||||
|
const bounds: Array<{ x: number; y: number; width: number; height: number }> = [];
|
||||||
|
let visibilityRefreshCalls = 0;
|
||||||
|
const tracker = {
|
||||||
|
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||||
|
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
||||||
|
onWindowLost: null as (() => void) | null,
|
||||||
|
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||||
|
start: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeOverlayRuntime({
|
||||||
|
backendOverride: null,
|
||||||
|
createMainWindow: () => {},
|
||||||
|
registerGlobalShortcuts: () => {},
|
||||||
|
updateVisibleOverlayBounds: (geometry) => {
|
||||||
|
bounds.push(geometry);
|
||||||
|
},
|
||||||
|
isVisibleOverlayVisible: () => true,
|
||||||
|
updateVisibleOverlayVisibility: () => {
|
||||||
|
visibilityRefreshCalls += 1;
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
getOverlayWindows: () => [],
|
||||||
|
syncOverlayShortcuts: () => {},
|
||||||
|
setWindowTracker: () => {},
|
||||||
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||||
|
createWindowTracker: () => tracker as never,
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
ankiConnect: { enabled: false } as never,
|
||||||
|
}),
|
||||||
|
getSubtitleTimingTracker: () => null,
|
||||||
|
getMpvClient: () => null,
|
||||||
|
getRuntimeOptionsManager: () => null,
|
||||||
|
setAnkiIntegration: () => {},
|
||||||
|
showDesktopNotification: () => {},
|
||||||
|
createFieldGroupingCallback: () => async () => ({
|
||||||
|
keepNoteId: 1,
|
||||||
|
deleteNoteId: 2,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
}),
|
||||||
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredGeometry = { x: 100, y: 200, width: 1280, height: 720 };
|
||||||
|
tracker.onWindowFound?.(restoredGeometry);
|
||||||
|
|
||||||
|
assert.deepEqual(bounds, [restoredGeometry]);
|
||||||
|
assert.equal(visibilityRefreshCalls, 2);
|
||||||
|
});
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export function initializeOverlayRuntime(options: {
|
|||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
isVisibleOverlayVisible: () => boolean;
|
isVisibleOverlayVisible: () => boolean;
|
||||||
updateVisibleOverlayVisibility: () => void;
|
updateVisibleOverlayVisibility: () => void;
|
||||||
|
refreshCurrentSubtitle?: () => void;
|
||||||
getOverlayWindows: () => BrowserWindow[];
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||||
@@ -78,6 +79,8 @@ export function initializeOverlayRuntime(options: {
|
|||||||
override?: string | null,
|
override?: string | null,
|
||||||
targetMpvSocketPath?: string | null,
|
targetMpvSocketPath?: string | null,
|
||||||
) => BaseWindowTracker | null;
|
) => BaseWindowTracker | null;
|
||||||
|
bindOverlayOwner?: () => void;
|
||||||
|
releaseOverlayOwner?: () => void;
|
||||||
}): void {
|
}): void {
|
||||||
options.createMainWindow();
|
options.createMainWindow();
|
||||||
options.registerGlobalShortcuts();
|
options.registerGlobalShortcuts();
|
||||||
@@ -94,11 +97,14 @@ export function initializeOverlayRuntime(options: {
|
|||||||
};
|
};
|
||||||
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
||||||
options.updateVisibleOverlayBounds(geometry);
|
options.updateVisibleOverlayBounds(geometry);
|
||||||
|
options.bindOverlayOwner?.();
|
||||||
if (options.isVisibleOverlayVisible()) {
|
if (options.isVisibleOverlayVisible()) {
|
||||||
options.updateVisibleOverlayVisibility();
|
options.updateVisibleOverlayVisibility();
|
||||||
|
options.refreshCurrentSubtitle?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
windowTracker.onWindowLost = () => {
|
windowTracker.onWindowLost = () => {
|
||||||
|
options.releaseOverlayOwner?.();
|
||||||
for (const window of options.getOverlayWindows()) {
|
for (const window of options.getOverlayWindows()) {
|
||||||
window.hide();
|
window.hide();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
OverlayShortcutRuntimeDeps,
|
OverlayShortcutRuntimeDeps,
|
||||||
runOverlayShortcutLocalFallback,
|
runOverlayShortcutLocalFallback,
|
||||||
} from './overlay-shortcut-handler';
|
} from './overlay-shortcut-handler';
|
||||||
import { shouldActivateOverlayShortcuts } from './overlay-shortcut';
|
import {
|
||||||
|
registerOverlayShortcutsRuntime,
|
||||||
|
shouldActivateOverlayShortcuts,
|
||||||
|
unregisterOverlayShortcutsRuntime,
|
||||||
|
} from './overlay-shortcut';
|
||||||
|
|
||||||
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||||
return {
|
return {
|
||||||
@@ -313,3 +317,85 @@ test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registerOverlayShortcutsRuntime reports active shortcuts when configured', () => {
|
||||||
|
const result = registerOverlayShortcutsRuntime({
|
||||||
|
getConfiguredShortcuts: () =>
|
||||||
|
({
|
||||||
|
toggleVisibleOverlayGlobal: null,
|
||||||
|
copySubtitle: null,
|
||||||
|
copySubtitleMultiple: null,
|
||||||
|
updateLastCardFromClipboard: null,
|
||||||
|
triggerFieldGrouping: null,
|
||||||
|
triggerSubsync: null,
|
||||||
|
mineSentence: null,
|
||||||
|
mineSentenceMultiple: null,
|
||||||
|
multiCopyTimeoutMs: 2500,
|
||||||
|
toggleSecondarySub: null,
|
||||||
|
markAudioCard: null,
|
||||||
|
openRuntimeOptions: null,
|
||||||
|
openJimaku: 'Ctrl+J',
|
||||||
|
}) as never,
|
||||||
|
getOverlayHandlers: () => ({
|
||||||
|
copySubtitle: () => {},
|
||||||
|
copySubtitleMultiple: () => {},
|
||||||
|
updateLastCardFromClipboard: () => {},
|
||||||
|
triggerFieldGrouping: () => {},
|
||||||
|
triggerSubsync: () => {},
|
||||||
|
mineSentence: () => {},
|
||||||
|
mineSentenceMultiple: () => {},
|
||||||
|
toggleSecondarySub: () => {},
|
||||||
|
markAudioCard: () => {},
|
||||||
|
openRuntimeOptions: () => {},
|
||||||
|
openJimaku: () => {},
|
||||||
|
}),
|
||||||
|
cancelPendingMultiCopy: () => {},
|
||||||
|
cancelPendingMineSentenceMultiple: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const result = unregisterOverlayShortcutsRuntime(true, {
|
||||||
|
getConfiguredShortcuts: () =>
|
||||||
|
({
|
||||||
|
toggleVisibleOverlayGlobal: null,
|
||||||
|
copySubtitle: null,
|
||||||
|
copySubtitleMultiple: null,
|
||||||
|
updateLastCardFromClipboard: null,
|
||||||
|
triggerFieldGrouping: null,
|
||||||
|
triggerSubsync: null,
|
||||||
|
mineSentence: null,
|
||||||
|
mineSentenceMultiple: null,
|
||||||
|
multiCopyTimeoutMs: 2500,
|
||||||
|
toggleSecondarySub: null,
|
||||||
|
markAudioCard: null,
|
||||||
|
openRuntimeOptions: null,
|
||||||
|
openJimaku: null,
|
||||||
|
}) as never,
|
||||||
|
getOverlayHandlers: () => ({
|
||||||
|
copySubtitle: () => {},
|
||||||
|
copySubtitleMultiple: () => {},
|
||||||
|
updateLastCardFromClipboard: () => {},
|
||||||
|
triggerFieldGrouping: () => {},
|
||||||
|
triggerSubsync: () => {},
|
||||||
|
mineSentence: () => {},
|
||||||
|
mineSentenceMultiple: () => {},
|
||||||
|
toggleSecondarySub: () => {},
|
||||||
|
markAudioCard: () => {},
|
||||||
|
openRuntimeOptions: () => {},
|
||||||
|
openJimaku: () => {},
|
||||||
|
}),
|
||||||
|
cancelPendingMultiCopy: () => {
|
||||||
|
calls.push('cancel-multi-copy');
|
||||||
|
},
|
||||||
|
cancelPendingMineSentenceMultiple: () => {
|
||||||
|
calls.push('cancel-mine-sentence-multiple');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
|
||||||
|
});
|
||||||
|
|||||||
94
src/core/services/overlay-shortcut.test.ts
Normal file
94
src/core/services/overlay-shortcut.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
|
||||||
|
import {
|
||||||
|
registerOverlayShortcuts,
|
||||||
|
syncOverlayShortcutsRuntime,
|
||||||
|
unregisterOverlayShortcutsRuntime,
|
||||||
|
} from './overlay-shortcut';
|
||||||
|
|
||||||
|
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||||
|
return {
|
||||||
|
toggleVisibleOverlayGlobal: null,
|
||||||
|
copySubtitle: null,
|
||||||
|
copySubtitleMultiple: null,
|
||||||
|
updateLastCardFromClipboard: null,
|
||||||
|
triggerFieldGrouping: null,
|
||||||
|
triggerSubsync: null,
|
||||||
|
mineSentence: null,
|
||||||
|
mineSentenceMultiple: null,
|
||||||
|
multiCopyTimeoutMs: 2500,
|
||||||
|
toggleSecondarySub: null,
|
||||||
|
markAudioCard: null,
|
||||||
|
openRuntimeOptions: null,
|
||||||
|
openJimaku: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('registerOverlayShortcuts reports active overlay shortcuts when configured', () => {
|
||||||
|
assert.equal(
|
||||||
|
registerOverlayShortcuts(createShortcuts({ openJimaku: 'Ctrl+J' }), {
|
||||||
|
copySubtitle: () => {},
|
||||||
|
copySubtitleMultiple: () => {},
|
||||||
|
updateLastCardFromClipboard: () => {},
|
||||||
|
triggerFieldGrouping: () => {},
|
||||||
|
triggerSubsync: () => {},
|
||||||
|
mineSentence: () => {},
|
||||||
|
mineSentenceMultiple: () => {},
|
||||||
|
toggleSecondarySub: () => {},
|
||||||
|
markAudioCard: () => {},
|
||||||
|
openRuntimeOptions: () => {},
|
||||||
|
openJimaku: () => {},
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent', () => {
|
||||||
|
assert.equal(
|
||||||
|
registerOverlayShortcuts(createShortcuts(), {
|
||||||
|
copySubtitle: () => {},
|
||||||
|
copySubtitleMultiple: () => {},
|
||||||
|
updateLastCardFromClipboard: () => {},
|
||||||
|
triggerFieldGrouping: () => {},
|
||||||
|
triggerSubsync: () => {},
|
||||||
|
mineSentence: () => {},
|
||||||
|
mineSentenceMultiple: () => {},
|
||||||
|
toggleSecondarySub: () => {},
|
||||||
|
markAudioCard: () => {},
|
||||||
|
openRuntimeOptions: () => {},
|
||||||
|
openJimaku: () => {},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const result = syncOverlayShortcutsRuntime(false, true, {
|
||||||
|
getConfiguredShortcuts: () => createShortcuts(),
|
||||||
|
getOverlayHandlers: () => ({
|
||||||
|
copySubtitle: () => {},
|
||||||
|
copySubtitleMultiple: () => {},
|
||||||
|
updateLastCardFromClipboard: () => {},
|
||||||
|
triggerFieldGrouping: () => {},
|
||||||
|
triggerSubsync: () => {},
|
||||||
|
mineSentence: () => {},
|
||||||
|
mineSentenceMultiple: () => {},
|
||||||
|
toggleSecondarySub: () => {},
|
||||||
|
markAudioCard: () => {},
|
||||||
|
openRuntimeOptions: () => {},
|
||||||
|
openJimaku: () => {},
|
||||||
|
}),
|
||||||
|
cancelPendingMultiCopy: () => {
|
||||||
|
calls.push('cancel-multi-copy');
|
||||||
|
},
|
||||||
|
cancelPendingMineSentenceMultiple: () => {
|
||||||
|
calls.push('cancel-mine-sentence-multiple');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
|
||||||
|
});
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
import electron from 'electron';
|
|
||||||
import { ConfiguredShortcuts } from '../utils/shortcut-config';
|
import { ConfiguredShortcuts } from '../utils/shortcut-config';
|
||||||
import { isGlobalShortcutRegisteredSafe } from './shortcut-fallback';
|
|
||||||
import { createLogger } from '../../logger';
|
|
||||||
|
|
||||||
const { globalShortcut } = electron;
|
|
||||||
const logger = createLogger('main:overlay-shortcut-service');
|
|
||||||
|
|
||||||
export interface OverlayShortcutHandlers {
|
export interface OverlayShortcutHandlers {
|
||||||
copySubtitle: () => void;
|
copySubtitle: () => void;
|
||||||
@@ -27,6 +21,27 @@ export interface OverlayShortcutLifecycleDeps {
|
|||||||
cancelPendingMineSentenceMultiple: () => void;
|
cancelPendingMineSentenceMultiple: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>> = [
|
||||||
|
'copySubtitle',
|
||||||
|
'copySubtitleMultiple',
|
||||||
|
'updateLastCardFromClipboard',
|
||||||
|
'triggerFieldGrouping',
|
||||||
|
'triggerSubsync',
|
||||||
|
'mineSentence',
|
||||||
|
'mineSentenceMultiple',
|
||||||
|
'toggleSecondarySub',
|
||||||
|
'markAudioCard',
|
||||||
|
'openRuntimeOptions',
|
||||||
|
'openJimaku',
|
||||||
|
];
|
||||||
|
|
||||||
|
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
|
||||||
|
return OVERLAY_SHORTCUT_KEYS.some((key) => {
|
||||||
|
const shortcut = shortcuts[key];
|
||||||
|
return typeof shortcut === 'string' && shortcut.trim().length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldActivateOverlayShortcuts(args: {
|
export function shouldActivateOverlayShortcuts(args: {
|
||||||
overlayRuntimeInitialized: boolean;
|
overlayRuntimeInitialized: boolean;
|
||||||
isMacOSPlatform: boolean;
|
isMacOSPlatform: boolean;
|
||||||
@@ -43,139 +58,12 @@ export function shouldActivateOverlayShortcuts(args: {
|
|||||||
|
|
||||||
export function registerOverlayShortcuts(
|
export function registerOverlayShortcuts(
|
||||||
shortcuts: ConfiguredShortcuts,
|
shortcuts: ConfiguredShortcuts,
|
||||||
handlers: OverlayShortcutHandlers,
|
_handlers: OverlayShortcutHandlers,
|
||||||
): boolean {
|
): boolean {
|
||||||
let registeredAny = false;
|
return hasConfiguredOverlayShortcuts(shortcuts);
|
||||||
const registerOverlayShortcut = (
|
|
||||||
accelerator: string,
|
|
||||||
handler: () => void,
|
|
||||||
label: string,
|
|
||||||
): void => {
|
|
||||||
if (isGlobalShortcutRegisteredSafe(accelerator)) {
|
|
||||||
registeredAny = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ok = globalShortcut.register(accelerator, handler);
|
|
||||||
if (!ok) {
|
|
||||||
logger.warn(`Failed to register overlay shortcut ${label}: ${accelerator}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
registeredAny = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (shortcuts.copySubtitleMultiple) {
|
|
||||||
registerOverlayShortcut(
|
|
||||||
shortcuts.copySubtitleMultiple,
|
|
||||||
() => handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs),
|
|
||||||
'copySubtitleMultiple',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shortcuts.copySubtitle) {
|
|
||||||
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,27 +6,59 @@ import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './over
|
|||||||
type WindowTrackerStub = {
|
type WindowTrackerStub = {
|
||||||
isTracking: () => boolean;
|
isTracking: () => boolean;
|
||||||
getGeometry: () => { x: number; y: number; width: number; height: number } | null;
|
getGeometry: () => { x: number; y: number; width: number; height: number } | null;
|
||||||
|
isTargetWindowFocused?: () => boolean;
|
||||||
|
isTargetWindowMinimized?: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMainWindowRecorder() {
|
function createMainWindowRecorder() {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
let visible = false;
|
||||||
|
let focused = false;
|
||||||
|
let opacity = 1;
|
||||||
const window = {
|
const window = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
|
isVisible: () => visible,
|
||||||
|
isFocused: () => focused,
|
||||||
hide: () => {
|
hide: () => {
|
||||||
|
visible = false;
|
||||||
|
focused = false;
|
||||||
calls.push('hide');
|
calls.push('hide');
|
||||||
},
|
},
|
||||||
show: () => {
|
show: () => {
|
||||||
|
visible = true;
|
||||||
calls.push('show');
|
calls.push('show');
|
||||||
},
|
},
|
||||||
|
showInactive: () => {
|
||||||
|
visible = true;
|
||||||
|
calls.push('show-inactive');
|
||||||
|
},
|
||||||
focus: () => {
|
focus: () => {
|
||||||
|
focused = true;
|
||||||
calls.push('focus');
|
calls.push('focus');
|
||||||
},
|
},
|
||||||
|
setAlwaysOnTop: (flag: boolean) => {
|
||||||
|
calls.push(`always-on-top:${flag}`);
|
||||||
|
},
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||||
},
|
},
|
||||||
|
setOpacity: (nextOpacity: number) => {
|
||||||
|
opacity = nextOpacity;
|
||||||
|
calls.push(`opacity:${nextOpacity}`);
|
||||||
|
},
|
||||||
|
moveTop: () => {
|
||||||
|
calls.push('move-top');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { window, calls };
|
return {
|
||||||
|
window,
|
||||||
|
calls,
|
||||||
|
getOpacity: () => opacity,
|
||||||
|
setFocused: (nextFocused: boolean) => {
|
||||||
|
focused = nextFocused;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
|
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
|
||||||
@@ -163,7 +195,286 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
|
|||||||
assert.ok(!calls.includes('osd'));
|
assert.ok(!calls.includes('osd'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Windows visible overlay stays click-through and does not steal focus while tracked', () => {
|
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('opacity:0'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('show-inactive'));
|
||||||
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
|
assert.ok(!calls.includes('move-top'));
|
||||||
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
|
assert.ok(!calls.includes('focus'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows visible overlay restores opacity after the deferred reveal delay', async () => {
|
||||||
|
const { window, calls, getOpacity } = createMainWindowRecorder();
|
||||||
|
let syncWindowsZOrderCalls = 0;
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
syncWindowsZOrderCalls += 1;
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(getOpacity(), 0);
|
||||||
|
assert.equal(syncWindowsZOrderCalls, 1);
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 60));
|
||||||
|
assert.equal(getOpacity(), 1);
|
||||||
|
assert.equal(syncWindowsZOrderCalls, 2);
|
||||||
|
assert.ok(calls.includes('opacity:1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracked Windows overlay refresh rebinds while already visible', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
|
assert.ok(!calls.includes('move-top'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forced passthrough still reapplies while visible on Windows', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
forceMousePassthrough: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(!calls.includes('move-top'));
|
||||||
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forced passthrough still shows tracked overlay while bound to mpv on Windows', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
forceMousePassthrough: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('show-inactive'));
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(!calls.includes('move-top'));
|
||||||
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
isTracking: () => true,
|
isTracking: () => true,
|
||||||
@@ -191,13 +502,209 @@ test('Windows visible overlay stays click-through and does not steal focus while
|
|||||||
syncOverlayShortcuts: () => {
|
syncOverlayShortcuts: () => {
|
||||||
calls.push('sync-shortcuts');
|
calls.push('sync-shortcuts');
|
||||||
},
|
},
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
forceMousePassthrough: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracked Windows overlay rebinds without hiding when tracker focus changes', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
let focused = true;
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => focused,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
isMacOSPlatform: false,
|
isMacOSPlatform: false,
|
||||||
isWindowsPlatform: true,
|
isWindowsPlatform: true,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
focused = false;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(!calls.includes('move-top'));
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('focus'));
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracked Windows overlay stays interactive while the overlay window itself is focused', () => {
|
||||||
|
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
setFocused(true);
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||||
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
|
assert.ok(!calls.includes('move-top'));
|
||||||
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracked Windows overlay binds above mpv even when tracker focus lags', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(!calls.includes('move-top'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('visible overlay stays hidden while a modal window is active', () => {
|
test('visible overlay stays hidden while a modal window is active', () => {
|
||||||
@@ -355,6 +862,157 @@ test('Windows keeps visible overlay hidden while tracker is not ready', () => {
|
|||||||
assert.ok(!calls.includes('update-bounds'));
|
assert.ok(!calls.includes('update-bounds'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Windows preserves visible overlay and rebinds to mpv while tracker transiently loses a non-minimized window', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
let tracking = true;
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => tracking,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
isTargetWindowMinimized: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
tracking = false;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(!calls.includes('hide'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(!calls.includes('move-top'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows hides the visible overlay when the tracked window is minimized', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
let tracking = true;
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => tracking,
|
||||||
|
getGeometry: () => (tracking ? { x: 0, y: 0, width: 1280, height: 720 } : null),
|
||||||
|
isTargetWindowMinimized: () => !tracking,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
tracking = false;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => {
|
||||||
|
calls.push('sync-windows-z-order');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('hide'));
|
||||||
|
assert.ok(!calls.includes('sync-windows-z-order'));
|
||||||
|
});
|
||||||
|
|
||||||
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
|
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
let trackerWarning = false;
|
let trackerWarning = false;
|
||||||
|
|||||||
@@ -2,16 +2,67 @@ import type { BrowserWindow } from 'electron';
|
|||||||
import { BaseWindowTracker } from '../../window-trackers';
|
import { BaseWindowTracker } from '../../window-trackers';
|
||||||
import { WindowGeometry } from '../../types';
|
import { WindowGeometry } from '../../types';
|
||||||
|
|
||||||
|
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
||||||
|
const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
|
||||||
|
BrowserWindow,
|
||||||
|
ReturnType<typeof setTimeout>
|
||||||
|
>();
|
||||||
|
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
||||||
|
|
||||||
|
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
|
||||||
|
const opacityCapableWindow = window as BrowserWindow & {
|
||||||
|
setOpacity?: (opacity: number) => void;
|
||||||
|
};
|
||||||
|
opacityCapableWindow.setOpacity?.(opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
|
||||||
|
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
|
||||||
|
if (!pendingTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(pendingTimeout);
|
||||||
|
pendingWindowsOverlayRevealTimeoutByWindow.delete(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWindowsOverlayReveal(
|
||||||
|
window: BrowserWindow,
|
||||||
|
onReveal?: (window: BrowserWindow) => void,
|
||||||
|
): void {
|
||||||
|
clearPendingWindowsOverlayReveal(window);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingWindowsOverlayRevealTimeoutByWindow.delete(window);
|
||||||
|
if (window.isDestroyed() || !window.isVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOverlayWindowOpacity(window, 1);
|
||||||
|
onReveal?.(window);
|
||||||
|
}, WINDOWS_OVERLAY_REVEAL_DELAY_MS);
|
||||||
|
pendingWindowsOverlayRevealTimeoutByWindow.set(window, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverlayWindowContentReady(window: BrowserWindow): boolean {
|
||||||
|
return (
|
||||||
|
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
||||||
|
OVERLAY_WINDOW_CONTENT_READY_FLAG
|
||||||
|
] === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function updateVisibleOverlayVisibility(args: {
|
export function updateVisibleOverlayVisibility(args: {
|
||||||
visibleOverlayVisible: boolean;
|
visibleOverlayVisible: boolean;
|
||||||
modalActive?: boolean;
|
modalActive?: boolean;
|
||||||
forceMousePassthrough?: boolean;
|
forceMousePassthrough?: boolean;
|
||||||
mainWindow: BrowserWindow | null;
|
mainWindow: BrowserWindow | null;
|
||||||
windowTracker: BaseWindowTracker | null;
|
windowTracker: BaseWindowTracker | null;
|
||||||
|
lastKnownWindowsForegroundProcessName?: string | null;
|
||||||
|
windowsOverlayProcessName?: string | null;
|
||||||
|
windowsFocusHandoffGraceActive?: boolean;
|
||||||
trackerNotReadyWarningShown: boolean;
|
trackerNotReadyWarningShown: boolean;
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||||
|
syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void;
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||||
enforceOverlayLayerOrder: () => void;
|
enforceOverlayLayerOrder: () => void;
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
@@ -30,6 +81,10 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
const mainWindow = args.mainWindow;
|
const mainWindow = args.mainWindow;
|
||||||
|
|
||||||
if (args.modalActive) {
|
if (args.modalActive) {
|
||||||
|
if (args.isWindowsPlatform) {
|
||||||
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
}
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
return;
|
return;
|
||||||
@@ -37,13 +92,92 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
|
|
||||||
const showPassiveVisibleOverlay = (): void => {
|
const showPassiveVisibleOverlay = (): void => {
|
||||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||||
if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) {
|
const shouldDefaultToPassthrough =
|
||||||
|
args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough;
|
||||||
|
const isVisibleOverlayFocused =
|
||||||
|
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
|
||||||
|
const windowsForegroundProcessName =
|
||||||
|
args.lastKnownWindowsForegroundProcessName?.trim().toLowerCase() ?? null;
|
||||||
|
const windowsOverlayProcessName = args.windowsOverlayProcessName?.trim().toLowerCase() ?? null;
|
||||||
|
const hasWindowsForegroundProcessSignal =
|
||||||
|
args.isWindowsPlatform && windowsForegroundProcessName !== null;
|
||||||
|
const isTrackedWindowsTargetFocused = args.windowTracker?.isTargetWindowFocused?.() ?? true;
|
||||||
|
const isTrackedWindowsTargetMinimized =
|
||||||
|
args.isWindowsPlatform &&
|
||||||
|
typeof args.windowTracker?.isTargetWindowMinimized === 'function' &&
|
||||||
|
args.windowTracker.isTargetWindowMinimized();
|
||||||
|
const shouldPreserveWindowsOverlayDuringFocusHandoff =
|
||||||
|
args.isWindowsPlatform &&
|
||||||
|
args.windowsFocusHandoffGraceActive === true &&
|
||||||
|
!!args.windowTracker &&
|
||||||
|
(!hasWindowsForegroundProcessSignal ||
|
||||||
|
windowsForegroundProcessName === 'mpv' ||
|
||||||
|
(windowsOverlayProcessName !== null &&
|
||||||
|
windowsForegroundProcessName === windowsOverlayProcessName)) &&
|
||||||
|
!isTrackedWindowsTargetMinimized &&
|
||||||
|
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
|
||||||
|
const shouldIgnoreMouseEvents =
|
||||||
|
forceMousePassthrough || (shouldDefaultToPassthrough && !isVisibleOverlayFocused);
|
||||||
|
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||||
|
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||||
|
!args.isWindowsPlatform ||
|
||||||
|
!args.windowTracker ||
|
||||||
|
isVisibleOverlayFocused ||
|
||||||
|
isTrackedWindowsTargetFocused ||
|
||||||
|
shouldPreserveWindowsOverlayDuringFocusHandoff ||
|
||||||
|
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
|
||||||
|
const wasVisible = mainWindow.isVisible();
|
||||||
|
|
||||||
|
if (shouldIgnoreMouseEvents) {
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
args.ensureOverlayWindowLevel(mainWindow);
|
|
||||||
mainWindow.show();
|
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);
|
||||||
|
} 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();
|
||||||
|
if (args.isWindowsPlatform) {
|
||||||
|
scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay
|
||||||
|
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
|
||||||
|
: undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldBindTrackedWindowsOverlay) {
|
||||||
|
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
||||||
|
}
|
||||||
|
|
||||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
@@ -63,12 +197,27 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
if (!args.visibleOverlayVisible) {
|
if (!args.visibleOverlayVisible) {
|
||||||
args.setTrackerNotReadyWarningShown(false);
|
args.setTrackerNotReadyWarningShown(false);
|
||||||
args.resetOverlayLoadingOsdSuppression?.();
|
args.resetOverlayLoadingOsdSuppression?.();
|
||||||
|
if (args.isWindowsPlatform) {
|
||||||
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
}
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.windowTracker && args.windowTracker.isTracking()) {
|
if (args.windowTracker && args.windowTracker.isTracking()) {
|
||||||
|
if (
|
||||||
|
args.isWindowsPlatform &&
|
||||||
|
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
|
||||||
|
args.windowTracker.isTargetWindowMinimized()
|
||||||
|
) {
|
||||||
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
mainWindow.hide();
|
||||||
|
args.syncOverlayShortcuts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
args.setTrackerNotReadyWarningShown(false);
|
args.setTrackerNotReadyWarningShown(false);
|
||||||
const geometry = args.windowTracker.getGeometry();
|
const geometry = args.windowTracker.getGeometry();
|
||||||
if (geometry) {
|
if (geometry) {
|
||||||
@@ -76,7 +225,9 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
}
|
}
|
||||||
args.syncPrimaryOverlayWindowLayer('visible');
|
args.syncPrimaryOverlayWindowLayer('visible');
|
||||||
showPassiveVisibleOverlay();
|
showPassiveVisibleOverlay();
|
||||||
args.enforceOverlayLayerOrder();
|
if (!args.forceMousePassthrough && !args.isWindowsPlatform) {
|
||||||
|
args.enforceOverlayLayerOrder();
|
||||||
|
}
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,6 +238,10 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
args.setTrackerNotReadyWarningShown(true);
|
args.setTrackerNotReadyWarningShown(true);
|
||||||
maybeShowOverlayLoadingOsd();
|
maybeShowOverlayLoadingOsd();
|
||||||
}
|
}
|
||||||
|
if (args.isWindowsPlatform) {
|
||||||
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
}
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
return;
|
return;
|
||||||
@@ -99,11 +254,32 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
args.isWindowsPlatform &&
|
||||||
|
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
|
||||||
|
!args.windowTracker.isTargetWindowMinimized() &&
|
||||||
|
(mainWindow.isVisible() || args.windowTracker.getGeometry() !== null)
|
||||||
|
) {
|
||||||
|
args.setTrackerNotReadyWarningShown(false);
|
||||||
|
const geometry = args.windowTracker.getGeometry();
|
||||||
|
if (geometry) {
|
||||||
|
args.updateVisibleOverlayBounds(geometry);
|
||||||
|
}
|
||||||
|
args.syncPrimaryOverlayWindowLayer('visible');
|
||||||
|
showPassiveVisibleOverlay();
|
||||||
|
args.syncOverlayShortcuts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!args.trackerNotReadyWarningShown) {
|
if (!args.trackerNotReadyWarningShown) {
|
||||||
args.setTrackerNotReadyWarningShown(true);
|
args.setTrackerNotReadyWarningShown(true);
|
||||||
maybeShowOverlayLoadingOsd();
|
maybeShowOverlayLoadingOsd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.isWindowsPlatform) {
|
||||||
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
}
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,31 @@ test('overlay window config explicitly disables renderer sandbox for preload com
|
|||||||
yomitanSession: null,
|
yomitanSession: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
assert.equal(options.backgroundColor, '#00000000');
|
||||||
assert.equal(options.webPreferences?.sandbox, false);
|
assert.equal(options.webPreferences?.sandbox, false);
|
||||||
|
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows visible overlay window config does not start as always-on-top', () => {
|
||||||
|
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
|
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = buildOverlayWindowOptions('visible', {
|
||||||
|
isDev: false,
|
||||||
|
yomitanSession: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(options.alwaysOnTop, false);
|
||||||
|
} finally {
|
||||||
|
if (originalPlatformDescriptor) {
|
||||||
|
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('overlay window config uses the provided Yomitan session when available', () => {
|
test('overlay window config uses the provided Yomitan session when available', () => {
|
||||||
|
|||||||
@@ -66,7 +66,14 @@ export function handleOverlayWindowBlurred(options: {
|
|||||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||||
ensureOverlayWindowLevel: () => void;
|
ensureOverlayWindowLevel: () => void;
|
||||||
moveWindowTop: () => void;
|
moveWindowTop: () => void;
|
||||||
|
onWindowsVisibleOverlayBlur?: () => void;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
|
if ((options.platform ?? process.platform) === 'win32' && options.kind === 'visible') {
|
||||||
|
options.onWindowsVisibleOverlayBlur?.();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
|
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function buildOverlayWindowOptions(
|
|||||||
},
|
},
|
||||||
): BrowserWindowConstructorOptions {
|
): BrowserWindowConstructorOptions {
|
||||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||||
|
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
show: false,
|
show: false,
|
||||||
@@ -18,8 +19,9 @@ export function buildOverlayWindowOptions(
|
|||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
|
backgroundColor: '#00000000',
|
||||||
frame: false,
|
frame: false,
|
||||||
alwaysOnTop: true,
|
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
hasShadow: false,
|
hasShadow: false,
|
||||||
@@ -31,6 +33,7 @@ export function buildOverlayWindowOptions(
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
|
backgroundThrottling: false,
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
session: options.yomitanSession ?? undefined,
|
session: options.yomitanSession ?? undefined,
|
||||||
additionalArguments: [`--overlay-layer=${kind}`],
|
additionalArguments: [`--overlay-layer=${kind}`],
|
||||||
|
|||||||
@@ -103,6 +103,49 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleOverlayWindowBlurred skips Windows visible overlay restacking after focus loss', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const handled = handleOverlayWindowBlurred({
|
||||||
|
kind: 'visible',
|
||||||
|
windowVisible: true,
|
||||||
|
isOverlayVisible: () => true,
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
moveWindowTop: () => {
|
||||||
|
calls.push('move-top');
|
||||||
|
},
|
||||||
|
platform: 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, false);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback without restacking', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const handled = handleOverlayWindowBlurred({
|
||||||
|
kind: 'visible',
|
||||||
|
windowVisible: true,
|
||||||
|
isOverlayVisible: () => true,
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
moveWindowTop: () => {
|
||||||
|
calls.push('move-top');
|
||||||
|
},
|
||||||
|
onWindowsVisibleOverlayBlur: () => {
|
||||||
|
calls.push('windows-visible-blur');
|
||||||
|
},
|
||||||
|
platform: 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, false);
|
||||||
|
assert.deepEqual(calls, ['windows-visible-blur']);
|
||||||
|
});
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
@@ -117,6 +160,7 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking'
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
calls.push('move-visible');
|
calls.push('move-visible');
|
||||||
},
|
},
|
||||||
|
platform: 'linux',
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds
|
|||||||
|
|
||||||
const logger = createLogger('main:overlay-window');
|
const logger = createLogger('main:overlay-window');
|
||||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||||
|
const overlayWindowContentReady = new WeakSet<BrowserWindow>();
|
||||||
|
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
||||||
|
|
||||||
|
export function isOverlayWindowContentReady(window: BrowserWindow): boolean {
|
||||||
|
if (window.isDestroyed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
overlayWindowContentReady.has(window) ||
|
||||||
|
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
||||||
|
OVERLAY_WINDOW_CONTENT_READY_FLAG
|
||||||
|
] === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getOverlayWindowHtmlPath(): string {
|
function getOverlayWindowHtmlPath(): string {
|
||||||
return path.join(__dirname, '..', '..', 'renderer', 'index.html');
|
return path.join(__dirname, '..', '..', 'renderer', 'index.html');
|
||||||
@@ -76,13 +90,17 @@ export function createOverlayWindow(
|
|||||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
|
onVisibleWindowBlurred?: () => void;
|
||||||
|
onWindowContentReady?: () => void;
|
||||||
onWindowClosed: (kind: OverlayWindowKind) => void;
|
onWindowClosed: (kind: OverlayWindowKind) => void;
|
||||||
yomitanSession?: Session | null;
|
yomitanSession?: Session | null;
|
||||||
},
|
},
|
||||||
): BrowserWindow {
|
): BrowserWindow {
|
||||||
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
|
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
|
||||||
|
|
||||||
options.ensureOverlayWindowLevel(window);
|
if (!(process.platform === 'win32' && kind === 'visible')) {
|
||||||
|
options.ensureOverlayWindowLevel(window);
|
||||||
|
}
|
||||||
loadOverlayWindowLayer(window, kind);
|
loadOverlayWindowLayer(window, kind);
|
||||||
|
|
||||||
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
||||||
@@ -93,6 +111,14 @@ export function createOverlayWindow(
|
|||||||
options.onRuntimeOptionsChanged();
|
options.onRuntimeOptionsChanged();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.once('ready-to-show', () => {
|
||||||
|
overlayWindowContentReady.add(window);
|
||||||
|
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
||||||
|
OVERLAY_WINDOW_CONTENT_READY_FLAG
|
||||||
|
] = true;
|
||||||
|
options.onWindowContentReady?.();
|
||||||
|
});
|
||||||
|
|
||||||
if (kind === 'visible') {
|
if (kind === 'visible') {
|
||||||
window.webContents.on('devtools-opened', () => {
|
window.webContents.on('devtools-opened', () => {
|
||||||
options.setOverlayDebugVisualizationEnabled(true);
|
options.setOverlayDebugVisualizationEnabled(true);
|
||||||
@@ -136,6 +162,8 @@ export function createOverlayWindow(
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
window.moveTop();
|
window.moveTop();
|
||||||
},
|
},
|
||||||
|
onWindowsVisibleOverlayBlur:
|
||||||
|
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
109
src/core/services/session-actions.ts
Normal file
109
src/core/services/session-actions.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../../types';
|
||||||
|
import type { SessionActionId } from '../../types/session-bindings';
|
||||||
|
import type { SessionActionDispatchRequest } from '../../types/runtime';
|
||||||
|
|
||||||
|
export interface SessionActionExecutorDeps {
|
||||||
|
toggleStatsOverlay: () => void;
|
||||||
|
toggleVisibleOverlay: () => void;
|
||||||
|
copyCurrentSubtitle: () => void;
|
||||||
|
copySubtitleCount: (count: number) => void;
|
||||||
|
updateLastCardFromClipboard: () => Promise<void>;
|
||||||
|
triggerFieldGrouping: () => Promise<void>;
|
||||||
|
triggerSubsyncFromConfig: () => Promise<void>;
|
||||||
|
mineSentenceCard: () => Promise<void>;
|
||||||
|
mineSentenceCount: (count: number) => void;
|
||||||
|
toggleSecondarySub: () => void;
|
||||||
|
markLastCardAsAudioCard: () => Promise<void>;
|
||||||
|
openRuntimeOptionsPalette: () => void;
|
||||||
|
openJimaku: () => void;
|
||||||
|
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||||
|
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||||
|
replayCurrentSubtitle: () => void;
|
||||||
|
playNextSubtitle: () => void;
|
||||||
|
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||||
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
|
showMpvOsd: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCount(count: number | undefined): number {
|
||||||
|
const normalized = typeof count === 'number' && Number.isInteger(count) ? count : 1;
|
||||||
|
return Math.min(9, Math.max(1, normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchSessionAction(
|
||||||
|
request: SessionActionDispatchRequest,
|
||||||
|
deps: SessionActionExecutorDeps,
|
||||||
|
): Promise<void> {
|
||||||
|
switch (request.actionId) {
|
||||||
|
case 'toggleStatsOverlay':
|
||||||
|
deps.toggleStatsOverlay();
|
||||||
|
return;
|
||||||
|
case 'toggleVisibleOverlay':
|
||||||
|
deps.toggleVisibleOverlay();
|
||||||
|
return;
|
||||||
|
case 'copySubtitle':
|
||||||
|
deps.copyCurrentSubtitle();
|
||||||
|
return;
|
||||||
|
case 'copySubtitleMultiple':
|
||||||
|
deps.copySubtitleCount(resolveCount(request.payload?.count));
|
||||||
|
return;
|
||||||
|
case 'updateLastCardFromClipboard':
|
||||||
|
await deps.updateLastCardFromClipboard();
|
||||||
|
return;
|
||||||
|
case 'triggerFieldGrouping':
|
||||||
|
await deps.triggerFieldGrouping();
|
||||||
|
return;
|
||||||
|
case 'triggerSubsync':
|
||||||
|
await deps.triggerSubsyncFromConfig();
|
||||||
|
return;
|
||||||
|
case 'mineSentence':
|
||||||
|
await deps.mineSentenceCard();
|
||||||
|
return;
|
||||||
|
case 'mineSentenceMultiple':
|
||||||
|
deps.mineSentenceCount(resolveCount(request.payload?.count));
|
||||||
|
return;
|
||||||
|
case 'toggleSecondarySub':
|
||||||
|
deps.toggleSecondarySub();
|
||||||
|
return;
|
||||||
|
case 'markAudioCard':
|
||||||
|
await deps.markLastCardAsAudioCard();
|
||||||
|
return;
|
||||||
|
case 'openRuntimeOptions':
|
||||||
|
deps.openRuntimeOptionsPalette();
|
||||||
|
return;
|
||||||
|
case 'openJimaku':
|
||||||
|
deps.openJimaku();
|
||||||
|
return;
|
||||||
|
case 'openYoutubePicker':
|
||||||
|
await deps.openYoutubeTrackPicker();
|
||||||
|
return;
|
||||||
|
case 'openPlaylistBrowser':
|
||||||
|
await deps.openPlaylistBrowser();
|
||||||
|
return;
|
||||||
|
case 'replayCurrentSubtitle':
|
||||||
|
deps.replayCurrentSubtitle();
|
||||||
|
return;
|
||||||
|
case 'playNextSubtitle':
|
||||||
|
deps.playNextSubtitle();
|
||||||
|
return;
|
||||||
|
case 'shiftSubDelayPrevLine':
|
||||||
|
await deps.shiftSubDelayToAdjacentSubtitle('previous');
|
||||||
|
return;
|
||||||
|
case 'shiftSubDelayNextLine':
|
||||||
|
await deps.shiftSubDelayToAdjacentSubtitle('next');
|
||||||
|
return;
|
||||||
|
case 'cycleRuntimeOption': {
|
||||||
|
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
|
||||||
|
if (!runtimeOptionId) {
|
||||||
|
deps.showMpvOsd('Runtime option id is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const direction = request.payload?.direction === -1 ? -1 : 1;
|
||||||
|
const result = deps.cycleRuntimeOption(runtimeOptionId, direction);
|
||||||
|
if (!result.ok && result.error) {
|
||||||
|
deps.showMpvOsd(result.error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
247
src/core/services/session-bindings.test.ts
Normal file
247
src/core/services/session-bindings.test.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { Keybinding } from '../../types';
|
||||||
|
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
|
||||||
|
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||||
|
import { compileSessionBindings } from './session-bindings';
|
||||||
|
|
||||||
|
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||||
|
return {
|
||||||
|
toggleVisibleOverlayGlobal: null,
|
||||||
|
copySubtitle: null,
|
||||||
|
copySubtitleMultiple: null,
|
||||||
|
updateLastCardFromClipboard: null,
|
||||||
|
triggerFieldGrouping: null,
|
||||||
|
triggerSubsync: null,
|
||||||
|
mineSentence: null,
|
||||||
|
mineSentenceMultiple: null,
|
||||||
|
multiCopyTimeoutMs: 2500,
|
||||||
|
toggleSecondarySub: null,
|
||||||
|
markAudioCard: null,
|
||||||
|
openRuntimeOptions: null,
|
||||||
|
openJimaku: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createKeybinding(key: string, command: Keybinding['command']): Keybinding {
|
||||||
|
return { key, command };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('compileSessionBindings merges shortcuts and keybindings into one canonical list', () => {
|
||||||
|
const result = compileSessionBindings({
|
||||||
|
shortcuts: createShortcuts({
|
||||||
|
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||||
|
openJimaku: 'Ctrl+Shift+J',
|
||||||
|
}),
|
||||||
|
keybindings: [
|
||||||
|
createKeybinding('KeyF', ['cycle', 'fullscreen']),
|
||||||
|
createKeybinding('Ctrl+Shift+Y', [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]),
|
||||||
|
],
|
||||||
|
platform: 'linux',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.warnings.length, 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.bindings.map((binding) => ({
|
||||||
|
actionType: binding.actionType,
|
||||||
|
sourcePath: binding.sourcePath,
|
||||||
|
code: binding.key.code,
|
||||||
|
modifiers: binding.key.modifiers,
|
||||||
|
target:
|
||||||
|
binding.actionType === 'session-action'
|
||||||
|
? binding.actionId
|
||||||
|
: binding.command.join(' '),
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
actionType: 'mpv-command',
|
||||||
|
sourcePath: 'keybindings[0].key',
|
||||||
|
code: 'KeyF',
|
||||||
|
modifiers: [],
|
||||||
|
target: 'cycle fullscreen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actionType: 'session-action',
|
||||||
|
sourcePath: 'keybindings[1].key',
|
||||||
|
code: 'KeyY',
|
||||||
|
modifiers: ['ctrl', 'shift'],
|
||||||
|
target: 'openYoutubePicker',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actionType: 'session-action',
|
||||||
|
sourcePath: 'shortcuts.openJimaku',
|
||||||
|
code: 'KeyJ',
|
||||||
|
modifiers: ['ctrl', 'shift'],
|
||||||
|
target: 'openJimaku',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actionType: 'session-action',
|
||||||
|
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
|
||||||
|
code: 'KeyO',
|
||||||
|
modifiers: ['alt', 'shift'],
|
||||||
|
target: 'toggleVisibleOverlay',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileSessionBindings resolves CommandOrControl per platform', () => {
|
||||||
|
const input = {
|
||||||
|
shortcuts: createShortcuts({
|
||||||
|
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
|
||||||
|
}),
|
||||||
|
keybindings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const windows = compileSessionBindings({ ...input, platform: 'win32' });
|
||||||
|
const mac = compileSessionBindings({ ...input, platform: 'darwin' });
|
||||||
|
|
||||||
|
assert.deepEqual(windows.bindings[0]?.key.modifiers, ['ctrl', 'shift']);
|
||||||
|
assert.deepEqual(mac.bindings[0]?.key.modifiers, ['shift', 'meta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileSessionBindings resolves CommandOrControl in DOM key strings per platform', () => {
|
||||||
|
const input = {
|
||||||
|
shortcuts: createShortcuts(),
|
||||||
|
keybindings: [createKeybinding('CommandOrControl+Shift+J', ['cycle', 'fullscreen'])],
|
||||||
|
statsToggleKey: 'CommandOrControl+Backquote',
|
||||||
|
};
|
||||||
|
|
||||||
|
const windows = compileSessionBindings({ ...input, platform: 'win32' });
|
||||||
|
const mac = compileSessionBindings({ ...input, platform: 'darwin' });
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
windows.bindings
|
||||||
|
.map((binding) => ({
|
||||||
|
sourcePath: binding.sourcePath,
|
||||||
|
modifiers: binding.key.modifiers,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sourcePath: 'keybindings[0].key',
|
||||||
|
modifiers: ['ctrl', 'shift'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourcePath: 'stats.toggleKey',
|
||||||
|
modifiers: ['ctrl'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mac.bindings
|
||||||
|
.map((binding) => ({
|
||||||
|
sourcePath: binding.sourcePath,
|
||||||
|
modifiers: binding.key.modifiers,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sourcePath: 'keybindings[0].key',
|
||||||
|
modifiers: ['shift', 'meta'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourcePath: 'stats.toggleKey',
|
||||||
|
modifiers: ['meta'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileSessionBindings drops conflicting bindings that canonicalize to the same key', () => {
|
||||||
|
const result = compileSessionBindings({
|
||||||
|
shortcuts: createShortcuts({
|
||||||
|
openJimaku: 'Ctrl+Shift+J',
|
||||||
|
}),
|
||||||
|
keybindings: [createKeybinding('Ctrl+Shift+J', [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN])],
|
||||||
|
platform: 'linux',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result.bindings, []);
|
||||||
|
assert.equal(result.warnings.length, 1);
|
||||||
|
assert.equal(result.warnings[0]?.kind, 'conflict');
|
||||||
|
assert.deepEqual(result.warnings[0]?.conflictingPaths, [
|
||||||
|
'shortcuts.openJimaku',
|
||||||
|
'keybindings[0].key',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileSessionBindings omits disabled bindings', () => {
|
||||||
|
const result = compileSessionBindings({
|
||||||
|
shortcuts: createShortcuts({
|
||||||
|
openJimaku: null,
|
||||||
|
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||||
|
}),
|
||||||
|
keybindings: [createKeybinding('Ctrl+Shift+J', null)],
|
||||||
|
platform: 'linux',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.warnings.length, 0);
|
||||||
|
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), [
|
||||||
|
'shortcuts.toggleVisibleOverlayGlobal',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileSessionBindings warns on unsupported shortcut and keybinding syntax', () => {
|
||||||
|
const result = compileSessionBindings({
|
||||||
|
shortcuts: createShortcuts({
|
||||||
|
openJimaku: 'Hyper+J',
|
||||||
|
}),
|
||||||
|
keybindings: [createKeybinding('Ctrl+ß', ['cycle', 'fullscreen'])],
|
||||||
|
platform: 'linux',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result.bindings, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
|
||||||
|
['unsupported:shortcuts.openJimaku', 'unsupported:keybindings[0].key'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileSessionBindings warns on deprecated toggleVisibleOverlayGlobal config', () => {
|
||||||
|
const result = compileSessionBindings({
|
||||||
|
shortcuts: createShortcuts(),
|
||||||
|
keybindings: [],
|
||||||
|
platform: 'linux',
|
||||||
|
rawConfig: {
|
||||||
|
shortcuts: {
|
||||||
|
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.bindings.length, 0);
|
||||||
|
assert.deepEqual(result.warnings, [
|
||||||
|
{
|
||||||
|
kind: 'deprecated-config',
|
||||||
|
path: 'shortcuts.toggleVisibleOverlayGlobal',
|
||||||
|
value: 'Alt+Shift+O',
|
||||||
|
message: 'Rename shortcuts.toggleVisibleOverlayGlobal to shortcuts.toggleVisibleOverlay.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileSessionBindings includes stats toggle in the shared session binding artifact', () => {
|
||||||
|
const result = compileSessionBindings({
|
||||||
|
shortcuts: createShortcuts(),
|
||||||
|
keybindings: [],
|
||||||
|
statsToggleKey: 'Backquote',
|
||||||
|
platform: 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.warnings.length, 0);
|
||||||
|
assert.deepEqual(result.bindings, [
|
||||||
|
{
|
||||||
|
sourcePath: 'stats.toggleKey',
|
||||||
|
originalKey: 'Backquote',
|
||||||
|
key: {
|
||||||
|
code: 'Backquote',
|
||||||
|
modifiers: [],
|
||||||
|
},
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'toggleStatsOverlay',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
431
src/core/services/session-bindings.ts
Normal file
431
src/core/services/session-bindings.ts
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import type { Keybinding, ResolvedConfig } from '../../types';
|
||||||
|
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
|
||||||
|
import type {
|
||||||
|
CompiledMpvCommandBinding,
|
||||||
|
CompiledSessionActionBinding,
|
||||||
|
CompiledSessionBinding,
|
||||||
|
PluginSessionBindingsArtifact,
|
||||||
|
SessionActionId,
|
||||||
|
SessionBindingWarning,
|
||||||
|
SessionKeyModifier,
|
||||||
|
SessionKeySpec,
|
||||||
|
} from '../../types/session-bindings';
|
||||||
|
import { SPECIAL_COMMANDS } from '../../config';
|
||||||
|
|
||||||
|
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
|
||||||
|
|
||||||
|
type CompileSessionBindingsInput = {
|
||||||
|
keybindings: Keybinding[];
|
||||||
|
shortcuts: ConfiguredShortcuts;
|
||||||
|
statsToggleKey?: string | null;
|
||||||
|
platform: PlatformKeyModel;
|
||||||
|
rawConfig?: ResolvedConfig | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraftBinding = {
|
||||||
|
binding: CompiledSessionBinding;
|
||||||
|
actionFingerprint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODIFIER_ORDER: SessionKeyModifier[] = ['ctrl', 'alt', 'shift', 'meta'];
|
||||||
|
|
||||||
|
const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||||
|
key: keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>;
|
||||||
|
actionId: SessionActionId;
|
||||||
|
}> = [
|
||||||
|
{ key: 'toggleVisibleOverlayGlobal', actionId: 'toggleVisibleOverlay' },
|
||||||
|
{ key: 'copySubtitle', actionId: 'copySubtitle' },
|
||||||
|
{ key: 'copySubtitleMultiple', actionId: 'copySubtitleMultiple' },
|
||||||
|
{ key: 'updateLastCardFromClipboard', actionId: 'updateLastCardFromClipboard' },
|
||||||
|
{ key: 'triggerFieldGrouping', actionId: 'triggerFieldGrouping' },
|
||||||
|
{ key: 'triggerSubsync', actionId: 'triggerSubsync' },
|
||||||
|
{ key: 'mineSentence', actionId: 'mineSentence' },
|
||||||
|
{ key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' },
|
||||||
|
{ key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' },
|
||||||
|
{ key: 'markAudioCard', actionId: 'markAudioCard' },
|
||||||
|
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||||
|
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
|
||||||
|
return [...new Set(modifiers)].sort(
|
||||||
|
(left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCodeToken(token: string): string | null {
|
||||||
|
const normalized = token.trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (/^[a-z]$/i.test(normalized)) {
|
||||||
|
return `Key${normalized.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
if (/^[0-9]$/.test(normalized)) {
|
||||||
|
return `Digit${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactMap: Record<string, string> = {
|
||||||
|
space: 'Space',
|
||||||
|
tab: 'Tab',
|
||||||
|
enter: 'Enter',
|
||||||
|
return: 'Enter',
|
||||||
|
esc: 'Escape',
|
||||||
|
escape: 'Escape',
|
||||||
|
up: 'ArrowUp',
|
||||||
|
down: 'ArrowDown',
|
||||||
|
left: 'ArrowLeft',
|
||||||
|
right: 'ArrowRight',
|
||||||
|
backspace: 'Backspace',
|
||||||
|
delete: 'Delete',
|
||||||
|
slash: 'Slash',
|
||||||
|
backslash: 'Backslash',
|
||||||
|
minus: 'Minus',
|
||||||
|
plus: 'Equal',
|
||||||
|
equal: 'Equal',
|
||||||
|
comma: 'Comma',
|
||||||
|
period: 'Period',
|
||||||
|
quote: 'Quote',
|
||||||
|
semicolon: 'Semicolon',
|
||||||
|
bracketleft: 'BracketLeft',
|
||||||
|
bracketright: 'BracketRight',
|
||||||
|
backquote: 'Backquote',
|
||||||
|
};
|
||||||
|
const lower = normalized.toLowerCase();
|
||||||
|
if (exactMap[lower]) return exactMap[lower];
|
||||||
|
if (
|
||||||
|
/^key[a-z]$/i.test(normalized) ||
|
||||||
|
/^digit[0-9]$/i.test(normalized) ||
|
||||||
|
/^arrow(?:up|down|left|right)$/i.test(normalized) ||
|
||||||
|
/^f\d{1,2}$/i.test(normalized)
|
||||||
|
) {
|
||||||
|
return normalized[0]!.toUpperCase() + normalized.slice(1);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAccelerator(
|
||||||
|
accelerator: string,
|
||||||
|
platform: PlatformKeyModel,
|
||||||
|
): { key: SessionKeySpec | null; message?: string } {
|
||||||
|
const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl');
|
||||||
|
if (!normalized) {
|
||||||
|
return { key: null, message: 'Empty accelerator is not supported.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = normalized.split('+').filter(Boolean);
|
||||||
|
const keyToken = parts.pop();
|
||||||
|
if (!keyToken) {
|
||||||
|
return { key: null, message: 'Missing accelerator key token.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiers: SessionKeyModifier[] = [];
|
||||||
|
for (const modifier of parts) {
|
||||||
|
const lower = modifier.toLowerCase();
|
||||||
|
if (lower === 'ctrl' || lower === 'control') {
|
||||||
|
modifiers.push('ctrl');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower === 'alt' || lower === 'option') {
|
||||||
|
modifiers.push('alt');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower === 'shift') {
|
||||||
|
modifiers.push('shift');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') {
|
||||||
|
modifiers.push('meta');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower === 'commandorcontrol') {
|
||||||
|
modifiers.push(platform === 'darwin' ? 'meta' : 'ctrl');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: null,
|
||||||
|
message: `Unsupported accelerator modifier: ${modifier}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = normalizeCodeToken(keyToken);
|
||||||
|
if (!code) {
|
||||||
|
return {
|
||||||
|
key: null,
|
||||||
|
message: `Unsupported accelerator key token: ${keyToken}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: {
|
||||||
|
code,
|
||||||
|
modifiers: normalizeModifiers(modifiers),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDomKeyString(
|
||||||
|
key: string,
|
||||||
|
platform: PlatformKeyModel,
|
||||||
|
): { key: SessionKeySpec | null; message?: string } {
|
||||||
|
const parts = key
|
||||||
|
.split('+')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const keyToken = parts.pop();
|
||||||
|
if (!keyToken) {
|
||||||
|
return { key: null, message: 'Missing keybinding key token.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiers: SessionKeyModifier[] = [];
|
||||||
|
for (const modifier of parts) {
|
||||||
|
const lower = modifier.toLowerCase();
|
||||||
|
if (lower === 'ctrl' || lower === 'control') {
|
||||||
|
modifiers.push('ctrl');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower === 'alt' || lower === 'option') {
|
||||||
|
modifiers.push('alt');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower === 'shift') {
|
||||||
|
modifiers.push('shift');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lower === 'meta' ||
|
||||||
|
lower === 'super' ||
|
||||||
|
lower === 'command' ||
|
||||||
|
lower === 'cmd' ||
|
||||||
|
lower === 'commandorcontrol'
|
||||||
|
) {
|
||||||
|
modifiers.push(
|
||||||
|
lower === 'commandorcontrol' ? (platform === 'darwin' ? 'meta' : 'ctrl') : 'meta',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: null,
|
||||||
|
message: `Unsupported keybinding modifier: ${modifier}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = normalizeCodeToken(keyToken);
|
||||||
|
if (!code) {
|
||||||
|
return {
|
||||||
|
key: null,
|
||||||
|
message: `Unsupported keybinding token: ${keyToken}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: {
|
||||||
|
code,
|
||||||
|
modifiers: normalizeModifiers(modifiers),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionKeySpecSignature(key: SessionKeySpec): string {
|
||||||
|
return [...key.modifiers, key.code].join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCommandBinding(
|
||||||
|
binding: Keybinding,
|
||||||
|
):
|
||||||
|
| Omit<CompiledMpvCommandBinding, 'key' | 'sourcePath' | 'originalKey'>
|
||||||
|
| Omit<CompiledSessionActionBinding, 'key' | 'sourcePath' | 'originalKey'>
|
||||||
|
| null {
|
||||||
|
const command = binding.command ?? [];
|
||||||
|
const first = typeof command[0] === 'string' ? command[0] : '';
|
||||||
|
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
|
||||||
|
return { actionType: 'session-action', actionId: 'triggerSubsync' };
|
||||||
|
}
|
||||||
|
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) {
|
||||||
|
return { actionType: 'session-action', actionId: 'openRuntimeOptions' };
|
||||||
|
}
|
||||||
|
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) {
|
||||||
|
return { actionType: 'session-action', actionId: 'openJimaku' };
|
||||||
|
}
|
||||||
|
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
|
||||||
|
return { actionType: 'session-action', actionId: 'openYoutubePicker' };
|
||||||
|
}
|
||||||
|
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) {
|
||||||
|
return { actionType: 'session-action', actionId: 'openPlaylistBrowser' };
|
||||||
|
}
|
||||||
|
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) {
|
||||||
|
return { actionType: 'session-action', actionId: 'replayCurrentSubtitle' };
|
||||||
|
}
|
||||||
|
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) {
|
||||||
|
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
|
||||||
|
}
|
||||||
|
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
|
||||||
|
return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' };
|
||||||
|
}
|
||||||
|
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
|
||||||
|
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
|
||||||
|
}
|
||||||
|
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||||
|
const [, runtimeOptionId, rawDirection] = first.split(':');
|
||||||
|
return {
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'cycleRuntimeOption',
|
||||||
|
payload: {
|
||||||
|
runtimeOptionId,
|
||||||
|
direction: rawDirection === 'prev' ? -1 : 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
actionType: 'mpv-command',
|
||||||
|
command,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBindingFingerprint(binding: CompiledSessionBinding): string {
|
||||||
|
if (binding.actionType === 'mpv-command') {
|
||||||
|
return `mpv:${JSON.stringify(binding.command)}`;
|
||||||
|
}
|
||||||
|
return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileSessionBindings(
|
||||||
|
input: CompileSessionBindingsInput,
|
||||||
|
): {
|
||||||
|
bindings: CompiledSessionBinding[];
|
||||||
|
warnings: SessionBindingWarning[];
|
||||||
|
} {
|
||||||
|
const warnings: SessionBindingWarning[] = [];
|
||||||
|
const candidates = new Map<string, DraftBinding[]>();
|
||||||
|
const legacyToggleVisibleOverlayGlobal = (
|
||||||
|
input.rawConfig?.shortcuts as Record<string, unknown> | undefined
|
||||||
|
)?.toggleVisibleOverlayGlobal;
|
||||||
|
const statsToggleKey = input.statsToggleKey ?? input.rawConfig?.stats?.toggleKey ?? null;
|
||||||
|
|
||||||
|
if (legacyToggleVisibleOverlayGlobal !== undefined) {
|
||||||
|
warnings.push({
|
||||||
|
kind: 'deprecated-config',
|
||||||
|
path: 'shortcuts.toggleVisibleOverlayGlobal',
|
||||||
|
value: legacyToggleVisibleOverlayGlobal,
|
||||||
|
message: 'Rename shortcuts.toggleVisibleOverlayGlobal to shortcuts.toggleVisibleOverlay.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const shortcut of SESSION_SHORTCUT_ACTIONS) {
|
||||||
|
const accelerator = input.shortcuts[shortcut.key];
|
||||||
|
if (!accelerator) continue;
|
||||||
|
const parsed = parseAccelerator(accelerator, input.platform);
|
||||||
|
if (!parsed.key) {
|
||||||
|
warnings.push({
|
||||||
|
kind: 'unsupported',
|
||||||
|
path: `shortcuts.${shortcut.key}`,
|
||||||
|
value: accelerator,
|
||||||
|
message: parsed.message ?? 'Unsupported accelerator syntax.',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const binding: CompiledSessionActionBinding = {
|
||||||
|
sourcePath: `shortcuts.${shortcut.key}`,
|
||||||
|
originalKey: accelerator,
|
||||||
|
key: parsed.key,
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: shortcut.actionId,
|
||||||
|
};
|
||||||
|
const signature = getSessionKeySpecSignature(parsed.key);
|
||||||
|
const draft = candidates.get(signature) ?? [];
|
||||||
|
draft.push({
|
||||||
|
binding,
|
||||||
|
actionFingerprint: getBindingFingerprint(binding),
|
||||||
|
});
|
||||||
|
candidates.set(signature, draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statsToggleKey) {
|
||||||
|
const parsed = parseDomKeyString(statsToggleKey, input.platform);
|
||||||
|
if (!parsed.key) {
|
||||||
|
warnings.push({
|
||||||
|
kind: 'unsupported',
|
||||||
|
path: 'stats.toggleKey',
|
||||||
|
value: statsToggleKey,
|
||||||
|
message: parsed.message ?? 'Unsupported stats toggle key syntax.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const binding: CompiledSessionActionBinding = {
|
||||||
|
sourcePath: 'stats.toggleKey',
|
||||||
|
originalKey: statsToggleKey,
|
||||||
|
key: parsed.key,
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'toggleStatsOverlay',
|
||||||
|
};
|
||||||
|
const signature = getSessionKeySpecSignature(parsed.key);
|
||||||
|
const draft = candidates.get(signature) ?? [];
|
||||||
|
draft.push({
|
||||||
|
binding,
|
||||||
|
actionFingerprint: getBindingFingerprint(binding),
|
||||||
|
});
|
||||||
|
candidates.set(signature, draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.keybindings.forEach((binding, index) => {
|
||||||
|
if (!binding.command) return;
|
||||||
|
const parsed = parseDomKeyString(binding.key, input.platform);
|
||||||
|
if (!parsed.key) {
|
||||||
|
warnings.push({
|
||||||
|
kind: 'unsupported',
|
||||||
|
path: `keybindings[${index}].key`,
|
||||||
|
value: binding.key,
|
||||||
|
message: parsed.message ?? 'Unsupported keybinding syntax.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = resolveCommandBinding(binding);
|
||||||
|
if (!resolved) return;
|
||||||
|
const compiled: CompiledSessionBinding = {
|
||||||
|
sourcePath: `keybindings[${index}].key`,
|
||||||
|
originalKey: binding.key,
|
||||||
|
key: parsed.key,
|
||||||
|
...resolved,
|
||||||
|
};
|
||||||
|
const signature = getSessionKeySpecSignature(parsed.key);
|
||||||
|
const draft = candidates.get(signature) ?? [];
|
||||||
|
draft.push({
|
||||||
|
binding: compiled,
|
||||||
|
actionFingerprint: getBindingFingerprint(compiled),
|
||||||
|
});
|
||||||
|
candidates.set(signature, draft);
|
||||||
|
});
|
||||||
|
|
||||||
|
const bindings: CompiledSessionBinding[] = [];
|
||||||
|
for (const [signature, draftBindings] of candidates.entries()) {
|
||||||
|
const uniqueFingerprints = new Set(draftBindings.map((entry) => entry.actionFingerprint));
|
||||||
|
if (uniqueFingerprints.size > 1) {
|
||||||
|
warnings.push({
|
||||||
|
kind: 'conflict',
|
||||||
|
path: draftBindings[0]!.binding.sourcePath,
|
||||||
|
value: signature,
|
||||||
|
conflictingPaths: draftBindings.map((entry) => entry.binding.sourcePath),
|
||||||
|
message: `Conflicting session bindings compile to ${signature}; SubMiner will bind neither action.`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bindings.push(draftBindings[0]!.binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
|
||||||
|
return { bindings, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPluginSessionBindingsArtifact(input: {
|
||||||
|
bindings: CompiledSessionBinding[];
|
||||||
|
warnings: SessionBindingWarning[];
|
||||||
|
numericSelectionTimeoutMs: number;
|
||||||
|
now?: Date;
|
||||||
|
}): PluginSessionBindingsArtifact {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
generatedAt: (input.now ?? new Date()).toISOString(),
|
||||||
|
numericSelectionTimeoutMs: input.numericSelectionTimeoutMs,
|
||||||
|
bindings: input.bindings,
|
||||||
|
warnings: input.warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,42 +20,6 @@ export interface RegisterGlobalShortcutsServiceOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void {
|
export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void {
|
||||||
const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal;
|
|
||||||
const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase();
|
|
||||||
const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase();
|
|
||||||
const normalizedSettings = 'alt+shift+y';
|
|
||||||
|
|
||||||
if (visibleShortcut) {
|
|
||||||
const toggleVisibleRegistered = globalShortcut.register(visibleShortcut, () => {
|
|
||||||
options.onToggleVisibleOverlay();
|
|
||||||
});
|
|
||||||
if (!toggleVisibleRegistered) {
|
|
||||||
logger.warn(
|
|
||||||
`Failed to register global shortcut toggleVisibleOverlayGlobal: ${visibleShortcut}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.shortcuts.openJimaku && options.onOpenJimaku) {
|
|
||||||
if (
|
|
||||||
normalizedJimaku &&
|
|
||||||
(normalizedJimaku === normalizedVisible || normalizedJimaku === normalizedSettings)
|
|
||||||
) {
|
|
||||||
logger.warn(
|
|
||||||
'Skipped registering openJimaku because it collides with another global shortcut',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const openJimakuRegistered = globalShortcut.register(options.shortcuts.openJimaku, () => {
|
|
||||||
options.onOpenJimaku?.();
|
|
||||||
});
|
|
||||||
if (!openJimakuRegistered) {
|
|
||||||
logger.warn(
|
|
||||||
`Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => {
|
const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => {
|
||||||
options.onOpenYomitanSettings();
|
options.onOpenYomitanSettings();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
triggerSubsync: false,
|
triggerSubsync: false,
|
||||||
markAudioCard: false,
|
markAudioCard: false,
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
|
openJimaku: false,
|
||||||
|
openYoutubePicker: false,
|
||||||
|
openPlaylistBrowser: false,
|
||||||
|
replayCurrentSubtitle: false,
|
||||||
|
playNextSubtitle: false,
|
||||||
|
shiftSubDelayPrevLine: false,
|
||||||
|
shiftSubDelayNextLine: false,
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
|
|||||||
402
src/main.ts
402
src/main.ts
@@ -109,11 +109,13 @@ import * as os from 'os';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { MecabTokenizer } from './mecab-tokenizer';
|
import { MecabTokenizer } from './mecab-tokenizer';
|
||||||
import type {
|
import type {
|
||||||
|
CompiledSessionBinding,
|
||||||
JimakuApiResponse,
|
JimakuApiResponse,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
MpvSubtitleRenderMetrics,
|
MpvSubtitleRenderMetrics,
|
||||||
ResolvedConfig,
|
ResolvedConfig,
|
||||||
RuntimeOptionState,
|
RuntimeOptionState,
|
||||||
|
SessionActionDispatchRequest,
|
||||||
SecondarySubMode,
|
SecondarySubMode,
|
||||||
SubtitleData,
|
SubtitleData,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
@@ -130,6 +132,14 @@ import {
|
|||||||
type LogLevelSource,
|
type LogLevelSource,
|
||||||
} from './logger';
|
} from './logger';
|
||||||
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
||||||
|
import {
|
||||||
|
bindWindowsOverlayAboveMpvNative,
|
||||||
|
clearWindowsOverlayOwnerNative,
|
||||||
|
ensureWindowsOverlayTransparencyNative,
|
||||||
|
getWindowsForegroundProcessNameNative,
|
||||||
|
queryWindowsForegroundProcessName,
|
||||||
|
setWindowsOverlayOwnerNative,
|
||||||
|
} from './window-trackers/windows-helper';
|
||||||
import {
|
import {
|
||||||
commandNeedsOverlayStartupPrereqs,
|
commandNeedsOverlayStartupPrereqs,
|
||||||
commandNeedsOverlayRuntime,
|
commandNeedsOverlayRuntime,
|
||||||
@@ -342,6 +352,7 @@ import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-reso
|
|||||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||||
import { startStatsServer } from './core/services/stats-server';
|
import { startStatsServer } from './core/services/stats-server';
|
||||||
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
||||||
|
import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/stats-window.js';
|
||||||
import {
|
import {
|
||||||
createFirstRunSetupService,
|
createFirstRunSetupService,
|
||||||
getFirstRunSetupCompletionMessage,
|
getFirstRunSetupCompletionMessage,
|
||||||
@@ -404,6 +415,8 @@ import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
|||||||
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
|
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
|
||||||
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
|
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
|
||||||
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
|
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
|
||||||
|
import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './core/services/session-bindings';
|
||||||
|
import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions';
|
||||||
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
|
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
|
||||||
import { createMainRuntimeRegistry } from './main/runtime/registry';
|
import { createMainRuntimeRegistry } from './main/runtime/registry';
|
||||||
import {
|
import {
|
||||||
@@ -440,6 +453,7 @@ import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
|||||||
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
||||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||||
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
||||||
|
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
|
||||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||||
import {
|
import {
|
||||||
createFrequencyDictionaryRuntimeService,
|
createFrequencyDictionaryRuntimeService,
|
||||||
@@ -1526,6 +1540,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
|||||||
setKeybindings: (keybindings) => {
|
setKeybindings: (keybindings) => {
|
||||||
appState.keybindings = keybindings;
|
appState.keybindings = keybindings;
|
||||||
},
|
},
|
||||||
|
setSessionBindings: (sessionBindings) => {
|
||||||
|
persistSessionBindings(sessionBindings);
|
||||||
|
},
|
||||||
refreshGlobalAndOverlayShortcuts: () => {
|
refreshGlobalAndOverlayShortcuts: () => {
|
||||||
refreshGlobalAndOverlayShortcuts();
|
refreshGlobalAndOverlayShortcuts();
|
||||||
},
|
},
|
||||||
@@ -1835,6 +1852,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
||||||
getWindowTracker: () => appState.windowTracker,
|
getWindowTracker: () => appState.windowTracker,
|
||||||
|
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||||
|
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
||||||
|
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
|
||||||
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||||
appState.trackerNotReadyWarningShown = shown;
|
appState.trackerNotReadyWarningShown = shown;
|
||||||
@@ -1843,6 +1863,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
ensureOverlayWindowLevel: (window) => {
|
ensureOverlayWindowLevel: (window) => {
|
||||||
ensureOverlayWindowLevel(window);
|
ensureOverlayWindowLevel(window);
|
||||||
},
|
},
|
||||||
|
syncWindowsOverlayToMpvZOrder: (_window) => {
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
},
|
||||||
syncPrimaryOverlayWindowLayer: (layer) => {
|
syncPrimaryOverlayWindowLayer: (layer) => {
|
||||||
syncPrimaryOverlayWindowLayer(layer);
|
syncPrimaryOverlayWindowLayer(layer);
|
||||||
},
|
},
|
||||||
@@ -1870,6 +1893,231 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
},
|
},
|
||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
||||||
|
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||||
|
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||||
|
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||||
|
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
|
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||||
|
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let windowsVisibleOverlayForegroundPollInFlight = false;
|
||||||
|
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||||
|
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||||
|
|
||||||
|
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
|
||||||
|
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
windowsVisibleOverlayBlurRefreshTimeouts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||||
|
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
windowsVisibleOverlayZOrderRetryTimeouts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
|
||||||
|
const handle = window.getNativeWindowHandle();
|
||||||
|
return handle.length >= 8
|
||||||
|
? handle.readBigUInt64LE(0).toString()
|
||||||
|
: BigInt(handle.readUInt32LE(0)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
|
||||||
|
const handle = window.getNativeWindowHandle();
|
||||||
|
return handle.length >= 8
|
||||||
|
? Number(handle.readBigUInt64LE(0))
|
||||||
|
: handle.readUInt32LE(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | null): number | null {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32');
|
||||||
|
const poll = win32.findMpvWindows();
|
||||||
|
const focused = poll.matches.find((m) => m.isForeground);
|
||||||
|
return focused?.hwnd ?? poll.matches.sort((a, b) => b.area - a.area)[0]?.hwnd ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (
|
||||||
|
!mainWindow ||
|
||||||
|
mainWindow.isDestroyed() ||
|
||||||
|
!mainWindow.isVisible() ||
|
||||||
|
!overlayManager.getVisibleOverlayVisible()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowTracker = appState.windowTracker;
|
||||||
|
if (!windowTracker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||||
|
windowTracker.isTargetWindowMinimized()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||||
|
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
|
||||||
|
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) {
|
||||||
|
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWindowsVisibleOverlayZOrderSync(): void {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowsVisibleOverlayZOrderSyncInFlight) {
|
||||||
|
windowsVisibleOverlayZOrderSyncQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsVisibleOverlayZOrderSyncInFlight = true;
|
||||||
|
void syncWindowsVisibleOverlayToMpvZOrder()
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn('Failed to bind Windows overlay z-order to mpv', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
|
if (!windowsVisibleOverlayZOrderSyncQueued) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsVisibleOverlayZOrderSyncQueued = false;
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearWindowsVisibleOverlayZOrderRetryTimeouts();
|
||||||
|
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) {
|
||||||
|
const retryTimeout = setTimeout(() => {
|
||||||
|
windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter(
|
||||||
|
(timeout) => timeout !== retryTimeout,
|
||||||
|
);
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
}, delayMs);
|
||||||
|
windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean {
|
||||||
|
return (
|
||||||
|
process.platform === 'win32' &&
|
||||||
|
lastWindowsVisibleOverlayBlurredAtMs > 0 &&
|
||||||
|
Date.now() - lastWindowsVisibleOverlayBlurredAtMs <=
|
||||||
|
WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean {
|
||||||
|
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowTracker = appState.windowTracker;
|
||||||
|
if (!windowTracker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||||
|
windowTracker.isTargetWindowMinimized()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayFocused = mainWindow.isFocused();
|
||||||
|
const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false;
|
||||||
|
return !overlayFocused && !trackerFocused;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePollWindowsVisibleOverlayForegroundProcess(): void {
|
||||||
|
if (!shouldPollWindowsVisibleOverlayForegroundProcess()) {
|
||||||
|
lastWindowsVisibleOverlayForegroundProcessName = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processName = getWindowsForegroundProcessNameNative();
|
||||||
|
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
|
||||||
|
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
|
||||||
|
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
|
||||||
|
|
||||||
|
if (normalizedProcessName !== previousProcessName) {
|
||||||
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') {
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||||
|
if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
||||||
|
maybePollWindowsVisibleOverlayForegroundProcess();
|
||||||
|
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||||
|
clearWindowsVisibleOverlayBlurRefreshTimeouts();
|
||||||
|
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||||
|
const refreshTimeout = setTimeout(() => {
|
||||||
|
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
|
||||||
|
(timeout) => timeout !== refreshTimeout,
|
||||||
|
);
|
||||||
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
|
}, delayMs);
|
||||||
|
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||||
|
|
||||||
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
|
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
|
||||||
{
|
{
|
||||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||||
@@ -3146,6 +3394,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
loadSubtitlePosition: () => loadSubtitlePosition(),
|
loadSubtitlePosition: () => loadSubtitlePosition(),
|
||||||
resolveKeybindings: () => {
|
resolveKeybindings: () => {
|
||||||
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
|
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
|
||||||
|
refreshCurrentSessionBindings();
|
||||||
},
|
},
|
||||||
createMpvClient: () => {
|
createMpvClient: () => {
|
||||||
appState.mpvClient = createMpvClientRuntimeService();
|
appState.mpvClient = createMpvClientRuntimeService();
|
||||||
@@ -3288,6 +3537,9 @@ function ensureOverlayStartupPrereqs(): void {
|
|||||||
}
|
}
|
||||||
if (appState.keybindings.length === 0) {
|
if (appState.keybindings.length === 0) {
|
||||||
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
|
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
|
||||||
|
refreshCurrentSessionBindings();
|
||||||
|
} else if (appState.sessionBindings.length === 0) {
|
||||||
|
refreshCurrentSessionBindings();
|
||||||
}
|
}
|
||||||
if (!appState.mpvClient) {
|
if (!appState.mpvClient) {
|
||||||
appState.mpvClient = createMpvClientRuntimeService();
|
appState.mpvClient = createMpvClientRuntimeService();
|
||||||
@@ -3674,6 +3926,12 @@ function applyOverlayRegions(geometry: WindowGeometry): void {
|
|||||||
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
||||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||||
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||||
|
afterSetOverlayWindowBounds: () => {
|
||||||
|
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleWindowsVisibleOverlayZOrderSyncBurst();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
|
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
|
||||||
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
||||||
@@ -3796,7 +4054,14 @@ function createModalWindow(): BrowserWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMainWindow(): BrowserWindow {
|
function createMainWindow(): BrowserWindow {
|
||||||
return createMainWindowHandler();
|
const window = createMainWindowHandler();
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const overlayHwnd = getWindowsNativeWindowHandleNumber(window);
|
||||||
|
if (!ensureWindowsOverlayTransparencyNative(overlayHwnd)) {
|
||||||
|
logger.warn('Failed to eagerly extend Windows overlay transparency via koffi');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureTray(): void {
|
function ensureTray(): void {
|
||||||
@@ -3873,6 +4138,54 @@ const {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
||||||
|
if (process.platform === 'darwin') return 'darwin';
|
||||||
|
if (process.platform === 'win32') return 'win32';
|
||||||
|
return 'linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileCurrentSessionBindings(): {
|
||||||
|
bindings: CompiledSessionBinding[];
|
||||||
|
warnings: ReturnType<typeof compileSessionBindings>['warnings'];
|
||||||
|
} {
|
||||||
|
return compileSessionBindings({
|
||||||
|
keybindings: appState.keybindings,
|
||||||
|
shortcuts: getConfiguredShortcuts(),
|
||||||
|
statsToggleKey: getResolvedConfig().stats.toggleKey,
|
||||||
|
platform: resolveSessionBindingPlatform(),
|
||||||
|
rawConfig: getResolvedConfig(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSessionBindings(
|
||||||
|
bindings: CompiledSessionBinding[],
|
||||||
|
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||||
|
): void {
|
||||||
|
appState.sessionBindings = bindings;
|
||||||
|
writeSessionBindingsArtifact(
|
||||||
|
CONFIG_DIR,
|
||||||
|
buildPluginSessionBindingsArtifact({
|
||||||
|
bindings,
|
||||||
|
warnings,
|
||||||
|
numericSelectionTimeoutMs: getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (appState.mpvClient?.connected) {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, [
|
||||||
|
'script-message',
|
||||||
|
'subminer-reload-session-bindings',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCurrentSessionBindings(): void {
|
||||||
|
const compiled = compileCurrentSessionBindings();
|
||||||
|
for (const warning of compiled.warnings) {
|
||||||
|
logger.warn(`[session-bindings] ${warning.message}`);
|
||||||
|
}
|
||||||
|
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
||||||
appendToMpvLogMainDeps: {
|
appendToMpvLogMainDeps: {
|
||||||
logPath: DEFAULT_MPV_LOG_PATH,
|
logPath: DEFAULT_MPV_LOG_PATH,
|
||||||
@@ -4184,6 +4497,51 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
|||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showMpvOsd(text),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
||||||
|
await dispatchSessionActionCore(request, {
|
||||||
|
toggleStatsOverlay: () =>
|
||||||
|
toggleStatsOverlayWindow({
|
||||||
|
staticDir: statsDistPath,
|
||||||
|
preloadPath: statsPreloadPath,
|
||||||
|
getApiBaseUrl: () => ensureStatsServerStarted(),
|
||||||
|
getToggleKey: () => getResolvedConfig().stats.toggleKey,
|
||||||
|
resolveBounds: () => getCurrentOverlayGeometry(),
|
||||||
|
onVisibilityChanged: (visible) => {
|
||||||
|
appState.statsOverlayVisible = visible;
|
||||||
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
|
copyCurrentSubtitle: () => copyCurrentSubtitle(),
|
||||||
|
copySubtitleCount: (count) => handleMultiCopyDigit(count),
|
||||||
|
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
|
||||||
|
triggerFieldGrouping: () => triggerFieldGrouping(),
|
||||||
|
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||||
|
mineSentenceCard: () => mineSentenceCard(),
|
||||||
|
mineSentenceCount: (count) => handleMineSentenceDigit(count),
|
||||||
|
toggleSecondarySub: () => handleCycleSecondarySubMode(),
|
||||||
|
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
||||||
|
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||||
|
openJimaku: () => overlayModalRuntime.openJimaku(),
|
||||||
|
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||||
|
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||||
|
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||||
|
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||||
|
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||||
|
shiftSubtitleDelayToAdjacentCueHandler(direction),
|
||||||
|
cycleRuntimeOption: (id, direction) => {
|
||||||
|
if (!appState.runtimeOptionsManager) {
|
||||||
|
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||||
|
}
|
||||||
|
return applyRuntimeOptionResultRuntime(
|
||||||
|
appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||||
|
(text) => showMpvOsd(text),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
showMpvOsd: (text) => showMpvOsd(text),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, {
|
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, {
|
||||||
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
|
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
|
||||||
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
|
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||||
@@ -4341,7 +4699,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
||||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||||
getKeybindings: () => appState.keybindings,
|
getKeybindings: () => appState.keybindings,
|
||||||
|
getSessionBindings: () => appState.sessionBindings,
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
|
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||||
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
|
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
|
||||||
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
|
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
|
||||||
getControllerConfig: () => getResolvedConfig().controller,
|
getControllerConfig: () => getResolvedConfig().controller,
|
||||||
@@ -4462,6 +4822,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
|||||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||||
stopApp: () => requestAppQuit(),
|
stopApp: () => requestAppQuit(),
|
||||||
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
|
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
|
||||||
|
dispatchSessionAction: (request: SessionActionDispatchRequest) => dispatchSessionAction(request),
|
||||||
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||||
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
||||||
logInfo: (message: string) => logger.info(message),
|
logInfo: (message: string) => logger.info(message),
|
||||||
@@ -4595,6 +4956,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
|||||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||||
|
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
||||||
|
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||||
onWindowClosed: (windowKind) => {
|
onWindowClosed: (windowKind) => {
|
||||||
if (windowKind === 'visible') {
|
if (windowKind === 'visible') {
|
||||||
overlayManager.setMainWindow(null);
|
overlayManager.setMainWindow(null);
|
||||||
@@ -4696,6 +5059,9 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
updateVisibleOverlayVisibility: () =>
|
updateVisibleOverlayVisibility: () =>
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||||
},
|
},
|
||||||
|
refreshCurrentSubtitle: () => {
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||||
|
},
|
||||||
overlayShortcutsRuntime: {
|
overlayShortcutsRuntime: {
|
||||||
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||||
},
|
},
|
||||||
@@ -4719,6 +5085,40 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
},
|
},
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||||
updateVisibleOverlayBounds(geometry),
|
updateVisibleOverlayBounds(geometry),
|
||||||
|
bindOverlayOwner: () => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||||
|
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
|
||||||
|
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tracker = appState.windowTracker;
|
||||||
|
const mpvResult = tracker
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32');
|
||||||
|
const poll = win32.findMpvWindows();
|
||||||
|
const focused = poll.matches.find((m) => m.isForeground);
|
||||||
|
return focused ?? poll.matches.sort((a, b) => b.area - a.area)[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
if (!mpvResult) return;
|
||||||
|
if (!setWindowsOverlayOwnerNative(overlayHwnd, mpvResult.hwnd)) {
|
||||||
|
logger.warn('Failed to set overlay owner via koffi');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
releaseOverlayOwner: () => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||||
|
if (!clearWindowsOverlayOwnerNative(overlayHwnd)) {
|
||||||
|
logger.warn('Failed to clear overlay owner via koffi');
|
||||||
|
}
|
||||||
|
},
|
||||||
getOverlayWindows: () => getOverlayWindows(),
|
getOverlayWindows: () => getOverlayWindows(),
|
||||||
getResolvedConfig: () => getResolvedConfig(),
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
showDesktopNotification,
|
showDesktopNotification,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
triggerFieldGrouping: () => Promise<void>;
|
triggerFieldGrouping: () => Promise<void>;
|
||||||
triggerSubsyncFromConfig: () => Promise<void>;
|
triggerSubsyncFromConfig: () => Promise<void>;
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
markLastCardAsAudioCard: () => Promise<void>;
|
||||||
|
dispatchSessionAction: CliCommandRuntimeServiceDepsParams['dispatchSessionAction'];
|
||||||
getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus'];
|
getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus'];
|
||||||
clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken'];
|
clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken'];
|
||||||
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
|
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
|
||||||
@@ -113,6 +114,7 @@ function createCliCommandDepsFromContext(
|
|||||||
hasMainWindow: context.hasMainWindow,
|
hasMainWindow: context.hasMainWindow,
|
||||||
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
||||||
},
|
},
|
||||||
|
dispatchSessionAction: context.dispatchSessionAction,
|
||||||
ui: {
|
ui: {
|
||||||
openFirstRunSetup: context.openFirstRunSetup,
|
openFirstRunSetup: context.openFirstRunSetup,
|
||||||
openYomitanSettings: context.openYomitanSettings,
|
openYomitanSettings: context.openYomitanSettings,
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
|
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
|
||||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||||
|
getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings'];
|
||||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||||
|
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
|
||||||
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
|
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
|
||||||
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
|
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
|
||||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||||
@@ -178,6 +180,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||||
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
||||||
};
|
};
|
||||||
|
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
|
||||||
ui: {
|
ui: {
|
||||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||||
@@ -233,7 +236,9 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
getMecabTokenizer: params.getMecabTokenizer,
|
getMecabTokenizer: params.getMecabTokenizer,
|
||||||
handleMpvCommand: params.handleMpvCommand,
|
handleMpvCommand: params.handleMpvCommand,
|
||||||
getKeybindings: params.getKeybindings,
|
getKeybindings: params.getKeybindings,
|
||||||
|
getSessionBindings: params.getSessionBindings,
|
||||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||||
|
dispatchSessionAction: params.dispatchSessionAction,
|
||||||
getStatsToggleKey: params.getStatsToggleKey,
|
getStatsToggleKey: params.getStatsToggleKey,
|
||||||
getMarkWatchedKey: params.getMarkWatchedKey,
|
getMarkWatchedKey: params.getMarkWatchedKey,
|
||||||
getControllerConfig: params.getControllerConfig,
|
getControllerConfig: params.getControllerConfig,
|
||||||
@@ -347,6 +352,7 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
hasMainWindow: params.app.hasMainWindow,
|
hasMainWindow: params.app.hasMainWindow,
|
||||||
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
||||||
},
|
},
|
||||||
|
dispatchSessionAction: params.dispatchSessionAction,
|
||||||
ui: {
|
ui: {
|
||||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||||
openYomitanSettings: params.ui.openYomitanSettings,
|
openYomitanSettings: params.ui.openYomitanSettings,
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ export interface OverlayVisibilityRuntimeDeps {
|
|||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
getForceMousePassthrough: () => boolean;
|
getForceMousePassthrough: () => boolean;
|
||||||
getWindowTracker: () => BaseWindowTracker | null;
|
getWindowTracker: () => BaseWindowTracker | null;
|
||||||
|
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||||
|
getWindowsOverlayProcessName?: () => string | null;
|
||||||
|
getWindowsFocusHandoffGraceActive?: () => boolean;
|
||||||
getTrackerNotReadyWarningShown: () => boolean;
|
getTrackerNotReadyWarningShown: () => boolean;
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||||
|
syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void;
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||||
enforceOverlayLayerOrder: () => void;
|
enforceOverlayLayerOrder: () => void;
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
@@ -36,12 +40,20 @@ export function createOverlayVisibilityRuntimeService(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
updateVisibleOverlayVisibility(): void {
|
updateVisibleOverlayVisibility(): void {
|
||||||
|
const visibleOverlayVisible = deps.getVisibleOverlayVisible();
|
||||||
|
const forceMousePassthrough = deps.getForceMousePassthrough();
|
||||||
|
const windowTracker = deps.getWindowTracker();
|
||||||
|
const mainWindow = deps.getMainWindow();
|
||||||
|
|
||||||
updateVisibleOverlayVisibility({
|
updateVisibleOverlayVisibility({
|
||||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
visibleOverlayVisible,
|
||||||
modalActive: deps.getModalActive(),
|
modalActive: deps.getModalActive(),
|
||||||
forceMousePassthrough: deps.getForceMousePassthrough(),
|
forceMousePassthrough,
|
||||||
mainWindow: deps.getMainWindow(),
|
mainWindow,
|
||||||
windowTracker: deps.getWindowTracker(),
|
windowTracker,
|
||||||
|
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||||
|
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
|
||||||
|
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||||
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||||
deps.setTrackerNotReadyWarningShown(shown);
|
deps.setTrackerNotReadyWarningShown(shown);
|
||||||
@@ -49,6 +61,8 @@ export function createOverlayVisibilityRuntimeService(
|
|||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||||
deps.updateVisibleOverlayBounds(geometry),
|
deps.updateVisibleOverlayBounds(geometry),
|
||||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||||
|
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
|
||||||
|
deps.syncWindowsOverlayToMpvZOrder?.(window),
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
|
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
|
||||||
deps.syncPrimaryOverlayWindowLayer(layer),
|
deps.syncPrimaryOverlayWindowLayer(layer),
|
||||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ test('build cli command context deps maps handlers and values', () => {
|
|||||||
markLastCardAsAudioCard: async () => {
|
markLastCardAsAudioCard: async () => {
|
||||||
calls.push('mark');
|
calls.push('mark');
|
||||||
},
|
},
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getAnilistStatus: () => ({}) as never,
|
getAnilistStatus: () => ({}) as never,
|
||||||
clearAnilistToken: () => calls.push('clear-token'),
|
clearAnilistToken: () => calls.push('clear-token'),
|
||||||
openAnilistSetup: () => calls.push('anilist'),
|
openAnilistSetup: () => calls.push('anilist'),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
triggerFieldGrouping: () => Promise<void>;
|
triggerFieldGrouping: () => Promise<void>;
|
||||||
triggerSubsyncFromConfig: () => Promise<void>;
|
triggerSubsyncFromConfig: () => Promise<void>;
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
markLastCardAsAudioCard: () => Promise<void>;
|
||||||
|
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
|
||||||
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
|
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
|
||||||
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
|
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
|
||||||
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
|
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
|
||||||
@@ -77,6 +78,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
triggerFieldGrouping: deps.triggerFieldGrouping,
|
triggerFieldGrouping: deps.triggerFieldGrouping,
|
||||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||||
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
|
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
|
||||||
|
dispatchSessionAction: deps.dispatchSessionAction,
|
||||||
getAnilistStatus: deps.getAnilistStatus,
|
getAnilistStatus: deps.getAnilistStatus,
|
||||||
clearAnilistToken: deps.clearAnilistToken,
|
clearAnilistToken: deps.clearAnilistToken,
|
||||||
openAnilistSetup: deps.openAnilistSetup,
|
openAnilistSetup: deps.openAnilistSetup,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
|||||||
triggerFieldGrouping: async () => {},
|
triggerFieldGrouping: async () => {},
|
||||||
triggerSubsyncFromConfig: async () => {},
|
triggerSubsyncFromConfig: async () => {},
|
||||||
markLastCardAsAudioCard: async () => {},
|
markLastCardAsAudioCard: async () => {},
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getAnilistStatus: () => ({
|
getAnilistStatus: () => ({
|
||||||
tokenStatus: 'resolved',
|
tokenStatus: 'resolved',
|
||||||
tokenSource: 'literal',
|
tokenSource: 'literal',
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
|||||||
markLastCardAsAudioCard: async () => {
|
markLastCardAsAudioCard: async () => {
|
||||||
calls.push('mark-audio');
|
calls.push('mark-audio');
|
||||||
},
|
},
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
|
|
||||||
getAnilistStatus: () => ({
|
getAnilistStatus: () => ({
|
||||||
tokenStatus: 'resolved',
|
tokenStatus: 'resolved',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
triggerFieldGrouping: () => Promise<void>;
|
triggerFieldGrouping: () => Promise<void>;
|
||||||
triggerSubsyncFromConfig: () => Promise<void>;
|
triggerSubsyncFromConfig: () => Promise<void>;
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
markLastCardAsAudioCard: () => Promise<void>;
|
||||||
|
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
|
||||||
|
|
||||||
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
|
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
|
||||||
clearAnilistToken: () => void;
|
clearAnilistToken: () => void;
|
||||||
@@ -103,6 +104,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
|
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
|
||||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||||
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
|
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
|
||||||
|
dispatchSessionAction: (request) => deps.dispatchSessionAction(request),
|
||||||
getAnilistStatus: () => deps.getAnilistStatus(),
|
getAnilistStatus: () => deps.getAnilistStatus(),
|
||||||
clearAnilistToken: () => deps.clearAnilistToken(),
|
clearAnilistToken: () => deps.clearAnilistToken(),
|
||||||
openAnilistSetup: () => deps.openAnilistSetupWindow(),
|
openAnilistSetup: () => deps.openAnilistSetupWindow(),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function createDeps() {
|
|||||||
triggerFieldGrouping: async () => {},
|
triggerFieldGrouping: async () => {},
|
||||||
triggerSubsyncFromConfig: async () => {},
|
triggerSubsyncFromConfig: async () => {},
|
||||||
markLastCardAsAudioCard: async () => {},
|
markLastCardAsAudioCard: async () => {},
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getAnilistStatus: () => ({}) as never,
|
getAnilistStatus: () => ({}) as never,
|
||||||
clearAnilistToken: () => {},
|
clearAnilistToken: () => {},
|
||||||
openAnilistSetup: () => {},
|
openAnilistSetup: () => {},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type CliCommandContextFactoryDeps = {
|
|||||||
triggerFieldGrouping: () => Promise<void>;
|
triggerFieldGrouping: () => Promise<void>;
|
||||||
triggerSubsyncFromConfig: () => Promise<void>;
|
triggerSubsyncFromConfig: () => Promise<void>;
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
markLastCardAsAudioCard: () => Promise<void>;
|
||||||
|
dispatchSessionAction: CliCommandRuntimeServiceContext['dispatchSessionAction'];
|
||||||
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
||||||
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
|
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
|
||||||
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
|
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
|
||||||
@@ -89,6 +90,7 @@ export function createCliCommandContext(
|
|||||||
triggerFieldGrouping: deps.triggerFieldGrouping,
|
triggerFieldGrouping: deps.triggerFieldGrouping,
|
||||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||||
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
|
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
|
||||||
|
dispatchSessionAction: deps.dispatchSessionAction,
|
||||||
getAnilistStatus: deps.getAnilistStatus,
|
getAnilistStatus: deps.getAnilistStatus,
|
||||||
clearAnilistToken: deps.clearAnilistToken,
|
clearAnilistToken: deps.clearAnilistToken,
|
||||||
openAnilistSetup: deps.openAnilistSetup,
|
openAnilistSetup: deps.openAnilistSetup,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
|||||||
triggerFieldGrouping: async () => {},
|
triggerFieldGrouping: async () => {},
|
||||||
triggerSubsyncFromConfig: async () => {},
|
triggerSubsyncFromConfig: async () => {},
|
||||||
markLastCardAsAudioCard: async () => {},
|
markLastCardAsAudioCard: async () => {},
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getAnilistStatus: () => ({}) as never,
|
getAnilistStatus: () => ({}) as never,
|
||||||
clearAnilistToken: () => {},
|
clearAnilistToken: () => {},
|
||||||
openAnilistSetupWindow: () => {},
|
openAnilistSetupWindow: () => {},
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
saveSubtitlePosition: () => {},
|
saveSubtitlePosition: () => {},
|
||||||
getMecabTokenizer: () => null,
|
getMecabTokenizer: () => null,
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
|
getSessionBindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}) as never,
|
getConfiguredShortcuts: () => ({}) as never,
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
getStatsToggleKey: () => 'Backquote',
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getMarkWatchedKey: () => 'KeyW',
|
getMarkWatchedKey: () => 'KeyW',
|
||||||
getControllerConfig: () => ({}) as never,
|
getControllerConfig: () => ({}) as never,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
|||||||
|
|
||||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||||
setKeybindings: () => calls.push('set:keybindings'),
|
setKeybindings: () => calls.push('set:keybindings'),
|
||||||
|
setSessionBindings: () => calls.push('set:session-bindings'),
|
||||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||||
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
|
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
|
||||||
broadcastToOverlayWindows: (channel, payload) =>
|
broadcastToOverlayWindows: (channel, payload) =>
|
||||||
@@ -37,6 +38,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(calls.includes('set:keybindings'));
|
assert.ok(calls.includes('set:keybindings'));
|
||||||
|
assert.ok(calls.includes('set:session-bindings'));
|
||||||
assert.ok(calls.includes('refresh:shortcuts'));
|
assert.ok(calls.includes('refresh:shortcuts'));
|
||||||
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
|
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
|
||||||
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
|
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
|
||||||
@@ -50,6 +52,7 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
|
|||||||
|
|
||||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||||
setKeybindings: () => calls.push('set:keybindings'),
|
setKeybindings: () => calls.push('set:keybindings'),
|
||||||
|
setSessionBindings: () => calls.push('set:session-bindings'),
|
||||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||||
setSecondarySubMode: () => calls.push('set:secondary'),
|
setSecondarySubMode: () => calls.push('set:secondary'),
|
||||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||||
@@ -64,7 +67,7 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
|
|||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(calls, ['set:keybindings']);
|
assert.deepEqual(calls, ['set:keybindings', 'set:session-bindings']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
|
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
||||||
|
import { compileSessionBindings } from '../../core/services/session-bindings';
|
||||||
import { resolveKeybindings } from '../../core/utils/keybindings';
|
import { resolveKeybindings } from '../../core/utils/keybindings';
|
||||||
import { DEFAULT_KEYBINDINGS } from '../../config';
|
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||||
|
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
|
||||||
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
||||||
|
|
||||||
type ConfigHotReloadAppliedDeps = {
|
type ConfigHotReloadAppliedDeps = {
|
||||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||||
|
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void;
|
||||||
refreshGlobalAndOverlayShortcuts: () => void;
|
refreshGlobalAndOverlayShortcuts: () => void;
|
||||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||||
@@ -33,8 +36,21 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
|
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
|
||||||
|
const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS);
|
||||||
|
const { bindings: sessionBindings } = compileSessionBindings({
|
||||||
|
keybindings,
|
||||||
|
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
|
||||||
|
platform:
|
||||||
|
process.platform === 'darwin'
|
||||||
|
? 'darwin'
|
||||||
|
: process.platform === 'win32'
|
||||||
|
? 'win32'
|
||||||
|
: 'linux',
|
||||||
|
rawConfig: config,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
|
keybindings,
|
||||||
|
sessionBindings,
|
||||||
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
||||||
subtitleSidebar: config.subtitleSidebar,
|
subtitleSidebar: config.subtitleSidebar,
|
||||||
secondarySubMode: config.secondarySub.defaultMode,
|
secondarySubMode: config.secondarySub.defaultMode,
|
||||||
@@ -45,6 +61,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
|||||||
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
|
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
|
||||||
const payload = buildConfigHotReloadPayload(config);
|
const payload = buildConfigHotReloadPayload(config);
|
||||||
deps.setKeybindings(payload.keybindings);
|
deps.setKeybindings(payload.keybindings);
|
||||||
|
deps.setSessionBindings(payload.sessionBindings);
|
||||||
|
|
||||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
if (diff.hotReloadFields.includes('shortcuts')) {
|
||||||
deps.refreshGlobalAndOverlayShortcuts();
|
deps.refreshGlobalAndOverlayShortcuts();
|
||||||
|
|||||||
@@ -86,21 +86,25 @@ test('config hot reload message main deps builder maps notifications', () => {
|
|||||||
|
|
||||||
test('config hot reload applied main deps builder maps callbacks', () => {
|
test('config hot reload applied main deps builder maps callbacks', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const deps = createBuildConfigHotReloadAppliedMainDepsHandler({
|
const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({
|
||||||
setKeybindings: () => calls.push('keybindings'),
|
setKeybindings: () => calls.push('keybindings'),
|
||||||
|
setSessionBindings: () => calls.push('session-bindings'),
|
||||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
|
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
|
||||||
setSecondarySubMode: () => calls.push('set-secondary'),
|
setSecondarySubMode: () => calls.push('set-secondary'),
|
||||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||||
applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'),
|
applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'),
|
||||||
})();
|
});
|
||||||
|
|
||||||
|
const deps = buildDeps();
|
||||||
deps.setKeybindings([]);
|
deps.setKeybindings([]);
|
||||||
|
deps.setSessionBindings([]);
|
||||||
deps.refreshGlobalAndOverlayShortcuts();
|
deps.refreshGlobalAndOverlayShortcuts();
|
||||||
deps.setSecondarySubMode('hover');
|
deps.setSecondarySubMode('hover');
|
||||||
deps.broadcastToOverlayWindows('config:hot-reload', {});
|
deps.broadcastToOverlayWindows('config:hot-reload', {});
|
||||||
deps.applyAnkiRuntimeConfigPatch({ ai: true });
|
deps.applyAnkiRuntimeConfigPatch({ ai: true });
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'keybindings',
|
'keybindings',
|
||||||
|
'session-bindings',
|
||||||
'refresh-shortcuts',
|
'refresh-shortcuts',
|
||||||
'set-secondary',
|
'set-secondary',
|
||||||
'broadcast:config:hot-reload',
|
'broadcast:config:hot-reload',
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
|
|||||||
|
|
||||||
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||||
|
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void;
|
||||||
refreshGlobalAndOverlayShortcuts: () => void;
|
refreshGlobalAndOverlayShortcuts: () => void;
|
||||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||||
@@ -72,6 +73,8 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
|||||||
return () => ({
|
return () => ({
|
||||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
||||||
deps.setKeybindings(keybindings),
|
deps.setKeybindings(keybindings),
|
||||||
|
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) =>
|
||||||
|
deps.setSessionBindings(sessionBindings),
|
||||||
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
|
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
|
||||||
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
|
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
|
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
triggerSubsync: false,
|
triggerSubsync: false,
|
||||||
markAudioCard: false,
|
markAudioCard: false,
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
|
openJimaku: false,
|
||||||
|
openYoutubePicker: false,
|
||||||
|
openPlaylistBrowser: false,
|
||||||
|
replayCurrentSubtitle: false,
|
||||||
|
playNextSubtitle: false,
|
||||||
|
shiftSubDelayPrevLine: false,
|
||||||
|
shiftSubDelayNextLine: false,
|
||||||
anilistStatus: false,
|
anilistStatus: false,
|
||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type InitializeOverlayRuntimeCore = (options: {
|
|||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
|
bindOverlayOwner?: () => void;
|
||||||
|
releaseOverlayOwner?: () => void;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
|||||||
overlayVisibilityRuntime: {
|
overlayVisibilityRuntime: {
|
||||||
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
||||||
},
|
},
|
||||||
|
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||||
overlayShortcutsRuntime: {
|
overlayShortcutsRuntime: {
|
||||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||||
},
|
},
|
||||||
@@ -53,6 +54,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
|||||||
deps.registerGlobalShortcuts();
|
deps.registerGlobalShortcuts();
|
||||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||||
deps.updateVisibleOverlayVisibility();
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
deps.refreshCurrentSubtitle?.();
|
||||||
deps.syncOverlayShortcuts();
|
deps.syncOverlayShortcuts();
|
||||||
deps.showDesktopNotification('title', {});
|
deps.showDesktopNotification('title', {});
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
|||||||
'register-shortcuts',
|
'register-shortcuts',
|
||||||
'visible-bounds',
|
'visible-bounds',
|
||||||
'update-visible',
|
'update-visible',
|
||||||
|
'refresh-subtitle',
|
||||||
'sync-shortcuts',
|
'sync-shortcuts',
|
||||||
'notify',
|
'notify',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
overlayVisibilityRuntime: {
|
overlayVisibilityRuntime: {
|
||||||
updateVisibleOverlayVisibility: () => void;
|
updateVisibleOverlayVisibility: () => void;
|
||||||
};
|
};
|
||||||
|
refreshCurrentSubtitle?: () => void;
|
||||||
overlayShortcutsRuntime: {
|
overlayShortcutsRuntime: {
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
};
|
};
|
||||||
@@ -39,6 +40,8 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
|
bindOverlayOwner?: () => void;
|
||||||
|
releaseOverlayOwner?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (): OverlayRuntimeOptionsMainDeps => ({
|
return (): OverlayRuntimeOptionsMainDeps => ({
|
||||||
getBackendOverride: () => deps.appState.backendOverride,
|
getBackendOverride: () => deps.appState.backendOverride,
|
||||||
@@ -53,6 +56,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
|
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
|
||||||
updateVisibleOverlayVisibility: () =>
|
updateVisibleOverlayVisibility: () =>
|
||||||
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||||
|
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
|
||||||
getOverlayWindows: () => deps.getOverlayWindows(),
|
getOverlayWindows: () => deps.getOverlayWindows(),
|
||||||
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||||
setWindowTracker: (tracker) => {
|
setWindowTracker: (tracker) => {
|
||||||
@@ -71,5 +75,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||||
|
bindOverlayOwner: deps.bindOverlayOwner,
|
||||||
|
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
|||||||
updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'),
|
updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'),
|
||||||
isVisibleOverlayVisible: () => true,
|
isVisibleOverlayVisible: () => true,
|
||||||
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
||||||
|
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||||
getOverlayWindows: () => [],
|
getOverlayWindows: () => [],
|
||||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||||
setWindowTracker: () => calls.push('set-tracker'),
|
setWindowTracker: () => calls.push('set-tracker'),
|
||||||
@@ -41,6 +42,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
|||||||
options.registerGlobalShortcuts();
|
options.registerGlobalShortcuts();
|
||||||
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||||
options.updateVisibleOverlayVisibility();
|
options.updateVisibleOverlayVisibility();
|
||||||
|
options.refreshCurrentSubtitle?.();
|
||||||
options.syncOverlayShortcuts();
|
options.syncOverlayShortcuts();
|
||||||
options.setWindowTracker(null);
|
options.setWindowTracker(null);
|
||||||
options.setAnkiIntegration(null);
|
options.setAnkiIntegration(null);
|
||||||
@@ -51,6 +53,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
|||||||
'register-shortcuts',
|
'register-shortcuts',
|
||||||
'update-visible-bounds',
|
'update-visible-bounds',
|
||||||
'update-visible',
|
'update-visible',
|
||||||
|
'refresh-subtitle',
|
||||||
'sync-shortcuts',
|
'sync-shortcuts',
|
||||||
'set-tracker',
|
'set-tracker',
|
||||||
'set-anki',
|
'set-anki',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type OverlayRuntimeOptions = {
|
|||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
isVisibleOverlayVisible: () => boolean;
|
isVisibleOverlayVisible: () => boolean;
|
||||||
updateVisibleOverlayVisibility: () => void;
|
updateVisibleOverlayVisibility: () => void;
|
||||||
|
refreshCurrentSubtitle?: () => void;
|
||||||
getOverlayWindows: () => BrowserWindow[];
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||||
@@ -35,6 +36,8 @@ type OverlayRuntimeOptions = {
|
|||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
|
bindOverlayOwner?: () => void;
|
||||||
|
releaseOverlayOwner?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||||
@@ -44,6 +47,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
isVisibleOverlayVisible: () => boolean;
|
isVisibleOverlayVisible: () => boolean;
|
||||||
updateVisibleOverlayVisibility: () => void;
|
updateVisibleOverlayVisibility: () => void;
|
||||||
|
refreshCurrentSubtitle?: () => void;
|
||||||
getOverlayWindows: () => BrowserWindow[];
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||||
@@ -65,6 +69,8 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
|
bindOverlayOwner?: () => void;
|
||||||
|
releaseOverlayOwner?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (): OverlayRuntimeOptions => ({
|
return (): OverlayRuntimeOptions => ({
|
||||||
backendOverride: deps.getBackendOverride(),
|
backendOverride: deps.getBackendOverride(),
|
||||||
@@ -73,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
|
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
|
||||||
isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
|
isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
|
||||||
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
|
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
|
||||||
|
refreshCurrentSubtitle: deps.refreshCurrentSubtitle,
|
||||||
getOverlayWindows: deps.getOverlayWindows,
|
getOverlayWindows: deps.getOverlayWindows,
|
||||||
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
||||||
setWindowTracker: deps.setWindowTracker,
|
setWindowTracker: deps.setWindowTracker,
|
||||||
@@ -87,5 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||||
|
bindOverlayOwner: deps.bindOverlayOwner,
|
||||||
|
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
getVisibleOverlayVisible: () => true,
|
getVisibleOverlayVisible: () => true,
|
||||||
getForceMousePassthrough: () => true,
|
getForceMousePassthrough: () => true,
|
||||||
getWindowTracker: () => tracker,
|
getWindowTracker: () => tracker,
|
||||||
|
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
||||||
|
getWindowsOverlayProcessName: () => 'subminer',
|
||||||
|
getWindowsFocusHandoffGraceActive: () => true,
|
||||||
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
|
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
|
||||||
setTrackerNotReadyWarningShown: (shown) => {
|
setTrackerNotReadyWarningShown: (shown) => {
|
||||||
trackerNotReadyWarningShown = shown;
|
trackerNotReadyWarningShown = shown;
|
||||||
@@ -23,6 +26,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
},
|
},
|
||||||
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
|
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
|
||||||
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
||||||
|
syncWindowsOverlayToMpvZOrder: () => calls.push('sync-windows-z-order'),
|
||||||
syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`),
|
syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`),
|
||||||
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
|
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
|
||||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||||
@@ -36,10 +40,14 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
assert.equal(deps.getModalActive(), true);
|
assert.equal(deps.getModalActive(), true);
|
||||||
assert.equal(deps.getVisibleOverlayVisible(), true);
|
assert.equal(deps.getVisibleOverlayVisible(), true);
|
||||||
assert.equal(deps.getForceMousePassthrough(), true);
|
assert.equal(deps.getForceMousePassthrough(), true);
|
||||||
|
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
||||||
|
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
||||||
|
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
||||||
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
|
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
|
||||||
deps.setTrackerNotReadyWarningShown(true);
|
deps.setTrackerNotReadyWarningShown(true);
|
||||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||||
deps.ensureOverlayWindowLevel(mainWindow);
|
deps.ensureOverlayWindowLevel(mainWindow);
|
||||||
|
deps.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
||||||
deps.syncPrimaryOverlayWindowLayer('visible');
|
deps.syncPrimaryOverlayWindowLayer('visible');
|
||||||
deps.enforceOverlayLayerOrder();
|
deps.enforceOverlayLayerOrder();
|
||||||
deps.syncOverlayShortcuts();
|
deps.syncOverlayShortcuts();
|
||||||
@@ -52,6 +60,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
'tracker-warning:true',
|
'tracker-warning:true',
|
||||||
'visible-bounds',
|
'visible-bounds',
|
||||||
'ensure-level',
|
'ensure-level',
|
||||||
|
'sync-windows-z-order',
|
||||||
'primary-layer:visible',
|
'primary-layer:visible',
|
||||||
'enforce-order',
|
'enforce-order',
|
||||||
'sync-shortcuts',
|
'sync-shortcuts',
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
|||||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||||
getWindowTracker: () => deps.getWindowTracker(),
|
getWindowTracker: () => deps.getWindowTracker(),
|
||||||
|
getLastKnownWindowsForegroundProcessName: () =>
|
||||||
|
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
||||||
|
getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null,
|
||||||
|
getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||||
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
|
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
|
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||||
deps.updateVisibleOverlayBounds(geometry),
|
deps.updateVisibleOverlayBounds(geometry),
|
||||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||||
|
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
|
||||||
|
deps.syncWindowsOverlayToMpvZOrder?.(window),
|
||||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer),
|
syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer),
|
||||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
|
onVisibleWindowBlurred?: () => void;
|
||||||
|
onWindowContentReady?: () => void;
|
||||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||||
yomitanSession?: Session | null;
|
yomitanSession?: Session | null;
|
||||||
},
|
},
|
||||||
@@ -22,6 +24,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
|
onVisibleWindowBlurred?: () => void;
|
||||||
|
onWindowContentReady?: () => void;
|
||||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||||
getYomitanSession?: () => Session | null;
|
getYomitanSession?: () => Session | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -34,6 +38,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: deps.isOverlayVisible,
|
isOverlayVisible: deps.isOverlayVisible,
|
||||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||||
forwardTabToMpv: deps.forwardTabToMpv,
|
forwardTabToMpv: deps.forwardTabToMpv,
|
||||||
|
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||||
|
onWindowContentReady: deps.onWindowContentReady,
|
||||||
onWindowClosed: deps.onWindowClosed,
|
onWindowClosed: deps.onWindowClosed,
|
||||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
|
onVisibleWindowBlurred?: () => void;
|
||||||
|
onWindowContentReady?: () => void;
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||||
yomitanSession?: Session | null;
|
yomitanSession?: Session | null;
|
||||||
},
|
},
|
||||||
@@ -24,6 +26,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
forwardTabToMpv: () => void;
|
forwardTabToMpv: () => void;
|
||||||
|
onVisibleWindowBlurred?: () => void;
|
||||||
|
onWindowContentReady?: () => void;
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||||
getYomitanSession?: () => Session | null;
|
getYomitanSession?: () => Session | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -36,6 +40,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
isOverlayVisible: deps.isOverlayVisible,
|
isOverlayVisible: deps.isOverlayVisible,
|
||||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||||
forwardTabToMpv: deps.forwardTabToMpv,
|
forwardTabToMpv: deps.forwardTabToMpv,
|
||||||
|
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||||
|
onWindowContentReady: deps.onWindowContentReady,
|
||||||
onWindowClosed: deps.onWindowClosed,
|
onWindowClosed: deps.onWindowClosed,
|
||||||
yomitanSession: deps.getYomitanSession?.() ?? null,
|
yomitanSession: deps.getYomitanSession?.() ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
|
|||||||
) {
|
) {
|
||||||
return (): UpdateVisibleOverlayBoundsMainDeps => ({
|
return (): UpdateVisibleOverlayBoundsMainDeps => ({
|
||||||
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
|
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
|
||||||
|
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ test('visible bounds handler writes visible layer geometry', () => {
|
|||||||
assert.deepEqual(calls, [geometry]);
|
assert.deepEqual(calls, [geometry]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('visible bounds handler runs follow-up callback after applying geometry', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const geometry = { x: 0, y: 0, width: 100, height: 50 };
|
||||||
|
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
|
||||||
|
setOverlayWindowBounds: () => calls.push('set-bounds'),
|
||||||
|
afterSetOverlayWindowBounds: (nextGeometry) => {
|
||||||
|
assert.deepEqual(nextGeometry, geometry);
|
||||||
|
calls.push('after-bounds');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleVisible(geometry);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['set-bounds', 'after-bounds']);
|
||||||
|
});
|
||||||
|
|
||||||
test('ensure overlay window level handler delegates to core', () => {
|
test('ensure overlay window level handler delegates to core', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import type { WindowGeometry } from '../../types';
|
|||||||
|
|
||||||
export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
||||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
|
afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void;
|
||||||
}) {
|
}) {
|
||||||
return (geometry: WindowGeometry): void => {
|
return (geometry: WindowGeometry): void => {
|
||||||
deps.setOverlayWindowBounds(geometry);
|
deps.setOverlayWindowBounds(geometry);
|
||||||
|
deps.afterSetOverlayWindowBounds?.(geometry);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
src/main/runtime/session-bindings-artifact.ts
Normal file
17
src/main/runtime/session-bindings-artifact.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { PluginSessionBindingsArtifact } from '../../types';
|
||||||
|
|
||||||
|
export function getSessionBindingsArtifactPath(configDir: string): string {
|
||||||
|
return path.join(configDir, 'session-bindings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSessionBindingsArtifact(
|
||||||
|
configDir: string,
|
||||||
|
artifact: PluginSessionBindingsArtifact,
|
||||||
|
): string {
|
||||||
|
const artifactPath = getSessionBindingsArtifactPath(configDir);
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
|
||||||
|
return artifactPath;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CompiledSessionBinding,
|
||||||
Keybinding,
|
Keybinding,
|
||||||
MpvSubtitleRenderMetrics,
|
MpvSubtitleRenderMetrics,
|
||||||
SecondarySubMode,
|
SecondarySubMode,
|
||||||
@@ -170,6 +171,7 @@ export interface AppState {
|
|||||||
anilistClientSecretState: AnilistSecretResolutionState;
|
anilistClientSecretState: AnilistSecretResolutionState;
|
||||||
mecabTokenizer: MecabTokenizer | null;
|
mecabTokenizer: MecabTokenizer | null;
|
||||||
keybindings: Keybinding[];
|
keybindings: Keybinding[];
|
||||||
|
sessionBindings: CompiledSessionBinding[];
|
||||||
subtitleTimingTracker: SubtitleTimingTracker | null;
|
subtitleTimingTracker: SubtitleTimingTracker | null;
|
||||||
immersionTracker: ImmersionTrackerService | null;
|
immersionTracker: ImmersionTrackerService | null;
|
||||||
ankiIntegration: AnkiIntegration | null;
|
ankiIntegration: AnkiIntegration | null;
|
||||||
@@ -252,6 +254,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
|||||||
anilistClientSecretState: createInitialAnilistSecretResolutionState(),
|
anilistClientSecretState: createInitialAnilistSecretResolutionState(),
|
||||||
mecabTokenizer: null,
|
mecabTokenizer: null,
|
||||||
keybindings: [],
|
keybindings: [],
|
||||||
|
sessionBindings: [],
|
||||||
subtitleTimingTracker: null,
|
subtitleTimingTracker: null,
|
||||||
immersionTracker: null,
|
immersionTracker: null,
|
||||||
ankiIntegration: null,
|
ankiIntegration: null,
|
||||||
|
|||||||
@@ -223,8 +223,11 @@ const electronAPI: ElectronAPI = {
|
|||||||
|
|
||||||
getKeybindings: (): Promise<Keybinding[]> =>
|
getKeybindings: (): Promise<Keybinding[]> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
||||||
|
getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings),
|
||||||
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
||||||
|
dispatchSessionAction: (actionId, payload) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.command.dispatchSessionAction, { actionId, payload }),
|
||||||
getStatsToggleKey: (): Promise<string> =>
|
getStatsToggleKey: (): Promise<string> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey),
|
||||||
getMarkWatchedKey: (): Promise<string> =>
|
getMarkWatchedKey: (): Promise<string> =>
|
||||||
|
|||||||
61
src/prerelease-workflow.test.ts
Normal file
61
src/prerelease-workflow.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml');
|
||||||
|
const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8');
|
||||||
|
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||||
|
scripts: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('prerelease workflow triggers on beta and rc tags only', () => {
|
||||||
|
assert.match(prereleaseWorkflow, /name: Prerelease/);
|
||||||
|
assert.match(prereleaseWorkflow, /tags:\s*\n\s*-\s*'v\*-beta\.\*'/);
|
||||||
|
assert.match(prereleaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'v\*-rc\.\*'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('package scripts expose prerelease notes generation separately from stable changelog build', () => {
|
||||||
|
assert.equal(
|
||||||
|
packageJson.scripts['changelog:prerelease-notes'],
|
||||||
|
'bun run scripts/build-changelog.ts prerelease-notes',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prerelease workflow generates prerelease notes from pending fragments', () => {
|
||||||
|
assert.match(prereleaseWorkflow, /bun run changelog:prerelease-notes --version/);
|
||||||
|
assert.doesNotMatch(prereleaseWorkflow, /bun run changelog:build --version/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => {
|
||||||
|
assert.match(prereleaseWorkflow, /gh release edit[\s\S]*--prerelease/);
|
||||||
|
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--prerelease/);
|
||||||
|
assert.match(prereleaseWorkflow, /gh release create[\s\S]*--latest=false/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prerelease workflow builds and uploads all release platforms', () => {
|
||||||
|
assert.match(prereleaseWorkflow, /build-linux:/);
|
||||||
|
assert.match(prereleaseWorkflow, /build-macos:/);
|
||||||
|
assert.match(prereleaseWorkflow, /build-windows:/);
|
||||||
|
assert.match(prereleaseWorkflow, /name: appimage/);
|
||||||
|
assert.match(prereleaseWorkflow, /name: macos/);
|
||||||
|
assert.match(prereleaseWorkflow, /name: windows/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prerelease workflow publishes the same release assets as the stable workflow', () => {
|
||||||
|
assert.match(
|
||||||
|
prereleaseWorkflow,
|
||||||
|
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
prereleaseWorkflow,
|
||||||
|
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prerelease workflow does not publish to AUR', () => {
|
||||||
|
assert.doesNotMatch(prereleaseWorkflow, /aur-publish:/);
|
||||||
|
assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
|
||||||
|
assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/);
|
||||||
|
});
|
||||||
@@ -22,6 +22,12 @@ test('publish release leaves prerelease unset so gh creates a normal release', (
|
|||||||
assert.ok(!releaseWorkflow.includes('--prerelease'));
|
assert.ok(!releaseWorkflow.includes('--prerelease'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stable release workflow excludes prerelease beta and rc tags', () => {
|
||||||
|
assert.match(releaseWorkflow, /tags:\s*\n\s*-\s*'v\*'/);
|
||||||
|
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-beta\.\*'/);
|
||||||
|
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-rc\.\*'/);
|
||||||
|
});
|
||||||
|
|
||||||
test('publish release forces an existing draft tag release to become public', () => {
|
test('publish release forces an existing draft tag release to become public', () => {
|
||||||
assert.ok(releaseWorkflow.includes('--draft=false'));
|
assert.ok(releaseWorkflow.includes('--draft=false'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { createRendererRecoveryController } from './error-recovery.js';
|
import { createRendererRecoveryController } from './error-recovery.js';
|
||||||
import {
|
import {
|
||||||
|
YOMITAN_POPUP_HOST_SELECTOR,
|
||||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||||
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
hasYomitanPopupIframe,
|
hasYomitanPopupIframe,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
isYomitanPopupVisible,
|
isYomitanPopupVisible,
|
||||||
@@ -228,6 +230,42 @@ test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolvePlatformInfo flags Windows platforms', () => {
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getOverlayLayer: () => 'visible',
|
||||||
|
},
|
||||||
|
location: { search: '' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
platform: 'Win32',
|
||||||
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = resolvePlatformInfo();
|
||||||
|
assert.equal(info.isWindowsPlatform, true);
|
||||||
|
assert.equal(info.isMacOSPlatform, false);
|
||||||
|
assert.equal(info.isLinuxPlatform, false);
|
||||||
|
assert.equal(info.shouldToggleMouseIgnore, true);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousNavigator,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
|
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
|
||||||
const createElement = (options: {
|
const createElement = (options: {
|
||||||
tagName: string;
|
tagName: string;
|
||||||
@@ -284,9 +322,25 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
|
|||||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('hasYomitanPopupIframe falls back to popup host selector for shadow-hosted popups', () => {
|
||||||
|
const selectors: string[] = [];
|
||||||
|
const root = {
|
||||||
|
querySelector: (value: string) => {
|
||||||
|
selectors.push(value);
|
||||||
|
if (value === YOMITAN_POPUP_HOST_SELECTOR) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
} as unknown as ParentNode;
|
||||||
|
|
||||||
|
assert.equal(hasYomitanPopupIframe(root), true);
|
||||||
|
assert.deepEqual(selectors, [YOMITAN_POPUP_IFRAME_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]);
|
||||||
|
});
|
||||||
|
|
||||||
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
||||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
let selector = '';
|
const selectors: string[] = [];
|
||||||
const visibleFrame = {
|
const visibleFrame = {
|
||||||
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
||||||
} as unknown as HTMLIFrameElement;
|
} as unknown as HTMLIFrameElement;
|
||||||
@@ -309,18 +363,40 @@ test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
|||||||
try {
|
try {
|
||||||
const root = {
|
const root = {
|
||||||
querySelectorAll: (value: string) => {
|
querySelectorAll: (value: string) => {
|
||||||
selector = value;
|
selectors.push(value);
|
||||||
|
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || value === YOMITAN_POPUP_HOST_SELECTOR) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [hiddenFrame, visibleFrame];
|
return [hiddenFrame, visibleFrame];
|
||||||
},
|
},
|
||||||
} as unknown as ParentNode;
|
} as unknown as ParentNode;
|
||||||
|
|
||||||
assert.equal(isYomitanPopupVisible(root), true);
|
assert.equal(isYomitanPopupVisible(root), true);
|
||||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
assert.deepEqual(selectors, [
|
||||||
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
|
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isYomitanPopupVisible detects visible shadow-hosted popup marker without iframe access', () => {
|
||||||
|
let selector = '';
|
||||||
|
const root = {
|
||||||
|
querySelectorAll: (value: string) => {
|
||||||
|
selector = value;
|
||||||
|
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
|
||||||
|
return [{ getAttribute: () => 'true' }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
} as unknown as ParentNode;
|
||||||
|
|
||||||
|
assert.equal(isYomitanPopupVisible(root), true);
|
||||||
|
assert.equal(selector, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
||||||
|
});
|
||||||
|
|
||||||
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
||||||
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
||||||
const activeItem = {
|
const activeItem = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import { createKeyboardHandlers } from './keyboard.js';
|
import { createKeyboardHandlers } from './keyboard.js';
|
||||||
import { createRendererState } from '../state.js';
|
import { createRendererState } from '../state.js';
|
||||||
|
import type { CompiledSessionBinding } from '../../types';
|
||||||
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
|
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
|
||||||
|
|
||||||
type CommandEventDetail = {
|
type CommandEventDetail = {
|
||||||
@@ -50,6 +51,8 @@ function installKeyboardTestGlobals() {
|
|||||||
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
const commandEvents: CommandEventDetail[] = [];
|
const commandEvents: CommandEventDetail[] = [];
|
||||||
const mpvCommands: Array<Array<string | number>> = [];
|
const mpvCommands: Array<Array<string | number>> = [];
|
||||||
|
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
|
||||||
|
let sessionBindings: CompiledSessionBinding[] = [];
|
||||||
let playbackPausedResponse: boolean | null = false;
|
let playbackPausedResponse: boolean | null = false;
|
||||||
let statsToggleKey = 'Backquote';
|
let statsToggleKey = 'Backquote';
|
||||||
let markWatchedKey = 'KeyW';
|
let markWatchedKey = 'KeyW';
|
||||||
@@ -153,10 +156,14 @@ function installKeyboardTestGlobals() {
|
|||||||
},
|
},
|
||||||
electronAPI: {
|
electronAPI: {
|
||||||
getKeybindings: async () => [],
|
getKeybindings: async () => [],
|
||||||
|
getSessionBindings: async () => sessionBindings,
|
||||||
getConfiguredShortcuts: async () => configuredShortcuts,
|
getConfiguredShortcuts: async () => configuredShortcuts,
|
||||||
sendMpvCommand: (command: Array<string | number>) => {
|
sendMpvCommand: (command: Array<string | number>) => {
|
||||||
mpvCommands.push(command);
|
mpvCommands.push(command);
|
||||||
},
|
},
|
||||||
|
dispatchSessionAction: async (actionId: string, payload?: unknown) => {
|
||||||
|
sessionActions.push({ actionId, payload });
|
||||||
|
},
|
||||||
getPlaybackPaused: async () => playbackPausedResponse,
|
getPlaybackPaused: async () => playbackPausedResponse,
|
||||||
getStatsToggleKey: async () => statsToggleKey,
|
getStatsToggleKey: async () => statsToggleKey,
|
||||||
getMarkWatchedKey: async () => markWatchedKey,
|
getMarkWatchedKey: async () => markWatchedKey,
|
||||||
@@ -273,6 +280,7 @@ function installKeyboardTestGlobals() {
|
|||||||
return {
|
return {
|
||||||
commandEvents,
|
commandEvents,
|
||||||
mpvCommands,
|
mpvCommands,
|
||||||
|
sessionActions,
|
||||||
overlay,
|
overlay,
|
||||||
overlayFocusCalls,
|
overlayFocusCalls,
|
||||||
focusMainWindowCalls: () => focusMainWindowCalls,
|
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||||
@@ -292,6 +300,9 @@ function installKeyboardTestGlobals() {
|
|||||||
setConfiguredShortcuts: (value: typeof configuredShortcuts) => {
|
setConfiguredShortcuts: (value: typeof configuredShortcuts) => {
|
||||||
configuredShortcuts = value;
|
configuredShortcuts = value;
|
||||||
},
|
},
|
||||||
|
setSessionBindings: (value: CompiledSessionBinding[]) => {
|
||||||
|
sessionBindings = value;
|
||||||
|
},
|
||||||
setMarkActiveVideoWatchedResult: (value: boolean) => {
|
setMarkActiveVideoWatchedResult: (value: boolean) => {
|
||||||
markActiveVideoWatchedResult = value;
|
markActiveVideoWatchedResult = value;
|
||||||
},
|
},
|
||||||
@@ -521,13 +532,19 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await handlers.setupMpvInputForwarding();
|
await handlers.setupMpvInputForwarding();
|
||||||
handlers.updateKeybindings([
|
handlers.updateSessionBindings([
|
||||||
{
|
{
|
||||||
key: 'Space',
|
sourcePath: 'keybindings[0].key',
|
||||||
|
originalKey: 'Space',
|
||||||
|
key: { code: 'Space', modifiers: [] },
|
||||||
|
actionType: 'mpv-command',
|
||||||
command: ['cycle', 'pause'],
|
command: ['cycle', 'pause'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'KeyQ',
|
sourcePath: 'keybindings[1].key',
|
||||||
|
originalKey: 'KeyQ',
|
||||||
|
key: { code: 'KeyQ', modifiers: [] },
|
||||||
|
actionType: 'mpv-command',
|
||||||
command: ['quit'],
|
command: ['quit'],
|
||||||
},
|
},
|
||||||
] as never);
|
] as never);
|
||||||
@@ -549,9 +566,12 @@ test('paused configured subtitle-jump keybinding re-applies pause after backward
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await handlers.setupMpvInputForwarding();
|
await handlers.setupMpvInputForwarding();
|
||||||
handlers.updateKeybindings([
|
handlers.updateSessionBindings([
|
||||||
{
|
{
|
||||||
key: 'Shift+KeyH',
|
sourcePath: 'keybindings[0].key',
|
||||||
|
originalKey: 'Shift+KeyH',
|
||||||
|
key: { code: 'KeyH', modifiers: ['shift'] },
|
||||||
|
actionType: 'mpv-command',
|
||||||
command: ['sub-seek', -1],
|
command: ['sub-seek', -1],
|
||||||
},
|
},
|
||||||
] as never);
|
] as never);
|
||||||
@@ -574,9 +594,12 @@ test('configured subtitle-jump keybinding preserves pause when pause state is un
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await handlers.setupMpvInputForwarding();
|
await handlers.setupMpvInputForwarding();
|
||||||
handlers.updateKeybindings([
|
handlers.updateSessionBindings([
|
||||||
{
|
{
|
||||||
key: 'Shift+KeyH',
|
sourcePath: 'keybindings[0].key',
|
||||||
|
originalKey: 'Shift+KeyH',
|
||||||
|
key: { code: 'KeyH', modifiers: ['shift'] },
|
||||||
|
actionType: 'mpv-command',
|
||||||
command: ['sub-seek', -1],
|
command: ['sub-seek', -1],
|
||||||
},
|
},
|
||||||
] as never);
|
] as never);
|
||||||
@@ -763,13 +786,19 @@ test('youtube picker: unhandled keys still dispatch mpv keybindings', async () =
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await handlers.setupMpvInputForwarding();
|
await handlers.setupMpvInputForwarding();
|
||||||
handlers.updateKeybindings([
|
handlers.updateSessionBindings([
|
||||||
{
|
{
|
||||||
key: 'Space',
|
sourcePath: 'keybindings[0].key',
|
||||||
|
originalKey: 'Space',
|
||||||
|
key: { code: 'Space', modifiers: [] },
|
||||||
|
actionType: 'mpv-command',
|
||||||
command: ['cycle', 'pause'],
|
command: ['cycle', 'pause'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'KeyQ',
|
sourcePath: 'keybindings[1].key',
|
||||||
|
originalKey: 'KeyQ',
|
||||||
|
key: { code: 'KeyQ', modifiers: [] },
|
||||||
|
actionType: 'mpv-command',
|
||||||
command: ['quit'],
|
command: ['quit'],
|
||||||
},
|
},
|
||||||
] as never);
|
] as never);
|
||||||
@@ -785,46 +814,72 @@ test('youtube picker: unhandled keys still dispatch mpv keybindings', async () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('linux overlay shortcut: Ctrl+Alt+S dispatches subsync special command locally', async () => {
|
test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ctx.platform.isLinuxPlatform = true;
|
|
||||||
await handlers.setupMpvInputForwarding();
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.updateSessionBindings([
|
||||||
|
{
|
||||||
|
sourcePath: 'shortcuts.triggerSubsync',
|
||||||
|
originalKey: 'Ctrl+Alt+S',
|
||||||
|
key: { code: 'KeyS', modifiers: ['ctrl', 'alt'] },
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'triggerSubsync',
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS', ctrlKey: true, altKey: true });
|
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS', ctrlKey: true, altKey: true });
|
||||||
|
|
||||||
assert.deepEqual(testGlobals.mpvCommands, [['__subsync-trigger']]);
|
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'triggerSubsync', payload: undefined }]);
|
||||||
} finally {
|
} finally {
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('linux overlay shortcut: Ctrl+Shift+J dispatches jimaku special command locally', async () => {
|
test('session binding: Ctrl+Shift+J dispatches jimaku action locally', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ctx.platform.isLinuxPlatform = true;
|
|
||||||
await handlers.setupMpvInputForwarding();
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.updateSessionBindings([
|
||||||
|
{
|
||||||
|
sourcePath: 'shortcuts.openJimaku',
|
||||||
|
originalKey: 'Ctrl+Shift+J',
|
||||||
|
key: { code: 'KeyJ', modifiers: ['ctrl', 'shift'] },
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'openJimaku',
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({ key: 'J', code: 'KeyJ', ctrlKey: true, shiftKey: true });
|
testGlobals.dispatchKeydown({ key: 'J', code: 'KeyJ', ctrlKey: true, shiftKey: true });
|
||||||
|
|
||||||
assert.deepEqual(testGlobals.mpvCommands, [['__jimaku-open']]);
|
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openJimaku', payload: undefined }]);
|
||||||
} finally {
|
} finally {
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('linux overlay shortcut: CommandOrControl+Shift+O dispatches runtime options locally', async () => {
|
test('session binding: Ctrl+Shift+O dispatches runtime options locally', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ctx.platform.isLinuxPlatform = true;
|
|
||||||
await handlers.setupMpvInputForwarding();
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.updateSessionBindings([
|
||||||
|
{
|
||||||
|
sourcePath: 'shortcuts.openRuntimeOptions',
|
||||||
|
originalKey: 'CommandOrControl+Shift+O',
|
||||||
|
key: { code: 'KeyO', modifiers: ['ctrl', 'shift'] },
|
||||||
|
actionType: 'session-action',
|
||||||
|
actionId: 'openRuntimeOptions',
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', ctrlKey: true, shiftKey: true });
|
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', ctrlKey: true, shiftKey: true });
|
||||||
|
|
||||||
assert.deepEqual(testGlobals.mpvCommands, [['__runtime-options-open']]);
|
assert.deepEqual(testGlobals.sessionActions, [
|
||||||
|
{ actionId: 'openRuntimeOptions', payload: undefined },
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
import type { CompiledSessionBinding, ShortcutsConfig } from '../../types';
|
||||||
import type { Keybinding, ShortcutsConfig } from '../../types';
|
|
||||||
import type { RendererContext } from '../context';
|
import type { RendererContext } from '../context';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
@@ -36,11 +35,16 @@ export function createKeyboardHandlers(
|
|||||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||||
const CHORD_TIMEOUT_MS = 1000;
|
const CHORD_TIMEOUT_MS = 1000;
|
||||||
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||||
const linuxOverlayShortcutCommands = new Map<string, (string | number)[]>();
|
|
||||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
let resetSelectionToStartOnNextSubtitleSync = false;
|
let resetSelectionToStartOnNextSubtitleSync = false;
|
||||||
let lookupScanFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
let lookupScanFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pendingNumericSelection:
|
||||||
|
| {
|
||||||
|
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
|
||||||
|
timeout: ReturnType<typeof setTimeout> | null;
|
||||||
|
}
|
||||||
|
| null = null;
|
||||||
|
|
||||||
const CHORD_MAP = new Map<
|
const CHORD_MAP = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -76,115 +80,117 @@ export function createKeyboardHandlers(
|
|||||||
return parts.join('+');
|
return parts.join('+');
|
||||||
}
|
}
|
||||||
|
|
||||||
function acceleratorToKeyToken(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)) {
|
|
||||||
return normalized[0]!.toUpperCase() + normalized.slice(1);
|
|
||||||
}
|
|
||||||
if (/^arrow(?:up|down|left|right)$/i.test(normalized)) {
|
|
||||||
return normalized[0]!.toUpperCase() + normalized.slice(1);
|
|
||||||
}
|
|
||||||
if (/^f\d{1,2}$/i.test(normalized)) {
|
|
||||||
return normalized.toUpperCase();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceleratorToKeyString(accelerator: string): string | null {
|
|
||||||
const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl');
|
|
||||||
if (!normalized) return null;
|
|
||||||
const parts = normalized.split('+').filter(Boolean);
|
|
||||||
const keyToken = parts.pop();
|
|
||||||
if (!keyToken) return null;
|
|
||||||
|
|
||||||
const eventParts: string[] = [];
|
|
||||||
for (const modifier of parts) {
|
|
||||||
const lower = modifier.toLowerCase();
|
|
||||||
if (lower === 'ctrl' || lower === 'control') {
|
|
||||||
eventParts.push('Ctrl');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (lower === 'alt' || lower === 'option') {
|
|
||||||
eventParts.push('Alt');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (lower === 'shift') {
|
|
||||||
eventParts.push('Shift');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') {
|
|
||||||
eventParts.push('Meta');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (lower === 'commandorcontrol') {
|
|
||||||
eventParts.push(ctx.platform.isMacOSPlatform ? 'Meta' : 'Ctrl');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedKey = acceleratorToKeyToken(keyToken);
|
|
||||||
if (!normalizedKey) return null;
|
|
||||||
eventParts.push(normalizedKey);
|
|
||||||
return eventParts.join('+');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateConfiguredShortcuts(shortcuts: Required<ShortcutsConfig>): void {
|
function updateConfiguredShortcuts(shortcuts: Required<ShortcutsConfig>): void {
|
||||||
linuxOverlayShortcutCommands.clear();
|
ctx.state.sessionActionTimeoutMs = shortcuts.multiCopyTimeoutMs;
|
||||||
const bindings: Array<[string | null, (string | number)[]]> = [
|
|
||||||
[shortcuts.triggerSubsync, [SPECIAL_COMMANDS.SUBSYNC_TRIGGER]],
|
|
||||||
[shortcuts.openRuntimeOptions, [SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN]],
|
|
||||||
[shortcuts.openJimaku, [SPECIAL_COMMANDS.JIMAKU_OPEN]],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [accelerator, command] of bindings) {
|
|
||||||
if (!accelerator) continue;
|
|
||||||
const keyString = acceleratorToKeyString(accelerator);
|
|
||||||
if (keyString) {
|
|
||||||
linuxOverlayShortcutCommands.set(keyString, command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshConfiguredShortcuts(): Promise<void> {
|
async function refreshConfiguredShortcuts(): Promise<void> {
|
||||||
updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts());
|
updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
|
||||||
|
ctx.state.sessionBindings = bindings;
|
||||||
|
ctx.state.sessionBindingMap = new Map(
|
||||||
|
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyEventToStringFromBinding(binding: CompiledSessionBinding): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const modifier of binding.key.modifiers) {
|
||||||
|
if (modifier === 'ctrl') parts.push('Ctrl');
|
||||||
|
else if (modifier === 'alt') parts.push('Alt');
|
||||||
|
else if (modifier === 'shift') parts.push('Shift');
|
||||||
|
else if (modifier === 'meta') parts.push('Meta');
|
||||||
|
}
|
||||||
|
parts.push(binding.key.code);
|
||||||
|
return parts.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextEntryTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!target || typeof target !== 'object' || !('closest' in target)) return false;
|
||||||
|
const element = target as { closest: (selector: string) => unknown };
|
||||||
|
if (element.closest('[contenteditable="true"]')) return true;
|
||||||
|
return Boolean(element.closest('input, textarea, select'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSessionSelectionMessage(message: string): void {
|
||||||
|
window.electronAPI.sendMpvCommand(['show-text', message, '3000']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPendingNumericSelection(showCancelled: boolean): void {
|
||||||
|
if (!pendingNumericSelection) return;
|
||||||
|
if (pendingNumericSelection.timeout !== null) {
|
||||||
|
clearTimeout(pendingNumericSelection.timeout);
|
||||||
|
}
|
||||||
|
pendingNumericSelection = null;
|
||||||
|
if (showCancelled) {
|
||||||
|
showSessionSelectionMessage('Cancelled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPendingNumericSelection(
|
||||||
|
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||||
|
): void {
|
||||||
|
cancelPendingNumericSelection(false);
|
||||||
|
const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout';
|
||||||
|
const promptMessage =
|
||||||
|
actionId === 'copySubtitleMultiple'
|
||||||
|
? 'Copy how many lines? Press 1-9 (Esc to cancel)'
|
||||||
|
: 'Mine how many lines? Press 1-9 (Esc to cancel)';
|
||||||
|
pendingNumericSelection = {
|
||||||
|
actionId,
|
||||||
|
timeout: setTimeout(() => {
|
||||||
|
pendingNumericSelection = null;
|
||||||
|
showSessionSelectionMessage(timeoutMessage);
|
||||||
|
}, ctx.state.sessionActionTimeoutMs),
|
||||||
|
};
|
||||||
|
showSessionSelectionMessage(promptMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginSessionNumericSelection(
|
||||||
|
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||||
|
): void {
|
||||||
|
startPendingNumericSelection(actionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePendingNumericSelection(e: KeyboardEvent): boolean {
|
||||||
|
if (!pendingNumericSelection) return false;
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelPendingNumericSelection(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[1-9]$/.test(e.key) || e.ctrlKey || e.metaKey || e.altKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const count = Number(e.key);
|
||||||
|
const actionId = pendingNumericSelection.actionId;
|
||||||
|
cancelPendingNumericSelection(false);
|
||||||
|
void window.electronAPI.dispatchSessionAction(actionId, { count });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchSessionBinding(binding: CompiledSessionBinding): void {
|
||||||
|
if (
|
||||||
|
binding.actionType === 'session-action' &&
|
||||||
|
(binding.actionId === 'copySubtitleMultiple' || binding.actionId === 'mineSentenceMultiple')
|
||||||
|
) {
|
||||||
|
startPendingNumericSelection(binding.actionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.actionType === 'mpv-command') {
|
||||||
|
dispatchConfiguredMpvCommand(binding.command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void window.electronAPI.dispatchSessionAction(binding.actionId, binding.payload);
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchYomitanPopupKeydown(
|
function dispatchYomitanPopupKeydown(
|
||||||
key: string,
|
key: string,
|
||||||
code: string,
|
code: string,
|
||||||
@@ -508,7 +514,7 @@ export function createKeyboardHandlers(
|
|||||||
clientY: number,
|
clientY: number,
|
||||||
modifiers: ScanModifierState = {},
|
modifiers: ScanModifierState = {},
|
||||||
): void {
|
): void {
|
||||||
if (typeof PointerEvent !== 'undefined') {
|
if (typeof PointerEvent === 'function') {
|
||||||
const pointerEventInit = {
|
const pointerEventInit = {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
@@ -531,23 +537,25 @@ export function createKeyboardHandlers(
|
|||||||
target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit));
|
target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit));
|
||||||
}
|
}
|
||||||
|
|
||||||
const mouseEventInit = {
|
if (typeof MouseEvent === 'function') {
|
||||||
bubbles: true,
|
const mouseEventInit = {
|
||||||
cancelable: true,
|
bubbles: true,
|
||||||
composed: true,
|
cancelable: true,
|
||||||
clientX,
|
composed: true,
|
||||||
clientY,
|
clientX,
|
||||||
button: 0,
|
clientY,
|
||||||
buttons: 0,
|
button: 0,
|
||||||
shiftKey: modifiers.shiftKey ?? false,
|
buttons: 0,
|
||||||
ctrlKey: modifiers.ctrlKey ?? false,
|
shiftKey: modifiers.shiftKey ?? false,
|
||||||
altKey: modifiers.altKey ?? false,
|
ctrlKey: modifiers.ctrlKey ?? false,
|
||||||
metaKey: modifiers.metaKey ?? false,
|
altKey: modifiers.altKey ?? false,
|
||||||
} satisfies MouseEventInit;
|
metaKey: modifiers.metaKey ?? false,
|
||||||
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
|
} satisfies MouseEventInit;
|
||||||
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
|
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
|
||||||
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
|
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
|
||||||
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
|
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
|
||||||
|
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void {
|
function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void {
|
||||||
@@ -820,7 +828,7 @@ export function createKeyboardHandlers(
|
|||||||
if (modifierOnlyCodes.has(e.code)) return false;
|
if (modifierOnlyCodes.has(e.code)) return false;
|
||||||
|
|
||||||
const keyString = keyEventToString(e);
|
const keyString = keyEventToString(e);
|
||||||
if (ctx.state.keybindingsMap.has(keyString)) {
|
if (ctx.state.sessionBindingMap.has(keyString)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,7 +854,7 @@ export function createKeyboardHandlers(
|
|||||||
fallbackUnavailable: boolean;
|
fallbackUnavailable: boolean;
|
||||||
} {
|
} {
|
||||||
const firstChoice = 'KeyH';
|
const firstChoice = 'KeyH';
|
||||||
if (!ctx.state.keybindingsMap.has('KeyH')) {
|
if (!ctx.state.sessionBindingMap.has('KeyH')) {
|
||||||
return {
|
return {
|
||||||
bindingKey: firstChoice,
|
bindingKey: firstChoice,
|
||||||
fallbackUsed: false,
|
fallbackUsed: false,
|
||||||
@@ -854,18 +862,18 @@ export function createKeyboardHandlers(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.state.keybindingsMap.has('KeyK')) {
|
if (!ctx.state.sessionBindingMap.has('KeyK')) {
|
||||||
return {
|
return {
|
||||||
bindingKey: 'KeyK',
|
bindingKey: 'KeyK',
|
||||||
fallbackUsed: true,
|
fallbackUsed: true,
|
||||||
fallbackUnavailable: true,
|
fallbackUnavailable: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bindingKey: 'KeyK',
|
bindingKey: 'KeyK',
|
||||||
fallbackUsed: true,
|
fallbackUsed: true,
|
||||||
fallbackUnavailable: false,
|
fallbackUnavailable: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,13 +898,13 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setupMpvInputForwarding(): Promise<void> {
|
async function setupMpvInputForwarding(): Promise<void> {
|
||||||
const [keybindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
|
const [sessionBindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
|
||||||
window.electronAPI.getKeybindings(),
|
window.electronAPI.getSessionBindings(),
|
||||||
window.electronAPI.getConfiguredShortcuts(),
|
window.electronAPI.getConfiguredShortcuts(),
|
||||||
window.electronAPI.getStatsToggleKey(),
|
window.electronAPI.getStatsToggleKey(),
|
||||||
window.electronAPI.getMarkWatchedKey(),
|
window.electronAPI.getMarkWatchedKey(),
|
||||||
]);
|
]);
|
||||||
updateKeybindings(keybindings);
|
updateSessionBindings(sessionBindings);
|
||||||
updateConfiguredShortcuts(shortcuts);
|
updateConfiguredShortcuts(shortcuts);
|
||||||
ctx.state.statsToggleKey = statsToggleKey;
|
ctx.state.statsToggleKey = statsToggleKey;
|
||||||
ctx.state.markWatchedKey = markWatchedKey;
|
ctx.state.markWatchedKey = markWatchedKey;
|
||||||
@@ -1006,6 +1014,14 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTextEntryTarget(e.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handlePendingNumericSelection(e)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isStatsOverlayToggle(e)) {
|
if (isStatsOverlayToggle(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.electronAPI.toggleStatsOverlay();
|
window.electronAPI.toggleStatsOverlay();
|
||||||
@@ -1095,19 +1111,10 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keyString = keyEventToString(e);
|
const keyString = keyEventToString(e);
|
||||||
const linuxOverlayCommand = ctx.platform.isLinuxPlatform
|
const binding = ctx.state.sessionBindingMap.get(keyString);
|
||||||
? linuxOverlayShortcutCommands.get(keyString)
|
if (binding) {
|
||||||
: undefined;
|
|
||||||
if (linuxOverlayCommand) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dispatchConfiguredMpvCommand(linuxOverlayCommand);
|
dispatchSessionBinding(binding);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const command = ctx.state.keybindingsMap.get(keyString);
|
|
||||||
|
|
||||||
if (command) {
|
|
||||||
e.preventDefault();
|
|
||||||
dispatchConfiguredMpvCommand(command);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1125,19 +1132,11 @@ export function createKeyboardHandlers(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateKeybindings(keybindings: Keybinding[]): void {
|
|
||||||
ctx.state.keybindingsMap = new Map();
|
|
||||||
for (const binding of keybindings) {
|
|
||||||
if (binding.command) {
|
|
||||||
ctx.state.keybindingsMap.set(binding.key, binding.command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
beginSessionNumericSelection,
|
||||||
setupMpvInputForwarding,
|
setupMpvInputForwarding,
|
||||||
refreshConfiguredShortcuts,
|
refreshConfiguredShortcuts,
|
||||||
updateKeybindings,
|
updateSessionBindings,
|
||||||
syncKeyboardTokenSelection,
|
syncKeyboardTokenSelection,
|
||||||
handleSubtitleContentUpdated,
|
handleSubtitleContentUpdated,
|
||||||
handleKeyboardModeToggleRequested,
|
handleKeyboardModeToggleRequested,
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import type { SubtitleSidebarConfig } from '../../types';
|
import type { SubtitleSidebarConfig } from '../../types';
|
||||||
import { createMouseHandlers } from './mouse.js';
|
import { createMouseHandlers } from './mouse.js';
|
||||||
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
import {
|
||||||
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_HOST_SELECTOR,
|
||||||
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
function createClassList() {
|
function createClassList() {
|
||||||
const classes = new Set<string>();
|
const classes = new Set<string>();
|
||||||
@@ -78,11 +83,13 @@ function createMouseTestContext() {
|
|||||||
},
|
},
|
||||||
platform: {
|
platform: {
|
||||||
shouldToggleMouseIgnore: false,
|
shouldToggleMouseIgnore: false,
|
||||||
|
isLinuxPlatform: false,
|
||||||
isMacOSPlatform: false,
|
isMacOSPlatform: false,
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
isOverSubtitle: false,
|
isOverSubtitle: false,
|
||||||
isOverSubtitleSidebar: false,
|
isOverSubtitleSidebar: false,
|
||||||
|
yomitanPopupVisible: false,
|
||||||
subtitleSidebarModalOpen: false,
|
subtitleSidebarModalOpen: false,
|
||||||
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
@@ -712,6 +719,257 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('nested popup close reasserts interactive state and focus when another popup remains visible on Windows', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||||
|
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||||
|
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||||
|
const windowListeners = new Map<string, Array<() => void>>();
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
let focusMainWindowCalls = 0;
|
||||||
|
let windowFocusCalls = 0;
|
||||||
|
let overlayFocusCalls = 0;
|
||||||
|
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
||||||
|
overlayFocusCalls += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visiblePopupHost = {
|
||||||
|
tagName: 'DIV',
|
||||||
|
getAttribute: (name: string) =>
|
||||||
|
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: () => void) => {
|
||||||
|
const bucket = windowListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
windowListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
focusMainWindow: () => {
|
||||||
|
focusMainWindowCalls += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
windowFocusCalls += 1;
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'visible',
|
||||||
|
display: 'block',
|
||||||
|
opacity: '1',
|
||||||
|
}),
|
||||||
|
innerHeight: 1000,
|
||||||
|
getSelection: () => null,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: (selector: string) => {
|
||||||
|
if (
|
||||||
|
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
||||||
|
selector === YOMITAN_POPUP_HOST_SELECTOR
|
||||||
|
) {
|
||||||
|
return [visiblePopupHost];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
elementFromPoint: () => null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: class {
|
||||||
|
observe() {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
ELEMENT_NODE: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
ignoreCalls.length = 0;
|
||||||
|
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
assert.equal(focusMainWindowCalls, 1);
|
||||||
|
assert.equal(windowFocusCalls, 1);
|
||||||
|
assert.equal(overlayFocusCalls, 1);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||||
|
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||||
|
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||||
|
const windowListeners = new Map<string, Array<() => void>>();
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
let focusMainWindowCalls = 0;
|
||||||
|
let windowFocusCalls = 0;
|
||||||
|
let overlayFocusCalls = 0;
|
||||||
|
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
||||||
|
overlayFocusCalls += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visiblePopupHost = {
|
||||||
|
tagName: 'DIV',
|
||||||
|
getAttribute: (name: string) =>
|
||||||
|
name === 'data-subminer-yomitan-popup-visible' ? 'true' : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: () => void) => {
|
||||||
|
const bucket = windowListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
windowListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
focusMainWindow: () => {
|
||||||
|
focusMainWindowCalls += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
windowFocusCalls += 1;
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'visible',
|
||||||
|
display: 'block',
|
||||||
|
opacity: '1',
|
||||||
|
}),
|
||||||
|
innerHeight: 1000,
|
||||||
|
getSelection: () => null,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
visibilityState: 'visible',
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: (selector: string) => {
|
||||||
|
if (
|
||||||
|
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
||||||
|
selector === YOMITAN_POPUP_HOST_SELECTOR
|
||||||
|
) {
|
||||||
|
return [visiblePopupHost];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
elementFromPoint: () => null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: class {
|
||||||
|
observe() {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
ELEMENT_NODE: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
assert.equal(ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
ignoreCalls.length = 0;
|
||||||
|
|
||||||
|
for (const listener of windowListeners.get('blur') ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.equal(ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
assert.equal(focusMainWindowCalls, 1);
|
||||||
|
assert.equal(windowFocusCalls, 1);
|
||||||
|
assert.equal(overlayFocusCalls, 1);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -783,6 +1041,361 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('visibility recovery re-enables subtitle hover without needing a fresh pointer move', () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
let visibilityState: 'hidden' | 'visible' = 'visible';
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'hidden',
|
||||||
|
display: 'none',
|
||||||
|
opacity: '0',
|
||||||
|
}),
|
||||||
|
focus: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||||
|
const bucket = documentListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
documentListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
get visibilityState() {
|
||||||
|
return visibilityState;
|
||||||
|
},
|
||||||
|
elementFromPoint: () => ctx.dom.subtitleContainer,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupPointerTracking();
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 120, clientY: 240 });
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.state.isOverSubtitle = false;
|
||||||
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
|
ignoreCalls.length = 0;
|
||||||
|
visibilityState = 'hidden';
|
||||||
|
visibilityState = 'visible';
|
||||||
|
|
||||||
|
for (const listener of documentListeners.get('visibilitychange') ?? []) {
|
||||||
|
listener({});
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(ctx.state.isOverSubtitle, true);
|
||||||
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visibility recovery ignores synthetic subtitle enter until the pointer moves again', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
let hoveredElement: unknown = ctx.dom.subtitleContainer;
|
||||||
|
let visibilityState: 'hidden' | 'visible' = 'visible';
|
||||||
|
let subtitleHoverAutoPauseEnabled = false;
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'hidden',
|
||||||
|
display: 'none',
|
||||||
|
opacity: '0',
|
||||||
|
}),
|
||||||
|
focus: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||||
|
const bucket = documentListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
documentListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
get visibilityState() {
|
||||||
|
return visibilityState;
|
||||||
|
},
|
||||||
|
elementFromPoint: () => hoveredElement,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => subtitleHoverAutoPauseEnabled,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupPointerTracking();
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 120, clientY: 240 });
|
||||||
|
}
|
||||||
|
await waitForNextTick();
|
||||||
|
|
||||||
|
ignoreCalls.length = 0;
|
||||||
|
visibilityState = 'hidden';
|
||||||
|
visibilityState = 'visible';
|
||||||
|
subtitleHoverAutoPauseEnabled = true;
|
||||||
|
for (const listener of documentListeners.get('visibilitychange') ?? []) {
|
||||||
|
listener({});
|
||||||
|
}
|
||||||
|
|
||||||
|
await handlers.handlePrimaryMouseEnter();
|
||||||
|
assert.deepEqual(mpvCommands, []);
|
||||||
|
|
||||||
|
hoveredElement = null;
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 32, clientY: 48 });
|
||||||
|
}
|
||||||
|
|
||||||
|
hoveredElement = ctx.dom.subtitleContainer;
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 120, clientY: 240 });
|
||||||
|
}
|
||||||
|
await waitForNextTick();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('window resize ignores synthetic subtitle enter until the pointer moves again', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
const windowListeners = new Map<string, Array<() => void>>();
|
||||||
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
let hoveredElement: unknown = ctx.dom.subtitleContainer;
|
||||||
|
let subtitleHoverAutoPauseEnabled = false;
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: () => {},
|
||||||
|
},
|
||||||
|
addEventListener: (type: string, listener: () => void) => {
|
||||||
|
const bucket = windowListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
windowListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'hidden',
|
||||||
|
display: 'none',
|
||||||
|
opacity: '0',
|
||||||
|
}),
|
||||||
|
focus: () => {},
|
||||||
|
innerHeight: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||||
|
const bucket = documentListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
documentListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
elementFromPoint: () => hoveredElement,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => subtitleHoverAutoPauseEnabled,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupPointerTracking();
|
||||||
|
handlers.setupResizeHandler();
|
||||||
|
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 120, clientY: 240 });
|
||||||
|
}
|
||||||
|
await waitForNextTick();
|
||||||
|
|
||||||
|
subtitleHoverAutoPauseEnabled = true;
|
||||||
|
for (const listener of windowListeners.get('resize') ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
await handlers.handlePrimaryMouseEnter();
|
||||||
|
assert.deepEqual(mpvCommands, []);
|
||||||
|
|
||||||
|
hoveredElement = null;
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 32, clientY: 48 });
|
||||||
|
}
|
||||||
|
|
||||||
|
hoveredElement = ctx.dom.subtitleContainer;
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 120, clientY: 240 });
|
||||||
|
}
|
||||||
|
await waitForNextTick();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visibility recovery keeps overlay click-through when pointer is not over subtitles', () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
let hoveredElement: unknown = null;
|
||||||
|
let visibilityState: 'hidden' | 'visible' = 'visible';
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'hidden',
|
||||||
|
display: 'none',
|
||||||
|
opacity: '0',
|
||||||
|
}),
|
||||||
|
focus: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||||
|
const bucket = documentListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
documentListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
get visibilityState() {
|
||||||
|
return visibilityState;
|
||||||
|
},
|
||||||
|
elementFromPoint: () => hoveredElement,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupPointerTracking();
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 320, clientY: 180 });
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
|
ignoreCalls.length = 0;
|
||||||
|
visibilityState = 'hidden';
|
||||||
|
visibilityState = 'visible';
|
||||||
|
|
||||||
|
for (const listener of documentListeners.get('visibilitychange') ?? []) {
|
||||||
|
listener({});
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(ctx.state.isOverSubtitle, false);
|
||||||
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', () => {
|
test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -916,10 +1529,8 @@ test('pointer tracking restores click-through after the cursor leaves subtitles'
|
|||||||
|
|
||||||
assert.equal(ctx.state.isOverSubtitle, false);
|
assert.equal(ctx.state.isOverSubtitle, false);
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
||||||
assert.deepEqual(ignoreCalls, [
|
assert.equal(ignoreCalls[0]?.ignore, false);
|
||||||
{ ignore: false, forward: undefined },
|
assert.deepEqual(ignoreCalls.at(-1), { ignore: true, forward: true });
|
||||||
{ ignore: true, forward: true },
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import type { ModalStateReader, RendererContext } from '../context';
|
|||||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||||
|
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
isYomitanPopupVisible,
|
isYomitanPopupVisible,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
} from '../yomitan-popup.js';
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
|
const VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE = 'visibility-recovery';
|
||||||
|
const WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE = 'window-resize';
|
||||||
|
|
||||||
export function createMouseHandlers(
|
export function createMouseHandlers(
|
||||||
ctx: RendererContext,
|
ctx: RendererContext,
|
||||||
options: {
|
options: {
|
||||||
@@ -33,6 +38,61 @@ export function createMouseHandlers(
|
|||||||
let pausedByYomitanPopup = false;
|
let pausedByYomitanPopup = false;
|
||||||
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
||||||
let pendingPointerResync = false;
|
let pendingPointerResync = false;
|
||||||
|
let suppressDirectHoverEnterSource: string | null = null;
|
||||||
|
|
||||||
|
function getPopupVisibilityFromDom(): boolean {
|
||||||
|
return typeof document !== 'undefined' && isYomitanPopupVisible(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPopupVisibilityState(assumeVisible = false): boolean {
|
||||||
|
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
|
||||||
|
yomitanPopupVisible = popupVisible;
|
||||||
|
ctx.state.yomitanPopupVisible = popupVisible;
|
||||||
|
return popupVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reclaimOverlayWindowFocusForPopup(): void {
|
||||||
|
if (!ctx.platform.shouldToggleMouseIgnore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.electronAPI.focusMainWindow === 'function') {
|
||||||
|
void window.electronAPI.focusMainWindow();
|
||||||
|
}
|
||||||
|
window.focus();
|
||||||
|
if (typeof ctx.dom.overlay.focus === 'function') {
|
||||||
|
ctx.dom.overlay.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sustainPopupInteraction(): void {
|
||||||
|
syncPopupVisibilityState(true);
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcilePopupInteraction(args: {
|
||||||
|
assumeVisible?: boolean;
|
||||||
|
reclaimFocus?: boolean;
|
||||||
|
allowPause?: boolean;
|
||||||
|
} = {}): boolean {
|
||||||
|
const popupVisible = syncPopupVisibilityState(args.assumeVisible === true);
|
||||||
|
if (!popupVisible) {
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
if (args.reclaimFocus === true) {
|
||||||
|
reclaimOverlayWindowFocusForPopup();
|
||||||
|
}
|
||||||
|
if (args.allowPause === true) {
|
||||||
|
void maybePauseForYomitanPopup();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -86,6 +146,7 @@ export function createMouseHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suppressDirectHoverEnterSource = null;
|
||||||
const wasOverSubtitle = ctx.state.isOverSubtitle;
|
const wasOverSubtitle = ctx.state.isOverSubtitle;
|
||||||
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
|
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
|
||||||
'secondary-sub-hover-active',
|
'secondary-sub-hover-active',
|
||||||
@@ -93,7 +154,7 @@ export function createMouseHandlers(
|
|||||||
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
|
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
|
||||||
|
|
||||||
if (!wasOverSubtitle && hoverState.isOverSubtitle) {
|
if (!wasOverSubtitle && hoverState.isOverSubtitle) {
|
||||||
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle);
|
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle, 'tracked-pointer');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,9 +171,13 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restorePointerInteractionState(): void {
|
function resyncPointerInteractionState(options: {
|
||||||
|
allowInteractiveFallback: boolean;
|
||||||
|
suppressDirectHoverEnterSource?: string | null;
|
||||||
|
}): void {
|
||||||
const pointerPosition = lastPointerPosition;
|
const pointerPosition = lastPointerPosition;
|
||||||
pendingPointerResync = false;
|
pendingPointerResync = false;
|
||||||
|
suppressDirectHoverEnterSource = options.suppressDirectHoverEnterSource ?? null;
|
||||||
if (pointerPosition) {
|
if (pointerPosition) {
|
||||||
syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY);
|
syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY);
|
||||||
} else {
|
} else {
|
||||||
@@ -121,7 +186,11 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
|
||||||
if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isOverSubtitle) {
|
if (
|
||||||
|
!options.allowInteractiveFallback ||
|
||||||
|
!ctx.platform.shouldToggleMouseIgnore ||
|
||||||
|
ctx.state.isOverSubtitle
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +199,10 @@ export function createMouseHandlers(
|
|||||||
window.electronAPI.setIgnoreMouseEvents(false);
|
window.electronAPI.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restorePointerInteractionState(): void {
|
||||||
|
resyncPointerInteractionState({ allowInteractiveFallback: true });
|
||||||
|
}
|
||||||
|
|
||||||
function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void {
|
function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void {
|
||||||
if (!pendingPointerResync) {
|
if (!pendingPointerResync) {
|
||||||
return;
|
return;
|
||||||
@@ -205,18 +278,14 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enablePopupInteraction(): void {
|
function enablePopupInteraction(): void {
|
||||||
yomitanPopupVisible = true;
|
sustainPopupInteraction();
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
|
||||||
if (ctx.platform.isMacOSPlatform) {
|
if (ctx.platform.isMacOSPlatform) {
|
||||||
window.focus();
|
window.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function disablePopupInteractionIfIdle(): void {
|
function disablePopupInteractionIfIdle(): void {
|
||||||
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
|
if (reconcilePopupInteraction({ reclaimFocus: true })) {
|
||||||
yomitanPopupVisible = true;
|
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +297,15 @@ export function createMouseHandlers(
|
|||||||
syncOverlayMouseIgnoreState(ctx);
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMouseEnter(_event?: MouseEvent, showSecondaryHover = false): Promise<void> {
|
async function handleMouseEnter(
|
||||||
|
_event?: MouseEvent,
|
||||||
|
showSecondaryHover = false,
|
||||||
|
source: 'direct' | 'tracked-pointer' = 'direct',
|
||||||
|
): Promise<void> {
|
||||||
|
if (source === 'direct' && suppressDirectHoverEnterSource !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.state.isOverSubtitle = true;
|
ctx.state.isOverSubtitle = true;
|
||||||
if (showSecondaryHover) {
|
if (showSecondaryHover) {
|
||||||
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
|
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
|
||||||
@@ -326,6 +403,10 @@ export function createMouseHandlers(
|
|||||||
function setupResizeHandler(): void {
|
function setupResizeHandler(): void {
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
options.applyYPercent(options.getCurrentYPercent());
|
options.applyYPercent(options.getCurrentYPercent());
|
||||||
|
resyncPointerInteractionState({
|
||||||
|
allowInteractiveFallback: false,
|
||||||
|
suppressDirectHoverEnterSource: WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +421,15 @@ export function createMouseHandlers(
|
|||||||
syncHoverStateFromTrackedPointer(event);
|
syncHoverStateFromTrackedPointer(event);
|
||||||
maybeResyncPointerHoverState(event);
|
maybeResyncPointerHoverState(event);
|
||||||
});
|
});
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resyncPointerInteractionState({
|
||||||
|
allowInteractiveFallback: false,
|
||||||
|
suppressDirectHoverEnterSource: VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupSelectionObserver(): void {
|
function setupSelectionObserver(): void {
|
||||||
@@ -356,19 +446,37 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupYomitanObserver(): void {
|
function setupYomitanObserver(): void {
|
||||||
yomitanPopupVisible = isYomitanPopupVisible(document);
|
reconcilePopupInteraction({ allowPause: true });
|
||||||
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
|
|
||||||
void maybePauseForYomitanPopup();
|
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
enablePopupInteraction();
|
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
|
||||||
void maybePauseForYomitanPopup();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
disablePopupInteractionIfIdle();
|
disablePopupInteractionIfIdle();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
|
||||||
|
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
||||||
|
reconcilePopupInteraction({ assumeVisible: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('focus', () => {
|
||||||
|
reconcilePopupInteraction();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('blur', () => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconcilePopupInteraction({ reclaimFocus: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
mutation.addedNodes.forEach((node) => {
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
|||||||
@@ -15,6 +15,53 @@ function createClassList() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('idle visible overlay starts click-through on platforms that toggle mouse ignore', () => {
|
||||||
|
const classList = createClassList();
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window: {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
syncOverlayMouseIgnoreState({
|
||||||
|
dom: {
|
||||||
|
overlay: { classList },
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
shouldToggleMouseIgnore: true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
isOverSubtitle: false,
|
||||||
|
isOverSubtitleSidebar: false,
|
||||||
|
yomitanPopupVisible: false,
|
||||||
|
controllerSelectModalOpen: false,
|
||||||
|
controllerDebugModalOpen: false,
|
||||||
|
jimakuModalOpen: false,
|
||||||
|
youtubePickerModalOpen: false,
|
||||||
|
kikuModalOpen: false,
|
||||||
|
runtimeOptionsModalOpen: false,
|
||||||
|
subsyncModalOpen: false,
|
||||||
|
sessionHelpModalOpen: false,
|
||||||
|
subtitleSidebarModalOpen: false,
|
||||||
|
subtitleSidebarConfig: null,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(classList.contains('interactive'), false);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
|
||||||
|
} finally {
|
||||||
|
Object.assign(globalThis, { window: originalWindow });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => {
|
test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => {
|
||||||
const classList = createClassList();
|
const classList = createClassList();
|
||||||
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
@@ -61,3 +108,62 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
|
|||||||
Object.assign(globalThis, { window: originalWindow });
|
Object.assign(globalThis, { window: originalWindow });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
|
||||||
|
const classList = createClassList();
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window: {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'visible',
|
||||||
|
display: 'block',
|
||||||
|
opacity: '1',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
querySelectorAll: (selector: string) =>
|
||||||
|
selector === '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
|
||||||
|
? [{ getAttribute: () => 'true' }]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
syncOverlayMouseIgnoreState({
|
||||||
|
dom: {
|
||||||
|
overlay: { classList },
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
shouldToggleMouseIgnore: true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
isOverSubtitle: false,
|
||||||
|
isOverSubtitleSidebar: false,
|
||||||
|
yomitanPopupVisible: false,
|
||||||
|
controllerSelectModalOpen: false,
|
||||||
|
controllerDebugModalOpen: false,
|
||||||
|
jimakuModalOpen: false,
|
||||||
|
youtubePickerModalOpen: false,
|
||||||
|
kikuModalOpen: false,
|
||||||
|
runtimeOptionsModalOpen: false,
|
||||||
|
subsyncModalOpen: false,
|
||||||
|
sessionHelpModalOpen: false,
|
||||||
|
subtitleSidebarModalOpen: false,
|
||||||
|
subtitleSidebarConfig: null,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
} finally {
|
||||||
|
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { RendererContext } from './context';
|
import type { RendererContext } from './context';
|
||||||
import type { RendererState } from './state';
|
import type { RendererState } from './state';
|
||||||
|
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
||||||
|
|
||||||
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
@@ -14,11 +15,21 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isYomitanPopupInteractionActive(state: RendererState): boolean {
|
||||||
|
if (state.yomitanPopupVisible) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isYomitanPopupVisible(document);
|
||||||
|
}
|
||||||
|
|
||||||
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||||
const shouldStayInteractive =
|
const shouldStayInteractive =
|
||||||
ctx.state.isOverSubtitle ||
|
ctx.state.isOverSubtitle ||
|
||||||
ctx.state.isOverSubtitleSidebar ||
|
ctx.state.isOverSubtitleSidebar ||
|
||||||
ctx.state.yomitanPopupVisible ||
|
isYomitanPopupInteractionActive(ctx.state) ||
|
||||||
isBlockingOverlayModalOpen(ctx.state);
|
isBlockingOverlayModalOpen(ctx.state);
|
||||||
|
|
||||||
if (shouldStayInteractive) {
|
if (shouldStayInteractive) {
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import { resolveRendererDom } from './utils/dom.js';
|
|||||||
import { resolvePlatformInfo } from './utils/platform.js';
|
import { resolvePlatformInfo } from './utils/platform.js';
|
||||||
import {
|
import {
|
||||||
buildMpvLoadfileCommands,
|
buildMpvLoadfileCommands,
|
||||||
|
buildMpvSubtitleAddCommands,
|
||||||
|
collectDroppedSubtitlePaths,
|
||||||
collectDroppedVideoPaths,
|
collectDroppedVideoPaths,
|
||||||
} from '../core/services/overlay-drop.js';
|
} from '../core/services/overlay-drop.js';
|
||||||
|
|
||||||
@@ -527,6 +529,12 @@ async function init(): Promise<void> {
|
|||||||
if (ctx.platform.isMacOSPlatform) {
|
if (ctx.platform.isMacOSPlatform) {
|
||||||
document.body.classList.add('platform-macos');
|
document.body.classList.add('platform-macos');
|
||||||
}
|
}
|
||||||
|
if (ctx.platform.isWindowsPlatform) {
|
||||||
|
document.body.classList.add('platform-windows');
|
||||||
|
}
|
||||||
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||||
runGuarded('subtitle:update', () => {
|
runGuarded('subtitle:update', () => {
|
||||||
@@ -620,7 +628,7 @@ async function init(): Promise<void> {
|
|||||||
});
|
});
|
||||||
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
|
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
|
||||||
runGuarded('config:hot-reload', () => {
|
runGuarded('config:hot-reload', () => {
|
||||||
keyboardHandlers.updateKeybindings(payload.keybindings);
|
keyboardHandlers.updateSessionBindings(payload.sessionBindings);
|
||||||
void keyboardHandlers.refreshConfiguredShortcuts();
|
void keyboardHandlers.refreshConfiguredShortcuts();
|
||||||
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
||||||
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
||||||
@@ -654,10 +662,6 @@ async function init(): Promise<void> {
|
|||||||
);
|
);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
|
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
measurementReporter.emitNow();
|
measurementReporter.emitNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,18 +710,28 @@ function setupDragDropToMpvQueue(): void {
|
|||||||
if (!event.dataTransfer) return;
|
if (!event.dataTransfer) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const droppedPaths = collectDroppedVideoPaths(event.dataTransfer);
|
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
|
||||||
const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey);
|
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
|
||||||
|
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
|
||||||
|
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
|
||||||
for (const command of loadCommands) {
|
for (const command of loadCommands) {
|
||||||
window.electronAPI.sendMpvCommand(command);
|
window.electronAPI.sendMpvCommand(command);
|
||||||
}
|
}
|
||||||
|
for (const command of subtitleCommands) {
|
||||||
|
window.electronAPI.sendMpvCommand(command);
|
||||||
|
}
|
||||||
|
const osdParts: string[] = [];
|
||||||
if (loadCommands.length > 0) {
|
if (loadCommands.length > 0) {
|
||||||
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
const action = event.shiftKey ? 'Queued' : 'Loaded';
|
||||||
window.electronAPI.sendMpvCommand([
|
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
|
||||||
'show-text',
|
}
|
||||||
`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`,
|
if (subtitleCommands.length > 0) {
|
||||||
'1500',
|
osdParts.push(
|
||||||
]);
|
`Loaded ${subtitleCommands.length} subtitle file${subtitleCommands.length === 1 ? '' : 's'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (osdParts.length > 0) {
|
||||||
|
window.electronAPI.sendMpvCommand(['show-text', osdParts.join(' | '), '1500']);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDropInteractive();
|
clearDropInteractive();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
CompiledSessionBinding,
|
||||||
PlaylistBrowserSnapshot,
|
PlaylistBrowserSnapshot,
|
||||||
ControllerButtonSnapshot,
|
ControllerButtonSnapshot,
|
||||||
ControllerDeviceInfo,
|
ControllerDeviceInfo,
|
||||||
@@ -116,7 +117,9 @@ export type RendererState = {
|
|||||||
frequencyDictionaryBand4Color: string;
|
frequencyDictionaryBand4Color: string;
|
||||||
frequencyDictionaryBand5Color: string;
|
frequencyDictionaryBand5Color: string;
|
||||||
|
|
||||||
keybindingsMap: Map<string, (string | number)[]>;
|
sessionBindings: CompiledSessionBinding[];
|
||||||
|
sessionBindingMap: Map<string, CompiledSessionBinding>;
|
||||||
|
sessionActionTimeoutMs: number;
|
||||||
statsToggleKey: string;
|
statsToggleKey: string;
|
||||||
markWatchedKey: string;
|
markWatchedKey: string;
|
||||||
chordPending: boolean;
|
chordPending: boolean;
|
||||||
@@ -219,7 +222,9 @@ export function createRendererState(): RendererState {
|
|||||||
frequencyDictionaryBand4Color: '#8bd5ca',
|
frequencyDictionaryBand4Color: '#8bd5ca',
|
||||||
frequencyDictionaryBand5Color: '#8aadf4',
|
frequencyDictionaryBand5Color: '#8aadf4',
|
||||||
|
|
||||||
keybindingsMap: new Map(),
|
sessionBindings: [],
|
||||||
|
sessionBindingMap: new Map(),
|
||||||
|
sessionActionTimeoutMs: 3000,
|
||||||
statsToggleKey: 'Backquote',
|
statsToggleKey: 'Backquote',
|
||||||
markWatchedKey: 'KeyW',
|
markWatchedKey: 'KeyW',
|
||||||
chordPending: false,
|
chordPending: false,
|
||||||
|
|||||||
@@ -684,7 +684,8 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.settings-modal-open iframe.yomitan-popup,
|
body.settings-modal-open iframe.yomitan-popup,
|
||||||
body.settings-modal-open iframe[id^='yomitan-popup'] {
|
body.settings-modal-open iframe[id^='yomitan-popup'],
|
||||||
|
body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
@@ -1130,6 +1131,11 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.platform-windows #secondarySubContainer.secondary-sub-hover {
|
||||||
|
top: 40px;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#secondarySubContainer.secondary-sub-hover #secondarySubRoot {
|
#secondarySubContainer.secondary-sub-hover #secondarySubRoot {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
@@ -1151,7 +1157,8 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
iframe.yomitan-popup,
|
iframe.yomitan-popup,
|
||||||
iframe[id^='yomitan-popup'] {
|
iframe[id^='yomitan-popup'],
|
||||||
|
[data-subminer-yomitan-popup-host='true'] {
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
z-index: 2147483647 !important;
|
z-index: 2147483647 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -989,6 +989,13 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
|||||||
/transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/,
|
/transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const secondaryHoverWindowsBlock = extractClassBlock(
|
||||||
|
cssText,
|
||||||
|
'body.platform-windows #secondarySubContainer.secondary-sub-hover',
|
||||||
|
);
|
||||||
|
assert.match(secondaryHoverWindowsBlock, /top:\s*40px;/);
|
||||||
|
assert.match(secondaryHoverWindowsBlock, /padding-top:\s*0;/);
|
||||||
|
|
||||||
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
|
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
|
||||||
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
|
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type PlatformInfo = {
|
|||||||
isModalLayer: boolean;
|
isModalLayer: boolean;
|
||||||
isLinuxPlatform: boolean;
|
isLinuxPlatform: boolean;
|
||||||
isMacOSPlatform: boolean;
|
isMacOSPlatform: boolean;
|
||||||
|
isWindowsPlatform: boolean;
|
||||||
shouldToggleMouseIgnore: boolean;
|
shouldToggleMouseIgnore: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,12 +25,15 @@ export function resolvePlatformInfo(): PlatformInfo {
|
|||||||
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
|
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
|
||||||
const isMacOSPlatform =
|
const isMacOSPlatform =
|
||||||
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
|
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
|
||||||
|
const isWindowsPlatform =
|
||||||
|
navigator.platform.toLowerCase().includes('win') || /windows/i.test(navigator.userAgent);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overlayLayer,
|
overlayLayer,
|
||||||
isModalLayer,
|
isModalLayer,
|
||||||
isLinuxPlatform,
|
isLinuxPlatform,
|
||||||
isMacOSPlatform,
|
isMacOSPlatform,
|
||||||
|
isWindowsPlatform,
|
||||||
shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer,
|
shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { YOMITAN_LOOKUP_EVENT, registerYomitanLookupListener } from './yomitan-popup.js';
|
import {
|
||||||
|
YOMITAN_LOOKUP_EVENT,
|
||||||
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
|
isYomitanPopupVisible,
|
||||||
|
registerYomitanLookupListener,
|
||||||
|
} from './yomitan-popup.js';
|
||||||
|
|
||||||
test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', () => {
|
test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', () => {
|
||||||
const target = new EventTarget();
|
const target = new EventTarget();
|
||||||
@@ -16,3 +21,12 @@ test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event',
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['lookup']);
|
assert.deepEqual(calls, ['lookup']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isYomitanPopupVisible falls back to querySelector when querySelectorAll is unavailable', () => {
|
||||||
|
const root = {
|
||||||
|
querySelector: (selector: string) =>
|
||||||
|
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ? ({} as Element) : null,
|
||||||
|
} as ParentNode;
|
||||||
|
|
||||||
|
assert.equal(isYomitanPopupVisible(root), true);
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user