mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Compare commits
20 Commits
7a64488ed5
...
windows-qo
| Author | SHA1 | Date | |
|---|---|---|---|
| 87fbe6c002 | |||
| e06f12634f | |||
| 48f74db239 | |||
| fd6dea9d33 | |||
| 0cdd79da9a | |||
| 3e7573c9fc | |||
| 20a0efe572 | |||
| 7698258f61 | |||
| ac25213255 | |||
| a5dbe055fc | |||
| 04742b1806 | |||
| f0e15c5dc4 | |||
| 9145c730b5 | |||
| cf86817cd8 | |||
| 3f7de73734 | |||
| de9b887798 | |||
|
9b4de93283
|
|||
|
16ffbbc4b3
|
|||
|
de4f3efa30
|
|||
|
|
bc7dde3b02 |
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 }}
|
||||||
|
|||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.11.2 (2026-04-07)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Launcher: Replaced the launcher-only fullscreen toggle with `mpv.launchMode` so SubMiner-managed mpv playback can start in normal, maximized, or fullscreen mode.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Launcher: Fixed launcher-managed mpv spawning to force an explicit X11 GPU path when Wayland trackers are unavailable.
|
||||||
|
- Launcher: Local playback now promotes a single unlabeled external subtitle sidecar to the primary slot instead of leaving mpv's embedded English auto-selection in place.
|
||||||
|
- Release: Fixed Linux AppImage startup packaging so Chromium child relaunches can resolve the bundled `libffmpeg.so` instead of crash-looping on startup.
|
||||||
|
|
||||||
## v0.11.1 (2026-04-04)
|
## v0.11.1 (2026-04-04)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
| Hyprland (`hyprctl`) · X11/Xwayland (`xdotool` + `xwininfo`) | Accessibility permission | No extra deps |
|
| Hyprland (`hyprctl`) · X11/Xwayland (`xdotool` + `xwininfo`) | Accessibility permission | No extra deps |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **Wayland support is compositor-specific.** Wayland has no universal API for window positioning and each compositor exposes its own IPC, so SubMiner needs a dedicated backend per compositor. Hyprland is the only native Wayland backend supported currenlty. All other Linux compositors require both mpv and SubMiner to run under X11 or Xwayland.
|
> **Wayland support is compositor-specific.** Wayland has no universal API for window positioning and each compositor exposes its own IPC, so SubMiner needs a dedicated backend per compositor. Hyprland is the only native Wayland backend supported currenlty. All other Linux compositors require both mpv and SubMiner to run under X11 or Xwayland. The launcher detects your compositor and configures this automatically.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Arch Linux</b></summary>
|
<summary><b>Arch Linux</b></summary>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- Fixed launcher-managed mpv spawning to force an explicit X11 GPU path when Wayland trackers are unavailable.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
Local playback now promotes a single unlabeled external subtitle sidecar to the primary slot instead of leaving mpv's embedded English auto-selection in place.
|
|
||||||
@@ -12,10 +12,27 @@ area: overlay
|
|||||||
- Added auto-pause toggle when opening the popup.
|
- Added auto-pause toggle when opening the popup.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For breaking changes, add `breaking: true`:
|
||||||
|
|
||||||
|
```md
|
||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
breaking: true
|
||||||
|
|
||||||
|
- Renamed `foo.bar` to `foo.baz`.
|
||||||
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- `type` required: `added`, `changed`, `fixed`, `docs`, or `internal`
|
- `type` required: `added`, `changed`, `fixed`, `docs`, or `internal`
|
||||||
- `area` required: short product area like `overlay`, `launcher`, `release`
|
- `area` required: short product area like `overlay`, `launcher`, `release`
|
||||||
|
- `breaking` optional: set to `true` to flag as a breaking change
|
||||||
- each non-empty body line becomes a bullet
|
- each non-empty body line becomes a bullet
|
||||||
- `README.md` is ignored by the generator
|
- `README.md` is ignored by the generator
|
||||||
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
|
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
|
||||||
|
|
||||||
|
Prerelease notes:
|
||||||
|
|
||||||
|
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
||||||
|
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
||||||
|
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Fixed Linux AppImage startup packaging so Chromium child relaunches can resolve the bundled `libffmpeg.so` instead of crash-looping on startup.
|
|
||||||
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.
|
||||||
@@ -461,10 +461,12 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// MPV Launcher
|
// MPV Launcher
|
||||||
// Optional mpv.exe override for Windows playback entry points.
|
// Optional mpv.exe override for Windows playback entry points.
|
||||||
|
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"mpv": {
|
"mpv": {
|
||||||
"executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
|
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
}, // Optional mpv.exe override for Windows playback entry points.
|
}, // Optional mpv.exe override for Windows playback entry points.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -1,148 +1,364 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.11.2 (2026-04-07)
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Launcher: Replaced the launcher-only fullscreen toggle with `mpv.launchMode` so SubMiner-managed mpv playback can start in normal, maximized, or fullscreen mode.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Launcher: Fixed launcher-managed mpv spawning to force an explicit X11 GPU path when Wayland trackers are unavailable.
|
||||||
|
- Launcher: Local playback now promotes a single unlabeled external subtitle sidecar to the primary slot instead of leaving mpv's embedded English auto-selection in place.
|
||||||
|
- Release: Fixed Linux AppImage startup packaging so Chromium child relaunches can resolve the bundled `libffmpeg.so` instead of crash-looping on startup.
|
||||||
|
|
||||||
## v0.11.1 (2026-04-04)
|
## v0.11.1 (2026-04-04)
|
||||||
|
|
||||||
- Fixed Linux packaged builds to expose the canonical `SubMiner` app identity to Electron's startup metadata so native Wayland compositors stop reporting the window class/app-id as lowercase `subminer`.
|
**Fixed**
|
||||||
- Fixed Linux to restore the runtime options, Jimaku, and Subsync shortcuts after the Electron 39 regression by routing those actions through the overlay's mpv/IPC shortcut path.
|
- Release: Linux packaged builds now expose the canonical `SubMiner` app identity to Electron's startup metadata so native Wayland compositors stop reporting the window class/app-id as lowercase `subminer`.
|
||||||
|
- Linux: Linux now restores the runtime options, Jimaku, and Subsync shortcuts after the Electron 39 regression by routing those actions through the overlay's mpv/IPC shortcut path.
|
||||||
|
|
||||||
## v0.11.0 (2026-04-03)
|
## v0.11.0 (2026-04-03)
|
||||||
|
|
||||||
- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback, with a default `Ctrl+Alt+P` keybinding.
|
**Added**
|
||||||
- Made mpv plugin installation mandatory in first-run setup (removed skip path); Finish stays disabled until the plugin is installed.
|
- Overlay: Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
|
||||||
- Fixed the Windows `SubMiner mpv` shortcut to launch mpv with required default args directly instead of requiring an `mpv.conf` profile named `subminer`.
|
- Overlay: Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.
|
||||||
- Fixed the Windows mpv idle launch so loading a video after opening the shortcut keeps mpv in the SubMiner-managed session and auto-starts the overlay.
|
|
||||||
- Added a blank-by-default `mpv.executablePath` config override for Windows playback when mpv is not on `PATH`, exposed in first-run setup.
|
|
||||||
- Fixed Kiku duplicate grouping to reuse duplicate note IDs from both sentence-card creation and Yomitan popup mining, with background card addition and proper merge-modal sequencing.
|
|
||||||
- Fixed configured subtitle-jump keybindings to keep playback paused when invoked from a paused state.
|
|
||||||
- Fixed managed local subtitle auto-selection to reuse configured language priorities instead of staying on mpv's initial `sid=auto` guess.
|
|
||||||
- Kept tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately.
|
|
||||||
- Stopped AniList post-watch from sending duplicate progress updates when already satisfied by a retry item.
|
|
||||||
- Kept integrated `--start --texthooker` launches on the full app-ready startup path.
|
|
||||||
- Honored `SUBMINER_YTDLP_BIN` consistently across all YouTube flows (playback URL resolution, track probing, subtitle downloads, metadata probing).
|
|
||||||
- Added `windows` as a recognized launcher backend option and auto-detection target.
|
|
||||||
- Added a dedicated Subtitle Sidebar guide to the docs site with links from homepage and configuration docs.
|
|
||||||
|
|
||||||
## v0.10.0 (2026-03-29)
|
**Changed**
|
||||||
|
- Setup: Made mpv plugin installation mandatory in the first-run setup flow, removed the skip path, and kept Finish disabled until the plugin is installed.
|
||||||
|
- Setup: Clarified that the mpv plugin requirement applies to setup on every platform, while the optional `SubMiner mpv` shortcut remains the recommended Windows playback entry point.
|
||||||
|
- Launcher: Streamlined Windows setup and config by making the `SubMiner mpv` shortcut self-contained and keeping `mpv.executablePath` as the simple fallback when `mpv.exe` is not on `PATH`.
|
||||||
|
- Overlay: Changed fresh-install default config to keep texthooker and stats from auto-opening browser tabs.
|
||||||
|
- Overlay: Changed fresh-install default config to enable AnkiConnect, Discord Rich Presence, subtitle-sidebar, and Yomitan-popup auto-pause by default, while disabling controller input by default.
|
||||||
|
|
||||||
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
|
**Fixed**
|
||||||
- Added a Node `http` fallback for Electron/runtime paths that do not expose Bun, so stats keeps working there too.
|
- Main: Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
|
||||||
- Updated Discord Rich Presence to the maintained `@xhayper/discord-rpc` wrapper.
|
- Main: Add regression coverage for the lazy socket-path lookup during Windows mpv startup.
|
||||||
- Fixed the macOS visible-overlay toggle path so manual hides stay hidden and the plugin uses the explicit visible-overlay toggle command.
|
- Main: Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
|
||||||
- Restored macOS mpv passthrough while the overlay subtitle sidebar is open so clicks outside the sidebar can refocus mpv and keep native keybindings working.
|
- Main: Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.
|
||||||
|
- Overlay: Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first.
|
||||||
|
- Overlay: Add regression coverage for the macOS visible-overlay passthrough default.
|
||||||
|
- Anilist: Stop AniList post-watch from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
|
||||||
|
- Anilist: Add regression coverage for the retry-queue plus live-update duplicate path.
|
||||||
|
- Overlay: Fixed Kiku duplicate grouping to reuse duplicate note IDs from both generic sentence-card creation and Yomitan popup mining instead of running extra duplicate scans after add.
|
||||||
|
- Overlay: Fixed the Yomitan popup mining flow to add cards in the background while keeping the stock popup progress feedback, then pause playback and close the lookup popup before the Kiku merge modal opens.
|
||||||
|
- Overlay: Fixed configured subtitle-jump keybindings so backward and forward subtitle seeks keep playback paused when invoked from a paused state.
|
||||||
|
- Launcher: Fixed the Windows `SubMiner mpv` shortcut and `SubMiner.exe --launch-mpv` flow to launch mpv with SubMiner's required default args directly instead of requiring an `mpv.conf` profile named `subminer`.
|
||||||
|
- Launcher: Clarified the Windows install and usage docs so the shortcut path is documented as self-contained, while the optional `subminer` mpv profile remains available for manual mpv launches.
|
||||||
|
- Launcher: Hardened the first-run setup blocker copy and stale custom-scheme handling so setup messages stay aligned with config, plugin, and dictionary readiness.
|
||||||
|
- Launcher: Fixed the Windows `SubMiner mpv` shortcut idle launch so loading a video after opening the shortcut keeps mpv in the expected SubMiner-managed session, auto-starts the overlay, and re-arms subtitle auto-selection for the newly opened file.
|
||||||
|
- Launcher: Removed the redundant `.` subtitle search path from the Windows shortcut launch args and deduped repeated subtitle source tracks in the manual sync picker so duplicate external subtitle entries no longer appear from the shortcut path.
|
||||||
|
- Playback: Fixed managed local playback so duplicate startup-ready retries no longer unpause media after a later manual pause on the same file.
|
||||||
|
- Playback: Fixed managed local subtitle auto-selection so local files reuse configured primary and secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess.
|
||||||
|
- Launcher: Added a blank-by-default `mpv.executablePath` override for Windows playback so users can point SubMiner at `mpv.exe` when it is not on `PATH`.
|
||||||
|
- Launcher: Kept the Windows shortcut and `--launch-mpv` flow simple by preserving PATH auto-discovery as the default and exposing the override in first-run setup.
|
||||||
|
- Launcher: Added `windows` as a recognized launcher backend option and auto-detection target on Windows.
|
||||||
|
- Launcher: Honored `SUBMINER_YTDLP_BIN` consistently across YouTube playback URL resolution, track probing, subtitle downloads, and metadata probing.
|
||||||
|
- Launcher: Kept the first-run setup window from navigating away on unexpected URLs.
|
||||||
|
- Launcher: Made Windows mpv honor an explicitly configured executable path instead of silently falling back to PATH.
|
||||||
|
- Launcher: Hardened `--launch-mpv` parsing and Windows binary resolution so valueless flags do not swallow media targets and symlinked launcher installs do not short-circuit PATH lookup.
|
||||||
|
- Launcher: Fixed first-run setup blocking playback on macOS when the SubMiner mpv plugin was already installed at the canonical `~/.config/mpv` path.
|
||||||
|
- Launcher: Fixed setup gating so stale cancelled setup state no longer prevents playback when the canonical mpv plugin entrypoint already exists.
|
||||||
|
- Playback: Prevented stale async playlist-browser subtitle rearm callbacks from overriding newer subtitle selections during rapid file changes.
|
||||||
|
|
||||||
## v0.9.3 (2026-03-25)
|
**Docs**
|
||||||
|
- Docs Site: Added a dedicated Subtitle Sidebar guide and linked it from the homepage and configuration docs.
|
||||||
|
- Docs Site: Linked Jimaku integration from the homepage to its dedicated docs page.
|
||||||
|
- Docs Site: Refreshed docs-site theme tokens and hover/selection styling for the updated pages.
|
||||||
|
|
||||||
- Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`.
|
**Internal**
|
||||||
- Removed the placeholder YouTube subtitle retime step; downloaded primary subtitle tracks are now used directly.
|
- Release: Retried AUR clone and push operations in the tagged release workflow.
|
||||||
- Removed the old internal YouTube retime helper and its tests.
|
- Release: Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.
|
||||||
- Clarified optional `alass` / `ffsubsync` subtitle-sync setup and fallback behavior in the docs.
|
- Release: Updated Electron to 39.8.6 and pinned patched transitive build dependencies to clear the reported high-severity audit findings.
|
||||||
- Removed the legacy `youtubeSubgen.primarySubLanguages` config path from generated config and docs.
|
|
||||||
|
|
||||||
## v0.9.2 (2026-03-25)
|
## Previous Versions
|
||||||
|
|
||||||
- Fixed overlay pointer tracking so Windows click-through toggles immediately when the cursor enters or leaves subtitle regions.
|
<details>
|
||||||
- Fixed Windows overlay window tracking on scaled displays by converting native tracked window bounds to Electron DIP coordinates.
|
<summary>v0.10.x</summary>
|
||||||
- Fixed Windows direct `--youtube-play` startup so MPV boots reliably, stays paused until the app-owned subtitle flow is ready, and reuses an already-running SubMiner instance.
|
|
||||||
- Fixed standalone Windows `--youtube-play` sessions so closing MPV fully exits SubMiner instead of leaving hidden overlay windows behind.
|
|
||||||
- Fixed `subminer <youtube-url>` on Linux so the YouTube playback flow waits for Yomitan to load before creating the overlay window.
|
|
||||||
|
|
||||||
## v0.9.1 (2026-03-24)
|
<h2>v0.10.0 (2026-03-29)</h2>
|
||||||
|
|
||||||
- Reduced packaged release size by excluding duplicate `extraResources` payload and pruning docs, tests, sourcemaps, and other source-only files from Electron bundles.
|
**Changed**
|
||||||
- Restored controller navigation and lookup/mining controls while the subtitle sidebar is open, while keeping true modal dialogs blocking controller actions.
|
- Integrations: Replaced the deprecated Discord Rich Presence wrapper with the maintained `@xhayper/discord-rpc` package.
|
||||||
- Fixed subtitle annotation clearing so explanatory contrast endings like `んですけど` are excluded consistently across the shared tokenizer filter and annotation stage.
|
|
||||||
|
|
||||||
## v0.9.0 (2026-03-23)
|
**Fixed**
|
||||||
|
- Stats: Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
|
||||||
|
- Stats: Stats server now falls back to a Node `http` listener in Electron/runtime paths that do not expose Bun.
|
||||||
|
- Overlay: Fixed the macOS visible-overlay toggle path so manual hides stay hidden and the plugin uses the explicit visible-overlay toggle command.
|
||||||
|
- Subtitle Sidebar: Restored macOS mpv passthrough while the overlay subtitle sidebar is open so clicks outside the sidebar can refocus mpv and keep native keybindings working.
|
||||||
|
|
||||||
- Added an app-owned YouTube subtitle flow with absPlayer-style timedtext parsing that auto-loads the default primary subtitle plus a best-effort secondary at startup and resumes once the primary is ready.
|
**Internal**
|
||||||
- Added a manual YouTube subtitle picker on `Ctrl+Alt+C` so subtitle selection can be retried on demand during active YouTube playback.
|
- Release: Added a maintained source coverage lane that shards Bun coverage one test file at a time and merges LCOV output into `coverage/test-src/lcov.info`.
|
||||||
- Added yt-dlp metadata probing so YouTube playback and immersion tracking record canonical video title and channel metadata.
|
- Release: CI and release quality-gate now upload the merged source-lane LCOV artifact for inspection.
|
||||||
- Disabled conflicting mpv native subtitle auto-selection for the app-owned flow so injected explicit tracks stay authoritative.
|
- Runtime: Extracted remaining inline runtime logic from `src/main.ts` into dedicated runtime modules and composer helpers.
|
||||||
- Added OSD status updates covering YouTube playback startup, subtitle acquisition, and subtitle loading.
|
- Runtime: Added focused regression tests for the extracted runtime/composer boundaries.
|
||||||
- Stopped forcing `--ytdl-raw-options=` before user-provided mpv options so existing YouTube cookie integrations are preserved.
|
- Runtime: Updated task tracking notes to mark TASK-238.6 complete and confirm follow-on boot-phase split can be deferred.
|
||||||
- Improved sidebar startup/resume behavior, scroll handling, and overlay/sidebar subtitle synchronization.
|
- Runtime: Split `src/main.ts` boot wiring into dedicated `src/main/boot/services.ts`, `src/main/boot/runtimes.ts`, and `src/main/boot/handlers.ts` modules.
|
||||||
- Stats Library tab now shows YouTube video title, channel name, and thumbnail for YouTube media entries.
|
- Runtime: Added focused tests for the new boot-phase seams and kept the startup/typecheck/build verification lanes green.
|
||||||
- Added a new WebSocket / Texthooker API integration guide covering payload formats, custom client patterns, and mpv plugin automation.
|
- Runtime: Updated internal architecture/task docs to record the boot-phase split and new ownership boundary.
|
||||||
- Fixed Anki media mining for mpv YouTube streams so audio and screenshot capture work correctly during YouTube playback sessions.
|
|
||||||
- Fixed YouTube media path handling in immersion tracking so YouTube sessions record correct media references and AniList state transitions do not fire for YouTube media.
|
|
||||||
- Reused existing authoritative YouTube subtitle tracks when present, fell back only for missing sides, and kept native mpv secondary subtitle rendering hidden so the overlay remains the visible secondary subtitle surface.
|
|
||||||
|
|
||||||
## v0.8.0 (2026-03-22)
|
</details>
|
||||||
|
|
||||||
- Added a configurable subtitle sidebar feature (`subtitleSidebar`) with overlay/embedded rendering, click-to-seek cue list, and hot-reloadable visibility and behavior controls.
|
<details>
|
||||||
- Added a rendered sidebar modal with cue list display, click-to-seek, active-cue highlighting, and embedded layout support.
|
<summary>v0.9.x</summary>
|
||||||
- Added sidebar snapshot plumbing between main and renderer for overlay/sidebar synchronization.
|
|
||||||
- Added sidebar configuration options for visibility and behavior (enabled, layout, toggle key, autoOpen, pauseOnHover, autoScroll) plus typography and sizing controls.
|
|
||||||
- Documented `subtitleSidebar` configuration and behavior in user-facing docs (configuration.md, shortcuts.md, config.example.jsonc).
|
|
||||||
- Updated subtitle prefetch/rendering flow to keep overlay and sidebar state in sync through media transitions.
|
|
||||||
- Kept sidebar cue tracking stable across playback transitions and timing edge cases.
|
|
||||||
- Fixed sidebar startup/resume positioning to jump directly to the first resolved active cue.
|
|
||||||
- Prevented stale subtitle refreshes from regressing active-cue state.
|
|
||||||
|
|
||||||
## v0.7.0 (2026-03-19)
|
<h2>v0.9.3 (2026-03-25)</h2>
|
||||||
|
|
||||||
- Added a full local immersion dashboard release line with Overview, Library, Trends, Vocabulary, and Sessions drill-down views backed by SQLite tracking data.
|
**Changed**
|
||||||
- Added browser-first stats workflows: `subminer stats`, background stats daemon controls (`-b` / `-s`), stats cleanup, and dashboard-side mining actions with media enrichment.
|
- Launcher: Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`.
|
||||||
- Improved stats accuracy and scale handling with Yomitan token counts, full session timelines, known-word timeline fixes, cross-media vocabulary fixes, and clearer session charts.
|
- Launcher: Removed the placeholder YouTube subtitle retime step and now uses downloaded primary subtitle tracks directly, so there is no fake path rewrite before playback/sidebar loading.
|
||||||
- Improved overlay/runtime stability with quieter macOS fullscreen recovery, reduced repeated loading OSD popups, and better frequency/noise handling for subtitle annotations.
|
- YouTube: Removed the `src/core/services/youtube/retime` helper and its tests after retiring the internal retime strategy.
|
||||||
- Added launcher mpv-args passthrough plus Linux plugin wrapper-name fallback for packaged installs.
|
- Docs: Clarified optional `alass` / `ffsubsync` subtitle-sync requirements and setup steps, including fallback behavior when sync tools are absent.
|
||||||
- Added a hover-revealed ↗ button on Sessions tab rows to navigate directly to the anime media-detail view, with correct "Back to Sessions" back-navigation.
|
- Launcher: Removed the old `youtubeSubgen.primarySubLanguages` config path from the generated config and docs.
|
||||||
- Excluded auxiliary-stem `そうだ` grammar tails (MeCab POS3 `助動詞語幹`) from subtitle annotation metadata so frequency, JLPT, and N+1 styling no longer bleed onto grammar-tail tokens.
|
|
||||||
|
|
||||||
## v0.6.5 (2026-03-15)
|
<h2>v0.9.2 (2026-03-25)</h2>
|
||||||
|
|
||||||
- Seeded the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.
|
**Fixed**
|
||||||
|
- Overlay: Fixed overlay pointer tracking so Windows click-through toggles immediately when the cursor enters or leaves subtitle regions, without waiting for a later hover resync.
|
||||||
|
- Overlay: Fixed Windows overlay window tracking on scaled displays by converting native tracked window bounds to Electron DIP coordinates before applying overlay bounds.
|
||||||
|
- Launcher: Fixed Windows direct `--youtube-play` startup so MPV boots reliably, stays paused until the app-owned subtitle flow is ready, and reuses an already-running SubMiner instance when available.
|
||||||
|
- Launcher: Fixed standalone Windows `--youtube-play` sessions so closing MPV fully exits SubMiner instead of leaving hidden overlay windows or a background process behind.
|
||||||
|
- Overlay: Fixed `subminer <youtube-url>` on Linux so the YouTube playback flow waits for Yomitan to load before creating the overlay window, avoiding the broken lookup popup state that previously required a manual overlay refresh.
|
||||||
|
|
||||||
## v0.6.4 (2026-03-15)
|
<h2>v0.9.1 (2026-03-24)</h2>
|
||||||
|
|
||||||
- Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
|
**Changed**
|
||||||
|
- Release: Reduced packaged release size by excluding duplicate `extraResources` payload and pruning docs, tests, sourcemaps, and other source-only files from Electron bundles.
|
||||||
|
|
||||||
## v0.6.3 (2026-03-15)
|
**Fixed**
|
||||||
|
- Overlay: Restored controller navigation and lookup/mining controls while the subtitle sidebar is open, while keeping true modal dialogs blocking controller actions.
|
||||||
|
- Tokenizer: Fixed subtitle annotation clearing so explanatory contrast endings like `んですけど` are excluded consistently across the shared tokenizer filter and annotation stage.
|
||||||
|
|
||||||
- Expanded `Alt+C` into an inline controller config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions.
|
<h2>v0.9.0 (2026-03-23)</h2>
|
||||||
- Automated `subminer-bin` AUR package updates from the tagged release workflow.
|
|
||||||
|
|
||||||
## v0.6.2 (2026-03-12)
|
**Added**
|
||||||
|
- Docs: Added a new WebSocket / Texthooker API and integration guide covering WebSocket payloads, custom client patterns, mpv plugin automation, and webhook-style relay examples. Linked from configuration and mining workflow docs for easier discovery.
|
||||||
|
|
||||||
- Added `yomitan.externalProfilePath` so SubMiner can reuse another Electron app's Yomitan profile in read-only mode.
|
**Changed**
|
||||||
- Reused external Yomitan dictionaries/settings without writing back to that profile.
|
- Launcher: Added an app-owned YouTube subtitle flow that pauses mpv, uses absPlayer-style YouTube timedtext parsing/conversion to download subtitle tracks, and injects them as external files before playback resumes.
|
||||||
- Let launcher-managed playback honor external Yomitan config instead of forcing first-run setup.
|
- Launcher: Changed YouTube subtitle startup to auto-load the best-available primary and secondary subtitle tracks at launch instead of forcing the picker modal first. Secondary subtitle failures no longer block playback resume.
|
||||||
- Seeded `config.jsonc` even when the default config directory already exists.
|
- Launcher: Added `Ctrl+Alt+C` as the default keybinding to manually open the YouTube subtitle picker during active YouTube playback.
|
||||||
- Let first-run setup complete without internal dictionaries while external Yomitan is configured, then require an internal dictionary again only if that external profile is later removed.
|
- Launcher: Added yt-dlp metadata probing so YouTube playback and immersion tracking record canonical video title and channel metadata.
|
||||||
|
- Launcher: Stopped forcing `--ytdl-raw-options=` before user-provided mpv options so existing YouTube cookie integrations in user `--args` are no longer clobbered.
|
||||||
|
- Launcher: Disabled mpv native YouTube subtitle auto-loading for the app-owned flow so injected external subtitle files remain authoritative.
|
||||||
|
- Launcher: Added OSD status messages for YouTube playback startup, subtitle acquisition, and subtitle loading so the flow stays visible before and during the picker.
|
||||||
|
- Subtitle Sidebar: Added startup-auto-open controls and resume positioning improvements so the sidebar jumps directly to the first resolved active cue.
|
||||||
|
- Subtitle Sidebar: Improved subtitle prefetch and embedded overlay passthrough sync so sidebar and overlay subtitle states stay consistent across media transitions.
|
||||||
|
- Subtitle Sidebar: Updated scroll handling, embedded layout styling, and active-cue visual behavior.
|
||||||
|
- Stats: Stats Library tab now displays YouTube video title, channel name, and channel thumbnail for YouTube media entries, with retry logic to fill in metadata that arrives after initial load.
|
||||||
|
|
||||||
## v0.6.1 (2026-03-12)
|
**Fixed**
|
||||||
|
- Launcher: Fixed Anki media mining for mpv YouTube streams by unwrapping the stream URL so audio and screenshot capture work correctly for YouTube playback sessions.
|
||||||
|
- Immersion: Fixed YouTube media path handling in the immersion runtime and tracking so YouTube sessions record correct media references, AniList guessing skips YouTube URLs, and post-watch state transitions do not fire for YouTube media.
|
||||||
|
- Launcher: Fixed startup-launched YouTube playback so primary subtitle overlay updates continue after auto-load completes.
|
||||||
|
- Launcher: Fixed auto-loaded YouTube primary subtitles so parsed cues appear in the subtitle sidebar without needing a manual picker retry.
|
||||||
|
- Launcher: Fixed the YouTube picker to guard against duplicate subtitle submissions and tightened YouTube URL detection so follow-up runtime flows only treat real YouTube hosts as YouTube playback.
|
||||||
|
- Launcher: Fixed primary subtitle failure notifications being shown while app-owned YouTube subtitle probing and downloads are still in flight.
|
||||||
|
- Launcher: Preserved existing authoritative YouTube subtitle tracks when available; downloaded tracks are used only to fill missing sides, and native mpv secondary subtitle rendering is hidden so the overlay remains the sole secondary display.
|
||||||
|
|
||||||
- Added Chrome Gamepad API controller support for keyboard-only overlay mode.
|
</details>
|
||||||
- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.
|
|
||||||
- Added smooth, slower popup scrolling for controller navigation.
|
|
||||||
- Expanded `Alt+C` into a controller config/remap modal with preferred-controller saving, inline learn mode, and kept `Alt+Shift+C` for raw input debugging.
|
|
||||||
- Added a transient in-overlay controller-detected indicator when a controller is first found.
|
|
||||||
- Fixed cleanup of stale keyboard-only token highlights when keyboard-only mode is disabled or when the Yomitan popup closes.
|
|
||||||
- Added an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently.
|
|
||||||
|
|
||||||
## v0.5.6 (2026-03-10)
|
<details>
|
||||||
|
<summary>v0.8.x</summary>
|
||||||
|
|
||||||
- Persisted merged character-dictionary MRU state as soon as a new retained set is built so revisits do not get dropped if later Yomitan import work fails.
|
<h2>v0.8.0 (2026-03-22)</h2>
|
||||||
- Fixed early Electron startup writing config and user data under a lowercase `~/.config/subminer` path instead of canonical `~/.config/SubMiner`.
|
|
||||||
- Kept JLPT underline colors stable during Yomitan hover and selection states, even when tokens also use known, N+1, name-match, or frequency styling.
|
|
||||||
|
|
||||||
## v0.5.1 (2026-03-09)
|
**Added**
|
||||||
|
- Overlay: Added the subtitle sidebar feature with a new `subtitleSidebar` configuration surface and rendered sidebar modal with cue list rendering, click-to-seek, active-cue highlighting, and embedded layout support.
|
||||||
|
- IPC: Added sidebar snapshot plumbing between renderer and main process for overlay/sidebar synchronization.
|
||||||
|
|
||||||
- Removed the old YouTube subtitle-generation mode switch; YouTube playback now resolves subtitles before mpv starts.
|
**Changed**
|
||||||
- Hardened YouTube AI subtitle fixing so fenced/text-only responses keep original cue timing.
|
- Config: Added hot-reloadable sidebar options for enablement, layout, visibility, typography, opacity, sizing, and interaction behavior (`autoOpen`, `pauseOnHover`, `autoScroll`, toggle key).
|
||||||
- Skipped AniSkip during URL/YouTube playback where anime metadata cannot be resolved reliably.
|
- Docs: Added full `subtitleSidebar` documentation coverage, including sample config, option table, and toggle shortcut notes.
|
||||||
- Kept the background SubMiner process warm across launcher-managed mpv exits so reconnects do not repeat startup pause/warmup work.
|
- Runtime: Improved subtitle prefetch/rendering flow so sidebar and overlay subtitle states stay in sync across media transitions.
|
||||||
- Fixed Windows single-instance reuse so overlay and video launches reuse the running background app instead of booting a second full app.
|
|
||||||
- Hardened the Windows signing/release workflow with SignPath retry handling for signed `.exe` and `.zip` artifacts.
|
|
||||||
|
|
||||||
## v0.5.0 (2026-03-08)
|
**Fixed**
|
||||||
|
- Overlay: Kept sidebar cue tracking stable across playback transitions and timing edge cases.
|
||||||
|
- Overlay: Improved sidebar resume/start behavior to jump directly to the first resolved active cue.
|
||||||
|
- Overlay: Stopped stale subtitle refreshes from regressing active-cue and text state.
|
||||||
|
|
||||||
- Added the initial packaged Windows release.
|
</details>
|
||||||
- Added Windows-native mpv window tracking, launcher/runtime plumbing, and packaged helper assets.
|
|
||||||
- Improved close behavior so ending playback hides the visible overlay while the background app stays running.
|
|
||||||
- Limited the native overlay outline/debug frame to debug mode on Windows.
|
|
||||||
|
|
||||||
## v0.3.0 (2026-03-05)
|
<details>
|
||||||
|
<summary>v0.7.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.7.0 (2026-03-19)</h2>
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- Immersion: Added Mine Word, Mine Sentence, and Mine Audio buttons to word detail example lines in the stats dashboard.
|
||||||
|
- Immersion: Mine Word creates a full Yomitan card (definition, reading, pitch accent) via the hidden search page bridge, then enriches with sentence audio, screenshot, and metadata extracted from the source video.
|
||||||
|
- Immersion: Mine Sentence and Mine Audio create cards directly with appropriate Lapis/Kiku flags, sentence highlighting, and media from the source file.
|
||||||
|
- Immersion: Media generation (audio + image/AVIF) runs in parallel and respects all AnkiConnect config options.
|
||||||
|
- Immersion: Added word exclusion list to the Vocabulary tab with localStorage persistence and a management modal.
|
||||||
|
- Immersion: Fixed truncated readings in the frequency rank table (e.g. お前 now shows おまえ instead of まえ).
|
||||||
|
- Immersion: Clicking a bar in the Top Repeated Words chart now opens the word detail panel.
|
||||||
|
- Immersion: Secondary subtitle text is now stored alongside primary subtitle lines for use as translation when mining cards from the stats page.
|
||||||
|
- Stats: Added `subminer stats -b` to start or reuse a dedicated background stats server without blocking normal SubMiner instances.
|
||||||
|
- Stats: Added `subminer stats -s` to stop the dedicated background stats server without closing browser tabs.
|
||||||
|
- Stats: Stats server startup now reuses a running background stats daemon instead of trying to bind a second local server in another SubMiner instance.
|
||||||
|
- Launcher: Added launcher passthrough for `-a/--args` so mpv receives raw extra launch flags (`--fs`, `--ytdl-format`, custom audio/video settings, etc.) from the `subminer` command.
|
||||||
|
- Launcher: Added `subminer stats` to launch the local stats dashboard, force-start the stats server on demand, and open the dashboard in your browser.
|
||||||
|
- Launcher: Added `subminer stats cleanup` to backfill vocabulary metadata and prune stale or excluded immersion rows on demand.
|
||||||
|
- Launcher: Added `stats.autoOpenBrowser` so browser launch after `subminer stats` can be enabled or disabled explicitly.
|
||||||
|
- Immersion: Added a local stats dashboard for immersion tracking with Overview, Anime, Trends, Vocabulary, and Sessions views.
|
||||||
|
- Immersion: Added anime progress, episode completion, Anki card links, and occurrence drill-down across the stats dashboard.
|
||||||
|
- Immersion: Added richer session timelines with new-word activity, cumulative totals, and pause/seek/card event markers.
|
||||||
|
- Immersion: Added completed-episodes and completed-anime totals to the Overview tracking snapshot.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Anki: Changed known-word cache settings to live under `ankiConnect.knownWords` instead of mixing them into `ankiConnect.nPlusOne`.
|
||||||
|
- Anki: Kept legacy `ankiConnect.nPlusOne` known-word keys and older `ankiConnect.behavior.nPlusOne*` keys as deprecated compatibility fallbacks.
|
||||||
|
- Stats: Added session deletion to the Sessions tab with the same confirmation prompt used by anime episode/session deletes, and removed all associated session rows from the stats database.
|
||||||
|
- Immersion: Kept immersion tracking history by default while preserving daily/monthly rollup maintenance.
|
||||||
|
- Immersion: Added exact lifetime summary reads for overview/anime/media stats so dashboard totals no longer depend on rescanning raw telemetry.
|
||||||
|
- Immersion: Reduced tracker storage overhead by removing duplicated subtitle text from subtitle-line event payloads.
|
||||||
|
- Immersion: Deduplicated episode cover-art blobs through a shared blob store and updated cover-art reads/writes to resolve shared images correctly.
|
||||||
|
- Immersion: Added indexes for large-history session, telemetry, vocabulary, kanji, and cover-art queries to keep dashboard reads fast as the SQLite database grows.
|
||||||
|
- Immersion: Renamed the stats dashboard's Anime tab to Library so the media browser label matches non-anime sources like YouTube and other yt-dlp-backed content.
|
||||||
|
- Anilist: Standardized episode completion threshold by introducing `DEFAULT_MIN_WATCH_RATIO` and using it for both local watched state transitions and AniList post-watch progress updates.
|
||||||
|
- Anilist: Episode auto-marking now uses the same threshold as AniList (`85%`), removing divergent completion behavior.
|
||||||
|
- Overlay: Excluded interjections and sound-effect tokens from subtitle annotation styling so they no longer inherit misleading lexical highlight treatment while still remaining visible and hoverable as plain subtitle tokens.
|
||||||
|
- Overlay: Expanded subtitle annotation noise filtering to also strip annotation metadata from standalone grammar-only helper tokens such as particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, and merged trailing quote-particle forms like `...って` while keeping them tokenized for hover lookup.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Launcher: Fixed mpv Lua plugin binary auto-detection on Linux to also search `/usr/bin/subminer` and `/usr/local/bin/subminer` (lowercase), matching the conventional Unix wrapper name used by packaged installs such as the AUR package.
|
||||||
|
- Stats: Fixed the in-app stats overlay so it connects to the configured `stats.serverPort` instead of falling back to the default port.
|
||||||
|
- Overlay: Fixed subtitle frequency tagging for merged lookup-backed tokens like `陰に` by falling back to exact surface-form Yomitan frequencies when the normalized headword lookup misses.
|
||||||
|
- Overlay: Fixed MeCab merged-token position mapping across line breaks so merged content-plus-particle tokens like `陰に` keep their matched Yomitan frequency instead of inheriting shifted POS tags.
|
||||||
|
- Overlay: Fixed grouped frequency parsing in both Yomitan and fallback frequency-dictionary lookups so display values like `118,121` use the leading rank instead of collapsing the rank and occurrence count into `118121`.
|
||||||
|
- Overlay: Fixed frequency-rank ingestion to ignore Yomitan dictionaries explicitly marked `occurrence-based`, so raw occurrence counts are no longer treated as subtitle rank values.
|
||||||
|
- Overlay: Fixed inflected headword frequency tagging to prefer ranks from the selected Yomitan `termsFind` popup entry itself, ordered by configured dictionary priority, so forms like `潜み` use primary-dictionary ranks like `4073` before falling back to lower-priority raw lemma metadata such as `CC100`.
|
||||||
|
- Overlay: Fixed annotation-stage frequency filtering so exact kanji noun tokens like `者` keep their matched rank even when MeCab labels them `名詞/非自立`, instead of dropping the highlight after scan-time frequency lookup succeeds.
|
||||||
|
- Anki: Fixed repeated character-dictionary startup work by scheduling auto-sync only from mpv media-path changes instead of also re-triggering it from connection and media-title events for the same title.
|
||||||
|
- Overlay: Fixed macOS fullscreen overlay stability by keeping the passive visible overlay from stealing focus, re-raising the overlay window when reasserting its macOS topmost level, and tolerating one transient macOS tracker/helper miss before hiding the overlay.
|
||||||
|
- Overlay: Kept subtitle tokenization warmup one-shot for the lifetime of the app so later fullscreen/media churn on macOS does not replay the startup warmup gate after the first file is ready.
|
||||||
|
- Overlay: Added a bounded macOS tracker loss-grace window so fullscreen enter/leave transitions do not immediately hide and reload the overlay when the helper briefly loses the mpv window.
|
||||||
|
- Overlay: Skipped subtitle/tokenization refresh invalidation on character-dictionary auto-sync completion when the dictionary was already current, preventing startup flash/reload loops on unchanged media.
|
||||||
|
- Stats: Fixed session stats so known-word counts track real known-word occurrences without collapsing subtitle-line gaps.
|
||||||
|
- Stats: Fixed session word totals in session-facing stats views to prefer token counts when available, preventing known words from exceeding total words in the session chart.
|
||||||
|
- Stats: Fixed the stats Vocabulary tab blank-screen regression caused by a hook-order crash after vocabulary data finished loading.
|
||||||
|
- Anki: Fixed card-mine OSD feedback so the final mine result stops the Anki spinner first, then shows a single-line `✓`/`x` status without being overwritten by a later spinner tick.
|
||||||
|
- Stats: Removed the misleading `New words` series from expanded session charts; session detail now shows only the real total-word and known-word lines.
|
||||||
|
- Stats: Restored the cross-anime word table behavior in stats vocabulary surfaces so shared vocabulary entries no longer disappear or merge incorrectly across related media.
|
||||||
|
- Stats: `subminer stats -b` now runs as a standalone background stats daemon instead of reusing the main SubMiner app process, so the overlay app can still be launched separately for normal video watching.
|
||||||
|
- Stats: Dashboard word mining still works against the background daemon by using a short-lived hidden helper for the Yomitan add-note flow.
|
||||||
|
- Stats: Load full session timelines by default in stats session detail views so long sessions preserve complete telemetry history instead of being truncated by a fixed sample limit.
|
||||||
|
- Stats: Replaced heuristic stats word counts with Yomitan token counts, so session, media, anime, and trend subtitle totals now come directly from parsed subtitle tokens.
|
||||||
|
- Stats: Updated stats UI labels and lookup-rate copy to refer to tokens instead of words where those counts are shown.
|
||||||
|
- Overlay: Reduced repeated `Overlay loading...` popups on macOS when fullscreen tracker flaps briefly hide and recover the visible overlay.
|
||||||
|
- Stats: Scaled expanded session-detail known-word charts to the session's actual percentage range so small changes no longer render as a nearly flat line.
|
||||||
|
- Jlpt: Reduced JLPT dictionary startup log noise by summarizing duplicate surface-form collisions instead of logging one line per duplicate entry.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.6.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.6.5 (2026-03-15)</h2>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Release: Seed the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.
|
||||||
|
|
||||||
|
<h2>v0.6.4 (2026-03-15)</h2>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Release: Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
|
||||||
|
|
||||||
|
<h2>v0.6.3 (2026-03-15)</h2>
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Overlay: Expanded the `Alt+C` controller modal into an inline config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions.
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Workflow: Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.
|
||||||
|
- Release: Automate `subminer-bin` AUR package updates from the tagged release workflow.
|
||||||
|
|
||||||
|
<h2>v0.6.2 (2026-03-12)</h2>
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Config: Added `yomitan.externalProfilePath` to reuse another Electron app's Yomitan profile in read-only mode.
|
||||||
|
- Config: SubMiner now reuses external Yomitan dictionaries/settings without writing back to that profile.
|
||||||
|
- Config: Launcher-managed playback now respects `yomitan.externalProfilePath` and no longer forces first-run setup when external Yomitan is configured.
|
||||||
|
- Config: SubMiner now seeds `config.jsonc` even when the default config directory already exists.
|
||||||
|
- Config: First-run setup now allows zero internal dictionaries when `yomitan.externalProfilePath` is configured, and falls back to requiring at least one internal dictionary if that external profile is later removed.
|
||||||
|
|
||||||
|
<h2>v0.6.1 (2026-03-12)</h2>
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- Overlay: Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
|
||||||
|
- Overlay: Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
|
||||||
|
- Overlay: Added a transient in-overlay controller-detected indicator when a controller is first found.
|
||||||
|
- Overlay: Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- Install: Added Arch Linux AUR install docs for `subminer-bin` in the README and installation guide.
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Config: add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
|
||||||
|
- Release: Fixed the release workflow token permissions so tagged builds can download `oven-sh/setup-bun` and publish artifacts again.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.5.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.5.6 (2026-03-10)</h2>
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- Dictionary: Persist merged character-dictionary MRU state as soon as a new retained set is built so revisits do not get dropped if later Yomitan import work fails, and skip merged dictionary rebuilds for reorder-only revisits when the retained anime set itself has not changed.
|
||||||
|
- Startup: Fixed early Electron startup writing config and user data under a lowercase `~/.config/subminer` path instead of the canonical `~/.config/SubMiner` directory.
|
||||||
|
- Overlay: Kept JLPT underline colors stable during Yomitan hover and selection states, even when tokens also use known, N+1, name-match, or frequency styling.
|
||||||
|
|
||||||
|
<h2>v0.5.5 (2026-03-09)</h2>
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
|
||||||
|
- Overlay: Added `f` as the default overlay fullscreen toggle and changed the default AniSkip intro-jump key to `Tab`.
|
||||||
|
- Dictionary: Aligned AniList character dictionary generation more closely with the upstream reference by preserving duplicate shared names across characters, skipping characters without native Japanese names, restoring richer character info fields, and using upstream-style role mapping plus hint-aware kanji readings.
|
||||||
|
- Startup: Ordered startup OSD messages so tokenization loads first, annotation loading appears next if still pending, and character dictionary sync progress waits until annotation loading finishes.
|
||||||
|
- Dictionary: Added a visible startup OSD step for merged character-dictionary building so long rebuilds show progress before the later import/upload phase.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- Dictionary: Fixed AniList media guessing for character dictionary auto-sync by using filename-only `guessit` input and preserving multi-part guessit titles instead of truncating them to the first segment.
|
||||||
|
- Dictionary: Refresh the current subtitle after character dictionary auto-sync completes so newly imported character names highlight on the active line instead of waiting for the next subtitle change.
|
||||||
|
- Dictionary: Show character dictionary auto-sync progress on the mpv OSD without sending desktop notifications.
|
||||||
|
- Dictionary: Keep character dictionary auto-sync non-blocking during startup by letting snapshot/build work run in parallel and delaying only the Yomitan import/settings phase until current-media tokenization is already ready.
|
||||||
|
- Overlay: Fixed visible overlay keyboard handling so pressing `Tab` still reaches mpv and triggers the default AniSkip skip-intro binding while the overlay has focus.
|
||||||
|
- Plugin: Fix Windows mpv plugin binary override lookup so `SUBMINER_BINARY_PATH` still resolves to `SubMiner.exe` when no AppImage override is set.
|
||||||
|
|
||||||
|
<h2>v0.5.3 (2026-03-09)</h2>
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
|
||||||
|
- Release: Publish unsigned Windows `.exe` and `.zip` artifacts directly from release CI instead of routing them through SignPath.
|
||||||
|
- Release: Added `bun run build:win:unsigned` for explicit local unsigned Windows packaging.
|
||||||
|
|
||||||
|
<h2>v0.5.2 (2026-03-09)</h2>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
|
||||||
|
- Release: Pinned the Windows SignPath submission workflow to an explicit artifact-configuration slug instead of relying on the SignPath project's default configuration.
|
||||||
|
|
||||||
|
<h2>v0.5.1 (2026-03-09)</h2>
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
|
||||||
|
- Launcher: Removed the YouTube subtitle generation mode switch so YouTube playback always preloads subtitles before mpv starts.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- Launcher: Hardened YouTube AI subtitle fixing so fenced SRT output and text-only one-cue-per-block responses can still be applied without losing original cue timing.
|
||||||
|
- Launcher: Skipped AniSkip lookup during URL playback and YouTube subtitle-preload playback, limiting AniSkip to local file targets where it can actually resolve anime metadata.
|
||||||
|
- Launcher: Keep the background SubMiner process running after a launcher-managed mpv session exits so the next mpv instance can reconnect without restarting the app.
|
||||||
|
- Launcher: Reuse prior tokenization readiness after the background app is already warm so reopening a video does not pause again waiting for duplicate warmup completion.
|
||||||
|
- Windows: Acquire the app single-instance lock earlier so Windows overlay/video launches reuse the running background SubMiner process instead of booting a second full app and repeating startup warmups.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.3.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.3.0 (2026-03-05)</h2>
|
||||||
|
|
||||||
- Added keyboard-driven Yomitan navigation and popup controls, including optional auto-pause.
|
- Added keyboard-driven Yomitan navigation and popup controls, including optional auto-pause.
|
||||||
- Added subtitle/jump keyboard handling fixes for smoother subtitle playback control.
|
- Added subtitle/jump keyboard handling fixes for smoother subtitle playback control.
|
||||||
@@ -153,7 +369,12 @@
|
|||||||
- Added release build quality-of-life for CLI publish (`gh`-based clobber upload).
|
- Added release build quality-of-life for CLI publish (`gh`-based clobber upload).
|
||||||
- Removed docs Plausible integration and cleaned associated tracker settings.
|
- Removed docs Plausible integration and cleaned associated tracker settings.
|
||||||
|
|
||||||
## v0.2.3 (2026-03-02)
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.2.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.2.3 (2026-03-02)</h2>
|
||||||
|
|
||||||
- Added performance and tokenization optimizations (faster warmup, persistent MeCab usage, reduced enrichment lookups).
|
- Added performance and tokenization optimizations (faster warmup, persistent MeCab usage, reduced enrichment lookups).
|
||||||
- Added subtitle controls for no-jump delay shifts.
|
- Added subtitle controls for no-jump delay shifts.
|
||||||
@@ -162,38 +383,45 @@
|
|||||||
- Fixed Jellyfin remote resume behavior and improved autoplay/tokenization interaction.
|
- Fixed Jellyfin remote resume behavior and improved autoplay/tokenization interaction.
|
||||||
- Updated startup flow to load dictionaries asynchronously and unblock first tokenization sooner.
|
- Updated startup flow to load dictionaries asynchronously and unblock first tokenization sooner.
|
||||||
|
|
||||||
## v0.2.2 (2026-03-01)
|
<h2>v0.2.2 (2026-03-01)</h2>
|
||||||
|
|
||||||
- Improved subtitle highlighting reliability for frequency modes.
|
- Improved subtitle highlighting reliability for frequency modes.
|
||||||
- Fixed Jellyfin misc info formatting cleanup.
|
- Fixed Jellyfin misc info formatting cleanup.
|
||||||
- Version bump maintenance for 0.2.2.
|
- Version bump maintenance for 0.2.2.
|
||||||
|
|
||||||
## v0.2.1 (2026-03-01)
|
<h2>v0.2.1 (2026-03-01)</h2>
|
||||||
|
|
||||||
- Delivered Jellyfin and Subsync fixes from release patch cycle.
|
- Delivered Jellyfin and Subsync fixes from release patch cycle.
|
||||||
- Version bump maintenance for 0.2.1.
|
- Version bump maintenance for 0.2.1.
|
||||||
|
|
||||||
## v0.2.0 (2026-03-01)
|
<h2>v0.2.0 (2026-03-01)</h2>
|
||||||
|
|
||||||
- Added task-related release work for the overlay 2.0 cycle.
|
- Added task-related release work for the overlay 2.0 cycle.
|
||||||
- Introduced Overlay 2.0.
|
- Introduced Overlay 2.0.
|
||||||
- Improved release automation reliability.
|
- Improved release automation reliability.
|
||||||
|
|
||||||
## v0.1.2 (2026-02-24)
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.1.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.1.2 (2026-02-24)</h2>
|
||||||
|
|
||||||
- Added encrypted AniList token handling and default GNOME keyring support.
|
- Added encrypted AniList token handling and default GNOME keyring support.
|
||||||
- Added launcher passthrough for password-store flows (Jellyfin path).
|
- Added launcher passthrough for password-store flows (Jellyfin path).
|
||||||
- Updated docs for auth and integration behavior.
|
- Updated docs for auth and integration behavior.
|
||||||
- Version bump maintenance for 0.1.2.
|
- Version bump maintenance for 0.1.2.
|
||||||
|
|
||||||
## v0.1.1 (2026-02-23)
|
<h2>v0.1.1 (2026-02-23)</h2>
|
||||||
|
|
||||||
- Fixed overlay modal focus handling (`grab input`) behavior.
|
- Fixed overlay modal focus handling (`grab input`) behavior.
|
||||||
- Version bump maintenance for 0.1.1.
|
- Version bump maintenance for 0.1.1.
|
||||||
|
|
||||||
## v0.1.0 (2026-02-23)
|
<h2>v0.1.0 (2026-02-23)</h2>
|
||||||
|
|
||||||
- Bootstrapped Electron runtime, services, and composition model.
|
- Bootstrapped Electron runtime, services, and composition model.
|
||||||
- Added runtime asset packaging and dependency vendoring.
|
- Added runtime asset packaging and dependency vendoring.
|
||||||
- Added project docs baseline, setup guides, architecture notes, and submodule/runtime assets.
|
- Added project docs baseline, setup guides, architecture notes, and submodule/runtime assets.
|
||||||
- Added CI release job dependency ordering fixes before launcher build.
|
- Added CI release job dependency ordering fixes before launcher build.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ The configuration file includes several main sections:
|
|||||||
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
||||||
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
||||||
- [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress
|
- [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress
|
||||||
|
- [**MPV Launcher**](#mpv-launcher) - mpv executable path and window launch mode
|
||||||
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
|
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
|
||||||
|
|
||||||
## Core Settings
|
## Core Settings
|
||||||
@@ -237,10 +238,10 @@ This stream includes subtitle text plus token metadata (N+1, known-word, frequen
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------- | ------------------ | -------------------------------------------------------- |
|
| --------- | --------------- | -------------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) |
|
| `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) |
|
||||||
| `port` | number | Annotation websocket port (default: 6678) |
|
| `port` | number | Annotation websocket port (default: 6678) |
|
||||||
|
|
||||||
### Texthooker
|
### Texthooker
|
||||||
|
|
||||||
@@ -257,10 +258,10 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------ |
|
| ----------------- | --------------- | ---------------------------------------------------------------------- |
|
||||||
| `launchAtStartup`| `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) |
|
| `launchAtStartup` | `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) |
|
||||||
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
|
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
|
||||||
|
|
||||||
## Subtitle Display
|
## Subtitle Display
|
||||||
|
|
||||||
@@ -365,24 +366,24 @@ Configure the parsed-subtitle sidebar modal.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------------------------- | ---------------- | -------------------------------------------------------------------------------- |
|
| --------------------------- | --------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
| `enabled` | boolean | Enable subtitle sidebar support (`true` by default) |
|
| `enabled` | boolean | Enable subtitle sidebar support (`true` by default) |
|
||||||
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
|
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
|
||||||
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
||||||
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
||||||
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
|
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
|
||||||
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
|
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
|
||||||
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
||||||
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
|
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
|
||||||
| `backgroundColor` | string | Sidebar shell background color |
|
| `backgroundColor` | string | Sidebar shell background color |
|
||||||
| `textColor` | hex color | Default cue text color |
|
| `textColor` | hex color | Default cue text color |
|
||||||
| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text |
|
| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text |
|
||||||
| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) |
|
| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) |
|
||||||
| `timestampColor` | hex color | Cue timestamp color |
|
| `timestampColor` | hex color | Cue timestamp color |
|
||||||
| `activeLineColor` | hex color | Active cue text color |
|
| `activeLineColor` | hex color | Active cue text color |
|
||||||
| `activeLineBackgroundColor` | string | Active cue background color |
|
| `activeLineBackgroundColor` | string | Active cue background color |
|
||||||
| `hoverLineBackgroundColor` | string | Hovered cue background color |
|
| `hoverLineBackgroundColor` | string | Hovered cue background color |
|
||||||
|
|
||||||
The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog.
|
The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog.
|
||||||
|
|
||||||
@@ -466,25 +467,25 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
|||||||
|
|
||||||
**Default keybindings:**
|
**Default keybindings:**
|
||||||
|
|
||||||
| Key | Command | Description |
|
| Key | Command | Description |
|
||||||
| -------------------- | ---------------------------- | ------------------------------------- |
|
| -------------------- | ----------------------------- | --------------------------------------- |
|
||||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||||
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
||||||
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
||||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||||
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
||||||
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
||||||
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
|
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
|
||||||
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
|
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
|
||||||
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||||
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
||||||
|
|
||||||
**Custom keybindings example:**
|
**Custom keybindings example:**
|
||||||
|
|
||||||
@@ -604,7 +605,7 @@ Important behavior:
|
|||||||
"leftStickPress": 9,
|
"leftStickPress": 9,
|
||||||
"rightStickPress": 10,
|
"rightStickPress": 10,
|
||||||
"leftTrigger": 6,
|
"leftTrigger": 6,
|
||||||
"rightTrigger": 7
|
"rightTrigger": 7,
|
||||||
},
|
},
|
||||||
"bindings": {
|
"bindings": {
|
||||||
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
||||||
@@ -619,9 +620,9 @@ Important behavior:
|
|||||||
"leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" },
|
"leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" },
|
||||||
"leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" },
|
"leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" },
|
||||||
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
||||||
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" }
|
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -649,9 +650,9 @@ If you bind a discrete action to an axis manually, include `direction`:
|
|||||||
{
|
{
|
||||||
"controller": {
|
"controller": {
|
||||||
"bindings": {
|
"bindings": {
|
||||||
"toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" }
|
"toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" },
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -758,15 +759,15 @@ Anki reads this provider directly. Legacy subtitle fallback keeps the same provi
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------ | --------------------- | ---------------------------------------------------- |
|
| ------------------ | -------------------- | ------------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable shared AI provider features |
|
| `enabled` | `true`, `false` | Enable shared AI provider features |
|
||||||
| `apiKey` | string | Static API key for the shared provider |
|
| `apiKey` | string | Static API key for the shared provider |
|
||||||
| `apiKeyCommand` | string | Shell command used to resolve the API key |
|
| `apiKeyCommand` | string | Shell command used to resolve the API key |
|
||||||
| `baseUrl` | string (URL) | OpenAI-compatible base URL |
|
| `baseUrl` | string (URL) | OpenAI-compatible base URL |
|
||||||
| `model` | string | Optional model override for shared provider workflows |
|
| `model` | string | Optional model override for shared provider workflows |
|
||||||
| `systemPrompt` | string | Optional system prompt override for shared provider workflows |
|
| `systemPrompt` | string | Optional system prompt override for shared provider workflows |
|
||||||
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
||||||
|
|
||||||
SubMiner uses the shared provider for:
|
SubMiner uses the shared provider for:
|
||||||
|
|
||||||
@@ -844,59 +845,59 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
|
|
||||||
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||||
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
|
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||||
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
|
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
|
||||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
|
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
|
||||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
|
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
|
||||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||||
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
|
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
|
||||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||||
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
|
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
||||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||||
|
|
||||||
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
||||||
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
||||||
@@ -1022,8 +1023,8 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`. Both ar
|
|||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker |
|
| `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker |
|
||||||
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
|
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
|
||||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
|
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
|
||||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||||
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||||
|
|
||||||
@@ -1055,18 +1056,18 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------ |
|
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
||||||
| `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media |
|
| `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media |
|
||||||
| `characterDictionary.refreshTtlHours` | number | Legacy compatibility setting. Parsed and preserved, but merged dictionary retention is now usage-based |
|
| `characterDictionary.refreshTtlHours` | number | Legacy compatibility setting. Parsed and preserved, but merged dictionary retention is now usage-based |
|
||||||
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
||||||
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | Legacy compatibility setting. Parsed and preserved, but merged dictionary eviction is now usage-based |
|
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | Legacy compatibility setting. Parsed and preserved, but merged dictionary eviction is now usage-based |
|
||||||
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
|
||||||
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
|
||||||
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
|
||||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
|
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
|
||||||
|
|
||||||
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
|
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
|
||||||
|
|
||||||
@@ -1122,8 +1123,8 @@ For GameSentenceMiner on Linux, the default overlay profile path is typically `~
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. |
|
| `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. |
|
||||||
|
|
||||||
External-profile mode behavior:
|
External-profile mode behavior:
|
||||||
@@ -1208,12 +1209,12 @@ Discord Rich Presence is enabled by default. SubMiner publishes a polished activ
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- |
|
| ------------------ | ------------------------------------------------ | ---------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) |
|
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) |
|
||||||
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
|
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
|
||||||
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
|
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
|
||||||
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
|
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
|
||||||
|
|
||||||
Setup steps:
|
Setup steps:
|
||||||
|
|
||||||
@@ -1225,12 +1226,12 @@ Setup steps:
|
|||||||
|
|
||||||
While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images.
|
While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images.
|
||||||
|
|
||||||
| Preset | Idle details | Small image text | Vibe |
|
| Preset | Idle details | Small image text | Vibe |
|
||||||
| ------------ | ----------------------------------- | ------------------ | --------------------------------------- |
|
| ------------- | ---------------------------------- | ------------------ | --------------------------------------- |
|
||||||
| **`default`**| `Sentence Mining` | `日本語学習中` | Clean, bilingual flair |
|
| **`default`** | `Sentence Mining` | `日本語学習中` | Clean, bilingual flair |
|
||||||
| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke |
|
| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke |
|
||||||
| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese |
|
| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese |
|
||||||
| `minimal` | `SubMiner` | *(none)* | Bare essentials, no small image overlay |
|
| `minimal` | `SubMiner` | _(none)_ | Bare essentials, no small image overlay |
|
||||||
|
|
||||||
All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default.
|
All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default.
|
||||||
|
|
||||||
@@ -1273,23 +1274,23 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. |
|
| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. |
|
||||||
| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. |
|
| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. |
|
||||||
| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. |
|
| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. |
|
||||||
| `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. |
|
| `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. |
|
||||||
| `queueCap` | integer (`100`-`100000`) | In-memory queue cap. Overflow drops oldest writes. Default `1000`. |
|
| `queueCap` | integer (`100`-`100000`) | In-memory queue cap. Overflow drops oldest writes. Default `1000`. |
|
||||||
| `payloadCapBytes` | integer (`64`-`8192`) | Event payload byte cap before truncation marker. Default `256`. |
|
| `payloadCapBytes` | integer (`64`-`8192`) | Event payload byte cap before truncation marker. Default `256`. |
|
||||||
| `maintenanceIntervalMs` | integer (`60000`-`604800000`) | Prune + rollup maintenance cadence. Default `86400000` (24h). |
|
| `maintenanceIntervalMs` | integer (`60000`-`604800000`) | Prune + rollup maintenance cadence. Default `86400000` (24h). |
|
||||||
| `retentionMode` | `preset`,`advanced` | Retention mode. `preset` applies `retentionPreset`, `advanced` uses explicit values only. Default `preset`. |
|
| `retentionMode` | `preset`,`advanced` | Retention mode. `preset` applies `retentionPreset`, `advanced` uses explicit values only. Default `preset`. |
|
||||||
| `retentionPreset` | `minimal`,`balanced`,`deep-history` | Retention preset used when `retentionMode = "preset"`. Default `balanced`. |
|
| `retentionPreset` | `minimal`,`balanced`,`deep-history` | Retention preset used when `retentionMode = "preset"`. Default `balanced`. |
|
||||||
| `retention.eventsDays` | integer (`0`-`3650`) | Raw event retention window in days. Default `0` (keep all). |
|
| `retention.eventsDays` | integer (`0`-`3650`) | Raw event retention window in days. Default `0` (keep all). |
|
||||||
| `retention.telemetryDays` | integer (`0`-`3650`) | Telemetry retention window in days. Default `0` (keep all). |
|
| `retention.telemetryDays` | integer (`0`-`3650`) | Telemetry retention window in days. Default `0` (keep all). |
|
||||||
| `retention.sessionsDays` | integer (`0`-`3650`) | Session retention window in days. Default `0` (keep all). |
|
| `retention.sessionsDays` | integer (`0`-`3650`) | Session retention window in days. Default `0` (keep all). |
|
||||||
| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
|
| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
|
||||||
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
|
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
|
||||||
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
|
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
|
||||||
|
|
||||||
You can also disable immersion tracking for a single session using:
|
You can also disable immersion tracking for a single session using:
|
||||||
|
|
||||||
@@ -1326,11 +1327,11 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------- | ----------------- | --------------------------------------------------------------------------- |
|
| ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
|
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
|
||||||
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
|
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
|
||||||
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
|
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
|
||||||
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. |
|
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. |
|
||||||
|
|
||||||
Usage notes:
|
Usage notes:
|
||||||
@@ -1340,6 +1341,30 @@ Usage notes:
|
|||||||
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
|
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
|
||||||
- The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs.
|
- The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs.
|
||||||
|
|
||||||
|
### MPV Launcher
|
||||||
|
|
||||||
|
Configure the mpv executable and window state for SubMiner-managed mpv launches (launcher playback, Windows `--launch-mpv`, and Jellyfin idle mpv startup):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mpv": {
|
||||||
|
"executablePath": "",
|
||||||
|
"launchMode": "normal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| ---------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||||
|
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||||
|
|
||||||
|
Launch mode behavior:
|
||||||
|
|
||||||
|
- **`normal`** — mpv opens at its default window size with no extra flags.
|
||||||
|
- **`maximized`** — mpv starts maximized via `--window-maximized=yes`, keeping taskbar access.
|
||||||
|
- **`fullscreen`** — mpv starts in true fullscreen via `--fullscreen`.
|
||||||
|
|
||||||
### YouTube Playback Settings
|
### YouTube Playback Settings
|
||||||
|
|
||||||
Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow:
|
Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow:
|
||||||
@@ -1352,9 +1377,9 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
|
| --------------------- | -------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||||
|
|
||||||
Current launcher behavior:
|
Current launcher behavior:
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ const installationContents = readFileSync(new URL('./installation.md', import.me
|
|||||||
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
|
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
|
||||||
const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8');
|
const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8');
|
||||||
const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
|
const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
|
||||||
const ankiIntegrationContents = readFileSync(new URL('./anki-integration.md', import.meta.url), 'utf8');
|
const ankiIntegrationContents = readFileSync(
|
||||||
|
new URL('./anki-integration.md', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
||||||
|
|
||||||
function extractReleaseHeadings(content: string, count: number): string[] {
|
function extractReleaseHeadings(content: string, count: number): string[] {
|
||||||
@@ -17,6 +20,13 @@ function extractReleaseHeadings(content: string, count: number): string[] {
|
|||||||
.slice(0, count);
|
.slice(0, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCurrentMinorHeadings(content: string): string[] {
|
||||||
|
const allHeadings = Array.from(content.matchAll(/^## v(\d+\.\d+)\.\d+[^\n]*$/gm));
|
||||||
|
if (allHeadings.length === 0) return [];
|
||||||
|
const currentMinor = allHeadings[0]![1];
|
||||||
|
return allHeadings.filter(([, minor]) => minor === currentMinor).map(([heading]) => heading);
|
||||||
|
}
|
||||||
|
|
||||||
test('docs reflect current launcher and release surfaces', () => {
|
test('docs reflect current launcher and release surfaces', () => {
|
||||||
expect(usageContents).not.toContain('--mode preprocess');
|
expect(usageContents).not.toContain('--mode preprocess');
|
||||||
expect(usageContents).not.toContain('"automatic" (default)');
|
expect(usageContents).not.toContain('"automatic" (default)');
|
||||||
@@ -44,9 +54,11 @@ test('docs reflect current launcher and release surfaces', () => {
|
|||||||
expect(configurationContents).toContain('youtube.primarySubLanguages');
|
expect(configurationContents).toContain('youtube.primarySubLanguages');
|
||||||
expect(configurationContents).toContain('### Shared AI Provider');
|
expect(configurationContents).toContain('### Shared AI Provider');
|
||||||
|
|
||||||
expect(changelogContents).toContain('## v0.5.1 (2026-03-09)');
|
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('docs changelog keeps the newest release headings aligned with the root changelog', () => {
|
test('docs changelog keeps the current minor release headings aligned with the root changelog', () => {
|
||||||
expect(extractReleaseHeadings(changelogContents, 3)).toEqual(extractReleaseHeadings(rootChangelogContents, 3));
|
const docsHeadings = extractCurrentMinorHeadings(changelogContents);
|
||||||
|
expect(docsHeadings.length).toBeGreaterThan(0);
|
||||||
|
expect(docsHeadings).toEqual(extractReleaseHeadings(rootChangelogContents, docsHeadings.length));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -461,10 +461,12 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// MPV Launcher
|
// MPV Launcher
|
||||||
// Optional mpv.exe override for Windows playback entry points.
|
// Optional mpv.exe override for Windows playback entry points.
|
||||||
|
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"mpv": {
|
"mpv": {
|
||||||
"executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
|
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
}, // Optional mpv.exe override for Windows playback entry points.
|
}, // Optional mpv.exe override for Windows playback entry points.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -125,10 +125,7 @@ function titleOverlapScore(expectedTitle: string, candidateTitle: string): numbe
|
|||||||
if (!expected || !candidate) return 0;
|
if (!expected || !candidate) return 0;
|
||||||
|
|
||||||
if (candidate.includes(expected)) return 120;
|
if (candidate.includes(expected)) return 120;
|
||||||
if (
|
if (candidate.split(' ').length >= 2 && ` ${expected} `.includes(` ${candidate} `)) {
|
||||||
candidate.split(' ').length >= 2 &&
|
|
||||||
` ${expected} `.includes(` ${candidate} `)
|
|
||||||
) {
|
|
||||||
return 90;
|
return 90;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function createContext(): LauncherCommandContext {
|
|||||||
jellyfinServer: '',
|
jellyfinServer: '',
|
||||||
jellyfinUsername: '',
|
jellyfinUsername: '',
|
||||||
jellyfinPassword: '',
|
jellyfinPassword: '',
|
||||||
|
launchMode: 'normal',
|
||||||
},
|
},
|
||||||
scriptPath: '/tmp/subminer',
|
scriptPath: '/tmp/subminer',
|
||||||
scriptName: 'subminer',
|
scriptName: 'subminer',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||||
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||||
|
import { parseLauncherMpvConfig } from './config/mpv-config.js';
|
||||||
import { readExternalYomitanProfilePath } from './config.js';
|
import { readExternalYomitanProfilePath } from './config.js';
|
||||||
import {
|
import {
|
||||||
getPluginConfigCandidates,
|
getPluginConfigCandidates,
|
||||||
@@ -80,6 +81,27 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () =>
|
|||||||
assert.equal('userId' in parsed, false);
|
assert.equal('userId' in parsed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseLauncherMpvConfig reads launch mode preference', () => {
|
||||||
|
const parsed = parseLauncherMpvConfig({
|
||||||
|
mpv: {
|
||||||
|
launchMode: ' maximized ',
|
||||||
|
executablePath: 'ignored-here',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.launchMode, 'maximized');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
||||||
|
const parsed = parseLauncherMpvConfig({
|
||||||
|
mpv: {
|
||||||
|
launchMode: 'wide',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.launchMode, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
|
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
|
||||||
const parsed = parsePluginRuntimeConfigContent(`
|
const parsed = parsePluginRuntimeConfigContent(`
|
||||||
# comment
|
# comment
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { fail } from './log.js';
|
|||||||
import type {
|
import type {
|
||||||
Args,
|
Args,
|
||||||
LauncherJellyfinConfig,
|
LauncherJellyfinConfig,
|
||||||
|
LauncherMpvConfig,
|
||||||
LauncherYoutubeSubgenConfig,
|
LauncherYoutubeSubgenConfig,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
PluginRuntimeConfig,
|
PluginRuntimeConfig,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from './config/args-normalizer.js';
|
} from './config/args-normalizer.js';
|
||||||
import { parseCliPrograms, resolveTopLevelCommand } from './config/cli-parser-builder.js';
|
import { parseCliPrograms, resolveTopLevelCommand } from './config/cli-parser-builder.js';
|
||||||
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||||
|
import { parseLauncherMpvConfig } from './config/mpv-config.js';
|
||||||
import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './config/plugin-runtime-config.js';
|
import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './config/plugin-runtime-config.js';
|
||||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||||
@@ -44,6 +46,12 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
|
|||||||
return parseLauncherJellyfinConfig(root);
|
return parseLauncherJellyfinConfig(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadLauncherMpvConfig(): LauncherMpvConfig {
|
||||||
|
const root = readLauncherMainConfigObject();
|
||||||
|
if (!root) return {};
|
||||||
|
return parseLauncherMpvConfig(root);
|
||||||
|
}
|
||||||
|
|
||||||
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||||
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||||
}
|
}
|
||||||
@@ -56,9 +64,10 @@ export function parseArgs(
|
|||||||
argv: string[],
|
argv: string[],
|
||||||
scriptName: string,
|
scriptName: string,
|
||||||
launcherConfig: LauncherYoutubeSubgenConfig,
|
launcherConfig: LauncherYoutubeSubgenConfig,
|
||||||
|
launcherMpvConfig: LauncherMpvConfig = {},
|
||||||
): Args {
|
): Args {
|
||||||
const topLevelCommand = resolveTopLevelCommand(argv);
|
const topLevelCommand = resolveTopLevelCommand(argv);
|
||||||
const parsed = createDefaultArgs(launcherConfig);
|
const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig);
|
||||||
|
|
||||||
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
|
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
|
||||||
parsed.appPassthrough = true;
|
parsed.appPassthrough = true;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fail } from '../log.js';
|
import { fail } from '../log.js';
|
||||||
import type { Args, Backend, LauncherYoutubeSubgenConfig, LogLevel } from '../types.js';
|
import type {
|
||||||
|
Args,
|
||||||
|
Backend,
|
||||||
|
LauncherMpvConfig,
|
||||||
|
LauncherYoutubeSubgenConfig,
|
||||||
|
LogLevel,
|
||||||
|
} from '../types.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_JIMAKU_API_BASE_URL,
|
DEFAULT_JIMAKU_API_BASE_URL,
|
||||||
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
|
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
|
||||||
@@ -83,7 +89,10 @@ function parseDictionaryTarget(value: string): string {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args {
|
export function createDefaultArgs(
|
||||||
|
launcherConfig: LauncherYoutubeSubgenConfig,
|
||||||
|
mpvConfig: LauncherMpvConfig = {},
|
||||||
|
): Args {
|
||||||
const configuredSecondaryLangs = uniqueNormalizedLangCodes(
|
const configuredSecondaryLangs = uniqueNormalizedLangCodes(
|
||||||
launcherConfig.secondarySubLanguages ?? [],
|
launcherConfig.secondarySubLanguages ?? [],
|
||||||
);
|
);
|
||||||
@@ -148,6 +157,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
|||||||
jellyfinServer: '',
|
jellyfinServer: '',
|
||||||
jellyfinUsername: '',
|
jellyfinUsername: '',
|
||||||
jellyfinPassword: '',
|
jellyfinPassword: '',
|
||||||
|
launchMode: mpvConfig.launchMode ?? 'normal',
|
||||||
youtubePrimarySubLangs: primarySubLangs,
|
youtubePrimarySubLangs: primarySubLangs,
|
||||||
youtubeSecondarySubLangs: secondarySubLangs,
|
youtubeSecondarySubLangs: secondarySubLangs,
|
||||||
youtubeAudioLangs,
|
youtubeAudioLangs,
|
||||||
|
|||||||
12
launcher/config/mpv-config.ts
Normal file
12
launcher/config/mpv-config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js';
|
||||||
|
import type { LauncherMpvConfig } from '../types.js';
|
||||||
|
|
||||||
|
export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherMpvConfig {
|
||||||
|
const mpvRaw = root.mpv;
|
||||||
|
if (!mpvRaw || typeof mpvRaw !== 'object') return {};
|
||||||
|
const mpv = mpvRaw as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
launchMode: parseMpvLaunchMode(mpv.launchMode),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
loadLauncherJellyfinConfig,
|
loadLauncherJellyfinConfig,
|
||||||
|
loadLauncherMpvConfig,
|
||||||
loadLauncherYoutubeSubgenConfig,
|
loadLauncherYoutubeSubgenConfig,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
readPluginRuntimeConfig,
|
readPluginRuntimeConfig,
|
||||||
@@ -52,7 +53,8 @@ async function main(): Promise<void> {
|
|||||||
const scriptPath = process.argv[1] || 'subminer';
|
const scriptPath = process.argv[1] || 'subminer';
|
||||||
const scriptName = path.basename(scriptPath);
|
const scriptName = path.basename(scriptPath);
|
||||||
const launcherConfig = loadLauncherYoutubeSubgenConfig();
|
const launcherConfig = loadLauncherYoutubeSubgenConfig();
|
||||||
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig);
|
const launcherMpvConfig = loadLauncherMpvConfig();
|
||||||
|
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig, launcherMpvConfig);
|
||||||
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
|
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
|
||||||
const appPath = findAppBinary(scriptPath);
|
const appPath = findAppBinary(scriptPath);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import net from 'node:net';
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { Args } from './types';
|
import type { Args } from './types';
|
||||||
import {
|
import {
|
||||||
|
buildConfiguredMpvDefaultArgs,
|
||||||
buildMpvBackendArgs,
|
buildMpvBackendArgs,
|
||||||
buildMpvEnv,
|
buildMpvEnv,
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
@@ -234,6 +235,33 @@ test('buildMpvBackendArgs keeps supported Hyprland and Sway auto backends unchan
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured defaults', () => {
|
||||||
|
withPlatform('linux', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildConfiguredMpvDefaultArgs(makeArgs({ launchMode: 'maximized' }), {
|
||||||
|
DISPLAY: ':1',
|
||||||
|
WAYLAND_DISPLAY: 'wayland-0',
|
||||||
|
XDG_SESSION_TYPE: 'wayland',
|
||||||
|
XDG_CURRENT_DESKTOP: 'KDE',
|
||||||
|
XDG_SESSION_DESKTOP: 'plasma',
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
'--sub-auto=fuzzy',
|
||||||
|
'--sub-file-paths=.;subs;subtitles',
|
||||||
|
'--sid=auto',
|
||||||
|
'--secondary-sid=auto',
|
||||||
|
'--secondary-sub-visibility=no',
|
||||||
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--vo=gpu',
|
||||||
|
'--gpu-api=opengl',
|
||||||
|
'--gpu-context=x11egl,x11',
|
||||||
|
'--window-maximized=yes',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||||
const error = withProcessExitIntercept(() => {
|
const error = withProcessExitIntercept(() => {
|
||||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||||
@@ -401,6 +429,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
jellyfinServer: '',
|
jellyfinServer: '',
|
||||||
jellyfinUsername: '',
|
jellyfinUsername: '',
|
||||||
jellyfinPassword: '',
|
jellyfinPassword: '',
|
||||||
|
launchMode: 'normal',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -686,18 +715,18 @@ function runFindAppBinaryWindowsInstallDirCase(): void {
|
|||||||
process.env.SUBMINER_BINARY_PATH = installDir;
|
process.env.SUBMINER_BINARY_PATH = installDir;
|
||||||
|
|
||||||
withPlatform('win32', () => {
|
withPlatform('win32', () => {
|
||||||
withExistsAndStatSyncStubs(
|
withExistsAndStatSyncStubs({ existingPaths: [appExe], directoryPaths: [installDir] }, () => {
|
||||||
{ existingPaths: [appExe], directoryPaths: [installDir] },
|
withAccessSyncStub(
|
||||||
() => {
|
(filePath) => filePath === appExe,
|
||||||
withAccessSyncStub(
|
() => {
|
||||||
(filePath) => filePath === appExe,
|
const result = findAppBinary(
|
||||||
() => {
|
path.win32.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||||
const result = findAppBinary(path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), path.win32);
|
path.win32,
|
||||||
assert.equal(result, appExe);
|
);
|
||||||
},
|
assert.equal(result, appExe);
|
||||||
);
|
},
|
||||||
},
|
);
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
os.homedir = originalHomedir;
|
os.homedir = originalHomedir;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
|
||||||
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
||||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||||
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
||||||
@@ -263,10 +264,7 @@ function getLinuxDesktopEnv(env: NodeJS.ProcessEnv): LinuxDesktopEnv {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldForceX11MpvBackend(
|
function shouldForceX11MpvBackend(args: Pick<Args, 'backend'>, env: NodeJS.ProcessEnv): boolean {
|
||||||
args: Pick<Args, 'backend'>,
|
|
||||||
env: NodeJS.ProcessEnv,
|
|
||||||
): boolean {
|
|
||||||
if (process.platform !== 'linux' || !env.DISPLAY?.trim()) {
|
if (process.platform !== 'linux' || !env.DISPLAY?.trim()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -673,9 +671,7 @@ export async function startMpv(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mpvArgs: string[] = [];
|
const mpvArgs: string[] = [];
|
||||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
mpvArgs.push(...buildConfiguredMpvDefaultArgs(args));
|
||||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
|
||||||
mpvArgs.push(...buildMpvBackendArgs(args));
|
|
||||||
if (targetKind === 'url' && isYoutubeTarget(target)) {
|
if (targetKind === 'url' && isYoutubeTarget(target)) {
|
||||||
log('info', args.logLevel, 'Applying URL playback options');
|
log('info', args.logLevel, 'Applying URL playback options');
|
||||||
mpvArgs.push('--ytdl=yes');
|
mpvArgs.push('--ytdl=yes');
|
||||||
@@ -703,7 +699,6 @@ export async function startMpv(
|
|||||||
if (args.mpvArgs) {
|
if (args.mpvArgs) {
|
||||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preloadedSubtitles?.primaryPath) {
|
if (preloadedSubtitles?.primaryPath) {
|
||||||
mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`);
|
mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`);
|
||||||
}
|
}
|
||||||
@@ -979,6 +974,18 @@ export function buildMpvBackendArgs(
|
|||||||
return ['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'];
|
return ['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildConfiguredMpvDefaultArgs(
|
||||||
|
args: Pick<Args, 'profile' | 'backend' | 'launchMode'>,
|
||||||
|
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string[] {
|
||||||
|
const mpvArgs: string[] = [];
|
||||||
|
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||||
|
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||||
|
mpvArgs.push(...buildMpvBackendArgs(args, baseEnv));
|
||||||
|
mpvArgs.push(...buildMpvLaunchModeArgs(args.launchMode));
|
||||||
|
return mpvArgs;
|
||||||
|
}
|
||||||
|
|
||||||
function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void {
|
function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void {
|
||||||
const normalized = chunk.replace(/\r\n/g, '\n');
|
const normalized = chunk.replace(/\r\n/g, '\n');
|
||||||
for (const line of normalized.split('\n')) {
|
for (const line of normalized.split('\n')) {
|
||||||
@@ -1209,10 +1216,7 @@ export function launchMpvIdleDetached(
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
const mpvArgs: string[] = [];
|
const mpvArgs: string[] = buildConfiguredMpvDefaultArgs(args);
|
||||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
|
||||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
|
||||||
mpvArgs.push(...buildMpvBackendArgs(args));
|
|
||||||
if (args.mpvArgs) {
|
if (args.mpvArgs) {
|
||||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ test('parseArgs maps mpv idle action', () => {
|
|||||||
assert.equal(parsed.mpvStatus, false);
|
assert.equal(parsed.mpvStatus, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs applies configured mpv launch mode default', () => {
|
||||||
|
const parsed = parseArgs([], 'subminer', {}, { launchMode: 'maximized' });
|
||||||
|
|
||||||
|
assert.equal(parsed.launchMode, 'maximized');
|
||||||
|
});
|
||||||
|
|
||||||
test('parseArgs maps dictionary command and log-level override', () => {
|
test('parseArgs maps dictionary command and log-level override', () => {
|
||||||
const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {});
|
const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import type { MpvLaunchMode } from '../src/types/config.js';
|
||||||
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
|
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
|
||||||
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ export interface Args {
|
|||||||
jellyfinServer: string;
|
jellyfinServer: string;
|
||||||
jellyfinUsername: string;
|
jellyfinUsername: string;
|
||||||
jellyfinPassword: string;
|
jellyfinPassword: string;
|
||||||
|
launchMode: MpvLaunchMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LauncherYoutubeSubgenConfig {
|
export interface LauncherYoutubeSubgenConfig {
|
||||||
@@ -167,6 +169,10 @@ export interface LauncherJellyfinConfig {
|
|||||||
iconCacheDir?: string;
|
iconCacheDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LauncherMpvConfig {
|
||||||
|
launchMode?: MpvLaunchMode;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginRuntimeConfig {
|
export interface PluginRuntimeConfig {
|
||||||
socketPath: string;
|
socketPath: string;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.11.1",
|
"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",
|
||||||
@@ -20,11 +20,13 @@
|
|||||||
"dev:stats": "cd stats && bun run dev",
|
"dev:stats": "cd stats && bun run dev",
|
||||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
|
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
|
||||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||||
"changelog:build": "bun run scripts/build-changelog.ts build",
|
"changelog:build": "bun run scripts/build-changelog.ts build && bun run changelog:docs",
|
||||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||||
|
"changelog:docs": "bun run scripts/build-changelog.ts docs",
|
||||||
"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",
|
||||||
@@ -68,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",
|
||||||
@@ -111,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
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,49 @@ test('verifyChangelogReadyForRelease rejects explicit release versions that do n
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('writeChangelogArtifacts renders breaking changes section above type sections', async () => {
|
||||||
|
const { writeChangelogArtifacts } = await loadModule();
|
||||||
|
const workspace = createWorkspace('breaking-changes');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
|
||||||
|
fs.mkdirSync(projectRoot, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
['type: changed', 'area: config', 'breaking: true', '', '- Renamed `foo` to `bar`.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '002.md'),
|
||||||
|
['type: fixed', 'area: overlay', '', '- Fixed subtitle rendering.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeChangelogArtifacts({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.5.0',
|
||||||
|
date: '2026-04-06',
|
||||||
|
});
|
||||||
|
|
||||||
|
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
|
||||||
|
const breakingIndex = changelog.indexOf('### Breaking Changes');
|
||||||
|
const changedIndex = changelog.indexOf('### Changed');
|
||||||
|
const fixedIndex = changelog.indexOf('### Fixed');
|
||||||
|
|
||||||
|
assert.notEqual(breakingIndex, -1, 'Breaking Changes section should exist');
|
||||||
|
assert.notEqual(changedIndex, -1, 'Changed section should exist');
|
||||||
|
assert.notEqual(fixedIndex, -1, 'Fixed section should exist');
|
||||||
|
assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed');
|
||||||
|
assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed');
|
||||||
|
assert.match(changelog, /### Breaking Changes\n- Config: Renamed `foo` to `bar`\./);
|
||||||
|
assert.match(changelog, /### Changed\n- Config: Renamed `foo` to `bar`\./);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('verifyChangelogFragments rejects invalid metadata', async () => {
|
test('verifyChangelogFragments rejects invalid metadata', async () => {
|
||||||
const { verifyChangelogFragments } = await loadModule();
|
const { verifyChangelogFragments } = await loadModule();
|
||||||
const workspace = createWorkspace('lint-invalid');
|
const workspace = createWorkspace('lint-invalid');
|
||||||
@@ -267,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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type FragmentType = 'added' | 'changed' | 'fixed' | 'docs' | 'internal';
|
|||||||
|
|
||||||
type ChangeFragment = {
|
type ChangeFragment = {
|
||||||
area: string;
|
area: string;
|
||||||
|
breaking: boolean;
|
||||||
bullets: string[];
|
bullets: string[];
|
||||||
path: string;
|
path: string;
|
||||||
type: FragmentType;
|
type: FragmentType;
|
||||||
@@ -37,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> = {
|
||||||
@@ -74,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 {
|
||||||
@@ -144,6 +150,7 @@ function parseFragmentMetadata(
|
|||||||
): {
|
): {
|
||||||
area: string;
|
area: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
breaking: boolean;
|
||||||
type: FragmentType;
|
type: FragmentType;
|
||||||
} {
|
} {
|
||||||
const lines = content.split(/\r?\n/);
|
const lines = content.split(/\r?\n/);
|
||||||
@@ -186,9 +193,12 @@ function parseFragmentMetadata(
|
|||||||
throw new Error(`${fragmentPath} must include at least one changelog bullet.`);
|
throw new Error(`${fragmentPath} must include at least one changelog bullet.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const breaking = metadata.get('breaking')?.toLowerCase() === 'true';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
area,
|
area,
|
||||||
body,
|
body,
|
||||||
|
breaking,
|
||||||
type: type as FragmentType,
|
type: type as FragmentType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -199,6 +209,7 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen
|
|||||||
const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath);
|
const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath);
|
||||||
return {
|
return {
|
||||||
area: parsed.area,
|
area: parsed.area,
|
||||||
|
breaking: parsed.breaking,
|
||||||
bullets: normalizeFragmentBullets(parsed.body),
|
bullets: normalizeFragmentBullets(parsed.body),
|
||||||
path: fragmentPath,
|
path: fragmentPath,
|
||||||
type: parsed.type,
|
type: parsed.type,
|
||||||
@@ -219,10 +230,22 @@ function renderFragmentBullet(fragment: ChangeFragment, bullet: string): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderGroupedChanges(fragments: ChangeFragment[]): string {
|
function renderGroupedChanges(fragments: ChangeFragment[]): string {
|
||||||
const sections = CHANGE_TYPES.flatMap((type) => {
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
const breakingFragments = fragments.filter((fragment) => fragment.breaking);
|
||||||
|
if (breakingFragments.length > 0) {
|
||||||
|
const bullets = breakingFragments
|
||||||
|
.flatMap((fragment) =>
|
||||||
|
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
sections.push(`### Breaking Changes\n${bullets}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of CHANGE_TYPES) {
|
||||||
const typeFragments = fragments.filter((fragment) => fragment.type === type);
|
const typeFragments = fragments.filter((fragment) => fragment.type === type);
|
||||||
if (typeFragments.length === 0) {
|
if (typeFragments.length === 0) {
|
||||||
return [];
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bullets = typeFragments
|
const bullets = typeFragments
|
||||||
@@ -230,8 +253,8 @@ function renderGroupedChanges(fragments: ChangeFragment[]): string {
|
|||||||
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
|
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
|
||||||
)
|
)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
return [`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`];
|
sections.push(`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`);
|
||||||
});
|
}
|
||||||
|
|
||||||
return sections.join('\n\n');
|
return sections.join('\n\n');
|
||||||
}
|
}
|
||||||
@@ -296,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,
|
||||||
'',
|
'',
|
||||||
@@ -316,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +525,99 @@ function resolveChangedPathsFromGit(
|
|||||||
.filter((entry) => entry.path);
|
.filter((entry) => entry.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOCS_CHANGELOG_PATH = path.join('docs-site', 'changelog.md');
|
||||||
|
|
||||||
|
type VersionSection = {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
minor: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseVersionSections(changelog: string): VersionSection[] {
|
||||||
|
const sectionPattern = /^## v(\d+\.\d+\.\d+) \((\d{4}-\d{2}-\d{2})\)$/gm;
|
||||||
|
const sections: VersionSection[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = sectionPattern.exec(changelog)) !== null) {
|
||||||
|
const version = match[1]!;
|
||||||
|
const date = match[2]!;
|
||||||
|
const minor = version.replace(/\.\d+$/, '');
|
||||||
|
const headingEnd = match.index + match[0].length;
|
||||||
|
sections.push({ version, date, minor, body: '' });
|
||||||
|
|
||||||
|
if (sections.length > 1) {
|
||||||
|
const prev = sections[sections.length - 2]!;
|
||||||
|
prev.body = changelog.slice(prev.body as unknown as number, match.index).trim();
|
||||||
|
}
|
||||||
|
(sections[sections.length - 1] as { body: unknown }).body = headingEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.length > 0) {
|
||||||
|
const last = sections[sections.length - 1]!;
|
||||||
|
last.body = changelog.slice(last.body as unknown as number).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateDocsChangelog(options?: Pick<ChangelogOptions, 'cwd' | 'deps'>): string {
|
||||||
|
const cwd = options?.cwd ?? process.cwd();
|
||||||
|
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
|
||||||
|
const writeFileSync = options?.deps?.writeFileSync ?? fs.writeFileSync;
|
||||||
|
const log = options?.deps?.log ?? console.log;
|
||||||
|
|
||||||
|
const changelogPath = path.join(cwd, 'CHANGELOG.md');
|
||||||
|
const changelog = readFileSync(changelogPath, 'utf8');
|
||||||
|
const sections = parseVersionSections(changelog);
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
throw new Error('No version sections found in CHANGELOG.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMinor = sections[0]!.minor;
|
||||||
|
const currentSections = sections.filter((s) => s.minor === currentMinor);
|
||||||
|
const olderSections = sections.filter((s) => s.minor !== currentMinor);
|
||||||
|
|
||||||
|
const lines: string[] = ['# Changelog', ''];
|
||||||
|
|
||||||
|
for (const section of currentSections) {
|
||||||
|
const body = section.body.replace(/^### (.+)$/gm, '**$1**');
|
||||||
|
lines.push(`## v${section.version} (${section.date})`, '', body, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (olderSections.length > 0) {
|
||||||
|
lines.push('## Previous Versions', '');
|
||||||
|
|
||||||
|
const minorGroups = new Map<string, VersionSection[]>();
|
||||||
|
for (const section of olderSections) {
|
||||||
|
const group = minorGroups.get(section.minor) ?? [];
|
||||||
|
group.push(section);
|
||||||
|
minorGroups.set(section.minor, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [minor, group] of minorGroups) {
|
||||||
|
lines.push('<details>', `<summary>v${minor}.x</summary>`, '');
|
||||||
|
for (const section of group) {
|
||||||
|
const htmlBody = section.body.replace(/^### (.+)$/gm, '**$1**');
|
||||||
|
lines.push(`<h2>v${section.version} (${section.date})</h2>`, '', htmlBody, '');
|
||||||
|
}
|
||||||
|
lines.push('</details>', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output =
|
||||||
|
lines
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trimEnd() + '\n';
|
||||||
|
const outputPath = path.join(cwd, DOCS_CHANGELOG_PATH);
|
||||||
|
writeFileSync(outputPath, output, 'utf8');
|
||||||
|
log(`Generated ${outputPath}`);
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
|
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
|
||||||
const cwd = options?.cwd ?? process.cwd();
|
const cwd = options?.cwd ?? process.cwd();
|
||||||
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
|
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
|
||||||
@@ -502,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;
|
||||||
@@ -599,6 +754,16 @@ function main(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === 'prerelease-notes') {
|
||||||
|
writePrereleaseNotesForVersion(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'docs') {
|
||||||
|
generateDocsChangelog(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Unknown changelog command: ${command}`);
|
throw new Error(`Unknown changelog command: ${command}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2125,10 +2125,7 @@ test('template generator includes known keys', () => {
|
|||||||
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
|
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
|
||||||
);
|
);
|
||||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||||
assert.match(
|
assert.match(output, /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/);
|
||||||
output,
|
|
||||||
/"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/,
|
|
||||||
);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,
|
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
},
|
},
|
||||||
mpv: {
|
mpv: {
|
||||||
executablePath: '',
|
executablePath: '',
|
||||||
|
launchMode: 'normal',
|
||||||
},
|
},
|
||||||
anilist: {
|
anilist: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'anilist.characterDictionary.enabled',
|
'anilist.characterDictionary.enabled',
|
||||||
'anilist.characterDictionary.collapsibleSections.description',
|
'anilist.characterDictionary.collapsibleSections.description',
|
||||||
'mpv.executablePath',
|
'mpv.executablePath',
|
||||||
|
'mpv.launchMode',
|
||||||
'yomitan.externalProfilePath',
|
'yomitan.externalProfilePath',
|
||||||
'immersionTracking.enabled',
|
'immersionTracking.enabled',
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
|
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
||||||
import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
|
import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
|
||||||
|
|
||||||
export function buildIntegrationConfigOptionRegistry(
|
export function buildIntegrationConfigOptionRegistry(
|
||||||
@@ -245,6 +246,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.',
|
'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'mpv.launchMode',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: MPV_LAUNCH_MODE_VALUES,
|
||||||
|
defaultValue: defaultConfig.mpv.launchMode,
|
||||||
|
description: 'Default window state for SubMiner-managed mpv launches.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'jellyfin.enabled',
|
path: 'jellyfin.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'MPV Launcher',
|
title: 'MPV Launcher',
|
||||||
description: [
|
description: [
|
||||||
'Optional mpv.exe override for Windows playback entry points.',
|
'Optional mpv.exe override for Windows playback entry points.',
|
||||||
|
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
|
||||||
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
||||||
],
|
],
|
||||||
key: 'mpv',
|
key: 'mpv',
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ test('resolveConfig trims configured mpv executable path', () => {
|
|||||||
assert.deepEqual(warnings, []);
|
assert.deepEqual(warnings, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveConfig parses configured mpv launch mode', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
mpv: {
|
||||||
|
launchMode: 'maximized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.mpv.launchMode, 'maximized');
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveConfig warns for invalid mpv executable path type', () => {
|
test('resolveConfig warns for invalid mpv executable path type', () => {
|
||||||
const { resolved, warnings } = resolveConfig({
|
const { resolved, warnings } = resolveConfig({
|
||||||
mpv: {
|
mpv: {
|
||||||
@@ -29,3 +40,20 @@ test('resolveConfig warns for invalid mpv executable path type', () => {
|
|||||||
message: 'Expected string.',
|
message: 'Expected string.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveConfig warns for invalid mpv launch mode', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
mpv: {
|
||||||
|
launchMode: 'cinema' as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.mpv.launchMode, 'normal');
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.deepEqual(warnings[0], {
|
||||||
|
path: 'mpv.launchMode',
|
||||||
|
value: 'cinema',
|
||||||
|
fallback: 'normal',
|
||||||
|
message: "Expected one of: 'normal', 'maximized', 'fullscreen'.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { MPV_LAUNCH_MODE_VALUES, parseMpvLaunchMode } from '../../shared/mpv-launch-mode';
|
||||||
import { ResolveContext } from './context';
|
import { ResolveContext } from './context';
|
||||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||||
|
|
||||||
@@ -240,6 +241,18 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
|||||||
'Expected string.',
|
'Expected string.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const launchMode = parseMpvLaunchMode(src.mpv.launchMode);
|
||||||
|
if (launchMode !== undefined) {
|
||||||
|
resolved.mpv.launchMode = launchMode;
|
||||||
|
} else if (src.mpv.launchMode !== undefined) {
|
||||||
|
warn(
|
||||||
|
'mpv.launchMode',
|
||||||
|
src.mpv.launchMode,
|
||||||
|
resolved.mpv.launchMode,
|
||||||
|
`Expected one of: ${MPV_LAUNCH_MODE_VALUES.map((value) => `'${value}'`).join(', ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (src.mpv !== undefined) {
|
} else if (src.mpv !== undefined) {
|
||||||
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
|
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||||
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
|
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
|
||||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||||
|
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
|
||||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||||
|
|
||||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||||
@@ -36,21 +37,6 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readConfiguredWindowsMpvExecutablePath(configDir: string): string {
|
|
||||||
const loadResult = loadRawConfigStrict({
|
|
||||||
configDir,
|
|
||||||
configFileJsonc: path.join(configDir, 'config.jsonc'),
|
|
||||||
configFileJson: path.join(configDir, 'config.json'),
|
|
||||||
});
|
|
||||||
if (!loadResult.ok) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeof loadResult.config.mpv?.executablePath === 'string'
|
|
||||||
? loadResult.config.mpv.executablePath.trim()
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
|
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
|
||||||
const assets = resolvePackagedFirstRunPluginAssets({
|
const assets = resolvePackagedFirstRunPluginAssets({
|
||||||
dirname: __dirname,
|
dirname: __dirname,
|
||||||
@@ -64,6 +50,31 @@ function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
|
|||||||
return path.join(assets.pluginDirSource, 'main.lua');
|
return path.join(assets.pluginDirSource, 'main.lua');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readConfiguredWindowsMpvLaunch(configDir: string): {
|
||||||
|
executablePath: string;
|
||||||
|
launchMode: 'normal' | 'maximized' | 'fullscreen';
|
||||||
|
} {
|
||||||
|
const loadResult = loadRawConfigStrict({
|
||||||
|
configDir,
|
||||||
|
configFileJsonc: path.join(configDir, 'config.jsonc'),
|
||||||
|
configFileJson: path.join(configDir, 'config.json'),
|
||||||
|
});
|
||||||
|
if (!loadResult.ok) {
|
||||||
|
return {
|
||||||
|
executablePath: '',
|
||||||
|
launchMode: 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
executablePath:
|
||||||
|
typeof loadResult.config.mpv?.executablePath === 'string'
|
||||||
|
? loadResult.config.mpv.executablePath.trim()
|
||||||
|
: '',
|
||||||
|
launchMode: parseMpvLaunchMode(loadResult.config.mpv?.launchMode) ?? 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||||
const userDataPath = configureEarlyAppPaths(app);
|
const userDataPath = configureEarlyAppPaths(app);
|
||||||
@@ -92,6 +103,7 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
|||||||
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
|
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
|
||||||
applySanitizedEnv(sanitizedEnv);
|
applySanitizedEnv(sanitizedEnv);
|
||||||
void app.whenReady().then(async () => {
|
void app.whenReady().then(async () => {
|
||||||
|
const configuredMpvLaunch = readConfiguredWindowsMpvLaunch(userDataPath);
|
||||||
const result = await launchWindowsMpv(
|
const result = await launchWindowsMpv(
|
||||||
normalizeLaunchMpvTargets(process.argv),
|
normalizeLaunchMpvTargets(process.argv),
|
||||||
createWindowsMpvLaunchDeps({
|
createWindowsMpvLaunchDeps({
|
||||||
@@ -103,7 +115,8 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
|||||||
normalizeLaunchMpvExtraArgs(process.argv),
|
normalizeLaunchMpvExtraArgs(process.argv),
|
||||||
process.execPath,
|
process.execPath,
|
||||||
resolveBundledWindowsMpvPluginEntrypoint(),
|
resolveBundledWindowsMpvPluginEntrypoint(),
|
||||||
readConfiguredWindowsMpvExecutablePath(userDataPath),
|
configuredMpvLaunch.executablePath,
|
||||||
|
configuredMpvLaunch.launchMode,
|
||||||
);
|
);
|
||||||
app.exit(result.ok ? 0 : 1);
|
app.exit(result.ok ? 0 : 1);
|
||||||
});
|
});
|
||||||
|
|||||||
404
src/main.ts
404
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,
|
||||||
@@ -1052,6 +1066,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
|||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
getResolvedConfig().mpv.executablePath,
|
getResolvedConfig().mpv.executablePath,
|
||||||
|
getResolvedConfig().mpv.launchMode,
|
||||||
),
|
),
|
||||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
||||||
@@ -1525,6 +1540,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
|||||||
setKeybindings: (keybindings) => {
|
setKeybindings: (keybindings) => {
|
||||||
appState.keybindings = keybindings;
|
appState.keybindings = keybindings;
|
||||||
},
|
},
|
||||||
|
setSessionBindings: (sessionBindings) => {
|
||||||
|
persistSessionBindings(sessionBindings);
|
||||||
|
},
|
||||||
refreshGlobalAndOverlayShortcuts: () => {
|
refreshGlobalAndOverlayShortcuts: () => {
|
||||||
refreshGlobalAndOverlayShortcuts();
|
refreshGlobalAndOverlayShortcuts();
|
||||||
},
|
},
|
||||||
@@ -1834,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;
|
||||||
@@ -1842,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);
|
||||||
},
|
},
|
||||||
@@ -1869,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,
|
||||||
@@ -2031,6 +2280,7 @@ const {
|
|||||||
},
|
},
|
||||||
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
||||||
getSocketPath: () => appState.mpvSocketPath,
|
getSocketPath: () => appState.mpvSocketPath,
|
||||||
|
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
execPath: process.execPath,
|
execPath: process.execPath,
|
||||||
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
||||||
@@ -3144,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();
|
||||||
@@ -3286,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();
|
||||||
@@ -3672,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(
|
||||||
@@ -3794,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 {
|
||||||
@@ -3871,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,
|
||||||
@@ -4182,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,
|
||||||
@@ -4339,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,
|
||||||
@@ -4460,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),
|
||||||
@@ -4593,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);
|
||||||
@@ -4694,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(),
|
||||||
},
|
},
|
||||||
@@ -4717,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,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
|||||||
},
|
},
|
||||||
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
||||||
getSocketPath: () => '/tmp/test-mpv.sock',
|
getSocketPath: () => '/tmp/test-mpv.sock',
|
||||||
|
getLaunchMode: () => 'normal',
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
execPath: process.execPath,
|
execPath: process.execPath,
|
||||||
defaultMpvLogPath: '/tmp/test-mpv.log',
|
defaultMpvLogPath: '/tmp/test-mpv.log',
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -237,10 +237,7 @@ test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv co
|
|||||||
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
|
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
|
||||||
fs.writeFileSync(pluginEntrypointPath, '-- plugin');
|
fs.writeFileSync(pluginEntrypointPath, '-- plugin');
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
|
||||||
detectInstalledFirstRunPlugin(installPaths),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,10 +253,7 @@ test('detectInstalledFirstRunPlugin ignores scoped plugin layout path', () => {
|
|||||||
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
|
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
|
||||||
fs.writeFileSync(pluginEntrypointPath, '-- plugin');
|
fs.writeFileSync(pluginEntrypointPath, '-- plugin');
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(detectInstalledFirstRunPlugin(installPaths), false);
|
||||||
detectInstalledFirstRunPlugin(installPaths),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,10 +266,7 @@ test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
|
|||||||
fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true });
|
fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true });
|
||||||
fs.writeFileSync(legacyLoaderPath, '-- plugin');
|
fs.writeFileSync(legacyLoaderPath, '-- plugin');
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(detectInstalledFirstRunPlugin(installPaths), false);
|
||||||
detectInstalledFirstRunPlugin(installPaths),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,9 +279,6 @@ test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', ()
|
|||||||
fs.mkdirSync(pluginDir, { recursive: true });
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin');
|
fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin');
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(detectInstalledFirstRunPlugin(installPaths), false);
|
||||||
detectInstalledFirstRunPlugin(installPaths),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user