mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-06 19:57:26 -08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2f07c3407a
|
|||
|
a5554ec530
|
|||
|
f9f2fe6e87
|
|||
|
8ca05859a9
|
|||
|
0cac446725
|
|||
|
23623ad1e1
|
|||
|
b623c5e160
|
|||
|
5436e0cd49
|
|||
|
beeeee5ebd
|
|||
|
fdbf769760
|
|||
|
0a36d1aa99
|
|||
|
69ab87c25f
|
|||
|
9a30419a23
|
|||
|
092c56f98f
|
|||
|
10ef535f9a
|
|||
|
6c80bd5843
|
|||
|
f0bd0ba355
|
85
.github/workflows/release.yml
vendored
85
.github/workflows/release.yml
vendored
@@ -278,45 +278,70 @@ jobs:
|
|||||||
echo "$CHANGES" >> $GITHUB_OUTPUT
|
echo "$CHANGES" >> $GITHUB_OUTPUT
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Create Release
|
- name: Publish Release
|
||||||
uses: softprops/action-gh-release@v2
|
env:
|
||||||
with:
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
name: ${{ steps.version.outputs.VERSION }}
|
run: |
|
||||||
body: |
|
set -euo pipefail
|
||||||
## Changes
|
|
||||||
${{ steps.changelog.outputs.CHANGES }}
|
|
||||||
|
|
||||||
## Installation
|
cat > release-body.md <<'EOF'
|
||||||
|
## Changes
|
||||||
|
${{ steps.changelog.outputs.CHANGES }}
|
||||||
|
|
||||||
### AppImage (Recommended)
|
## Installation
|
||||||
1. Download the AppImage below
|
|
||||||
2. Make it executable: `chmod +x SubMiner.AppImage`
|
|
||||||
3. Run: `./SubMiner.AppImage`
|
|
||||||
|
|
||||||
### macOS
|
### AppImage (Recommended)
|
||||||
1. Download `subminer-*.dmg`
|
1. Download the AppImage below
|
||||||
2. Open the DMG and drag `SubMiner.app` into `/Applications`
|
2. Make it executable: `chmod +x SubMiner.AppImage`
|
||||||
3. If needed, use the ZIP artifact as an alternative
|
3. Run: `./SubMiner.AppImage`
|
||||||
|
|
||||||
### Manual Installation
|
### macOS
|
||||||
See the [README](https://github.com/${{ github.repository }}#installation) for manual installation instructions.
|
1. Download `subminer-*.dmg`
|
||||||
|
2. Open the DMG and drag `SubMiner.app` into `/Applications`
|
||||||
|
3. If needed, use the ZIP artifact as an alternative
|
||||||
|
|
||||||
### Optional Assets (config example + mpv plugin + rofi theme)
|
### Manual Installation
|
||||||
1. Download `subminer-assets.tar.gz`
|
See the [README](https://github.com/${{ github.repository }}#installation) for manual installation instructions.
|
||||||
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
|
||||||
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/`
|
|
||||||
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
|
||||||
5. Copy `assets/themes/subminer.rasi` to:
|
|
||||||
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
|
||||||
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
### Optional Assets (config example + mpv plugin + rofi theme)
|
||||||
files: |
|
1. Download `subminer-assets.tar.gz`
|
||||||
|
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
||||||
|
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/`
|
||||||
|
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
||||||
|
5. Copy `assets/themes/subminer.rasi` to:
|
||||||
|
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
||||||
|
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
||||||
|
|
||||||
|
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
|
||||||
|
gh release edit "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--notes-file release-body.md \
|
||||||
|
--prerelease false
|
||||||
|
else
|
||||||
|
gh release create "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
|
--notes-file release-body.md \
|
||||||
|
--prerelease false
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
artifacts=(
|
||||||
release/*.AppImage
|
release/*.AppImage
|
||||||
release/*.dmg
|
release/*.dmg
|
||||||
release/*.zip
|
release/*.zip
|
||||||
release/*.tar.gz
|
release/*.tar.gz
|
||||||
release/SHA256SUMS.txt
|
release/SHA256SUMS.txt
|
||||||
dist/launcher/subminer
|
dist/launcher/subminer
|
||||||
draft: false
|
)
|
||||||
prerelease: false
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
SubMiner is an Electron overlay that sits on top of mpv. It turns your video player into a full sentence-mining workstation:
|
SubMiner is an Electron overlay that sits on top of mpv. It turns your video player into a full sentence-mining workstation:
|
||||||
|
|
||||||
- **Hover to look up** — Yomitan dictionary popups directly on subtitles
|
- **Hover to look up** — Yomitan dictionary popups directly on subtitles
|
||||||
|
- **Keyboard-driven lookup mode** — Navigate token-by-token, keep lookup open across tokens, and control popup scrolling/audio/mining without leaving the overlay
|
||||||
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
||||||
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
|
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
|
||||||
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
|
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
|
||||||
|
|||||||
8
backlog/milestones/m-0 - codebase-health-remediation.md
Normal file
8
backlog/milestones/m-0 - codebase-health-remediation.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
id: m-0
|
||||||
|
title: 'Codebase Health Remediation'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Follow-up work from the March 6, 2026 codebase review: strengthen the runnable test gate, remove confirmed dead architecture, and continue decomposition of oversized runtime entrypoints.
|
||||||
@@ -6,7 +6,7 @@ title: >-
|
|||||||
status: Done
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-28 02:38'
|
created_date: '2026-02-28 02:38'
|
||||||
updated_date: '2026-02-28 22:36'
|
updated_date: '2026-03-04 13:55'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
@@ -49,4 +49,10 @@ Risk/impact context:
|
|||||||
|
|
||||||
Completed implementation in branch working tree; ready to merge once local changes are committed and test gate passes.
|
Completed implementation in branch working tree; ready to merge once local changes are committed and test gate passes.
|
||||||
|
|
||||||
|
Follow-up fix (2026-03-04):
|
||||||
|
|
||||||
|
- Updated bundled Yomitan server-sync behavior to target `profileCurrent` instead of hardcoded `profiles[0]`.
|
||||||
|
- Added proxy-mode force override so bundled Yomitan always points at SubMiner proxy URL when `ankiConnect.proxy.enabled=true`; this ensures mined cards pass through proxy and trigger auto-enrichment.
|
||||||
|
- Added regression tests for blocked existing-server case and force-override injection path.
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: 'Subtitle hover: auto-pause playback with config toggle'
|
|||||||
status: Done
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-28 22:43'
|
created_date: '2026-02-28 22:43'
|
||||||
updated_date: '2026-02-28 22:43'
|
updated_date: '2026-03-04 12:07'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: medium
|
priority: medium
|
||||||
@@ -43,4 +43,11 @@ Scope:
|
|||||||
|
|
||||||
Implemented `subtitleStyle.autoPauseVideoOnHover` with default `true`, wired through config defaults/resolution/types, renderer state/style, and mouse hover handlers. Added playback pause-state IPC (`getPlaybackPaused`) to avoid false resume when media was already paused. Added renderer hover behavior tests (including race/cancel case) and config/resolve tests. Updated config examples and docs (`README`, usage, shortcuts, mining workflow, configuration) to document default hover pause/resume behavior and disable path.
|
Implemented `subtitleStyle.autoPauseVideoOnHover` with default `true`, wired through config defaults/resolution/types, renderer state/style, and mouse hover handlers. Added playback pause-state IPC (`getPlaybackPaused`) to avoid false resume when media was already paused. Added renderer hover behavior tests (including race/cancel case) and config/resolve tests. Updated config examples and docs (`README`, usage, shortcuts, mining workflow, configuration) to document default hover pause/resume behavior and disable path.
|
||||||
|
|
||||||
|
Follow-up adjustments (2026-03-04):
|
||||||
|
|
||||||
|
- Hover pause now resumes immediately when leaving subtitle text (no Yomitan-popup hover retention).
|
||||||
|
- Added `subtitleStyle.autoPauseVideoOnYomitanPopup` (default `false`) to optionally keep playback paused while Yomitan popup is open, with auto-resume on close only when SubMiner initiated the popup pause.
|
||||||
|
- Yomitan popup control keybinds added while popup is open: `J/K` scroll, `M` mine, `P` audio play, `[` previous audio variant, `]` next audio variant (within selected source).
|
||||||
|
- Extension copy drift detection widened so popup runtime changes are reliably re-copied on launch (`popup.js`, `popup-main.js`, `display.js`, `display-audio.js`).
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
id: TASK-84
|
||||||
|
title: 'Docs Plausible endpoint uses /api/event path'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 00:00'
|
||||||
|
updated_date: '2026-03-03 00:00'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
ordinal: 12000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Fix VitePress docs Plausible tracker config to post to hosted worker API event endpoint instead of worker root URL.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Docs theme Plausible `endpoint` points to `https://worker.subminer.moe/api/event`.
|
||||||
|
- [x] #2 Plausible docs test asserts `/api/event` endpoint path.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Updated docs Plausible tracker endpoint to `https://worker.subminer.moe/api/event` and updated regression test expectation accordingly.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
id: TASK-84
|
||||||
|
title: Migrate AniSkip metadata+lookup orchestration to launcher/Electron
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- Codex
|
||||||
|
created_date: '2026-03-03 08:31'
|
||||||
|
updated_date: '2026-03-03 08:35'
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
- aniskip
|
||||||
|
- launcher
|
||||||
|
- mpv-plugin
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- launcher/aniskip-metadata.ts
|
||||||
|
- launcher/mpv.ts
|
||||||
|
- plugin/subminer/aniskip.lua
|
||||||
|
- plugin/subminer/options.lua
|
||||||
|
- plugin/subminer/state.lua
|
||||||
|
- plugin/subminer/lifecycle.lua
|
||||||
|
- plugin/subminer/messages.lua
|
||||||
|
- plugin/subminer.conf
|
||||||
|
- launcher/aniskip-metadata.test.ts
|
||||||
|
documentation:
|
||||||
|
- docs/mpv-plugin.md
|
||||||
|
- launcher/aniskip-metadata.ts
|
||||||
|
- plugin/subminer/aniskip.lua
|
||||||
|
- docs/architecture.md
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Move AniSkip MAL/title-to-MAL lookup and intro payload resolution from mpv Lua to launcher Electron flow, while keeping mpv-side intro skip UX and chapter/chapter prompt behavior in plugin. Launcher should infer/analyze file metadata, fetch AniSkip payload when launching files, and pass resolved skip window via script options; plugin should trust launcher payload and fall back only when absent.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Launcher infers AniSkip metadata for file targets using existing guessit/fallback logic and performs AniSkip MAL + payload resolution during mpv startup.
|
||||||
|
- [x] #2 Launcher injects script options containing resolved MAL id and intro window fields (or explicit lookup-failure status) into mpv startup.
|
||||||
|
- [x] #3 Lua plugin consumes launcher-provided AniSkip intro data and skips all network lookups when payload is present.
|
||||||
|
- [x] #4 Standalone mpv/plugin usage without launcher payload continues to function using existing async in-plugin lookup path.
|
||||||
|
- [x] #5 Docs and defaults are updated to document new script-option contract.
|
||||||
|
- [x] #6 Launcher tests cover payload generation contract and fallback behavior where metadata is unavailable.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
1. Add launcher-side AniSkip payload resolution helpers in launcher/aniskip-metadata.ts (MAL prefix lookup + AniSkip payload fetch + result normalization).
|
||||||
|
2. Wire launcher/mpv.ts + buildSubminerScriptOpts to pass resolved AniSkip fields/mode in --script-opts for file playback.
|
||||||
|
3. Update plugin/subminer/aniskip.lua plus options/state to consume injected payload: if intro_start/end present, apply immediately and skip network lookup; otherwise retain existing async behavior.
|
||||||
|
4. Ensure fallback for standalone mpv usage remains intact for no-launcher/manual refresh.
|
||||||
|
5. Add/update tests/docs/config references for new script-opt contract and edge cases.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Executed end-to-end migration so launcher resolves AniSkip title/MAL/payload before mpv start and injects it via --script-opts. Plugin now parses and consumes launcher payload (JSON/url/base64), applies OP intro from payload, tracks payload metadata in state, and keeps legacy async lookup path for non-launcher/absent payload playback. Added launcher config key aniskip_payload and updated launcher/aniskip-metadata tests for resolve/payload behavior and contract validation.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
id: TASK-85
|
||||||
|
title: 'Remove docs Plausible analytics integration'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-03 00:00'
|
||||||
|
updated_date: '2026-03-03 00:00'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
ordinal: 12001
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Remove Plausible analytics integration from docs theme and dependency graph. Keep docs build/runtime analytics-free.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Docs theme no longer imports or initializes Plausible tracker.
|
||||||
|
- [x] #2 `@plausible-analytics/tracker` removed from dependencies and lockfile.
|
||||||
|
- [x] #3 Docs analytics test reflects absence of Plausible wiring.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Deleted Plausible runtime wiring from VitePress theme, removed tracker package via `bun remove`, and updated docs test to assert no Plausible integration remains.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
id: TASK-86
|
||||||
|
title: 'Renderer: keyboard-driven Yomitan lookup mode and popup key forwarding'
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- Codex
|
||||||
|
created_date: '2026-03-04 13:40'
|
||||||
|
updated_date: '2026-03-05 11:30'
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
- renderer
|
||||||
|
- yomitan
|
||||||
|
dependencies:
|
||||||
|
- TASK-77
|
||||||
|
references:
|
||||||
|
- src/renderer/handlers/keyboard.ts
|
||||||
|
- src/renderer/handlers/mouse.ts
|
||||||
|
- src/renderer/renderer.ts
|
||||||
|
- src/renderer/state.ts
|
||||||
|
- src/renderer/yomitan-popup.ts
|
||||||
|
- src/core/services/overlay-window.ts
|
||||||
|
- src/preload.ts
|
||||||
|
- src/shared/ipc/contracts.ts
|
||||||
|
- src/types.ts
|
||||||
|
- vendor/yomitan/js/app/frontend.js
|
||||||
|
- vendor/yomitan/js/app/popup.js
|
||||||
|
- vendor/yomitan/js/display/display.js
|
||||||
|
- vendor/yomitan/js/display/popup-main.js
|
||||||
|
- vendor/yomitan/js/display/display-audio.js
|
||||||
|
documentation:
|
||||||
|
- README.md
|
||||||
|
- docs/usage.md
|
||||||
|
- docs/shortcuts.md
|
||||||
|
priority: medium
|
||||||
|
ordinal: 13000
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Add true keyboard-driven token lookup flow in overlay:
|
||||||
|
|
||||||
|
- Toggle keyboard token-selection mode and navigate tokens by keyboard (`Arrow` + `HJKL`).
|
||||||
|
- Toggle Yomitan lookup window for selected token via fixed accelerator (`Ctrl/Cmd+Y`) without requiring mouse click.
|
||||||
|
- Preserve keyboard-only workflow while popup is open by forwarding popup keys (`J/K`, `M`, `P`, `[`, `]`) and restoring overlay focus on popup close.
|
||||||
|
- Ensure selection styling and hover metadata tooltips (frequency/JLPT) work for keyboard-selected token.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Keyboard mode toggle exists and shows visual selection outline for active token.
|
||||||
|
- [x] #2 Navigation works via arrows and vim keys while keyboard mode is enabled.
|
||||||
|
- [x] #3 Lookup window toggles from selected token with `Ctrl/Cmd+Y`; close path restores overlay keyboard focus.
|
||||||
|
- [x] #4 Popup-local controls work via keyboard forwarding (`J/K`, `M`, `P`, `[`, `]`), including mine action.
|
||||||
|
- [x] #5 Frequency/JLPT hover tags render for keyboard-selected token.
|
||||||
|
- [x] #6 Renderer/runtime tests cover new visibility/selection behavior, and docs are updated.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Implemented keyboard-driven Yomitan workflow end-to-end in renderer + bundled Yomitan runtime bridge. Added overlay-level keyboard mode state, token selection sync, lookup toggle routing, popup command forwarding, and focus recovery after popup close. Follow-up fixes kept lookup open while moving between tokens, made popup-local `J/K` and `ArrowUp/ArrowDown` scroll work from overlay-owned focus with key repeat, skipped keyboard/token annotation flow for parser groups that have no dictionary-backed headword, and preserved paused playback when token navigation jumps across subtitle lines. Updated user docs/README to document the final shortcut behavior.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
id: TASK-87
|
||||||
|
title: >-
|
||||||
|
Codebase health: harden verification and retire dead architecture identified
|
||||||
|
in the March 2026 review
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-06 03:19'
|
||||||
|
updated_date: '2026-03-06 03:20'
|
||||||
|
labels:
|
||||||
|
- tech-debt
|
||||||
|
- tests
|
||||||
|
- maintainability
|
||||||
|
milestone: m-0
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- package.json
|
||||||
|
- README.md
|
||||||
|
- src/main.ts
|
||||||
|
- src/anki-integration.ts
|
||||||
|
- src/core/services/immersion-tracker-service.test.ts
|
||||||
|
- src/translators/index.ts
|
||||||
|
- src/subsync/engines.ts
|
||||||
|
- src/subtitle/pipeline.ts
|
||||||
|
documentation:
|
||||||
|
- docs/reports/2026-02-22-task-100-dead-code-report.md
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Track the remediation work from the March 6, 2026 code review. The review found that the default test gate only exercises 53 of 241 test files, the dedicated subtitle test lane is a no-op, SQLite-backed immersion tracking tests are conditionally skipped in the standard Bun run, src/main.ts still contains a large dead-symbol backlog, several registry/pipeline modules appear unreferenced from live execution paths, and src/anki-integration.ts remains an oversized orchestration file. This parent task should coordinate a safe sequence: improve verification first, then remove dead code and continue decomposition with good test coverage in place.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [ ] #1 Child tasks are created for each remediation workstream with explicit dependencies and enough context for an isolated agent to execute them.
|
||||||
|
- [ ] #2 The parent task records the recommended sequencing and parallelization strategy so replacement agents can resume without conversation history.
|
||||||
|
- [ ] #3 Completion of the parent task leaves the repository with a materially more trustworthy test gate, less dead architecture, and clearer ownership boundaries for the main runtime and Anki integration surfaces.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
Recommended sequencing:
|
||||||
|
|
||||||
|
1. Run TASK-87.1, TASK-87.2, TASK-87.3, and TASK-87.7 first. These are the safety-net and tooling tasks and can largely proceed in parallel.
|
||||||
|
2. Start TASK-87.4 once TASK-87.1 lands so src/main.ts cleanup happens under a more trustworthy test matrix.
|
||||||
|
3. Start TASK-87.5 after TASK-87.1 and TASK-87.2 so dead subsync/pipeline cleanup happens with stronger subtitle and runtime verification.
|
||||||
|
4. Start TASK-87.6 after TASK-87.1 so Anki refactors happen with broader default coverage in place.
|
||||||
|
5. Keep PRs focused: do not combine verification work with architectural cleanup unless a narrow dependency requires it.
|
||||||
|
|
||||||
|
Parallelization guidance:
|
||||||
|
|
||||||
|
- Wave 1 parallel: TASK-87.1, TASK-87.2, TASK-87.3, TASK-87.7
|
||||||
|
- Wave 2 parallel: TASK-87.4, TASK-87.5, TASK-87.6
|
||||||
|
|
||||||
|
Shared review context to restate in child tasks:
|
||||||
|
|
||||||
|
- Standard test scripts currently reference only 53 unique test files out of 241 discovered test and type-test files under src/ and launcher/.
|
||||||
|
- test:subtitle is currently a placeholder echo even though subtitle sync is a user-facing feature.
|
||||||
|
- SQLite-backed immersion tracker tests are conditionally skipped in the standard Bun run.
|
||||||
|
- src/main.ts trips many noUnusedLocals/noUnusedParameters diagnostics.
|
||||||
|
- src/translators/index.ts, src/subsync/engines.ts, src/subtitle/pipeline.ts, src/tokenizers/index.ts, and src/token-mergers/index.ts appeared unreferenced during review and must be re-verified before deletion.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: TASK-87.1
|
||||||
|
title: >-
|
||||||
|
Testing workflow: make standard test commands reflect the maintained test
|
||||||
|
surface
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-06 03:19'
|
||||||
|
updated_date: '2026-03-06 03:21'
|
||||||
|
labels:
|
||||||
|
- tests
|
||||||
|
- maintainability
|
||||||
|
milestone: m-0
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- package.json
|
||||||
|
- src/main-entry-runtime.test.ts
|
||||||
|
- src/anki-integration/anki-connect-proxy.test.ts
|
||||||
|
- src/main/runtime/jellyfin-remote-playback.test.ts
|
||||||
|
- src/main/runtime/registry.test.ts
|
||||||
|
documentation:
|
||||||
|
- docs/reports/2026-02-22-task-100-dead-code-report.md
|
||||||
|
parent_task_id: TASK-87
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
The current package scripts hand-enumerate a small subset of test files, which leaves the standard green signal misleading. A local audit found 241 test/type-test files under src/ and launcher/, but only 53 unique files referenced by the standard package.json test scripts. This task should redesign the runnable test matrix so maintained tests are either executed by the standard commands or intentionally excluded through a documented rule, instead of silently drifting out of coverage.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [ ] #1 The repository has a documented and reproducible test matrix for standard development commands, including which suites belong in the default lane versus slower or environment-specific lanes.
|
||||||
|
- [ ] #2 The standard test entrypoints stop relying on a brittle hand-maintained allowlist for the currently covered unit and integration suites, or an explicit documented mechanism exists that prevents silent omission of new tests.
|
||||||
|
- [ ] #3 Representative tests that were previously outside the standard lane from src/main/runtime, src/anki-integration, and entry/runtime surfaces are executed by an automated command and included in the documented matrix.
|
||||||
|
- [ ] #4 Documentation for contributors explains which command to run for fast verification, full verification, and environment-specific verification.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
1. Inventory the current test surface under src/ and launcher/ and compare it to package.json scripts to classify fast, full, slow, and environment-specific suites.
|
||||||
|
2. Replace or reduce the brittle hand-maintained allowlist so new maintained tests do not silently miss the standard matrix.
|
||||||
|
3. Update contributor docs with the intended fast/full/environment-specific commands.
|
||||||
|
4. Verify the new matrix by running the relevant commands and by demonstrating at least one previously omitted runtime/Anki/entry test now belongs to an automated lane.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: TASK-87.2
|
||||||
|
title: >-
|
||||||
|
Subtitle sync verification: replace the no-op subtitle lane with real
|
||||||
|
automated coverage
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-06 03:19'
|
||||||
|
updated_date: '2026-03-06 03:21'
|
||||||
|
labels:
|
||||||
|
- tests
|
||||||
|
- subsync
|
||||||
|
milestone: m-0
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- package.json
|
||||||
|
- README.md
|
||||||
|
- src/core/services/subsync.ts
|
||||||
|
- src/core/services/subsync.test.ts
|
||||||
|
- src/subsync/utils.ts
|
||||||
|
documentation:
|
||||||
|
- docs/reports/2026-02-22-task-100-dead-code-report.md
|
||||||
|
parent_task_id: TASK-87
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
SubMiner advertises subtitle syncing with alass and ffsubsync, but the dedicated test:subtitle command currently does not run any tests. There is already lower-level coverage in src/core/services/subsync.test.ts, but the test matrix and contributor-facing commands do not reflect that reality. This task should replace the no-op lane with real verification, align scripts with the existing subsync test surface, and make the user-facing docs honest about how subtitle sync is verified.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [ ] #1 The test:subtitle entrypoint runs real automated verification instead of echoing a placeholder message.
|
||||||
|
- [ ] #2 The subtitle verification lane covers both alass and ffsubsync behavior, including at least one non-happy-path scenario relevant to current functionality.
|
||||||
|
- [ ] #3 Contributor-facing documentation points to the real subtitle verification command and no longer implies a dedicated test lane exists when it does not.
|
||||||
|
- [ ] #4 The resulting verification strategy integrates cleanly with the repository-wide test matrix without duplicating or hiding existing subsync coverage.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
1. Audit the existing subtitle-sync test surface, especially src/core/services/subsync.test.ts, and decide whether test:subtitle should reuse or regroup that coverage.
|
||||||
|
2. Replace the placeholder script with a real automated command and keep the matrix legible alongside TASK-87.1 work.
|
||||||
|
3. Update README or related docs so the advertised subtitle verification path matches reality.
|
||||||
|
4. Verify both alass and ffsubsync behavior remain covered by the resulting lane.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
id: TASK-87.3
|
||||||
|
title: >-
|
||||||
|
Immersion tracking verification: make SQLite-backed persistence tests visible
|
||||||
|
and reproducible
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-06 03:19'
|
||||||
|
updated_date: '2026-03-06 03:21'
|
||||||
|
labels:
|
||||||
|
- tests
|
||||||
|
- immersion-tracking
|
||||||
|
milestone: m-0
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/core/services/immersion-tracker-service.test.ts
|
||||||
|
- src/core/services/immersion-tracker/storage-session.test.ts
|
||||||
|
- src/core/services/immersion-tracker-service.ts
|
||||||
|
- package.json
|
||||||
|
documentation:
|
||||||
|
- docs/reports/2026-02-22-task-100-dead-code-report.md
|
||||||
|
parent_task_id: TASK-87
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
The immersion tracker is persistence-heavy, but its SQLite-backed tests are conditionally skipped in the standard Bun run when node:sqlite support is unavailable. That creates a blind spot around session finalization, telemetry persistence, and retention behavior. This task should establish a reliable automated verification path for the database-backed cases and make the prerequisite/runtime behavior explicit to contributors and CI.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [ ] #1 Database-backed immersion tracking tests run in at least one documented automated command that is practical for contributors or CI to execute.
|
||||||
|
- [ ] #2 If the current runtime cannot execute the SQLite-backed tests, the repository exposes that limitation clearly instead of silently reporting a misleading green result.
|
||||||
|
- [ ] #3 Contributor-facing documentation explains how to run the immersion tracker verification lane and any environment prerequisites it depends on.
|
||||||
|
- [ ] #4 The resulting verification covers session persistence or finalization behavior that is not exercised by the pure seam tests alone.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
1. Confirm which SQLite-backed immersion tests are currently skipped and why in the standard Bun environment.
|
||||||
|
2. Establish a reproducible command or lane for the DB-backed cases, or make the unsupported-runtime limitation explicit and actionable.
|
||||||
|
3. Document prerequisites and expected behavior for contributors and CI.
|
||||||
|
4. Verify at least one persistence/finalization path beyond the seam tests is exercised by the new lane.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: TASK-87.4
|
||||||
|
title: >-
|
||||||
|
Runtime composition root: remove dead symbols and tighten module boundaries in
|
||||||
|
src/main.ts
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-06 03:19'
|
||||||
|
updated_date: '2026-03-06 03:21'
|
||||||
|
labels:
|
||||||
|
- tech-debt
|
||||||
|
- runtime
|
||||||
|
- maintainability
|
||||||
|
milestone: m-0
|
||||||
|
dependencies:
|
||||||
|
- TASK-87.1
|
||||||
|
references:
|
||||||
|
- src/main.ts
|
||||||
|
- src/main/runtime
|
||||||
|
- package.json
|
||||||
|
documentation:
|
||||||
|
- docs/reports/2026-02-22-task-100-dead-code-report.md
|
||||||
|
parent_task_id: TASK-87
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
A noUnusedLocals/noUnusedParameters compile pass reports a large concentration of dead imports and dead locals in src/main.ts. The file is also far beyond the repo’s preferred size guideline, which makes the runtime composition root difficult to review and easy to break. This task should remove confirmed dead symbols, continue extracting coherent slices where that improves readability, and leave the entrypoint materially easier to understand without changing behavior.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [ ] #1 src/main.ts no longer emits dead-symbol diagnostics under a noUnusedLocals/noUnusedParameters compile pass for the areas touched by this cleanup.
|
||||||
|
- [ ] #2 Unused imports, destructured values, and stale locals identified in the current composition root are removed or relocated without behavior changes.
|
||||||
|
- [ ] #3 The resulting composition root has clearer ownership boundaries for at least one runtime slice that is currently buried in the monolith.
|
||||||
|
- [ ] #4 Relevant runtime and startup verification commands pass after the cleanup, and any command changes are documented if needed.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
1. Re-run the noUnusedLocals/noUnusedParameters compile pass and capture the src/main.ts diagnostics cluster before editing.
|
||||||
|
2. Remove dead imports, destructured values, and stale locals in small reviewable slices; extract a coherent helper/module only where that materially reduces coupling.
|
||||||
|
3. Keep changes behavior-preserving and avoid mixing unrelated cleanup outside src/main.ts unless required to compile.
|
||||||
|
4. Verify with the updated runtime/startup test commands from TASK-87.1 plus a noUnused compile pass.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
id: TASK-87.5
|
||||||
|
title: >-
|
||||||
|
Dead architecture cleanup: delete unused registry and pipeline modules that
|
||||||
|
are off the live path
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-06 03:20'
|
||||||
|
updated_date: '2026-03-06 03:21'
|
||||||
|
labels:
|
||||||
|
- tech-debt
|
||||||
|
- dead-code
|
||||||
|
milestone: m-0
|
||||||
|
dependencies:
|
||||||
|
- TASK-87.1
|
||||||
|
- TASK-87.2
|
||||||
|
references:
|
||||||
|
- src/translators/index.ts
|
||||||
|
- src/subsync/engines.ts
|
||||||
|
- src/subtitle/pipeline.ts
|
||||||
|
- src/tokenizers/index.ts
|
||||||
|
- src/token-mergers/index.ts
|
||||||
|
- src/core/services/subsync.ts
|
||||||
|
- src/core/services/tokenizer.ts
|
||||||
|
documentation:
|
||||||
|
- docs/reports/2026-02-22-task-100-dead-code-report.md
|
||||||
|
parent_task_id: TASK-87
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
The review found several modules that appear self-contained but unused from the application’s live execution paths: src/translators/index.ts, src/subsync/engines.ts, src/subtitle/pipeline.ts, src/tokenizers/index.ts, and src/token-mergers/index.ts. At the same time, the real runtime behavior is implemented elsewhere. This task should verify those modules are truly unused, remove or consolidate them, and clean up any stale exports, docs, or tests so contributors are not misled by duplicate architecture.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [ ] #1 Each candidate module identified in the review is either removed as dead code or justified and reconnected to a real supported execution path.
|
||||||
|
- [ ] #2 Any stale exports, imports, or tests associated with the removed or consolidated modules are cleaned up so the codebase has a single obvious path for the affected behavior.
|
||||||
|
- [ ] #3 The cleanup does not regress live tokenization or subtitle sync behavior and the relevant verification commands remain green.
|
||||||
|
- [ ] #4 Contributor-facing documentation or internal notes no longer imply that removed duplicate architecture is part of the current design.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
1. Re-verify each candidate module is off the live path by tracing imports from current runtime entrypoints before deleting anything.
|
||||||
|
2. Remove or consolidate truly dead modules and clean associated exports/imports/tests so only the supported path remains obvious.
|
||||||
|
3. Pay special attention to subtitle sync and tokenization surfaces, since duplicate architecture exists near active code.
|
||||||
|
4. Verify the relevant tokenization and subsync commands/tests still pass and update any stale docs or notes.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
id: TASK-87.6
|
||||||
|
title: >-
|
||||||
|
Anki integration maintainability: continue decomposing the oversized
|
||||||
|
orchestration layer
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-06 03:20'
|
||||||
|
updated_date: '2026-03-06 03:21'
|
||||||
|
labels:
|
||||||
|
- tech-debt
|
||||||
|
- anki
|
||||||
|
- maintainability
|
||||||
|
milestone: m-0
|
||||||
|
dependencies:
|
||||||
|
- TASK-87.1
|
||||||
|
references:
|
||||||
|
- src/anki-integration.ts
|
||||||
|
- src/anki-integration/field-grouping-workflow.ts
|
||||||
|
- src/anki-integration/note-update-workflow.ts
|
||||||
|
- src/anki-integration/card-creation.ts
|
||||||
|
- src/anki-integration/anki-connect-proxy.ts
|
||||||
|
- src/anki-integration.test.ts
|
||||||
|
documentation:
|
||||||
|
- docs/reports/2026-02-22-task-100-dead-code-report.md
|
||||||
|
- docs/anki-integration.md
|
||||||
|
parent_task_id: TASK-87
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
src/anki-integration.ts remains an oversized orchestration file even after earlier extractions. It still mixes config normalization, polling setup, media generation, duplicate resolution, field grouping workflows, and user feedback coordination in one class. This task should continue the decomposition so the remaining orchestration surface is smaller and easier to reason about, while preserving existing Anki, proxy, field grouping, and note update behavior.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [ ] #1 The responsibilities currently concentrated in src/anki-integration.ts are split into clearer modules or services with narrow ownership boundaries.
|
||||||
|
- [ ] #2 The resulting orchestration surface is materially smaller and easier to review, with at least one mixed-responsibility cluster extracted behind a well-named interface.
|
||||||
|
- [ ] #3 Existing Anki integration behavior remains covered by automated verification, including note update, field grouping, and proxy-related flows that the refactor touches.
|
||||||
|
- [ ] #4 Any developer-facing docs or notes needed to understand the new structure are updated in the same task.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
1. Map the remaining responsibility clusters inside src/anki-integration.ts and choose one or more extraction seams that reduce mixed concerns without changing behavior.
|
||||||
|
2. Move logic behind narrow interfaces/modules rather than creating another giant helper; keep orchestration readable.
|
||||||
|
3. Preserve coverage for field grouping, note update, proxy, and card creation flows touched by the refactor.
|
||||||
|
4. Update docs or internal notes if the new structure changes where contributors should look for a given behavior.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
id: TASK-87.7
|
||||||
|
title: >-
|
||||||
|
Developer workflow hygiene: make docs watch reproducible and remove stale
|
||||||
|
small-surface drift
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-06 03:20'
|
||||||
|
updated_date: '2026-03-06 03:21'
|
||||||
|
labels:
|
||||||
|
- tooling
|
||||||
|
- tech-debt
|
||||||
|
milestone: m-0
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- package.json
|
||||||
|
- bun.lock
|
||||||
|
- src/anki-integration/field-grouping-workflow.ts
|
||||||
|
documentation:
|
||||||
|
- docs/reports/2026-02-22-task-100-dead-code-report.md
|
||||||
|
parent_task_id: TASK-87
|
||||||
|
priority: low
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
The review found a few low-risk but recurring hygiene issues: docs:watch depends on bunx concurrently even though concurrently is not declared in package metadata, and small stale API surface remains after recent refactors, such as unused parameters in field-grouping workflow code. This task should make the developer workflow reproducible and clean up low-risk stale symbols that do not warrant a dedicated architecture task.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [ ] #1 The docs:watch workflow runs through declared project tooling or is rewritten to avoid undeclared dependencies.
|
||||||
|
- [ ] #2 Small stale symbols or parameters identified during the review outside the main composition-root cleanup are removed without behavior changes.
|
||||||
|
- [ ] #3 Any contributor-facing command changes are reflected in repository documentation.
|
||||||
|
- [ ] #4 The cleanup remains scoped to low-risk workflow and hygiene fixes rather than expanding into large architectural refactors.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
1. Fix the docs:watch workflow so it relies on declared project tooling or an equivalent checked-in command path.
|
||||||
|
2. Clean up low-risk stale symbols surfaced by the review outside the main.ts architecture task, such as unused parameters left behind by refactors.
|
||||||
|
3. Keep the task scoped: avoid pulling in main composition-root cleanup or larger Anki/runtime refactors.
|
||||||
|
4. Verify the affected developer commands still work and document any usage changes.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
3
bun.lock
3
bun.lock
@@ -6,7 +6,6 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/vitepress": "^0.1.2",
|
"@catppuccin/vitepress": "^0.1.2",
|
||||||
"@plausible-analytics/tracker": "^0.4.4",
|
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
@@ -189,8 +188,6 @@
|
|||||||
|
|
||||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@plausible-analytics/tracker": ["@plausible-analytics/tracker@0.4.4", "", {}, "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
|
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -5,25 +5,8 @@ import '@catppuccin/vitepress/theme/macchiato/mauve.css';
|
|||||||
import './mermaid-modal.css';
|
import './mermaid-modal.css';
|
||||||
|
|
||||||
let mermaidLoader: Promise<any> | null = null;
|
let mermaidLoader: Promise<any> | null = null;
|
||||||
let plausibleTrackerInitialized = false;
|
|
||||||
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
|
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
|
||||||
|
|
||||||
async function initPlausibleTracker() {
|
|
||||||
if (typeof window === 'undefined' || plausibleTrackerInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { init } = await import('@plausible-analytics/tracker');
|
|
||||||
init({
|
|
||||||
domain: 'subminer.moe',
|
|
||||||
endpoint: 'https://worker.subminer.moe',
|
|
||||||
outboundLinks: true,
|
|
||||||
fileDownloads: true,
|
|
||||||
formSubmissions: true,
|
|
||||||
});
|
|
||||||
plausibleTrackerInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMermaidModal() {
|
function closeMermaidModal() {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -206,9 +189,6 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initPlausibleTracker().catch((error) => {
|
|
||||||
console.error('Failed to initialize Plausible tracker:', error);
|
|
||||||
});
|
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
watch(() => route.path, render);
|
watch(() => route.path, render);
|
||||||
|
|||||||
@@ -44,12 +44,15 @@ Polling mode uses the query `"deck:<your-deck>" added:1` to find recently added
|
|||||||
|
|
||||||
Then point Yomitan/clients to `http://127.0.0.1:8766` instead of `8765`.
|
Then point Yomitan/clients to `http://127.0.0.1:8766` instead of `8765`.
|
||||||
|
|
||||||
When SubMiner loads the bundled Yomitan extension, it also attempts to update the **default Yomitan profile** (`profiles[0].options.anki.server`) to the active SubMiner endpoint:
|
When SubMiner loads the bundled Yomitan extension, it also attempts to update the **active bundled Yomitan profile** (`profiles[profileCurrent].options.anki.server`) to the active SubMiner endpoint:
|
||||||
|
|
||||||
- proxy URL when `ankiConnect.proxy.enabled` is `true`
|
- proxy URL when `ankiConnect.proxy.enabled` is `true`
|
||||||
- direct `ankiConnect.url` when proxy mode is disabled
|
- direct `ankiConnect.url` when proxy mode is disabled
|
||||||
|
|
||||||
To avoid clobbering custom setups, this auto-update only changes the default profile when its current server is blank or the stock Yomitan default (`http://127.0.0.1:8765`).
|
Server update behavior differs by mode:
|
||||||
|
|
||||||
|
- Proxy mode (`ankiConnect.proxy.enabled: true`): SubMiner force-syncs the bundled active profile to the proxy URL so `addNote` traffic goes through the local proxy and auto-enrichment can trigger.
|
||||||
|
- Direct mode (`ankiConnect.proxy.enabled: false`): SubMiner only replaces blank/default server values (`http://127.0.0.1:8765`) to avoid overwriting custom direct-server setups.
|
||||||
|
|
||||||
For browser-based Yomitan or other external clients (for example Texthooker in a normal browser profile), set their Anki server to the same proxy URL separately: `http://127.0.0.1:8766` (or your configured `proxy.host` + `proxy.port`).
|
For browser-based Yomitan or other external clients (for example Texthooker in a normal browser profile), set their Anki server to the same proxy URL separately: `http://127.0.0.1:8766` (or your configured `proxy.host` + `proxy.port`).
|
||||||
|
|
||||||
@@ -69,7 +72,7 @@ In Yomitan, go to Settings → Profile and:
|
|||||||
3. Set server to `http://127.0.0.1:8766` (or your configured proxy URL).
|
3. Set server to `http://127.0.0.1:8766` (or your configured proxy URL).
|
||||||
4. Save and make that profile active when using SubMiner.
|
4. Save and make that profile active when using SubMiner.
|
||||||
|
|
||||||
This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets `profiles[0]` when it is blank or still default.
|
This is only for non-bundled, external/browser Yomitan or other clients. Bundled Yomitan profile sync behavior is described above (force-sync in proxy mode, conservative sync in direct mode).
|
||||||
|
|
||||||
### Proxy Troubleshooting (quick checks)
|
### Proxy Troubleshooting (quick checks)
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,8 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
|
||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text; resume after leaving subtitle area (`true` by default). |
|
||||||
|
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while Yomitan popup is open; resume when popup closes (`false` by default). |
|
||||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
@@ -771,17 +772,19 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
|||||||
"defaultMode": "auto",
|
"defaultMode": "auto",
|
||||||
"alass_path": "",
|
"alass_path": "",
|
||||||
"ffsubsync_path": "",
|
"ffsubsync_path": "",
|
||||||
"ffmpeg_path": ""
|
"ffmpeg_path": "",
|
||||||
|
"replace": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 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` falls back to `/usr/bin/alass`. |
|
| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. |
|
||||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
|
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
|
||||||
| `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>`. |
|
||||||
|
|
||||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||||
Customize it there, or set it to `null` to disable.
|
Customize it there, or set it to `null` to disable.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ The visible overlay renders subtitles as tokenized, clickable word spans. Each w
|
|||||||
|
|
||||||
- Word-level click targets for Yomitan lookup
|
- Word-level click targets for Yomitan lookup
|
||||||
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
||||||
|
- Optional auto-pause while Yomitan popup is open (`subtitleStyle.autoPauseVideoOnYomitanPopup`)
|
||||||
- Right-click to pause/resume
|
- Right-click to pause/resume
|
||||||
- Right-click + drag to reposition subtitles
|
- Right-click + drag to reposition subtitles
|
||||||
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
||||||
|
|||||||
@@ -120,27 +120,28 @@ aniskip_button_duration=3
|
|||||||
|
|
||||||
### Option Reference
|
### Option Reference
|
||||||
|
|
||||||
| Option | Default | Values | Description |
|
| Option | Default | Values | Description |
|
||||||
| ------------------------------ | ----------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- |
|
| ------------------------------ | ----------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||||
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
||||||
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
||||||
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
||||||
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
||||||
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
||||||
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
||||||
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
||||||
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
|
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
|
||||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
||||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
||||||
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
||||||
| `aniskip_title` | `""` | string | Override title used for lookup |
|
| `aniskip_title` | `""` | string | Override title used for lookup |
|
||||||
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
||||||
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
||||||
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
||||||
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
|
| `aniskip_payload` | `""` | JSON / base64-encoded JSON | Optional pre-fetched AniSkip payload for this media. When set, plugin skips network lookup |
|
||||||
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
|
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
|
||||||
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
|
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
|
||||||
| `aniskip_button_duration` | `3` | float seconds | OSD hint duration |
|
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
|
||||||
|
| `aniskip_button_duration` | `3` | float seconds | OSD hint duration |
|
||||||
|
|
||||||
## Binary Auto-Detection
|
## Binary Auto-Detection
|
||||||
|
|
||||||
@@ -208,7 +209,8 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
|||||||
- You explicitly call `script-message subminer-aniskip-refresh`.
|
- You explicitly call `script-message subminer-aniskip-refresh`.
|
||||||
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
||||||
- MAL/title resolution is cached for the current mpv session.
|
- MAL/title resolution is cached for the current mpv session.
|
||||||
- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title.
|
- When launched via `subminer`, launcher can pass `aniskip_payload` (pre-fetched AniSkip `skip-times` payload) and the plugin applies it directly without making API calls.
|
||||||
|
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
|
||||||
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
||||||
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
||||||
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
|
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { readFileSync } from 'node:fs';
|
|||||||
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
||||||
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
||||||
|
|
||||||
test('docs theme configures plausible tracker for subminer.moe via worker.subminer.moe', () => {
|
test('docs theme has no plausible analytics wiring', () => {
|
||||||
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
|
expect(docsThemeContents).not.toContain('@plausible-analytics/tracker');
|
||||||
expect(docsThemeContents).toContain('const { init } = await import');
|
expect(docsThemeContents).not.toContain('initPlausibleTracker');
|
||||||
expect(docsThemeContents).toContain("domain: 'subminer.moe'");
|
expect(docsThemeContents).not.toContain('worker.subminer.moe');
|
||||||
expect(docsThemeContents).toContain("endpoint: 'https://worker.subminer.moe'");
|
expect(docsThemeContents).not.toContain('domain:');
|
||||||
expect(docsThemeContents).toContain('outboundLinks: true');
|
expect(docsThemeContents).not.toContain('outboundLinks: true');
|
||||||
expect(docsThemeContents).toContain('fileDownloads: true');
|
expect(docsThemeContents).not.toContain('fileDownloads: true');
|
||||||
expect(docsThemeContents).toContain('formSubmissions: true');
|
expect(docsThemeContents).not.toContain('formSubmissions: true');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
|
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -106,7 +107,8 @@
|
|||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text; resume after leaving subtitle area. Values: true | false
|
||||||
|
"autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open; resume when popup closes. Values: true | false
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
||||||
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
|
|||||||
@@ -58,7 +58,32 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
|
|
||||||
These keybindings can be overridden or disabled via the `keybindings` config array.
|
These keybindings can be overridden or disabled via the `keybindings` config array.
|
||||||
|
|
||||||
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover; resume after leaving subtitle area). Optional popup behavior: set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true` to keep playback paused while Yomitan popup is open.
|
||||||
|
|
||||||
|
When a Yomitan popup is open, SubMiner also provides popup control shortcuts:
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| ----------- | ----------------------------------------------- |
|
||||||
|
| `J` | Scroll definitions down |
|
||||||
|
| `K` | Scroll definitions up |
|
||||||
|
| `ArrowDown` | Scroll definitions down |
|
||||||
|
| `ArrowUp` | Scroll definitions up |
|
||||||
|
| `M` | Mine/add selected term |
|
||||||
|
| `P` | Play selected term audio |
|
||||||
|
| `[` | Play previous available audio (selected source) |
|
||||||
|
| `]` | Play next available audio (selected source) |
|
||||||
|
|
||||||
|
## Keyboard-Driven Lookup Mode
|
||||||
|
|
||||||
|
These shortcuts are fixed (not configurable) and require overlay focus.
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| ------------------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
|
| `Ctrl/Cmd+Shift+Y` | Toggle keyboard-driven token selection mode on/off |
|
||||||
|
| `Ctrl/Cmd+Y` | Toggle lookup popup for selected token (open when closed, close when open) |
|
||||||
|
| `ArrowLeft/Right`, `H`, or `L` | Move selected token (previous/next); if lookup is open, refresh definition for the new token |
|
||||||
|
|
||||||
|
Keyboard-driven mode draws a selection outline around the active token. Use `Ctrl/Cmd+Y` to open or close lookup for that token. While the popup is open, popup-local controls still work from the overlay (`J/K`, `ArrowUp/ArrowDown`, `M`, `P`, `[`, `]`) and focus is forced back to the overlay so token navigation can continue without clicking subtitle text again. Moving left/right past the start or end of the line jumps to the previous or next subtitle line and keeps playback paused if it was already paused.
|
||||||
|
|
||||||
## Subtitle & Feature Shortcuts
|
## Subtitle & Feature Shortcuts
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,13 @@ Notes:
|
|||||||
|
|
||||||
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
|
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
|
||||||
|
|
||||||
By default, hovering over subtitle text pauses mpv playback and leaving the subtitle area resumes playback. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
|
By default, hovering over subtitle text pauses mpv playback. Playback resumes as soon as the cursor leaves subtitle text. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
|
||||||
|
|
||||||
|
If you want playback to stay paused while a Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true`. When enabled, SubMiner auto-resumes on popup close only if SubMiner paused playback for that popup.
|
||||||
|
|
||||||
|
Keyboard-driven lookup mode is available with fixed shortcuts: `Ctrl/Cmd+Shift+Y` toggles token-selection mode, `ArrowLeft/Right` (or `H/L`) moves the selected token, and `Ctrl/Cmd+Y` opens or closes lookup for that token.
|
||||||
|
|
||||||
|
If the Yomitan popup is open, you can control it directly from the overlay without moving focus into the popup: `J/K` or `ArrowUp/ArrowDown` scroll definitions, `M` mines/adds the selected term, `P` plays term audio, `[` plays the previous available audio, and `]` plays the next available audio in the selected source. While lookup stays open, `ArrowLeft/Right` (or `H/L`) moves to the previous or next token and refreshes the definition for the new token. If you move past the start or end of the current subtitle line, SubMiner jumps to the previous or next subtitle line, moves the selector to the edge token on that line, and keeps playback paused if it was already paused.
|
||||||
|
|
||||||
### Drag-and-drop Queueing
|
### Drag-and-drop Queueing
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,38 @@ import {
|
|||||||
inferAniSkipMetadataForFile,
|
inferAniSkipMetadataForFile,
|
||||||
buildSubminerScriptOpts,
|
buildSubminerScriptOpts,
|
||||||
parseAniSkipGuessitJson,
|
parseAniSkipGuessitJson,
|
||||||
|
resolveAniSkipMetadataForFile,
|
||||||
} from './aniskip-metadata';
|
} from './aniskip-metadata';
|
||||||
|
|
||||||
|
function makeMockResponse(payload: unknown): Response {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => payload,
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFetchInput(input: string | URL | Request): string {
|
||||||
|
if (typeof input === 'string') return input;
|
||||||
|
if (input instanceof URL) return input.toString();
|
||||||
|
return input.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withMockFetch(
|
||||||
|
handler: (input: string | URL | Request) => Promise<Response>,
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
(globalThis as { fetch: typeof fetch }).fetch = (async (input: string | URL | Request) => {
|
||||||
|
return handler(input);
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
(globalThis as { fetch: typeof fetch }).fetch = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
||||||
const parsed = parseAniSkipGuessitJson(
|
const parsed = parseAniSkipGuessitJson(
|
||||||
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
|
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
|
||||||
@@ -16,6 +46,10 @@ test('parseAniSkipGuessitJson extracts title season and episode', () => {
|
|||||||
season: 2,
|
season: 2,
|
||||||
episode: 7,
|
episode: 7,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,6 +68,10 @@ test('parseAniSkipGuessitJson prefers series over episode title', () => {
|
|||||||
season: 1,
|
season: 1,
|
||||||
episode: 10,
|
episode: 10,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,16 +98,80 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
|
|||||||
assert.equal(parsed.source, 'fallback');
|
assert.equal(parsed.source, 'fallback');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
|
test('resolveAniSkipMetadataForFile resolves MAL id and intro payload', async () => {
|
||||||
|
await withMockFetch(
|
||||||
|
async (input) => {
|
||||||
|
const url = normalizeFetchInput(input);
|
||||||
|
if (url.includes('myanimelist.net/search/prefix.json')) {
|
||||||
|
return makeMockResponse({
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ id: '9876', name: 'Wrong Match' },
|
||||||
|
{ id: '1234', name: 'My Show' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('api.aniskip.com/v1/skip-times/1234/7')) {
|
||||||
|
return makeMockResponse({
|
||||||
|
found: true,
|
||||||
|
results: [{ skip_type: 'op', interval: { start_time: 12.5, end_time: 54.2 } }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected url: ${url}`);
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const resolved = await resolveAniSkipMetadataForFile('/media/Anime.My.Show.S01E07.mkv');
|
||||||
|
assert.equal(resolved.malId, 1234);
|
||||||
|
assert.equal(resolved.introStart, 12.5);
|
||||||
|
assert.equal(resolved.introEnd, 54.2);
|
||||||
|
assert.equal(resolved.lookupStatus, 'ready');
|
||||||
|
assert.equal(resolved.title, 'Anime My Show');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses', async () => {
|
||||||
|
await withMockFetch(
|
||||||
|
async () => makeMockResponse({ categories: [] }),
|
||||||
|
async () => {
|
||||||
|
const resolved = await resolveAniSkipMetadataForFile('/media/NopeShow.S01E03.mkv');
|
||||||
|
assert.equal(resolved.malId, null);
|
||||||
|
assert.equal(resolved.lookupStatus, 'missing_mal_id');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSubminerScriptOpts includes aniskip payload fields', () => {
|
||||||
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
||||||
title: "Frieren: Beyond Journey's End",
|
title: "Frieren: Beyond Journey's End",
|
||||||
season: 1,
|
season: 1,
|
||||||
episode: 5,
|
episode: 5,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
malId: 1234,
|
||||||
|
introStart: 30.5,
|
||||||
|
introEnd: 62,
|
||||||
|
lookupStatus: 'ready',
|
||||||
});
|
});
|
||||||
|
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
|
||||||
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
||||||
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
||||||
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
|
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
|
||||||
assert.match(opts, /subminer-aniskip_season=1/);
|
assert.match(opts, /subminer-aniskip_season=1/);
|
||||||
assert.match(opts, /subminer-aniskip_episode=5/);
|
assert.match(opts, /subminer-aniskip_episode=5/);
|
||||||
|
assert.match(opts, /subminer-aniskip_mal_id=1234/);
|
||||||
|
assert.match(opts, /subminer-aniskip_intro_start=30.5/);
|
||||||
|
assert.match(opts, /subminer-aniskip_intro_end=62/);
|
||||||
|
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
|
||||||
|
assert.ok(payloadMatch !== null);
|
||||||
|
assert.equal(payloadMatch[1].includes('%'), false);
|
||||||
|
const payloadJson = Buffer.from(payloadMatch[1], 'base64url').toString('utf-8');
|
||||||
|
const payload = JSON.parse(payloadJson);
|
||||||
|
assert.equal(payload.found, true);
|
||||||
|
const first = payload.results?.[0];
|
||||||
|
assert.equal(first.skip_type, 'op');
|
||||||
|
assert.equal(first.interval.start_time, 30.5);
|
||||||
|
assert.equal(first.interval.end_time, 62);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ import path from 'node:path';
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { commandExists } from './util.js';
|
import { commandExists } from './util.js';
|
||||||
|
|
||||||
|
export type AniSkipLookupStatus =
|
||||||
|
| 'ready'
|
||||||
|
| 'missing_mal_id'
|
||||||
|
| 'missing_episode'
|
||||||
|
| 'missing_payload'
|
||||||
|
| 'lookup_failed';
|
||||||
|
|
||||||
export interface AniSkipMetadata {
|
export interface AniSkipMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
season: number | null;
|
season: number | null;
|
||||||
episode: number | null;
|
episode: number | null;
|
||||||
source: 'guessit' | 'fallback';
|
source: 'guessit' | 'fallback';
|
||||||
|
malId: number | null;
|
||||||
|
introStart: number | null;
|
||||||
|
introEnd: number | null;
|
||||||
|
lookupStatus?: AniSkipLookupStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InferAniSkipDeps {
|
interface InferAniSkipDeps {
|
||||||
@@ -14,6 +25,50 @@ interface InferAniSkipDeps {
|
|||||||
runGuessit: (mediaPath: string) => string | null;
|
runGuessit: (mediaPath: string) => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MalSearchResult {
|
||||||
|
id?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MalSearchCategory {
|
||||||
|
items?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MalSearchResponse {
|
||||||
|
categories?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AniSkipIntervalPayload {
|
||||||
|
start_time?: unknown;
|
||||||
|
end_time?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AniSkipSkipItemPayload {
|
||||||
|
skip_type?: unknown;
|
||||||
|
interval?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AniSkipPayloadResponse {
|
||||||
|
found?: unknown;
|
||||||
|
results?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword=';
|
||||||
|
const ANISKIP_PAYLOAD_API = 'https://api.aniskip.com/v1/skip-times/';
|
||||||
|
const MAL_USER_AGENT = 'SubMiner-launcher/ani-skip';
|
||||||
|
const MAL_MATCH_STOPWORDS = new Set([
|
||||||
|
'the',
|
||||||
|
'this',
|
||||||
|
'that',
|
||||||
|
'world',
|
||||||
|
'animated',
|
||||||
|
'series',
|
||||||
|
'season',
|
||||||
|
'no',
|
||||||
|
'on',
|
||||||
|
'and',
|
||||||
|
]);
|
||||||
|
|
||||||
function toPositiveInt(value: unknown): number | null {
|
function toPositiveInt(value: unknown): number | null {
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
return Math.floor(value);
|
return Math.floor(value);
|
||||||
@@ -27,6 +82,227 @@ function toPositiveInt(value: unknown): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPositiveNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForMatch(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeMatchWords(value: string): string[] {
|
||||||
|
const words = normalizeForMatch(value)
|
||||||
|
.split(' ')
|
||||||
|
.filter((word) => word.length >= 3);
|
||||||
|
return words.filter((word) => !MAL_MATCH_STOPWORDS.has(word));
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleOverlapScore(expectedTitle: string, candidateTitle: string): number {
|
||||||
|
const expected = normalizeForMatch(expectedTitle);
|
||||||
|
const candidate = normalizeForMatch(candidateTitle);
|
||||||
|
|
||||||
|
if (!expected || !candidate) return 0;
|
||||||
|
|
||||||
|
if (candidate.includes(expected)) return 120;
|
||||||
|
|
||||||
|
const expectedTokens = tokenizeMatchWords(expectedTitle);
|
||||||
|
if (expectedTokens.length === 0) return 0;
|
||||||
|
|
||||||
|
const candidateSet = new Set(tokenizeMatchWords(candidateTitle));
|
||||||
|
let score = 0;
|
||||||
|
let matched = 0;
|
||||||
|
|
||||||
|
for (const token of expectedTokens) {
|
||||||
|
if (candidateSet.has(token)) {
|
||||||
|
score += 30;
|
||||||
|
matched += 1;
|
||||||
|
} else {
|
||||||
|
score -= 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched === 0) {
|
||||||
|
score -= 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverage = matched / expectedTokens.length;
|
||||||
|
if (expectedTokens.length >= 2) {
|
||||||
|
if (coverage >= 0.8) score += 30;
|
||||||
|
else if (coverage >= 0.6) score += 10;
|
||||||
|
else score -= 50;
|
||||||
|
} else if (coverage >= 1) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnySequelMarker(candidateTitle: string): boolean {
|
||||||
|
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
|
||||||
|
if (!normalized.trim()) return false;
|
||||||
|
|
||||||
|
const markers = [
|
||||||
|
'season 2',
|
||||||
|
'season 3',
|
||||||
|
'season 4',
|
||||||
|
'2nd season',
|
||||||
|
'3rd season',
|
||||||
|
'4th season',
|
||||||
|
'second season',
|
||||||
|
'third season',
|
||||||
|
'fourth season',
|
||||||
|
' ii ',
|
||||||
|
' iii ',
|
||||||
|
' iv ',
|
||||||
|
];
|
||||||
|
return markers.some((marker) => normalized.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
function seasonSignalScore(requestedSeason: number | null, candidateTitle: string): number {
|
||||||
|
const season = toPositiveInt(requestedSeason);
|
||||||
|
if (!season || season < 1) return 0;
|
||||||
|
|
||||||
|
const normalized = ` ${normalizeForMatch(candidateTitle)} `;
|
||||||
|
if (!normalized.trim()) return 0;
|
||||||
|
|
||||||
|
if (season === 1) {
|
||||||
|
return hasAnySequelMarker(candidateTitle) ? -60 : 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericMarker = ` season ${season} `;
|
||||||
|
const ordinalMarker = ` ${season}th season `;
|
||||||
|
if (normalized.includes(numericMarker) || normalized.includes(ordinalMarker)) {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
const romanAliases = {
|
||||||
|
2: [' ii ', ' second season ', ' 2nd season '],
|
||||||
|
3: [' iii ', ' third season ', ' 3rd season '],
|
||||||
|
4: [' iv ', ' fourth season ', ' 4th season '],
|
||||||
|
5: [' v ', ' fifth season ', ' 5th season '],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const aliases = romanAliases[season] ?? [];
|
||||||
|
return aliases.some((alias) => normalized.includes(alias))
|
||||||
|
? 40
|
||||||
|
: hasAnySequelMarker(candidateTitle)
|
||||||
|
? -20
|
||||||
|
: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMalSearchItems(payload: unknown): MalSearchResult[] {
|
||||||
|
const parsed = payload as MalSearchResponse;
|
||||||
|
const categories = Array.isArray(parsed?.categories) ? parsed.categories : null;
|
||||||
|
if (!categories) return [];
|
||||||
|
|
||||||
|
const items: MalSearchResult[] = [];
|
||||||
|
for (const category of categories) {
|
||||||
|
const typedCategory = category as MalSearchCategory;
|
||||||
|
const rawItems = Array.isArray(typedCategory?.items) ? typedCategory.items : [];
|
||||||
|
for (const rawItem of rawItems) {
|
||||||
|
const item = rawItem as Record<string, unknown>;
|
||||||
|
items.push({
|
||||||
|
id: item?.id,
|
||||||
|
name: item?.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEpisodePayload(value: unknown): number | null {
|
||||||
|
return toPositiveNumber(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAniSkipPayload(payload: unknown): { start: number; end: number } | null {
|
||||||
|
const parsed = payload as AniSkipPayloadResponse;
|
||||||
|
const results = Array.isArray(parsed?.results) ? parsed.results : null;
|
||||||
|
if (!results) return null;
|
||||||
|
|
||||||
|
for (const rawResult of results) {
|
||||||
|
const result = rawResult as AniSkipSkipItemPayload;
|
||||||
|
if (
|
||||||
|
result.skip_type !== 'op' ||
|
||||||
|
typeof result.interval !== 'object' ||
|
||||||
|
result.interval === null
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const interval = result.interval as AniSkipIntervalPayload;
|
||||||
|
const start = normalizeEpisodePayload(interval?.start_time);
|
||||||
|
const end = normalizeEpisodePayload(interval?.end_time);
|
||||||
|
if (start !== null && end !== null && end > start) {
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': MAL_USER_AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
try {
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMalIdFromTitle(title: string, season: number | null): Promise<number | null> {
|
||||||
|
const lookup = season && season > 1 ? `${title} Season ${season}` : title;
|
||||||
|
const payload = await fetchJson<unknown>(`${MAL_PREFIX_API}${encodeURIComponent(lookup)}`);
|
||||||
|
const items = toMalSearchItems(payload);
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
let bestScore = Number.NEGATIVE_INFINITY;
|
||||||
|
let bestMalId: number | null = null;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const id = toPositiveInt(item.id);
|
||||||
|
if (!id) continue;
|
||||||
|
const name = typeof item.name === 'string' ? item.name : '';
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
const score = titleOverlapScore(title, name) + seasonSignalScore(season, name);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestMalId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAniSkipPayload(
|
||||||
|
malId: number,
|
||||||
|
episode: number,
|
||||||
|
): Promise<{ start: number; end: number } | null> {
|
||||||
|
const payload = await fetchJson<unknown>(
|
||||||
|
`${ANISKIP_PAYLOAD_API}${malId}/${episode}?types=op&types=ed`,
|
||||||
|
);
|
||||||
|
const parsed = payload as AniSkipPayloadResponse;
|
||||||
|
if (!parsed || parsed.found !== true) return null;
|
||||||
|
return parseAniSkipPayload(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function detectEpisodeFromName(baseName: string): number | null {
|
function detectEpisodeFromName(baseName: string): number | null {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/[Ss]\d+[Ee](\d{1,3})/,
|
/[Ss]\d+[Ee](\d{1,3})/,
|
||||||
@@ -133,6 +409,10 @@ export function parseAniSkipGuessitJson(stdout: string, mediaPath: string): AniS
|
|||||||
season,
|
season,
|
||||||
episode: episodeFromDirect ?? episodeFromList,
|
episode: episodeFromDirect ?? episodeFromList,
|
||||||
source: 'guessit',
|
source: 'guessit',
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -171,9 +451,70 @@ export function inferAniSkipMetadataForFile(
|
|||||||
season: detectSeasonFromNameOrDir(mediaPath),
|
season: detectSeasonFromNameOrDir(mediaPath),
|
||||||
episode: detectEpisodeFromName(baseName),
|
episode: detectEpisodeFromName(baseName),
|
||||||
source: 'fallback',
|
source: 'fallback',
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveAniSkipMetadataForFile(mediaPath: string): Promise<AniSkipMetadata> {
|
||||||
|
const inferred = inferAniSkipMetadataForFile(mediaPath);
|
||||||
|
if (!inferred.title) {
|
||||||
|
return { ...inferred, lookupStatus: 'lookup_failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const malId = await resolveMalIdFromTitle(inferred.title, inferred.season);
|
||||||
|
if (!malId) {
|
||||||
|
return {
|
||||||
|
...inferred,
|
||||||
|
malId: null,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'missing_mal_id',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inferred.episode) {
|
||||||
|
return {
|
||||||
|
...inferred,
|
||||||
|
malId,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'missing_episode',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await fetchAniSkipPayload(malId, inferred.episode);
|
||||||
|
if (!payload) {
|
||||||
|
return {
|
||||||
|
...inferred,
|
||||||
|
malId,
|
||||||
|
introStart: null,
|
||||||
|
introEnd: null,
|
||||||
|
lookupStatus: 'missing_payload',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...inferred,
|
||||||
|
malId,
|
||||||
|
introStart: payload.start,
|
||||||
|
introEnd: payload.end,
|
||||||
|
lookupStatus: 'ready',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
...inferred,
|
||||||
|
malId: inferred.malId,
|
||||||
|
introStart: inferred.introStart,
|
||||||
|
introEnd: inferred.introEnd,
|
||||||
|
lookupStatus: 'lookup_failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeScriptOptValue(value: string): string {
|
function sanitizeScriptOptValue(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/,/g, ' ')
|
.replace(/,/g, ' ')
|
||||||
@@ -182,6 +523,30 @@ function sanitizeScriptOptValue(value: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLauncherAniSkipPayload(aniSkipMetadata: AniSkipMetadata): string | null {
|
||||||
|
if (!aniSkipMetadata.malId || !aniSkipMetadata.introStart || !aniSkipMetadata.introEnd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (aniSkipMetadata.introEnd <= aniSkipMetadata.introStart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
found: true,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
skip_type: 'op',
|
||||||
|
interval: {
|
||||||
|
start_time: aniSkipMetadata.introStart,
|
||||||
|
end_time: aniSkipMetadata.introEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// mpv --script-opts treats `%` as an escape prefix, so URL-encoding can break parsing.
|
||||||
|
// Base64url stays script-opts-safe and is decoded by the plugin launcher payload parser.
|
||||||
|
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSubminerScriptOpts(
|
export function buildSubminerScriptOpts(
|
||||||
appPath: string,
|
appPath: string,
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
@@ -200,5 +565,23 @@ export function buildSubminerScriptOpts(
|
|||||||
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
|
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
|
||||||
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
|
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
|
||||||
}
|
}
|
||||||
|
if (aniSkipMetadata && aniSkipMetadata.malId && aniSkipMetadata.malId > 0) {
|
||||||
|
parts.push(`subminer-aniskip_mal_id=${aniSkipMetadata.malId}`);
|
||||||
|
}
|
||||||
|
if (aniSkipMetadata && aniSkipMetadata.introStart !== null && aniSkipMetadata.introStart > 0) {
|
||||||
|
parts.push(`subminer-aniskip_intro_start=${aniSkipMetadata.introStart}`);
|
||||||
|
}
|
||||||
|
if (aniSkipMetadata && aniSkipMetadata.introEnd !== null && aniSkipMetadata.introEnd > 0) {
|
||||||
|
parts.push(`subminer-aniskip_intro_end=${aniSkipMetadata.introEnd}`);
|
||||||
|
}
|
||||||
|
if (aniSkipMetadata?.lookupStatus) {
|
||||||
|
parts.push(
|
||||||
|
`subminer-aniskip_lookup_status=${sanitizeScriptOptValue(aniSkipMetadata.lookupStatus)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const aniskipPayload = aniSkipMetadata ? buildLauncherAniSkipPayload(aniSkipMetadata) : null;
|
||||||
|
if (aniskipPayload) {
|
||||||
|
parts.push(`subminer-aniskip_payload=${sanitizeScriptOptValue(aniskipPayload)}`);
|
||||||
|
}
|
||||||
return parts.join(',');
|
return parts.join(',');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
startMpv(
|
await startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
selectedTarget.kind,
|
selectedTarget.kind,
|
||||||
args,
|
args,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|||||||
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 { log, fail, getMpvLogPath } from './log.js';
|
import { log, fail, getMpvLogPath } from './log.js';
|
||||||
import { buildSubminerScriptOpts, inferAniSkipMetadataForFile } from './aniskip-metadata.js';
|
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||||
import {
|
import {
|
||||||
commandExists,
|
commandExists,
|
||||||
isExecutable,
|
isExecutable,
|
||||||
@@ -419,7 +419,7 @@ export async function loadSubtitleIntoMpv(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startMpv(
|
export async function startMpv(
|
||||||
target: string,
|
target: string,
|
||||||
targetKind: 'file' | 'url',
|
targetKind: 'file' | 'url',
|
||||||
args: Args,
|
args: Args,
|
||||||
@@ -479,7 +479,8 @@ export function startMpv(
|
|||||||
if (options?.startPaused) {
|
if (options?.startPaused) {
|
||||||
mpvArgs.push('--pause=yes');
|
mpvArgs.push('--pause=yes');
|
||||||
}
|
}
|
||||||
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
|
const aniSkipMetadata =
|
||||||
|
targetKind === 'file' ? await resolveAniSkipMetadataForFile(target) : null;
|
||||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
||||||
if (aniSkipMetadata) {
|
if (aniSkipMetadata) {
|
||||||
log(
|
log(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"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",
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
||||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
||||||
@@ -58,7 +58,6 @@
|
|||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/vitepress": "^0.1.2",
|
"@catppuccin/vitepress": "^0.1.2",
|
||||||
"@plausible-analytics/tracker": "^0.4.4",
|
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ aniskip_mal_id=
|
|||||||
# Force episode number (optional). Leave blank for filename/title detection.
|
# Force episode number (optional). Leave blank for filename/title detection.
|
||||||
aniskip_episode=
|
aniskip_episode=
|
||||||
|
|
||||||
|
# Optional pre-fetched AniSkip payload for this media (JSON or base64 JSON). When set, the plugin uses this directly and skips network lookup.
|
||||||
|
aniskip_payload=
|
||||||
|
|
||||||
# Show intro skip OSD button while inside OP range.
|
# Show intro skip OSD button while inside OP range.
|
||||||
aniskip_show_button=yes
|
aniskip_show_button=yes
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ function M.create(ctx)
|
|||||||
local mal_lookup_cache = {}
|
local mal_lookup_cache = {}
|
||||||
local payload_cache = {}
|
local payload_cache = {}
|
||||||
local title_context_cache = {}
|
local title_context_cache = {}
|
||||||
|
local base64_reverse = {}
|
||||||
|
local base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
|
||||||
|
for i = 1, #base64_chars do
|
||||||
|
base64_reverse[base64_chars:sub(i, i)] = i - 1
|
||||||
|
end
|
||||||
|
|
||||||
local function url_encode(text)
|
local function url_encode(text)
|
||||||
if type(text) ~= "string" then
|
if type(text) ~= "string" then
|
||||||
@@ -25,6 +31,109 @@ function M.create(ctx)
|
|||||||
return encoded:gsub(" ", "%%20")
|
return encoded:gsub(" ", "%%20")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function parse_json_payload(text)
|
||||||
|
if type(text) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local parsed, parse_error = utils.parse_json(text)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
return nil, parse_error
|
||||||
|
end
|
||||||
|
|
||||||
|
local function decode_base64(input)
|
||||||
|
if type(input) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local cleaned = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/")
|
||||||
|
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||||
|
if cleaned == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #cleaned % 4 == 1 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #cleaned % 4 ~= 0 then
|
||||||
|
cleaned = cleaned .. string.rep("=", 4 - (#cleaned % 4))
|
||||||
|
end
|
||||||
|
if not cleaned:match("^[A-Za-z0-9+/%=]+$") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
local out_len = 0
|
||||||
|
for index = 1, #cleaned, 4 do
|
||||||
|
local c1 = cleaned:sub(index, index)
|
||||||
|
local c2 = cleaned:sub(index + 1, index + 1)
|
||||||
|
local c3 = cleaned:sub(index + 2, index + 2)
|
||||||
|
local c4 = cleaned:sub(index + 3, index + 3)
|
||||||
|
local v1 = base64_reverse[c1]
|
||||||
|
local v2 = base64_reverse[c2]
|
||||||
|
if not v1 or not v2 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local v3 = c3 == "=" and 0 or base64_reverse[c3]
|
||||||
|
local v4 = c4 == "=" and 0 or base64_reverse[c4]
|
||||||
|
if (c3 ~= "=" and not v3) or (c4 ~= "=" and not v4) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local n = (((v1 * 64 + v2) * 64 + v3) * 64 + v4)
|
||||||
|
local b1 = math.floor(n / 65536)
|
||||||
|
local remaining = n % 65536
|
||||||
|
local b2 = math.floor(remaining / 256)
|
||||||
|
local b3 = remaining % 256
|
||||||
|
out_len = out_len + 1
|
||||||
|
out[out_len] = string.char(b1)
|
||||||
|
if c3 ~= "=" then
|
||||||
|
out_len = out_len + 1
|
||||||
|
out[out_len] = string.char(b2)
|
||||||
|
end
|
||||||
|
if c4 ~= "=" then
|
||||||
|
out_len = out_len + 1
|
||||||
|
out[out_len] = string.char(b3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_launcher_payload()
|
||||||
|
local raw_payload = type(opts.aniskip_payload) == "string" and opts.aniskip_payload or ""
|
||||||
|
local trimmed = raw_payload:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local parsed, parse_error = parse_json_payload(trimmed)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
|
||||||
|
local url_decoded = trimmed:gsub("%%(%x%x)", function(hex)
|
||||||
|
local value = tonumber(hex, 16)
|
||||||
|
if value then
|
||||||
|
return string.char(value)
|
||||||
|
end
|
||||||
|
return "%"
|
||||||
|
end)
|
||||||
|
if url_decoded ~= trimmed then
|
||||||
|
parsed, parse_error = parse_json_payload(url_decoded)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local b64_decoded = decode_base64(trimmed)
|
||||||
|
if type(b64_decoded) == "string" and b64_decoded ~= "" then
|
||||||
|
parsed, parse_error = parse_json_payload(b64_decoded)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subminer_log("warn", "aniskip", "Invalid launcher AniSkip payload: " .. tostring(parse_error or "unparseable"))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local function run_json_curl_async(url, callback)
|
local function run_json_curl_async(url, callback)
|
||||||
mp.command_native_async({
|
mp.command_native_async({
|
||||||
name = "subprocess",
|
name = "subprocess",
|
||||||
@@ -296,6 +405,8 @@ function M.create(ctx)
|
|||||||
state.aniskip.episode = nil
|
state.aniskip.episode = nil
|
||||||
state.aniskip.intro_start = nil
|
state.aniskip.intro_start = nil
|
||||||
state.aniskip.intro_end = nil
|
state.aniskip.intro_end = nil
|
||||||
|
state.aniskip.payload = nil
|
||||||
|
state.aniskip.payload_source = nil
|
||||||
remove_aniskip_chapters()
|
remove_aniskip_chapters()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -366,7 +477,17 @@ function M.create(ctx)
|
|||||||
state.aniskip.intro_end = intro_end
|
state.aniskip.intro_end = intro_end
|
||||||
state.aniskip.prompt_shown = false
|
state.aniskip.prompt_shown = false
|
||||||
set_intro_chapters(intro_start, intro_end)
|
set_intro_chapters(intro_start, intro_end)
|
||||||
subminer_log("info", "aniskip", string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode))
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
"Intro window %.3f -> %.3f (MAL %s, ep %s)",
|
||||||
|
intro_start,
|
||||||
|
intro_end,
|
||||||
|
tostring(mal_id or "-"),
|
||||||
|
tostring(episode or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -374,6 +495,10 @@ function M.create(ctx)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function has_launcher_payload()
|
||||||
|
return type(opts.aniskip_payload) == "string" and opts.aniskip_payload:match("%S") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
local function is_launcher_context()
|
local function is_launcher_context()
|
||||||
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
if forced_title ~= "" then
|
if forced_title ~= "" then
|
||||||
@@ -391,6 +516,9 @@ function M.create(ctx)
|
|||||||
if forced_season and forced_season > 0 then
|
if forced_season and forced_season > 0 then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
if has_launcher_payload() then
|
||||||
|
return true
|
||||||
|
end
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -500,6 +628,18 @@ function M.create(ctx)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fetch_payload_from_launcher(payload, mal_id, title, episode)
|
||||||
|
if not payload then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
state.aniskip.payload = payload
|
||||||
|
state.aniskip.payload_source = "launcher"
|
||||||
|
state.aniskip.mal_id = mal_id
|
||||||
|
state.aniskip.title = title
|
||||||
|
state.aniskip.episode = episode
|
||||||
|
return apply_aniskip_payload(mal_id, title, episode, payload)
|
||||||
|
end
|
||||||
|
|
||||||
local function fetch_aniskip_for_current_media(trigger_source)
|
local function fetch_aniskip_for_current_media(trigger_source)
|
||||||
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
||||||
if not opts.aniskip_enabled then
|
if not opts.aniskip_enabled then
|
||||||
@@ -518,6 +658,28 @@ function M.create(ctx)
|
|||||||
reset_aniskip_fields()
|
reset_aniskip_fields()
|
||||||
local title, episode, season = resolve_title_and_episode()
|
local title, episode, season = resolve_title_and_episode()
|
||||||
local lookup_titles = resolve_lookup_titles(title)
|
local lookup_titles = resolve_lookup_titles(title)
|
||||||
|
local launcher_payload = resolve_launcher_payload()
|
||||||
|
if launcher_payload then
|
||||||
|
local launcher_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if not launcher_mal_id then
|
||||||
|
launcher_mal_id = nil
|
||||||
|
end
|
||||||
|
if fetch_payload_from_launcher(launcher_payload, launcher_mal_id, title, episode) then
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
"Using launcher-provided AniSkip payload (title=%s, season=%s, episode=%s)",
|
||||||
|
tostring(title or ""),
|
||||||
|
tostring(season or "-"),
|
||||||
|
tostring(episode or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
subminer_log("info", "aniskip", "Launcher payload present but no OP interval was available")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
subminer_log(
|
subminer_log(
|
||||||
"info",
|
"info",
|
||||||
@@ -558,6 +720,8 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
state.aniskip.payload = payload
|
||||||
|
state.aniskip.payload_source = "remote"
|
||||||
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
||||||
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function M.load(options_lib, default_socket_path)
|
|||||||
aniskip_season = "",
|
aniskip_season = "",
|
||||||
aniskip_mal_id = "",
|
aniskip_mal_id = "",
|
||||||
aniskip_episode = "",
|
aniskip_episode = "",
|
||||||
|
aniskip_payload = "",
|
||||||
aniskip_show_button = true,
|
aniskip_show_button = true,
|
||||||
aniskip_button_text = "You can skip by pressing %s",
|
aniskip_button_text = "You can skip by pressing %s",
|
||||||
aniskip_button_key = "y-k",
|
aniskip_button_key = "y-k",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function M.create(ctx)
|
|||||||
local subminer_log = ctx.log.subminer_log
|
local subminer_log = ctx.log.subminer_log
|
||||||
local show_osd = ctx.log.show_osd
|
local show_osd = ctx.log.show_osd
|
||||||
local normalize_log_level = ctx.log.normalize_log_level
|
local normalize_log_level = ctx.log.normalize_log_level
|
||||||
|
local run_control_command_async
|
||||||
|
|
||||||
local function resolve_visible_overlay_startup()
|
local function resolve_visible_overlay_startup()
|
||||||
local raw_visible_overlay = opts.auto_start_visible_overlay
|
local raw_visible_overlay = opts.auto_start_visible_overlay
|
||||||
@@ -132,6 +133,11 @@ function M.create(ctx)
|
|||||||
|
|
||||||
local function notify_auto_play_ready()
|
local function notify_auto_play_ready()
|
||||||
release_auto_play_ready_gate("tokenization-ready")
|
release_auto_play_ready_gate("tokenization-ready")
|
||||||
|
if state.overlay_running and resolve_visible_overlay_startup() then
|
||||||
|
run_control_command_async("show-visible-overlay", {
|
||||||
|
socket_path = opts.socket_path,
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function build_command_args(action, overrides)
|
local function build_command_args(action, overrides)
|
||||||
@@ -156,22 +162,18 @@ function M.create(ctx)
|
|||||||
table.insert(args, "--socket")
|
table.insert(args, "--socket")
|
||||||
table.insert(args, socket_path)
|
table.insert(args, socket_path)
|
||||||
|
|
||||||
-- Keep auto-start --start requests idempotent for second-instance handling.
|
local should_show_visible = resolve_visible_overlay_startup()
|
||||||
-- Visibility is applied as a separate control command after startup.
|
if should_show_visible then
|
||||||
if overrides.auto_start_trigger ~= true then
|
table.insert(args, "--show-visible-overlay")
|
||||||
local should_show_visible = resolve_visible_overlay_startup()
|
else
|
||||||
if should_show_visible then
|
table.insert(args, "--hide-visible-overlay")
|
||||||
table.insert(args, "--show-visible-overlay")
|
|
||||||
else
|
|
||||||
table.insert(args, "--hide-visible-overlay")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return args
|
return args
|
||||||
end
|
end
|
||||||
|
|
||||||
local function run_control_command_async(action, overrides, callback)
|
run_control_command_async = function(action, overrides, callback)
|
||||||
local args = build_command_args(action, overrides)
|
local args = build_command_args(action, overrides)
|
||||||
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||||
mp.command_native_async({
|
mp.command_native_async({
|
||||||
@@ -290,6 +292,7 @@ function M.create(ctx)
|
|||||||
and "show-visible-overlay"
|
and "show-visible-overlay"
|
||||||
or "hide-visible-overlay"
|
or "hide-visible-overlay"
|
||||||
run_control_command_async(visibility_action, {
|
run_control_command_async(visibility_action, {
|
||||||
|
socket_path = socket_path,
|
||||||
log_level = overrides.log_level,
|
log_level = overrides.log_level,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -360,9 +363,10 @@ function M.create(ctx)
|
|||||||
local visibility_action = resolve_visible_overlay_startup()
|
local visibility_action = resolve_visible_overlay_startup()
|
||||||
and "show-visible-overlay"
|
and "show-visible-overlay"
|
||||||
or "hide-visible-overlay"
|
or "hide-visible-overlay"
|
||||||
run_control_command_async(visibility_action, {
|
run_control_command_async(visibility_action, {
|
||||||
log_level = overrides.log_level,
|
socket_path = socket_path,
|
||||||
})
|
log_level = overrides.log_level,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function M.new()
|
|||||||
episode = nil,
|
episode = nil,
|
||||||
intro_start = nil,
|
intro_start = nil,
|
||||||
intro_end = nil,
|
intro_end = nil,
|
||||||
|
payload = nil,
|
||||||
|
payload_source = nil,
|
||||||
found = false,
|
found = false,
|
||||||
prompt_shown = false,
|
prompt_shown = false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -507,12 +507,12 @@ do
|
|||||||
local start_call = find_start_call(recorded.async_calls)
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||||
assert_true(
|
assert_true(
|
||||||
not call_has_arg(start_call, "--show-visible-overlay"),
|
call_has_arg(start_call, "--show-visible-overlay"),
|
||||||
"auto-start should keep --start command free of --show-visible-overlay"
|
"auto-start with visible overlay enabled should include --show-visible-overlay on --start"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
not call_has_arg(start_call, "--hide-visible-overlay"),
|
not call_has_arg(start_call, "--hide-visible-overlay"),
|
||||||
"auto-start should keep --start command free of --hide-visible-overlay"
|
"auto-start with visible overlay enabled should not include --hide-visible-overlay on --start"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
|
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
|
||||||
@@ -583,8 +583,8 @@ do
|
|||||||
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
|
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 4,
|
||||||
"duplicate pause-until-ready auto-start should still re-assert visible overlay state"
|
"duplicate pause-until-ready auto-start should re-assert visible overlay on both start and ready events"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 2,
|
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 2,
|
||||||
@@ -644,6 +644,10 @@ do
|
|||||||
has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"),
|
has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"),
|
||||||
"autoplay-ready should show loaded OSD message"
|
"autoplay-ready should show loaded OSD message"
|
||||||
)
|
)
|
||||||
|
assert_true(
|
||||||
|
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||||
|
"autoplay-ready should re-assert visible overlay state"
|
||||||
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
#recorded.periodic_timers == 1,
|
#recorded.periodic_timers == 1,
|
||||||
"pause-until-ready auto-start should create periodic loading OSD refresher"
|
"pause-until-ready auto-start should create periodic loading OSD refresher"
|
||||||
@@ -703,12 +707,12 @@ do
|
|||||||
local start_call = find_start_call(recorded.async_calls)
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||||
assert_true(
|
assert_true(
|
||||||
not call_has_arg(start_call, "--hide-visible-overlay"),
|
call_has_arg(start_call, "--hide-visible-overlay"),
|
||||||
"auto-start should keep --start command free of --hide-visible-overlay"
|
"auto-start with visible overlay disabled should include --hide-visible-overlay on --start"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
not call_has_arg(start_call, "--show-visible-overlay"),
|
not call_has_arg(start_call, "--show-visible-overlay"),
|
||||||
"auto-start should keep --start command free of --show-visible-overlay"
|
"auto-start with visible overlay disabled should not include --show-visible-overlay on --start"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
find_control_call(recorded.async_calls, "--hide-visible-overlay") ~= nil,
|
find_control_call(recorded.async_calls, "--hide-visible-overlay") ~= nil,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
||||||
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
||||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
||||||
|
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
|
||||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -160,6 +161,44 @@ test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid values', () => {
|
||||||
|
const validDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(validDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"autoPauseVideoOnYomitanPopup": true
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const validService = new ConfigService(validDir);
|
||||||
|
assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"autoPauseVideoOnYomitanPopup": "yes"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(
|
||||||
|
invalidService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup,
|
||||||
|
DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnYomitanPopup,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
invalidService
|
||||||
|
.getWarnings()
|
||||||
|
.some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
||||||
const validDir = makeTempDir();
|
const validDir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
alass_path: '',
|
alass_path: '',
|
||||||
ffsubsync_path: '',
|
ffsubsync_path: '',
|
||||||
ffmpeg_path: '',
|
ffmpeg_path: '',
|
||||||
|
replace: true,
|
||||||
},
|
},
|
||||||
startupWarmups: {
|
startupWarmups: {
|
||||||
lowPowerMode: false,
|
lowPowerMode: false,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
|||||||
enableJlpt: false,
|
enableJlpt: false,
|
||||||
preserveLineBreaks: false,
|
preserveLineBreaks: false,
|
||||||
autoPauseVideoOnHover: true,
|
autoPauseVideoOnHover: true,
|
||||||
|
autoPauseVideoOnYomitanPopup: false,
|
||||||
hoverTokenColor: '#f4dbd6',
|
hoverTokenColor: '#f4dbd6',
|
||||||
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'logging.level',
|
'logging.level',
|
||||||
'startupWarmups.lowPowerMode',
|
'startupWarmups.lowPowerMode',
|
||||||
'subtitleStyle.enableJlpt',
|
'subtitleStyle.enableJlpt',
|
||||||
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
'immersionTracking.enabled',
|
'immersionTracking.enabled',
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.subsync.defaultMode,
|
defaultValue: defaultConfig.subsync.defaultMode,
|
||||||
description: 'Subsync default mode.',
|
description: 'Subsync default mode.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subsync.replace',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subsync.replace,
|
||||||
|
description: 'Replace the active subtitle file when sync completes.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'startupWarmups.lowPowerMode',
|
path: 'startupWarmups.lowPowerMode',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Automatically pause mpv playback while hovering subtitle text, then resume on leave.',
|
'Automatically pause mpv playback while hovering subtitle text, then resume on leave.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnYomitanPopup,
|
||||||
|
description:
|
||||||
|
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'subtitleStyle.hoverTokenColor',
|
path: 'subtitleStyle.hoverTokenColor',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
||||||
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
||||||
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
|
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
|
||||||
|
const replace = asBoolean(src.subsync.replace);
|
||||||
|
if (replace !== undefined) {
|
||||||
|
resolved.subsync.replace = replace;
|
||||||
|
} else if (src.subsync.replace !== undefined) {
|
||||||
|
warn('subsync.replace', src.subsync.replace, resolved.subsync.replace, 'Expected boolean.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isObject(src.subtitlePosition)) {
|
if (isObject(src.subtitlePosition)) {
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
||||||
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
|
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
|
||||||
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||||
|
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
|
||||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||||
@@ -171,6 +173,27 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoPauseVideoOnYomitanPopup = asBoolean(
|
||||||
|
(src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown })
|
||||||
|
.autoPauseVideoOnYomitanPopup,
|
||||||
|
);
|
||||||
|
if (autoPauseVideoOnYomitanPopup !== undefined) {
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup = autoPauseVideoOnYomitanPopup;
|
||||||
|
} else if (
|
||||||
|
(src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown })
|
||||||
|
.autoPauseVideoOnYomitanPopup !== undefined
|
||||||
|
) {
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup =
|
||||||
|
fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
|
(src.subtitleStyle as { autoPauseVideoOnYomitanPopup?: unknown })
|
||||||
|
.autoPauseVideoOnYomitanPopup,
|
||||||
|
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const hoverTokenColor = asColor(
|
const hoverTokenColor = asColor(
|
||||||
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
|
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,6 +47,25 @@ test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', () => {
|
||||||
|
const { context, warnings } = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
autoPauseVideoOnYomitanPopup: 'invalid' as unknown as boolean,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applySubtitleDomainConfig(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
|
||||||
|
assert.ok(
|
||||||
|
warnings.some(
|
||||||
|
(warning) =>
|
||||||
|
warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' &&
|
||||||
|
warning.message === 'Expected boolean.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
||||||
const valid = createResolveContext({
|
const valid = createResolveContext({
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BrowserWindow } from 'electron';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { WindowGeometry } from '../../types';
|
import { WindowGeometry } from '../../types';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
|
||||||
const logger = createLogger('main:overlay-window');
|
const logger = createLogger('main:overlay-window');
|
||||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||||
@@ -24,6 +25,24 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
|
|||||||
|
|
||||||
export type OverlayWindowKind = 'visible' | 'modal';
|
export type OverlayWindowKind = 'visible' | 'modal';
|
||||||
|
|
||||||
|
function isLookupWindowToggleInput(input: Electron.Input): boolean {
|
||||||
|
if (input.type !== 'keyDown') return false;
|
||||||
|
if (input.alt) return false;
|
||||||
|
if (!input.control && !input.meta) return false;
|
||||||
|
if (input.shift) return false;
|
||||||
|
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
||||||
|
return input.code === 'KeyY' || normalizedKey === 'y';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeyboardModeToggleInput(input: Electron.Input): boolean {
|
||||||
|
if (input.type !== 'keyDown') return false;
|
||||||
|
if (input.alt) return false;
|
||||||
|
if (!input.control && !input.meta) return false;
|
||||||
|
if (!input.shift) return false;
|
||||||
|
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
||||||
|
return input.code === 'KeyY' || normalizedKey === 'y';
|
||||||
|
}
|
||||||
|
|
||||||
export function updateOverlayWindowBounds(
|
export function updateOverlayWindowBounds(
|
||||||
geometry: WindowGeometry,
|
geometry: WindowGeometry,
|
||||||
window: BrowserWindow | null,
|
window: BrowserWindow | null,
|
||||||
@@ -118,6 +137,16 @@ export function createOverlayWindow(
|
|||||||
window.webContents.on('before-input-event', (event, input) => {
|
window.webContents.on('before-input-event', (event, input) => {
|
||||||
if (kind === 'modal') return;
|
if (kind === 'modal') return;
|
||||||
if (!window.isVisible()) return;
|
if (!window.isVisible()) return;
|
||||||
|
if (isKeyboardModeToggleInput(input)) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.webContents.send(IPC_CHANNELS.event.keyboardModeToggleRequested);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isLookupWindowToggleInput(input)) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.webContents.send(IPC_CHANNELS.event.lookupWindowToggleRequested);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
|
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -209,10 +209,73 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
|||||||
assert.ok(ffArgs.includes(primaryPath));
|
assert.ok(ffArgs.includes(primaryPath));
|
||||||
assert.ok(ffArgs.includes('--reference-stream'));
|
assert.ok(ffArgs.includes('--reference-stream'));
|
||||||
assert.ok(ffArgs.includes('0:2'));
|
assert.ok(ffArgs.includes('0:2'));
|
||||||
|
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
||||||
|
assert.equal(ffOutputFlagIndex >= 0, true);
|
||||||
|
assert.equal(ffArgs[ffOutputFlagIndex + 1], primaryPath);
|
||||||
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
||||||
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual writes deterministic _retimed filename when replace is false', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-no-replace-'));
|
||||||
|
const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log');
|
||||||
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||||
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||||
|
const primaryPath = path.join(tmpDir, 'episode.ja.srt');
|
||||||
|
|
||||||
|
fs.writeFileSync(videoPath, 'video');
|
||||||
|
fs.writeFileSync(primaryPath, 'sub');
|
||||||
|
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(
|
||||||
|
ffsubsyncPath,
|
||||||
|
`#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: () => {},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return videoPath;
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return null;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
'external-filename': primaryPath,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
defaultMode: 'manual',
|
||||||
|
alassPath,
|
||||||
|
ffsubsyncPath,
|
||||||
|
ffmpegPath,
|
||||||
|
replace: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
|
||||||
|
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
||||||
|
assert.equal(ffOutputFlagIndex >= 0, true);
|
||||||
|
const outputPath = ffArgs[ffOutputFlagIndex + 1];
|
||||||
|
assert.equal(outputPath, path.join(tmpDir, 'episode.ja_retimed.srt'));
|
||||||
|
});
|
||||||
|
|
||||||
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
|
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
|
||||||
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
||||||
@@ -281,6 +344,76 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
|||||||
assert.equal(alassArgs[1], primaryPath);
|
assert.equal(alassArgs[1], primaryPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
|
||||||
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||||
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||||
|
const primaryPath = path.join(tmpDir, 'primary.srt');
|
||||||
|
|
||||||
|
fs.writeFileSync(videoPath, 'video');
|
||||||
|
fs.writeFileSync(primaryPath, 'sub');
|
||||||
|
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(
|
||||||
|
ffmpegPath,
|
||||||
|
'#!/bin/sh\nout=""\nfor arg in "$@"; do out="$arg"; done\nif [ -n "$out" ]; then : > "$out"; fi\nexit 0\n',
|
||||||
|
);
|
||||||
|
writeExecutableScript(
|
||||||
|
alassPath,
|
||||||
|
'#!/bin/sh\nsleep 0.2\nif [ ! -f "$1" ]; then echo "missing reference subtitle" >&2; exit 1; fi\nif [ ! -f "$2" ]; then echo "missing input subtitle" >&2; exit 1; fi\n: > "$3"\nexit 0\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const sentCommands: Array<Array<string | number>> = [];
|
||||||
|
const deps = makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: (payload) => {
|
||||||
|
sentCommands.push(payload.command);
|
||||||
|
},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return videoPath;
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return null;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
'external-filename': primaryPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'sub',
|
||||||
|
selected: false,
|
||||||
|
external: false,
|
||||||
|
'ff-index': 2,
|
||||||
|
codec: 'ass',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
defaultMode: 'manual',
|
||||||
|
alassPath,
|
||||||
|
ffsubsyncPath,
|
||||||
|
ffmpegPath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.message, 'Subtitle synchronized with alass');
|
||||||
|
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
||||||
|
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||||
|
});
|
||||||
|
|
||||||
test('runSubsyncManual resolves string sid values from mpv stream properties', async () => {
|
test('runSubsyncManual resolves string sid values from mpv stream properties', async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
|
||||||
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
|||||||
@@ -215,10 +215,10 @@ function cleanupTemporaryFile(extraction: FileExtractionResult): void {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRetimedPath(subPath: string): string {
|
function buildRetimedPath(subPath: string, replace: boolean): string {
|
||||||
|
if (replace) return subPath;
|
||||||
const parsed = path.parse(subPath);
|
const parsed = path.parse(subPath);
|
||||||
const suffix = `_retimed_${Date.now()}`;
|
return path.join(parsed.dir, `${parsed.name}_retimed${parsed.ext || '.srt'}`);
|
||||||
return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAlassSync(
|
async function runAlassSync(
|
||||||
@@ -265,7 +265,8 @@ async function subsyncToReference(
|
|||||||
context.videoPath,
|
context.videoPath,
|
||||||
context.primaryTrack,
|
context.primaryTrack,
|
||||||
);
|
);
|
||||||
const outputPath = buildRetimedPath(primaryExtraction.path);
|
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
|
||||||
|
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: CommandResult;
|
let result: CommandResult;
|
||||||
@@ -389,7 +390,7 @@ export async function runSubsyncManual(
|
|||||||
let sourceExtraction: FileExtractionResult | null = null;
|
let sourceExtraction: FileExtractionResult | null = null;
|
||||||
try {
|
try {
|
||||||
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
||||||
return subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
||||||
} finally {
|
} finally {
|
||||||
if (sourceExtraction) {
|
if (sourceExtraction) {
|
||||||
cleanupTemporaryFile(sourceExtraction);
|
cleanupTemporaryFile(sourceExtraction);
|
||||||
|
|||||||
@@ -972,6 +972,34 @@ test('tokenizeSubtitle skips frequency rank when Yomitan token is enriched as pa
|
|||||||
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tokenizeSubtitle keeps frequency rank when mecab tags classify token as content-bearing', async () => {
|
||||||
|
const result = await tokenizeSubtitle(
|
||||||
|
'ふふ',
|
||||||
|
makeDepsFromYomitanTokens([{ surface: 'ふふ', reading: '', headword: 'ふふ' }], {
|
||||||
|
getFrequencyDictionaryEnabled: () => true,
|
||||||
|
getFrequencyRank: (text) => (text === 'ふふ' ? 3014 : null),
|
||||||
|
tokenizeWithMecab: async () => [
|
||||||
|
{
|
||||||
|
headword: 'ふふ',
|
||||||
|
surface: 'ふふ',
|
||||||
|
reading: 'フフ',
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 2,
|
||||||
|
partOfSpeech: PartOfSpeech.verb,
|
||||||
|
pos1: '動詞',
|
||||||
|
pos2: '自立',
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.tokens?.length, 1);
|
||||||
|
assert.equal(result.tokens?.[0]?.frequencyRank, 3014);
|
||||||
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle ignores invalid frequency ranks', async () => {
|
test('tokenizeSubtitle ignores invalid frequency ranks', async () => {
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
'猫',
|
'猫',
|
||||||
@@ -1143,6 +1171,106 @@ test('tokenizeSubtitle returns null tokens when Yomitan parsing is unavailable',
|
|||||||
assert.deepEqual(result, { text: '猫です', tokens: null });
|
assert.deepEqual(result, { text: '猫です', tokens: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tokenizeSubtitle skips token payload and annotations when Yomitan parse has no dictionary matches', async () => {
|
||||||
|
let frequencyRequested = false;
|
||||||
|
let jlptLookupCalls = 0;
|
||||||
|
let mecabCalls = 0;
|
||||||
|
|
||||||
|
const result = await tokenizeSubtitle(
|
||||||
|
'これはテスト',
|
||||||
|
makeDeps({
|
||||||
|
getFrequencyDictionaryEnabled: () => true,
|
||||||
|
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||||
|
getYomitanParserWindow: () =>
|
||||||
|
({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: {
|
||||||
|
executeJavaScript: async (script: string) => {
|
||||||
|
if (script.includes('getTermFrequencies')) {
|
||||||
|
frequencyRequested = true;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: 'scanning-parser',
|
||||||
|
index: 0,
|
||||||
|
content: [
|
||||||
|
[{ text: 'これは', reading: 'これは' }],
|
||||||
|
[{ text: 'テスト', reading: 'てすと' }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as unknown as Electron.BrowserWindow,
|
||||||
|
tokenizeWithMecab: async () => {
|
||||||
|
mecabCalls += 1;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getJlptLevel: () => {
|
||||||
|
jlptLookupCalls += 1;
|
||||||
|
return 'N5';
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, { text: 'これはテスト', tokens: null });
|
||||||
|
assert.equal(frequencyRequested, false);
|
||||||
|
assert.equal(jlptLookupCalls, 0);
|
||||||
|
assert.equal(mecabCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tokenizeSubtitle excludes Yomitan token groups without dictionary headwords from annotation paths', async () => {
|
||||||
|
let jlptLookupCalls = 0;
|
||||||
|
let frequencyLookupCalls = 0;
|
||||||
|
|
||||||
|
const result = await tokenizeSubtitle(
|
||||||
|
'(ダクネスの荒い息) 猫',
|
||||||
|
makeDeps({
|
||||||
|
getFrequencyDictionaryEnabled: () => true,
|
||||||
|
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
|
||||||
|
getYomitanParserWindow: () =>
|
||||||
|
({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: {
|
||||||
|
executeJavaScript: async (script: string) => {
|
||||||
|
if (script.includes('getTermFrequencies')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: 'scanning-parser',
|
||||||
|
index: 0,
|
||||||
|
content: [
|
||||||
|
[{ text: '(ダクネスの荒い息)', reading: 'だくねすのあらいいき' }],
|
||||||
|
[{ text: '猫', reading: 'ねこ', headwords: [[{ term: '猫' }]] }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as unknown as Electron.BrowserWindow,
|
||||||
|
getJlptLevel: (text) => {
|
||||||
|
jlptLookupCalls += 1;
|
||||||
|
return text === '猫' ? 'N5' : null;
|
||||||
|
},
|
||||||
|
getFrequencyRank: () => {
|
||||||
|
frequencyLookupCalls += 1;
|
||||||
|
return 12;
|
||||||
|
},
|
||||||
|
tokenizeWithMecab: async () => null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.tokens?.length, 1);
|
||||||
|
assert.equal(result.tokens?.[0]?.surface, '猫');
|
||||||
|
assert.equal(result.tokens?.[0]?.headword, '猫');
|
||||||
|
assert.equal(jlptLookupCalls, 1);
|
||||||
|
assert.equal(frequencyLookupCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle returns null tokens when mecab throws', async () => {
|
test('tokenizeSubtitle returns null tokens when mecab throws', async () => {
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
'猫です',
|
'猫です',
|
||||||
@@ -1156,7 +1284,7 @@ test('tokenizeSubtitle returns null tokens when mecab throws', async () => {
|
|||||||
assert.deepEqual(result, { text: '猫です', tokens: null });
|
assert.deepEqual(result, { text: '猫です', tokens: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle uses Yomitan parser result when available', async () => {
|
test('tokenizeSubtitle uses Yomitan parser result when available and drops no-headword groups', async () => {
|
||||||
const parserWindow = {
|
const parserWindow = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
webContents: {
|
webContents: {
|
||||||
@@ -1194,13 +1322,10 @@ test('tokenizeSubtitle uses Yomitan parser result when available', async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result.text, '猫です');
|
assert.equal(result.text, '猫です');
|
||||||
assert.equal(result.tokens?.length, 2);
|
assert.equal(result.tokens?.length, 1);
|
||||||
assert.equal(result.tokens?.[0]?.surface, '猫');
|
assert.equal(result.tokens?.[0]?.surface, '猫');
|
||||||
assert.equal(result.tokens?.[0]?.reading, 'ねこ');
|
assert.equal(result.tokens?.[0]?.reading, 'ねこ');
|
||||||
assert.equal(result.tokens?.[0]?.isKnown, false);
|
assert.equal(result.tokens?.[0]?.isKnown, false);
|
||||||
assert.equal(result.tokens?.[1]?.surface, 'です');
|
|
||||||
assert.equal(result.tokens?.[1]?.reading, 'です');
|
|
||||||
assert.equal(result.tokens?.[1]?.isKnown, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => {
|
test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => {
|
||||||
@@ -2400,7 +2525,7 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque
|
|||||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
|
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle keeps merged token when overlap contains at least one content pos1 tag', async () => {
|
test('tokenizeSubtitle excludes merged function/content token from frequency highlighting but keeps N+1', async () => {
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
'になれば',
|
'になれば',
|
||||||
makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], {
|
makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], {
|
||||||
@@ -2453,7 +2578,7 @@ test('tokenizeSubtitle keeps merged token when overlap contains at least one con
|
|||||||
|
|
||||||
assert.equal(result.tokens?.length, 1);
|
assert.equal(result.tokens?.length, 1);
|
||||||
assert.equal(result.tokens?.[0]?.pos1, '助詞|動詞');
|
assert.equal(result.tokens?.[0]?.pos1, '助詞|動詞');
|
||||||
assert.equal(result.tokens?.[0]?.frequencyRank, 13);
|
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
||||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, true);
|
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -314,6 +314,26 @@ test('annotateTokens excludes likely kana SFX tokens from frequency when POS tag
|
|||||||
assert.equal(result[0]?.frequencyRank, undefined);
|
assert.equal(result[0]?.frequencyRank, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('annotateTokens keeps frequency when mecab tags classify token as content-bearing', () => {
|
||||||
|
const tokens = [
|
||||||
|
makeToken({
|
||||||
|
surface: 'ふふ',
|
||||||
|
headword: 'ふふ',
|
||||||
|
pos1: '動詞',
|
||||||
|
pos2: '自立',
|
||||||
|
frequencyRank: 3014,
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 2,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = annotateTokens(tokens, makeDeps(), {
|
||||||
|
minSentenceWordsForNPlusOne: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result[0]?.frequencyRank, 3014);
|
||||||
|
});
|
||||||
|
|
||||||
test('annotateTokens allows previously default-excluded pos2 when removed from effective set', () => {
|
test('annotateTokens allows previously default-excluded pos2 when removed from effective set', () => {
|
||||||
const tokens = [
|
const tokens = [
|
||||||
makeToken({
|
makeToken({
|
||||||
@@ -337,7 +357,7 @@ test('annotateTokens allows previously default-excluded pos2 when removed from e
|
|||||||
assert.equal(result[0]?.isNPlusOneTarget, true);
|
assert.equal(result[0]?.isNPlusOneTarget, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('annotateTokens keeps composite tokens when any component pos tag is content-bearing', () => {
|
test('annotateTokens excludes composite function/content tokens from frequency but keeps N+1 eligible', () => {
|
||||||
const tokens = [
|
const tokens = [
|
||||||
makeToken({
|
makeToken({
|
||||||
surface: 'になれば',
|
surface: 'になれば',
|
||||||
@@ -354,7 +374,7 @@ test('annotateTokens keeps composite tokens when any component pos tag is conten
|
|||||||
minSentenceWordsForNPlusOne: 1,
|
minSentenceWordsForNPlusOne: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result[0]?.frequencyRank, 5);
|
assert.equal(result[0]?.frequencyRank, undefined);
|
||||||
assert.equal(result[0]?.isNPlusOneTarget, true);
|
assert.equal(result[0]?.isNPlusOneTarget, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
|
|||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Composite tags like "助詞|名詞" stay eligible unless every component is excluded.
|
// Frequency highlighting should be conservative: if any merged component is excluded,
|
||||||
return parts.every((part) => exclusions.has(part));
|
// skip highlighting the whole token to avoid noisy merged fragments.
|
||||||
|
return parts.some((part) => exclusions.has(part));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
|
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
|
||||||
|
|||||||
@@ -39,6 +39,30 @@ test('enrichTokensWithMecabPos1 fills missing pos1 using surface-sequence fallba
|
|||||||
assert.equal(enriched[0]?.pos1, '助詞');
|
assert.equal(enriched[0]?.pos1, '助詞');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('enrichTokensWithMecabPos1 keeps partOfSpeech unchanged and only enriches POS tags', () => {
|
||||||
|
const tokens = [makeToken({ surface: 'これは', startPos: 0, endPos: 3 })];
|
||||||
|
const mecabTokens = [
|
||||||
|
makeToken({
|
||||||
|
surface: 'これ',
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 2,
|
||||||
|
pos1: '名詞',
|
||||||
|
partOfSpeech: PartOfSpeech.noun,
|
||||||
|
}),
|
||||||
|
makeToken({
|
||||||
|
surface: 'は',
|
||||||
|
startPos: 2,
|
||||||
|
endPos: 3,
|
||||||
|
pos1: '助詞',
|
||||||
|
partOfSpeech: PartOfSpeech.particle,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
|
||||||
|
assert.equal(enriched[0]?.pos1, '名詞|助詞');
|
||||||
|
assert.equal(enriched[0]?.partOfSpeech, PartOfSpeech.other);
|
||||||
|
});
|
||||||
|
|
||||||
test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are null or empty', () => {
|
test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are null or empty', () => {
|
||||||
const tokens = [makeToken({ surface: '猫', startPos: 0, endPos: 1 })];
|
const tokens = [makeToken({ surface: '猫', startPos: 0, endPos: 1 })];
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ test('prefers scanning parser when scanning candidate has more than one token',
|
|||||||
test('keeps scanning parser candidate when scanning candidate is single token', () => {
|
test('keeps scanning parser candidate when scanning candidate is single token', () => {
|
||||||
const parseResults = [
|
const parseResults = [
|
||||||
makeParseItem('scanning-parser', [
|
makeParseItem('scanning-parser', [
|
||||||
[{ text: '俺は公園にいきたい', reading: 'おれはこうえんにいきたい' }],
|
[{ text: '俺は公園にいきたい', reading: 'おれはこうえんにいきたい', headword: '行きたい' }],
|
||||||
]),
|
]),
|
||||||
makeParseItem('mecab', [
|
makeParseItem('mecab', [
|
||||||
[{ text: '俺', reading: 'おれ', headword: '俺' }],
|
[{ text: '俺', reading: 'おれ', headword: '俺' }],
|
||||||
@@ -96,3 +96,34 @@ test('returns null when only mecab-source candidates are present', () => {
|
|||||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||||
assert.equal(tokens, null);
|
assert.equal(tokens, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns null when scanning parser candidates have no dictionary headwords', () => {
|
||||||
|
const parseResults = [
|
||||||
|
makeParseItem('scanning-parser', [
|
||||||
|
[{ text: 'これは', reading: 'これは' }],
|
||||||
|
[{ text: 'テスト', reading: 'てすと' }],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||||
|
assert.equal(tokens, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops scanning parser tokens which have no dictionary headword', () => {
|
||||||
|
const parseResults = [
|
||||||
|
makeParseItem('scanning-parser', [
|
||||||
|
[{ text: '(ダクネスの荒い息)', reading: 'だくねすのあらいいき' }],
|
||||||
|
[{ text: 'アクア', reading: 'あくあ', headword: 'アクア' }],
|
||||||
|
[{ text: 'トラウマ', reading: 'とらうま', headword: 'トラウマ' }],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||||
|
assert.deepEqual(
|
||||||
|
tokens?.map((token) => ({ surface: token.surface, headword: token.headword })),
|
||||||
|
[
|
||||||
|
{ surface: 'アクア', headword: 'アクア' },
|
||||||
|
{ surface: 'トラウマ', headword: 'トラウマ' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export function mapYomitanParseResultItemToMergedTokens(
|
|||||||
const tokens: MergedToken[] = [];
|
const tokens: MergedToken[] = [];
|
||||||
let charOffset = 0;
|
let charOffset = 0;
|
||||||
let validLineCount = 0;
|
let validLineCount = 0;
|
||||||
|
let hasDictionaryMatch = false;
|
||||||
|
|
||||||
for (const line of content) {
|
for (const line of content) {
|
||||||
if (!isYomitanParseLine(line)) {
|
if (!isYomitanParseLine(line)) {
|
||||||
@@ -163,7 +164,13 @@ export function mapYomitanParseResultItemToMergedTokens(
|
|||||||
const start = charOffset;
|
const start = charOffset;
|
||||||
const end = start + combinedSurface.length;
|
const end = start + combinedSurface.length;
|
||||||
charOffset = end;
|
charOffset = end;
|
||||||
const headword = combinedHeadword || combinedSurface;
|
if (!combinedHeadword) {
|
||||||
|
// No dictionary-backed headword for this merged unit; skip it entirely so
|
||||||
|
// downstream keyboard/frequency/JLPT flows only operate on lookup-backed tokens.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hasDictionaryMatch = true;
|
||||||
|
const headword = combinedHeadword;
|
||||||
|
|
||||||
tokens.push({
|
tokens.push({
|
||||||
surface: combinedSurface,
|
surface: combinedSurface,
|
||||||
@@ -182,7 +189,7 @@ export function mapYomitanParseResultItemToMergedTokens(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validLineCount === 0 || tokens.length === 0) {
|
if (validLineCount === 0 || tokens.length === 0 || !hasDictionaryMatch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ test('syncYomitanDefaultAnkiServer updates default profile server when script re
|
|||||||
assert.equal(updated, true);
|
assert.equal(updated, true);
|
||||||
assert.match(scriptValue, /optionsGetFull/);
|
assert.match(scriptValue, /optionsGetFull/);
|
||||||
assert.match(scriptValue, /setAllSettings/);
|
assert.match(scriptValue, /setAllSettings/);
|
||||||
|
assert.match(scriptValue, /profileCurrent/);
|
||||||
|
assert.match(scriptValue, /forceOverride = false/);
|
||||||
assert.equal(infoLogs.length, 1);
|
assert.equal(infoLogs.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +61,45 @@ test('syncYomitanDefaultAnkiServer returns true when script reports no change',
|
|||||||
assert.equal(infoLogCount, 0);
|
assert.equal(infoLogCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('syncYomitanDefaultAnkiServer returns false when existing non-default server blocks update', async () => {
|
||||||
|
const deps = createDeps(async () => ({
|
||||||
|
updated: false,
|
||||||
|
matched: false,
|
||||||
|
reason: 'blocked-existing-server',
|
||||||
|
}));
|
||||||
|
const infoLogs: string[] = [];
|
||||||
|
|
||||||
|
const synced = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
info: (message) => infoLogs.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(synced, false);
|
||||||
|
assert.equal(infoLogs.length, 1);
|
||||||
|
assert.match(infoLogs[0] ?? '', /blocked-existing-server/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncYomitanDefaultAnkiServer injects force override when enabled', async () => {
|
||||||
|
let scriptValue = '';
|
||||||
|
const deps = createDeps(async (script) => {
|
||||||
|
scriptValue = script;
|
||||||
|
return { updated: false, matched: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
const synced = await syncYomitanDefaultAnkiServer(
|
||||||
|
'http://127.0.0.1:8766',
|
||||||
|
deps,
|
||||||
|
{
|
||||||
|
error: () => undefined,
|
||||||
|
info: () => undefined,
|
||||||
|
},
|
||||||
|
{ forceOverride: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(synced, true);
|
||||||
|
assert.match(scriptValue, /forceOverride = true/);
|
||||||
|
});
|
||||||
|
|
||||||
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
|
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
|
||||||
const deps = createDeps(async () => {
|
const deps = createDeps(async () => {
|
||||||
throw new Error('execute failed');
|
throw new Error('execute failed');
|
||||||
|
|||||||
@@ -848,11 +848,15 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
deps: YomitanParserRuntimeDeps,
|
deps: YomitanParserRuntimeDeps,
|
||||||
logger: LoggerLike,
|
logger: LoggerLike,
|
||||||
|
options?: {
|
||||||
|
forceOverride?: boolean;
|
||||||
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const normalizedTargetServer = serverUrl.trim();
|
const normalizedTargetServer = serverUrl.trim();
|
||||||
if (!normalizedTargetServer) {
|
if (!normalizedTargetServer) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const forceOverride = options?.forceOverride === true;
|
||||||
|
|
||||||
const isReady = await ensureYomitanParserWindow(deps, logger);
|
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||||
const parserWindow = deps.getYomitanParserWindow();
|
const parserWindow = deps.getYomitanParserWindow();
|
||||||
@@ -882,35 +886,42 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const targetServer = ${JSON.stringify(normalizedTargetServer)};
|
const targetServer = ${JSON.stringify(normalizedTargetServer)};
|
||||||
|
const forceOverride = ${forceOverride ? 'true' : 'false'};
|
||||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||||
if (profiles.length === 0) {
|
if (profiles.length === 0) {
|
||||||
return { updated: false, reason: "no-profiles" };
|
return { updated: false, reason: "no-profiles" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultProfile = profiles[0];
|
const profileCurrent = Number.isInteger(optionsFull.profileCurrent)
|
||||||
if (!defaultProfile || typeof defaultProfile !== "object") {
|
? optionsFull.profileCurrent
|
||||||
|
: 0;
|
||||||
|
const targetProfile = profiles[profileCurrent];
|
||||||
|
if (!targetProfile || typeof targetProfile !== "object") {
|
||||||
return { updated: false, reason: "invalid-default-profile" };
|
return { updated: false, reason: "invalid-default-profile" };
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultProfile.options = defaultProfile.options && typeof defaultProfile.options === "object"
|
targetProfile.options = targetProfile.options && typeof targetProfile.options === "object"
|
||||||
? defaultProfile.options
|
? targetProfile.options
|
||||||
: {};
|
: {};
|
||||||
defaultProfile.options.anki = defaultProfile.options.anki && typeof defaultProfile.options.anki === "object"
|
targetProfile.options.anki = targetProfile.options.anki && typeof targetProfile.options.anki === "object"
|
||||||
? defaultProfile.options.anki
|
? targetProfile.options.anki
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const currentServerRaw = defaultProfile.options.anki.server;
|
const currentServerRaw = targetProfile.options.anki.server;
|
||||||
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
|
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
|
||||||
const canReplaceDefault =
|
if (currentServer === targetServer) {
|
||||||
currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer };
|
||||||
if (!canReplaceDefault || currentServer === targetServer) {
|
}
|
||||||
return { updated: false, reason: "no-change", currentServer, targetServer };
|
const canReplaceCurrent =
|
||||||
|
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
||||||
|
if (!canReplaceCurrent) {
|
||||||
|
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultProfile.options.anki.server = targetServer;
|
targetProfile.options.anki.server = targetServer;
|
||||||
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
||||||
return { updated: true, currentServer, targetServer };
|
return { updated: true, matched: true, currentServer, targetServer };
|
||||||
})();
|
})();
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -924,6 +935,24 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`);
|
logger.info?.(`Updated Yomitan default profile Anki server to ${normalizedTargetServer}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const matchedWithoutUpdate =
|
||||||
|
isObject(result) &&
|
||||||
|
result.updated === false &&
|
||||||
|
(result as { matched?: unknown }).matched === true;
|
||||||
|
if (matchedWithoutUpdate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const blockedByExistingServer =
|
||||||
|
isObject(result) &&
|
||||||
|
result.updated === false &&
|
||||||
|
(result as { matched?: unknown }).matched === false &&
|
||||||
|
typeof (result as { reason?: unknown }).reason === 'string';
|
||||||
|
if (blockedByExistingServer) {
|
||||||
|
logger.info?.(
|
||||||
|
`Skipped syncing Yomitan Anki server (reason=${String((result as { reason: string }).reason)})`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const checkedWithoutUpdate =
|
const checkedWithoutUpdate =
|
||||||
typeof result === 'object' &&
|
typeof result === 'object' &&
|
||||||
result !== null &&
|
result !== null &&
|
||||||
|
|||||||
53
src/core/services/yomitan-extension-copy.ts
Normal file
53
src/core/services/yomitan-extension-copy.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const YOMITAN_SYNC_SCRIPT_PATHS = [
|
||||||
|
path.join('js', 'app', 'popup.js'),
|
||||||
|
path.join('js', 'display', 'popup-main.js'),
|
||||||
|
path.join('js', 'display', 'display.js'),
|
||||||
|
path.join('js', 'display', 'display-audio.js'),
|
||||||
|
];
|
||||||
|
|
||||||
|
function readManifestVersion(manifestPath: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { version?: unknown };
|
||||||
|
return typeof parsed.version === 'string' ? parsed.version : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
|
||||||
|
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(sourcePath).equals(fs.readFileSync(targetPath));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string): boolean {
|
||||||
|
if (!fs.existsSync(targetDir)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceManifest = path.join(sourceDir, 'manifest.json');
|
||||||
|
const targetManifest = path.join(targetDir, 'manifest.json');
|
||||||
|
if (!fs.existsSync(sourceManifest) || !fs.existsSync(targetManifest)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceVersion = readManifestVersion(sourceManifest);
|
||||||
|
const targetVersion = readManifestVersion(targetManifest);
|
||||||
|
if (sourceVersion === null || targetVersion === null || sourceVersion !== targetVersion) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relativePath of YOMITAN_SYNC_SCRIPT_PATHS) {
|
||||||
|
if (!areFilesEqual(path.join(sourceDir, relativePath), path.join(targetDir, relativePath))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
52
src/core/services/yomitan-extension-loader.test.ts
Normal file
52
src/core/services/yomitan-extension-loader.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
||||||
|
|
||||||
|
function writeFile(filePath: string, content: string): void {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, content, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
|
||||||
|
const sourceDir = path.join(tempRoot, 'source');
|
||||||
|
const targetDir = path.join(tempRoot, 'target');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'source-popup-main');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'target-popup-main');
|
||||||
|
|
||||||
|
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldCopyYomitanExtension skips copy when versions and watched scripts match', () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
|
||||||
|
const sourceDir = path.join(tempRoot, 'source');
|
||||||
|
const targetDir = path.join(tempRoot, 'target');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'display', 'display.js'), 'same-display');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'display', 'display.js'), 'same-display');
|
||||||
|
|
||||||
|
writeFile(path.join(sourceDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
|
||||||
|
writeFile(path.join(targetDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
|
||||||
|
|
||||||
|
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), false);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { BrowserWindow, Extension, session } from 'electron';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
||||||
|
|
||||||
const logger = createLogger('main:yomitan-extension-loader');
|
const logger = createLogger('main:yomitan-extension-loader');
|
||||||
|
|
||||||
@@ -22,27 +23,7 @@ function ensureExtensionCopy(sourceDir: string, userDataPath: string): string {
|
|||||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||||
|
|
||||||
const sourceManifest = path.join(sourceDir, 'manifest.json');
|
const shouldCopy = shouldCopyYomitanExtension(sourceDir, targetDir);
|
||||||
const targetManifest = path.join(targetDir, 'manifest.json');
|
|
||||||
|
|
||||||
let shouldCopy = !fs.existsSync(targetDir);
|
|
||||||
if (!shouldCopy && fs.existsSync(sourceManifest) && fs.existsSync(targetManifest)) {
|
|
||||||
try {
|
|
||||||
const sourceVersion = (
|
|
||||||
JSON.parse(fs.readFileSync(sourceManifest, 'utf-8')) as {
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
).version;
|
|
||||||
const targetVersion = (
|
|
||||||
JSON.parse(fs.readFileSync(targetManifest, 'utf-8')) as {
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
).version;
|
|
||||||
shouldCopy = sourceVersion !== targetVersion;
|
|
||||||
} catch {
|
|
||||||
shouldCopy = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldCopy) {
|
if (shouldCopy) {
|
||||||
fs.mkdirSync(extensionsRoot, { recursive: true });
|
fs.mkdirSync(extensionsRoot, { recursive: true });
|
||||||
|
|||||||
13
src/main.ts
13
src/main.ts
@@ -871,12 +871,18 @@ function maybeSignalPluginAutoplayReady(
|
|||||||
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
autoPlayReadySignalMediaPath = mediaPath;
|
|
||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
|
||||||
const signalPluginAutoplayReady = (): void => {
|
const signalPluginAutoplayReady = (): void => {
|
||||||
logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`);
|
logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`);
|
||||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
|
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
|
||||||
};
|
};
|
||||||
|
if (duplicateMediaSignal && allowDuplicateWhilePaused) {
|
||||||
|
// Keep re-notifying the plugin while paused (for startup visibility sync), but
|
||||||
|
// do not run local unpause fallback on duplicates to avoid resuming user-paused playback.
|
||||||
|
signalPluginAutoplayReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
autoPlayReadySignalMediaPath = mediaPath;
|
||||||
|
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||||
signalPluginAutoplayReady();
|
signalPluginAutoplayReady();
|
||||||
const isPlaybackPaused = async (client: {
|
const isPlaybackPaused = async (client: {
|
||||||
requestProperty: (property: string) => Promise<unknown>;
|
requestProperty: (property: string) => Promise<unknown>;
|
||||||
@@ -2663,6 +2669,9 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
|||||||
logger.info(message, ...args);
|
logger.info(message, ...args);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
forceOverride: getResolvedConfig().ankiConnect.proxy?.enabled === true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (synced) {
|
if (synced) {
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ function createQueuedIpcListenerWithPayload<T>(
|
|||||||
|
|
||||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||||
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
|
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
|
||||||
|
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
|
||||||
|
IPC_CHANNELS.event.keyboardModeToggleRequested,
|
||||||
|
);
|
||||||
|
const onLookupWindowToggleRequestedEvent = createQueuedIpcListener(
|
||||||
|
IPC_CHANNELS.event.lookupWindowToggleRequested,
|
||||||
|
);
|
||||||
const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManualPayload>(
|
const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManualPayload>(
|
||||||
IPC_CHANNELS.event.subsyncOpenManual,
|
IPC_CHANNELS.event.subsyncOpenManual,
|
||||||
(payload) => payload as SubsyncManualPayload,
|
(payload) => payload as SubsyncManualPayload,
|
||||||
@@ -282,6 +288,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
||||||
onOpenJimaku: onOpenJimakuEvent,
|
onOpenJimaku: onOpenJimakuEvent,
|
||||||
|
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||||
|
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
YOMITAN_POPUP_IFRAME_SELECTOR,
|
YOMITAN_POPUP_IFRAME_SELECTOR,
|
||||||
hasYomitanPopupIframe,
|
hasYomitanPopupIframe,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
|
isYomitanPopupVisible,
|
||||||
} from './yomitan-popup.js';
|
} from './yomitan-popup.js';
|
||||||
import { scrollActiveRuntimeOptionIntoView } from './modals/runtime-options.js';
|
import { scrollActiveRuntimeOptionIntoView } from './modals/runtime-options.js';
|
||||||
import { resolvePlatformInfo } from './utils/platform.js';
|
import { resolvePlatformInfo } from './utils/platform.js';
|
||||||
@@ -283,6 +284,43 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
|
|||||||
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isYomitanPopupVisible requires visible iframe geometry', () => {
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
let selector = '';
|
||||||
|
const visibleFrame = {
|
||||||
|
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
||||||
|
} as unknown as HTMLIFrameElement;
|
||||||
|
const hiddenFrame = {
|
||||||
|
getBoundingClientRect: () => ({ width: 320, height: 180 }),
|
||||||
|
} as unknown as HTMLIFrameElement;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getComputedStyle: (element: Element) => {
|
||||||
|
if (element === hiddenFrame) {
|
||||||
|
return { visibility: 'hidden', display: 'block', opacity: '1' } as CSSStyleDeclaration;
|
||||||
|
}
|
||||||
|
return { visibility: 'visible', display: 'block', opacity: '1' } as CSSStyleDeclaration;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const root = {
|
||||||
|
querySelectorAll: (value: string) => {
|
||||||
|
selector = value;
|
||||||
|
return [hiddenFrame, visibleFrame];
|
||||||
|
},
|
||||||
|
} as unknown as ParentNode;
|
||||||
|
|
||||||
|
assert.equal(isYomitanPopupVisible(root), true);
|
||||||
|
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
|
||||||
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
|
||||||
const activeItem = {
|
const activeItem = {
|
||||||
|
|||||||
642
src/renderer/handlers/keyboard.test.ts
Normal file
642
src/renderer/handlers/keyboard.test.ts
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createKeyboardHandlers } from './keyboard.js';
|
||||||
|
import { createRendererState } from '../state.js';
|
||||||
|
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
|
||||||
|
|
||||||
|
type CommandEventDetail = {
|
||||||
|
type?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
key?: string;
|
||||||
|
code?: string;
|
||||||
|
repeat?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createClassList() {
|
||||||
|
const classes = new Set<string>();
|
||||||
|
return {
|
||||||
|
add: (...tokens: string[]) => {
|
||||||
|
for (const token of tokens) {
|
||||||
|
classes.add(token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: (...tokens: string[]) => {
|
||||||
|
for (const token of tokens) {
|
||||||
|
classes.delete(token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contains: (token: string) => classes.has(token),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installKeyboardTestGlobals() {
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||||
|
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||||
|
const previousCustomEvent = (globalThis as { CustomEvent?: unknown }).CustomEvent;
|
||||||
|
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
||||||
|
|
||||||
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
const commandEvents: CommandEventDetail[] = [];
|
||||||
|
const mpvCommands: Array<Array<string | number>> = [];
|
||||||
|
let playbackPausedResponse: boolean | null = false;
|
||||||
|
|
||||||
|
let popupVisible = false;
|
||||||
|
|
||||||
|
const popupIframe = {
|
||||||
|
tagName: 'IFRAME',
|
||||||
|
classList: {
|
||||||
|
contains: (token: string) => token === 'yomitan-popup',
|
||||||
|
},
|
||||||
|
id: 'yomitan-popup-1',
|
||||||
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const selection = {
|
||||||
|
removeAllRanges: () => {},
|
||||||
|
addRange: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
||||||
|
let focusMainWindowCalls = 0;
|
||||||
|
let windowFocusCalls = 0;
|
||||||
|
|
||||||
|
class TestCustomEvent extends Event {
|
||||||
|
detail: unknown;
|
||||||
|
|
||||||
|
constructor(type: string, init?: { detail?: unknown }) {
|
||||||
|
super(type);
|
||||||
|
this.detail = init?.detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestMouseEvent extends Event {
|
||||||
|
constructor(type: string) {
|
||||||
|
super(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||||
|
configurable: true,
|
||||||
|
value: TestCustomEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'MouseEvent', {
|
||||||
|
configurable: true,
|
||||||
|
value: TestMouseEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: () => {},
|
||||||
|
dispatchEvent: (event: Event) => {
|
||||||
|
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
||||||
|
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
||||||
|
commandEvents.push(detail ?? {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'visible',
|
||||||
|
display: 'block',
|
||||||
|
opacity: '1',
|
||||||
|
}),
|
||||||
|
getSelection: () => selection,
|
||||||
|
focus: () => {
|
||||||
|
windowFocusCalls += 1;
|
||||||
|
},
|
||||||
|
electronAPI: {
|
||||||
|
getKeybindings: async () => [],
|
||||||
|
sendMpvCommand: (command: Array<string | number>) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
getPlaybackPaused: async () => playbackPausedResponse,
|
||||||
|
toggleDevTools: () => {},
|
||||||
|
focusMainWindow: () => {
|
||||||
|
focusMainWindowCalls += 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||||
|
const listeners = documentListeners.get(type) ?? [];
|
||||||
|
listeners.push(listener);
|
||||||
|
documentListeners.set(type, listeners);
|
||||||
|
},
|
||||||
|
querySelectorAll: () => {
|
||||||
|
if (popupVisible) {
|
||||||
|
return [popupIframe];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
createRange: () => ({
|
||||||
|
selectNodeContents: () => {},
|
||||||
|
}),
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: class {
|
||||||
|
observe() {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function dispatchKeydown(event: {
|
||||||
|
key: string;
|
||||||
|
code: string;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
shiftKey?: boolean;
|
||||||
|
repeat?: boolean;
|
||||||
|
}): void {
|
||||||
|
const listeners = documentListeners.get('keydown') ?? [];
|
||||||
|
const keyboardEvent = {
|
||||||
|
key: event.key,
|
||||||
|
code: event.code,
|
||||||
|
ctrlKey: event.ctrlKey ?? false,
|
||||||
|
metaKey: event.metaKey ?? false,
|
||||||
|
altKey: event.altKey ?? false,
|
||||||
|
shiftKey: event.shiftKey ?? false,
|
||||||
|
repeat: event.repeat ?? false,
|
||||||
|
preventDefault: () => {},
|
||||||
|
target: null,
|
||||||
|
};
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(keyboardEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchFocusInOnPopup(): void {
|
||||||
|
const listeners = documentListeners.get('focusin') ?? [];
|
||||||
|
const focusEvent = {
|
||||||
|
target: popupIframe,
|
||||||
|
};
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(focusEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousCustomEvent,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MouseEvent', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMouseEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = {
|
||||||
|
focus: (options?: { preventScroll?: boolean }) => {
|
||||||
|
overlayFocusCalls.push(options ?? {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
commandEvents,
|
||||||
|
mpvCommands,
|
||||||
|
overlay,
|
||||||
|
overlayFocusCalls,
|
||||||
|
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||||
|
windowFocusCalls: () => windowFocusCalls,
|
||||||
|
dispatchKeydown,
|
||||||
|
dispatchFocusInOnPopup,
|
||||||
|
setPopupVisible: (value: boolean) => {
|
||||||
|
popupVisible = value;
|
||||||
|
},
|
||||||
|
getPlaybackPaused: async () => playbackPausedResponse,
|
||||||
|
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||||
|
playbackPausedResponse = value;
|
||||||
|
},
|
||||||
|
restore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createKeyboardHandlerHarness() {
|
||||||
|
const testGlobals = installKeyboardTestGlobals();
|
||||||
|
const subtitleRootClassList = createClassList();
|
||||||
|
|
||||||
|
const createWordNode = (left: number) => ({
|
||||||
|
classList: createClassList(),
|
||||||
|
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
});
|
||||||
|
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
subtitleRoot: {
|
||||||
|
classList: subtitleRootClassList,
|
||||||
|
querySelectorAll: () => wordNodes,
|
||||||
|
},
|
||||||
|
subtitleContainer: {
|
||||||
|
contains: () => false,
|
||||||
|
},
|
||||||
|
overlay: testGlobals.overlay,
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
shouldToggleMouseIgnore: false,
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
overlayLayer: 'always-on-top',
|
||||||
|
},
|
||||||
|
state: createRendererState(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlers = createKeyboardHandlers(ctx as never, {
|
||||||
|
handleRuntimeOptionsKeydown: () => false,
|
||||||
|
handleSubsyncKeydown: () => false,
|
||||||
|
handleKikuKeydown: () => false,
|
||||||
|
handleJimakuKeydown: () => false,
|
||||||
|
handleSessionHelpKeydown: () => false,
|
||||||
|
openSessionHelpModal: () => {},
|
||||||
|
appendClipboardVideoToQueue: () => {},
|
||||||
|
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
handlers,
|
||||||
|
testGlobals,
|
||||||
|
setWordCount: (count: number) => {
|
||||||
|
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keyboard mode: left and right move token selection while popup remains open', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
const closeEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' && event.visible === false,
|
||||||
|
);
|
||||||
|
assert.equal(closeEvents.length, 0);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: up/down/j/k do not open or close lookup when popup is closed', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' });
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||||
|
testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' });
|
||||||
|
testGlobals.dispatchKeydown({ key: 'k', code: 'KeyK' });
|
||||||
|
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
const openEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'scanSelectedText',
|
||||||
|
);
|
||||||
|
assert.equal(openEvents.length, 0);
|
||||||
|
const closeEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' && event.visible === false,
|
||||||
|
);
|
||||||
|
assert.equal(closeEvents.length, 0);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: up/down/j/k forward keydown to yomitan popup when open', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' });
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||||
|
testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' });
|
||||||
|
testGlobals.dispatchKeydown({ key: 'k', code: 'KeyK' });
|
||||||
|
|
||||||
|
const forwarded = testGlobals.commandEvents.filter((event) => event.type === 'forwardKeyDown');
|
||||||
|
assert.equal(forwarded.length, 4);
|
||||||
|
assert.equal(
|
||||||
|
forwarded.some((event) => event.code === 'ArrowUp'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
forwarded.some((event) => event.code === 'ArrowDown'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
forwarded.some((event) => event.code === 'KeyJ'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
forwarded.some((event) => event.code === 'KeyK'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const openEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'scanSelectedText',
|
||||||
|
);
|
||||||
|
assert.equal(openEvents.length, 0);
|
||||||
|
const closeEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' && event.visible === false,
|
||||||
|
);
|
||||||
|
assert.equal(closeEvents.length, 0);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: repeated popup navigation keys are forwarded while popup is open', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ', repeat: true });
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown', repeat: true });
|
||||||
|
|
||||||
|
const forwarded = testGlobals.commandEvents.filter((event) => event.type === 'forwardKeyDown');
|
||||||
|
assert.equal(forwarded.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
forwarded.map((event) => ({ code: event.code, repeat: event.repeat })),
|
||||||
|
[
|
||||||
|
{ code: 'KeyJ', repeat: true },
|
||||||
|
{ code: 'ArrowDown', repeat: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 2;
|
||||||
|
ctx.state.yomitanPopupVisible = false;
|
||||||
|
testGlobals.setPopupVisible(false);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
|
||||||
|
|
||||||
|
const closeEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' && event.visible === false,
|
||||||
|
);
|
||||||
|
assert.equal(closeEvents.length, 0);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: h moves left while popup is open and keeps lookup active', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 2;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
|
||||||
|
await wait(80);
|
||||||
|
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
|
||||||
|
const openEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'scanSelectedText',
|
||||||
|
);
|
||||||
|
assert.equal(openEvents.length > 0, true);
|
||||||
|
const closeEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'setVisible' && event.visible === false,
|
||||||
|
);
|
||||||
|
assert.equal(closeEvents.length, 0);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: opening lookup restores overlay keyboard focus', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.equal(testGlobals.focusMainWindowCalls() > 0, true);
|
||||||
|
assert.equal(testGlobals.windowFocusCalls() > 0, true);
|
||||||
|
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
||||||
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
setWordCount(3);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 2;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||||
|
await wait(0);
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||||
|
|
||||||
|
setWordCount(2);
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: moving left beyond start jumps previous subtitle and sets selector to end', async () => {
|
||||||
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
setWordCount(3);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 0;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||||
|
await wait(0);
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
||||||
|
|
||||||
|
setWordCount(4);
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 3);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
|
||||||
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
setWordCount(2);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||||
|
await wait(0);
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
||||||
|
|
||||||
|
setWordCount(3);
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
await wait(80);
|
||||||
|
|
||||||
|
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
|
||||||
|
const openEvents = testGlobals.commandEvents.filter(
|
||||||
|
(event) => event.type === 'scanSelectedText',
|
||||||
|
);
|
||||||
|
assert.equal(openEvents.length > 0, true);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
|
||||||
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
setWordCount(2);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
testGlobals.setPlaybackPausedResponse(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
|
||||||
|
['sub-seek', 1],
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: edge jump with unknown pause state re-applies pause conservatively', async () => {
|
||||||
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
setWordCount(2);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 1;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
testGlobals.setPlaybackPausedResponse(null);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
|
||||||
|
['sub-seek', 1],
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: popup iframe focusin reclaims overlay keyboard focus', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
testGlobals.setPopupVisible(true);
|
||||||
|
|
||||||
|
const before = testGlobals.focusMainWindowCalls();
|
||||||
|
testGlobals.dispatchFocusInOnPopup();
|
||||||
|
await wait(260);
|
||||||
|
|
||||||
|
assert.equal(testGlobals.focusMainWindowCalls() > before, true);
|
||||||
|
assert.equal(testGlobals.windowFocusCalls() > 0, true);
|
||||||
|
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { Keybinding } from '../../types';
|
import type { Keybinding } from '../../types';
|
||||||
import type { RendererContext } from '../context';
|
import type { RendererContext } from '../context';
|
||||||
import { hasYomitanPopupIframe, isYomitanPopupIframe } from '../yomitan-popup.js';
|
import {
|
||||||
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
|
YOMITAN_POPUP_COMMAND_EVENT,
|
||||||
|
isYomitanPopupVisible,
|
||||||
|
isYomitanPopupIframe,
|
||||||
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
export function createKeyboardHandlers(
|
export function createKeyboardHandlers(
|
||||||
ctx: RendererContext,
|
ctx: RendererContext,
|
||||||
@@ -16,10 +22,14 @@ export function createKeyboardHandlers(
|
|||||||
fallbackUnavailable: boolean;
|
fallbackUnavailable: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
appendClipboardVideoToQueue: () => void;
|
appendClipboardVideoToQueue: () => void;
|
||||||
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||||
const CHORD_TIMEOUT_MS = 1000;
|
const CHORD_TIMEOUT_MS = 1000;
|
||||||
|
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||||
|
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||||
|
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
|
|
||||||
const CHORD_MAP = new Map<
|
const CHORD_MAP = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -55,6 +65,419 @@ export function createKeyboardHandlers(
|
|||||||
return parts.join('+');
|
return parts.join('+');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanPopupKeydown(
|
||||||
|
key: string,
|
||||||
|
code: string,
|
||||||
|
modifiers: Array<'alt' | 'ctrl' | 'shift' | 'meta'>,
|
||||||
|
repeat: boolean,
|
||||||
|
) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'forwardKeyDown',
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
modifiers,
|
||||||
|
repeat,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanPopupVisibility(visible: boolean) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'setVisible',
|
||||||
|
visible,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanPopupMineSelected() {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'mineSelected',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchYomitanFrontendScanSelectedText() {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
|
detail: {
|
||||||
|
type: 'scanSelectedText',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
|
||||||
|
return e.ctrlKey || e.metaKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeyboardDrivenModeToggle(e: KeyboardEvent): boolean {
|
||||||
|
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
|
||||||
|
return isPrimaryModifierPressed(e) && !e.altKey && e.shiftKey && isYKey && !e.repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLookupWindowToggle(e: KeyboardEvent): boolean {
|
||||||
|
const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y';
|
||||||
|
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubtitleWordNodes(): HTMLElement[] {
|
||||||
|
return Array.from(
|
||||||
|
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncKeyboardTokenSelection(): void {
|
||||||
|
const wordNodes = getSubtitleWordNodes();
|
||||||
|
for (const wordNode of wordNodes) {
|
||||||
|
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||||
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
|
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||||
|
ctx.state.keyboardSelectedWordIndex =
|
||||||
|
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
||||||
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
|
const shouldRefreshLookup =
|
||||||
|
pendingLookupRefreshAfterSubtitleSeek &&
|
||||||
|
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
||||||
|
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
|
if (shouldRefreshLookup) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
triggerLookupForSelectedWord();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex = Math.min(
|
||||||
|
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||||
|
wordNodes.length - 1,
|
||||||
|
);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||||
|
const selectedWordNode = wordNodes[selectedIndex];
|
||||||
|
if (selectedWordNode) {
|
||||||
|
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
|
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
|
}
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleKeyboardDrivenMode(): void {
|
||||||
|
setKeyboardDrivenModeEnabled(!ctx.state.keyboardDrivenModeEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveKeyboardSelection(
|
||||||
|
delta: -1 | 1,
|
||||||
|
): 'moved' | 'start-boundary' | 'end-boundary' | 'no-words' {
|
||||||
|
const wordNodes = getSubtitleWordNodes();
|
||||||
|
if (wordNodes.length === 0) {
|
||||||
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
return 'no-words';
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = Math.min(
|
||||||
|
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||||
|
wordNodes.length - 1,
|
||||||
|
);
|
||||||
|
if (delta < 0 && currentIndex <= 0) {
|
||||||
|
return 'start-boundary';
|
||||||
|
}
|
||||||
|
if (delta > 0 && currentIndex >= wordNodes.length - 1) {
|
||||||
|
return 'end-boundary';
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = currentIndex + delta;
|
||||||
|
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
return 'moved';
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekAdjacentSubtitleAndQueueSelection(delta: -1 | 1, popupVisible: boolean): void {
|
||||||
|
pendingSelectionAnchorAfterSubtitleSeek = delta > 0 ? 'start' : 'end';
|
||||||
|
pendingLookupRefreshAfterSubtitleSeek = popupVisible;
|
||||||
|
void options
|
||||||
|
.getPlaybackPaused()
|
||||||
|
.then((paused) => {
|
||||||
|
window.electronAPI.sendMpvCommand(['sub-seek', delta]);
|
||||||
|
if (paused !== false) {
|
||||||
|
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
window.electronAPI.sendMpvCommand(['sub-seek', delta]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanModifierState = {
|
||||||
|
shiftKey?: boolean;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emitSyntheticScanEvents(
|
||||||
|
target: Element,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
modifiers: ScanModifierState = {},
|
||||||
|
): void {
|
||||||
|
if (typeof PointerEvent !== 'undefined') {
|
||||||
|
const pointerEventInit = {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
pointerType: 'mouse',
|
||||||
|
isPrimary: true,
|
||||||
|
button: 0,
|
||||||
|
buttons: 0,
|
||||||
|
shiftKey: modifiers.shiftKey ?? false,
|
||||||
|
ctrlKey: modifiers.ctrlKey ?? false,
|
||||||
|
altKey: modifiers.altKey ?? false,
|
||||||
|
metaKey: modifiers.metaKey ?? false,
|
||||||
|
} satisfies PointerEventInit;
|
||||||
|
|
||||||
|
target.dispatchEvent(new PointerEvent('pointerover', pointerEventInit));
|
||||||
|
target.dispatchEvent(new PointerEvent('pointermove', pointerEventInit));
|
||||||
|
target.dispatchEvent(new PointerEvent('pointerdown', { ...pointerEventInit, buttons: 1 }));
|
||||||
|
target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mouseEventInit = {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
button: 0,
|
||||||
|
buttons: 0,
|
||||||
|
shiftKey: modifiers.shiftKey ?? false,
|
||||||
|
ctrlKey: modifiers.ctrlKey ?? false,
|
||||||
|
altKey: modifiers.altKey ?? false,
|
||||||
|
metaKey: modifiers.metaKey ?? false,
|
||||||
|
} satisfies MouseEventInit;
|
||||||
|
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
|
||||||
|
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
|
||||||
|
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
|
||||||
|
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void {
|
||||||
|
emitSyntheticScanEvents(target, clientX, clientY, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWordNodeText(wordNode: HTMLElement): void {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(wordNode);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
ctx.dom.subtitleRoot.classList.add('has-selection');
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerLookupForSelectedWord(): boolean {
|
||||||
|
const wordNodes = getSubtitleWordNodes();
|
||||||
|
if (wordNodes.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex = Math.min(
|
||||||
|
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||||
|
wordNodes.length - 1,
|
||||||
|
);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||||
|
const selectedWordNode = wordNodes[selectedIndex];
|
||||||
|
if (!selectedWordNode) return false;
|
||||||
|
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
selectWordNodeText(selectedWordNode);
|
||||||
|
|
||||||
|
const rect = selectedWordNode.getBoundingClientRect();
|
||||||
|
const clientX = rect.left + rect.width / 2;
|
||||||
|
const clientY = rect.top + rect.height / 2;
|
||||||
|
|
||||||
|
dispatchYomitanFrontendScanSelectedText();
|
||||||
|
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
// Keep overlay as the keyboard focus owner so token navigation can continue
|
||||||
|
// while the popup is visible.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
scheduleOverlayFocusReclaim(8);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Fallback only if the explicit scan path did not open popup quickly.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Dispatch directly on the selected token span; when overlay pointer-events are disabled,
|
||||||
|
// elementFromPoint may resolve to the underlying video surface instead.
|
||||||
|
emitLookupScanFallback(selectedWordNode, clientX, clientY);
|
||||||
|
}, 60);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyboardModeToggleRequested(): void {
|
||||||
|
toggleKeyboardDrivenMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLookupWindowToggleRequested(): void {
|
||||||
|
if (ctx.state.yomitanPopupVisible) {
|
||||||
|
dispatchYomitanPopupVisibility(false);
|
||||||
|
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerLookupForSelectedWord();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreOverlayKeyboardFocus(): void {
|
||||||
|
void window.electronAPI.focusMainWindow();
|
||||||
|
window.focus();
|
||||||
|
ctx.dom.overlay.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleOverlayFocusReclaim(attempts: number = 0): void {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
if (attempts <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = attempts;
|
||||||
|
const reclaim = () => {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
setTimeout(reclaim, 25);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(reclaim, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyboardDrivenModeNavigation(e: KeyboardEvent): boolean {
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = e.code;
|
||||||
|
if (key === 'ArrowLeft') {
|
||||||
|
const result = moveKeyboardSelection(-1);
|
||||||
|
if (result === 'start-boundary') {
|
||||||
|
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
||||||
|
}
|
||||||
|
return result !== 'no-words';
|
||||||
|
}
|
||||||
|
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||||
|
const result = moveKeyboardSelection(1);
|
||||||
|
if (result === 'end-boundary') {
|
||||||
|
seekAdjacentSubtitleAndQueueSelection(1, false);
|
||||||
|
}
|
||||||
|
return result !== 'no-words';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyboardDrivenModeLookupControls(e: KeyboardEvent): boolean {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = e.code;
|
||||||
|
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||||
|
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||||
|
const result = moveKeyboardSelection(-1);
|
||||||
|
if (result === 'start-boundary') {
|
||||||
|
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
||||||
|
} else if (popupVisible && result === 'moved') {
|
||||||
|
triggerLookupForSelectedWord();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||||
|
const result = moveKeyboardSelection(1);
|
||||||
|
if (result === 'end-boundary') {
|
||||||
|
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
||||||
|
} else if (popupVisible && result === 'moved') {
|
||||||
|
triggerLookupForSelectedWord();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleYomitanPopupKeybind(e: KeyboardEvent): boolean {
|
||||||
|
const modifierOnlyCodes = new Set([
|
||||||
|
'ShiftLeft',
|
||||||
|
'ShiftRight',
|
||||||
|
'ControlLeft',
|
||||||
|
'ControlRight',
|
||||||
|
'AltLeft',
|
||||||
|
'AltRight',
|
||||||
|
'MetaLeft',
|
||||||
|
'MetaRight',
|
||||||
|
]);
|
||||||
|
if (modifierOnlyCodes.has(e.code)) return false;
|
||||||
|
|
||||||
|
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code === 'KeyM') {
|
||||||
|
if (e.repeat) return false;
|
||||||
|
dispatchYomitanPopupMineSelected();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiers: Array<'alt' | 'ctrl' | 'shift' | 'meta'> = [];
|
||||||
|
if (e.altKey) modifiers.push('alt');
|
||||||
|
if (e.ctrlKey) modifiers.push('ctrl');
|
||||||
|
if (e.shiftKey) modifiers.push('shift');
|
||||||
|
if (e.metaKey) modifiers.push('meta');
|
||||||
|
|
||||||
|
dispatchYomitanPopupKeydown(e.key, e.code, modifiers, e.repeat);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSessionHelpChordBinding(): {
|
function resolveSessionHelpChordBinding(): {
|
||||||
bindingKey: 'KeyH' | 'KeyK';
|
bindingKey: 'KeyH' | 'KeyK';
|
||||||
fallbackUsed: boolean;
|
fallbackUsed: boolean;
|
||||||
@@ -106,9 +529,76 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
async function setupMpvInputForwarding(): Promise<void> {
|
async function setupMpvInputForwarding(): Promise<void> {
|
||||||
updateKeybindings(await window.electronAPI.getKeybindings());
|
updateKeybindings(await window.electronAPI.getKeybindings());
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
|
||||||
|
const subtitleMutationObserver = new MutationObserver(() => {
|
||||||
|
syncKeyboardTokenSelection();
|
||||||
|
});
|
||||||
|
subtitleMutationObserver.observe(ctx.dom.subtitleRoot, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
});
|
||||||
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
scheduleOverlayFocusReclaim(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'focusin',
|
||||||
|
(e: FocusEvent) => {
|
||||||
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = e.target;
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
typeof target === 'object' &&
|
||||||
|
'tagName' in target &&
|
||||||
|
isYomitanPopupIframe(target as Element)
|
||||||
|
) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
scheduleOverlayFocusReclaim(8);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
if (hasYomitanPopupIframe(document)) return;
|
if (isKeyboardDrivenModeToggle(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleKeyboardModeToggleRequested();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLookupWindowToggle(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleLookupWindowToggleRequested();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handleKeyboardDrivenModeLookupControls(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||||
|
if (handleYomitanPopupKeybind(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.state.runtimeOptionsModalOpen) {
|
if (ctx.state.runtimeOptionsModalOpen) {
|
||||||
options.handleRuntimeOptionsKeydown(e);
|
options.handleRuntimeOptionsKeydown(e);
|
||||||
@@ -131,6 +621,11 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.state.chordPending) {
|
if (ctx.state.chordPending) {
|
||||||
const modifierKeys = [
|
const modifierKeys = [
|
||||||
'ShiftLeft',
|
'ShiftLeft',
|
||||||
@@ -211,5 +706,8 @@ export function createKeyboardHandlers(
|
|||||||
return {
|
return {
|
||||||
setupMpvInputForwarding,
|
setupMpvInputForwarding,
|
||||||
updateKeybindings,
|
updateKeybindings,
|
||||||
|
syncKeyboardTokenSelection,
|
||||||
|
handleKeyboardModeToggleRequested,
|
||||||
|
handleLookupWindowToggleRequested,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { createMouseHandlers } from './mouse.js';
|
import { createMouseHandlers } from './mouse.js';
|
||||||
|
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
||||||
|
|
||||||
function createClassList() {
|
function createClassList() {
|
||||||
const classes = new Set<string>();
|
const classes = new Set<string>();
|
||||||
@@ -28,6 +29,12 @@ function createDeferred<T>() {
|
|||||||
return { promise, resolve };
|
return { promise, resolve };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForNextTick(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createMouseTestContext() {
|
function createMouseTestContext() {
|
||||||
const overlayClassList = createClassList();
|
const overlayClassList = createClassList();
|
||||||
const subtitleRootClassList = createClassList();
|
const subtitleRootClassList = createClassList();
|
||||||
@@ -78,6 +85,7 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena
|
|||||||
getCurrentYPercent: () => 10,
|
getCurrentYPercent: () => 10,
|
||||||
persistSubtitlePositionPatch: () => {},
|
persistSubtitlePositionPatch: () => {},
|
||||||
getSubtitleHoverAutoPauseEnabled: () => true,
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
getPlaybackPaused: async () => false,
|
getPlaybackPaused: async () => false,
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
mpvCommands.push(command);
|
mpvCommands.push(command);
|
||||||
@@ -106,6 +114,7 @@ test('auto-pause on subtitle hover skips when playback is already paused', async
|
|||||||
getCurrentYPercent: () => 10,
|
getCurrentYPercent: () => 10,
|
||||||
persistSubtitlePositionPatch: () => {},
|
persistSubtitlePositionPatch: () => {},
|
||||||
getSubtitleHoverAutoPauseEnabled: () => true,
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
getPlaybackPaused: async () => true,
|
getPlaybackPaused: async () => true,
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
mpvCommands.push(command);
|
mpvCommands.push(command);
|
||||||
@@ -131,6 +140,7 @@ test('auto-pause on subtitle hover is skipped when disabled in config', async ()
|
|||||||
getCurrentYPercent: () => 10,
|
getCurrentYPercent: () => 10,
|
||||||
persistSubtitlePositionPatch: () => {},
|
persistSubtitlePositionPatch: () => {},
|
||||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
getPlaybackPaused: async () => false,
|
getPlaybackPaused: async () => false,
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
mpvCommands.push(command);
|
mpvCommands.push(command);
|
||||||
@@ -157,6 +167,7 @@ test('pending hover pause check is ignored when mouse leaves before pause state
|
|||||||
getCurrentYPercent: () => 10,
|
getCurrentYPercent: () => 10,
|
||||||
persistSubtitlePositionPatch: () => {},
|
persistSubtitlePositionPatch: () => {},
|
||||||
getSubtitleHoverAutoPauseEnabled: () => true,
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
getPlaybackPaused: async () => deferred.promise,
|
getPlaybackPaused: async () => deferred.promise,
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
mpvCommands.push(command);
|
mpvCommands.push(command);
|
||||||
@@ -170,3 +181,273 @@ test('pending hover pause check is ignored when mouse leaves before pause state
|
|||||||
|
|
||||||
assert.deepEqual(mpvCommands, []);
|
assert.deepEqual(mpvCommands, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('hover pause resumes immediately on subtitle leave even when yomitan popup is visible', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||||
|
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||||
|
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||||
|
const windowListeners = new Map<string, Array<() => void>>();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: () => void) => {
|
||||||
|
const bucket = windowListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
windowListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: () => {},
|
||||||
|
},
|
||||||
|
focus: () => {},
|
||||||
|
innerHeight: 1000,
|
||||||
|
getSelection: () => null,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
body: {},
|
||||||
|
elementFromPoint: () => null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: class {
|
||||||
|
observe() {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
ELEMENT_NODE: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
await handlers.handleMouseEnter();
|
||||||
|
await handlers.handleMouseLeave();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-pause still works when yomitan popup is already visible', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||||
|
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||||
|
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||||
|
const windowListeners = new Map<string, Array<() => void>>();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: () => void) => {
|
||||||
|
const bucket = windowListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
windowListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: () => {},
|
||||||
|
},
|
||||||
|
focus: () => {},
|
||||||
|
innerHeight: 1000,
|
||||||
|
getSelection: () => null,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
body: {},
|
||||||
|
elementFromPoint: () => null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: class {
|
||||||
|
observe() {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
ELEMENT_NODE: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
await handlers.handleMouseEnter();
|
||||||
|
await handlers.handleMouseLeave();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('popup open pauses and popup close resumes when yomitan popup auto-pause is enabled', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||||
|
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||||
|
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||||
|
const windowListeners = new Map<string, Array<() => void>>();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: () => void) => {
|
||||||
|
const bucket = windowListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
windowListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: () => {},
|
||||||
|
},
|
||||||
|
focus: () => {},
|
||||||
|
innerHeight: 1000,
|
||||||
|
getSelection: () => null,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
body: {},
|
||||||
|
elementFromPoint: () => null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: class {
|
||||||
|
observe() {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
ELEMENT_NODE: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => true,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command: (string | number)[]) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
await waitForNextTick();
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ModalStateReader, RendererContext } from '../context';
|
|||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
hasYomitanPopupIframe,
|
isYomitanPopupVisible,
|
||||||
isYomitanPopupIframe,
|
isYomitanPopupIframe,
|
||||||
} from '../yomitan-popup.js';
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
@@ -14,16 +14,72 @@ export function createMouseHandlers(
|
|||||||
getCurrentYPercent: () => number;
|
getCurrentYPercent: () => number;
|
||||||
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
||||||
getSubtitleHoverAutoPauseEnabled: () => boolean;
|
getSubtitleHoverAutoPauseEnabled: () => boolean;
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => boolean;
|
||||||
getPlaybackPaused: () => Promise<boolean | null>;
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let yomitanPopupVisible = false;
|
let yomitanPopupVisible = false;
|
||||||
let hoverPauseRequestId = 0;
|
let hoverPauseRequestId = 0;
|
||||||
|
let popupPauseRequestId = 0;
|
||||||
let pausedBySubtitleHover = false;
|
let pausedBySubtitleHover = false;
|
||||||
|
let pausedByYomitanPopup = false;
|
||||||
|
|
||||||
|
function maybeResumeHoverPause(): void {
|
||||||
|
if (!pausedBySubtitleHover) return;
|
||||||
|
if (pausedByYomitanPopup) return;
|
||||||
|
if (ctx.state.isOverSubtitle) return;
|
||||||
|
pausedBySubtitleHover = false;
|
||||||
|
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeResumeYomitanPopupPause(): void {
|
||||||
|
if (!pausedByYomitanPopup) return;
|
||||||
|
pausedByYomitanPopup = false;
|
||||||
|
if (ctx.state.isOverSubtitle && options.getSubtitleHoverAutoPauseEnabled()) {
|
||||||
|
pausedBySubtitleHover = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybePauseForYomitanPopup(): Promise<void> {
|
||||||
|
if (!yomitanPopupVisible || !options.getYomitanPopupAutoPauseEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++popupPauseRequestId;
|
||||||
|
if (pausedByYomitanPopup) return;
|
||||||
|
|
||||||
|
if (pausedBySubtitleHover) {
|
||||||
|
pausedBySubtitleHover = false;
|
||||||
|
pausedByYomitanPopup = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let paused: boolean | null = null;
|
||||||
|
try {
|
||||||
|
paused = await options.getPlaybackPaused();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestId !== popupPauseRequestId ||
|
||||||
|
!yomitanPopupVisible ||
|
||||||
|
!options.getYomitanPopupAutoPauseEnabled()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (paused !== false) return;
|
||||||
|
|
||||||
|
options.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||||
|
pausedByYomitanPopup = true;
|
||||||
|
}
|
||||||
|
|
||||||
function enablePopupInteraction(): void {
|
function enablePopupInteraction(): void {
|
||||||
yomitanPopupVisible = true;
|
yomitanPopupVisible = true;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||||
window.electronAPI.setIgnoreMouseEvents(false);
|
window.electronAPI.setIgnoreMouseEvents(false);
|
||||||
@@ -34,12 +90,17 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disablePopupInteractionIfIdle(): void {
|
function disablePopupInteractionIfIdle(): void {
|
||||||
if (typeof document !== 'undefined' && hasYomitanPopupIframe(document)) {
|
if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) {
|
||||||
yomitanPopupVisible = true;
|
yomitanPopupVisible = true;
|
||||||
|
ctx.state.yomitanPopupVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
yomitanPopupVisible = false;
|
yomitanPopupVisible = false;
|
||||||
|
ctx.state.yomitanPopupVisible = false;
|
||||||
|
popupPauseRequestId += 1;
|
||||||
|
maybeResumeYomitanPopupPause();
|
||||||
|
maybeResumeHoverPause();
|
||||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
ctx.dom.overlay.classList.remove('interactive');
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||||
@@ -55,6 +116,10 @@ export function createMouseHandlers(
|
|||||||
window.electronAPI.setIgnoreMouseEvents(false);
|
window.electronAPI.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.getSubtitleHoverAutoPauseEnabled()) {
|
if (!options.getSubtitleHoverAutoPauseEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,10 +144,7 @@ export function createMouseHandlers(
|
|||||||
async function handleMouseLeave(): Promise<void> {
|
async function handleMouseLeave(): Promise<void> {
|
||||||
ctx.state.isOverSubtitle = false;
|
ctx.state.isOverSubtitle = false;
|
||||||
hoverPauseRequestId += 1;
|
hoverPauseRequestId += 1;
|
||||||
if (pausedBySubtitleHover) {
|
maybeResumeHoverPause();
|
||||||
pausedBySubtitleHover = false;
|
|
||||||
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
|
||||||
}
|
|
||||||
if (yomitanPopupVisible) return;
|
if (yomitanPopupVisible) return;
|
||||||
disablePopupInteractionIfIdle();
|
disablePopupInteractionIfIdle();
|
||||||
}
|
}
|
||||||
@@ -143,10 +205,13 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupYomitanObserver(): void {
|
function setupYomitanObserver(): void {
|
||||||
yomitanPopupVisible = hasYomitanPopupIframe(document);
|
yomitanPopupVisible = isYomitanPopupVisible(document);
|
||||||
|
ctx.state.yomitanPopupVisible = yomitanPopupVisible;
|
||||||
|
void maybePauseForYomitanPopup();
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
enablePopupInteraction();
|
enablePopupInteraction();
|
||||||
|
void maybePauseForYomitanPopup();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
@@ -160,6 +225,7 @@ export function createMouseHandlers(
|
|||||||
const element = node as Element;
|
const element = node as Element;
|
||||||
if (isYomitanPopupIframe(element)) {
|
if (isYomitanPopupIframe(element)) {
|
||||||
enablePopupInteraction();
|
enablePopupInteraction();
|
||||||
|
void maybePauseForYomitanPopup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
appendClipboardVideoToQueue: () => {
|
appendClipboardVideoToQueue: () => {
|
||||||
void window.electronAPI.appendClipboardVideoToQueue();
|
void window.electronAPI.appendClipboardVideoToQueue();
|
||||||
},
|
},
|
||||||
|
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||||
});
|
});
|
||||||
const mouseHandlers = createMouseHandlers(ctx, {
|
const mouseHandlers = createMouseHandlers(ctx, {
|
||||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||||
@@ -121,6 +122,7 @@ const mouseHandlers = createMouseHandlers(ctx, {
|
|||||||
getCurrentYPercent: positioning.getCurrentYPercent,
|
getCurrentYPercent: positioning.getCurrentYPercent,
|
||||||
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
|
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
|
||||||
getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover,
|
getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => ctx.state.autoPauseVideoOnYomitanPopup,
|
||||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
window.electronAPI.sendMpvCommand(command);
|
window.electronAPI.sendMpvCommand(command);
|
||||||
@@ -139,6 +141,16 @@ function truncateForErrorLog(text: string): string {
|
|||||||
return `${normalized.slice(0, 177)}...`;
|
return `${normalized.slice(0, 177)}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (data && typeof data.text === 'string') {
|
||||||
|
return data.text;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function getActiveModal(): string | null {
|
function getActiveModal(): string | null {
|
||||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||||
@@ -244,6 +256,20 @@ function registerModalOpenHandlers(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerKeyboardCommandHandlers(): void {
|
||||||
|
window.electronAPI.onKeyboardModeToggleRequested(() => {
|
||||||
|
runGuarded('keyboard-mode-toggle:requested', () => {
|
||||||
|
keyboardHandlers.handleKeyboardModeToggleRequested();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electronAPI.onLookupWindowToggleRequested(() => {
|
||||||
|
runGuarded('lookup-window-toggle:requested', () => {
|
||||||
|
keyboardHandlers.handleLookupWindowToggleRequested();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function runGuarded(action: string, fn: () => void): void {
|
function runGuarded(action: string, fn: () => void): void {
|
||||||
try {
|
try {
|
||||||
fn();
|
fn();
|
||||||
@@ -261,6 +287,7 @@ function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerModalOpenHandlers();
|
registerModalOpenHandlers();
|
||||||
|
registerKeyboardCommandHandlers();
|
||||||
|
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
|
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
|
||||||
@@ -270,11 +297,7 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||||
runGuarded('subtitle:update', () => {
|
runGuarded('subtitle:update', () => {
|
||||||
if (typeof data === 'string') {
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||||
lastSubtitlePreview = truncateForErrorLog(data);
|
|
||||||
} else if (data && typeof data.text === 'string') {
|
|
||||||
lastSubtitlePreview = truncateForErrorLog(data.text);
|
|
||||||
}
|
|
||||||
subtitleRenderer.renderSubtitle(data);
|
subtitleRenderer.renderSubtitle(data);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
});
|
});
|
||||||
@@ -287,8 +310,13 @@ async function init(): Promise<void> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
let initialSubtitle: SubtitleData | string = '';
|
||||||
lastSubtitlePreview = truncateForErrorLog(initialSubtitle);
|
try {
|
||||||
|
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
|
||||||
|
} catch {
|
||||||
|
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
||||||
|
}
|
||||||
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
||||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export type RendererState = {
|
|||||||
jlptN5Color: string;
|
jlptN5Color: string;
|
||||||
preserveSubtitleLineBreaks: boolean;
|
preserveSubtitleLineBreaks: boolean;
|
||||||
autoPauseVideoOnSubtitleHover: boolean;
|
autoPauseVideoOnSubtitleHover: boolean;
|
||||||
|
autoPauseVideoOnYomitanPopup: boolean;
|
||||||
frequencyDictionaryEnabled: boolean;
|
frequencyDictionaryEnabled: boolean;
|
||||||
frequencyDictionaryTopX: number;
|
frequencyDictionaryTopX: number;
|
||||||
frequencyDictionaryMode: 'single' | 'banded';
|
frequencyDictionaryMode: 'single' | 'banded';
|
||||||
@@ -78,6 +79,9 @@ export type RendererState = {
|
|||||||
keybindingsMap: Map<string, (string | number)[]>;
|
keybindingsMap: Map<string, (string | number)[]>;
|
||||||
chordPending: boolean;
|
chordPending: boolean;
|
||||||
chordTimeout: ReturnType<typeof setTimeout> | null;
|
chordTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
|
keyboardDrivenModeEnabled: boolean;
|
||||||
|
keyboardSelectedWordIndex: number | null;
|
||||||
|
yomitanPopupVisible: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createRendererState(): RendererState {
|
export function createRendererState(): RendererState {
|
||||||
@@ -128,6 +132,7 @@ export function createRendererState(): RendererState {
|
|||||||
jlptN5Color: '#8aadf4',
|
jlptN5Color: '#8aadf4',
|
||||||
preserveSubtitleLineBreaks: false,
|
preserveSubtitleLineBreaks: false,
|
||||||
autoPauseVideoOnSubtitleHover: false,
|
autoPauseVideoOnSubtitleHover: false,
|
||||||
|
autoPauseVideoOnYomitanPopup: false,
|
||||||
frequencyDictionaryEnabled: false,
|
frequencyDictionaryEnabled: false,
|
||||||
frequencyDictionaryTopX: 1000,
|
frequencyDictionaryTopX: 1000,
|
||||||
frequencyDictionaryMode: 'single',
|
frequencyDictionaryMode: 'single',
|
||||||
@@ -141,5 +146,8 @@ export function createRendererState(): RendererState {
|
|||||||
keybindingsMap: new Map(),
|
keybindingsMap: new Map(),
|
||||||
chordPending: false,
|
chordPending: false,
|
||||||
chordTimeout: null,
|
chordTimeout: null,
|
||||||
|
keyboardDrivenModeEnabled: false,
|
||||||
|
keyboardSelectedWordIndex: null,
|
||||||
|
yomitanPopupVisible: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,6 +340,15 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
-webkit-text-fill-color: currentColor !important;
|
-webkit-text-fill-color: currentColor !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#subtitleRoot .word.keyboard-selected {
|
||||||
|
outline: 2px solid rgba(135, 201, 255, 0.92);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(12, 18, 28, 0.68),
|
||||||
|
0 0 18px rgba(120, 188, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
#subtitleRoot .word[data-frequency-rank]::before {
|
#subtitleRoot .word[data-frequency-rank]::before {
|
||||||
content: attr(data-frequency-rank);
|
content: attr(data-frequency-rank);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -363,7 +372,8 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#subtitleRoot .word[data-frequency-rank]:hover::before {
|
#subtitleRoot .word[data-frequency-rank]:hover::before,
|
||||||
|
#subtitleRoot .word.keyboard-selected[data-frequency-rank]::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
@@ -390,7 +400,8 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#subtitleRoot .word[data-jlpt-level]:hover::after {
|
#subtitleRoot .word[data-jlpt-level]:hover::after,
|
||||||
|
#subtitleRoot .word.keyboard-selected[data-jlpt-level]::after {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,11 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
|||||||
'#subtitleRoot .word[data-frequency-rank]:hover::before',
|
'#subtitleRoot .word[data-frequency-rank]:hover::before',
|
||||||
);
|
);
|
||||||
assert.match(frequencyTooltipHoverBlock, /opacity:\s*1;/);
|
assert.match(frequencyTooltipHoverBlock, /opacity:\s*1;/);
|
||||||
|
const frequencyTooltipKeyboardSelectedBlock = extractClassBlock(
|
||||||
|
cssText,
|
||||||
|
'#subtitleRoot .word.keyboard-selected[data-frequency-rank]::before',
|
||||||
|
);
|
||||||
|
assert.match(frequencyTooltipKeyboardSelectedBlock, /opacity:\s*1;/);
|
||||||
|
|
||||||
const jlptTooltipBaseBlock = extractClassBlock(
|
const jlptTooltipBaseBlock = extractClassBlock(
|
||||||
cssText,
|
cssText,
|
||||||
@@ -424,6 +429,11 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
|||||||
'#subtitleRoot .word[data-jlpt-level]:hover::after',
|
'#subtitleRoot .word[data-jlpt-level]:hover::after',
|
||||||
);
|
);
|
||||||
assert.match(jlptTooltipHoverBlock, /opacity:\s*1;/);
|
assert.match(jlptTooltipHoverBlock, /opacity:\s*1;/);
|
||||||
|
const jlptTooltipKeyboardSelectedBlock = extractClassBlock(
|
||||||
|
cssText,
|
||||||
|
'#subtitleRoot .word.keyboard-selected[data-jlpt-level]::after',
|
||||||
|
);
|
||||||
|
assert.match(jlptTooltipKeyboardSelectedBlock, /opacity:\s*1;/);
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
cssText,
|
cssText,
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
|||||||
ctx.state.jlptN5Color = jlptColors.N5;
|
ctx.state.jlptN5Color = jlptColors.N5;
|
||||||
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
|
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
|
||||||
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
|
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
|
||||||
|
ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false;
|
||||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
|
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
|
||||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
|
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
|
||||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
|
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
|
export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]';
|
||||||
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
|
export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown';
|
||||||
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden';
|
||||||
|
export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
|
||||||
|
export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
|
||||||
|
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||||
|
|
||||||
export function isYomitanPopupIframe(element: Element | null): boolean {
|
export function isYomitanPopupIframe(element: Element | null): boolean {
|
||||||
if (!element) return false;
|
if (!element) return false;
|
||||||
@@ -14,3 +17,19 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
|
|||||||
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
||||||
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
|
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
||||||
|
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||||
|
for (const iframe of popupIframes) {
|
||||||
|
const rect = iframe.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const styles = window.getComputedStyle(iframe);
|
||||||
|
if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export const IPC_CHANNELS = {
|
|||||||
runtimeOptionsChanged: 'runtime-options:changed',
|
runtimeOptionsChanged: 'runtime-options:changed',
|
||||||
runtimeOptionsOpen: 'runtime-options:open',
|
runtimeOptionsOpen: 'runtime-options:open',
|
||||||
jimakuOpen: 'jimaku:open',
|
jimakuOpen: 'jimaku:open',
|
||||||
|
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||||
|
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||||
configHotReload: 'config:hot-reload',
|
configHotReload: 'config:hot-reload',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { codecToExtension } from './utils';
|
import { codecToExtension, getSubsyncConfig } from './utils';
|
||||||
|
|
||||||
test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => {
|
test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => {
|
||||||
assert.equal(codecToExtension('subrip'), 'srt');
|
assert.equal(codecToExtension('subrip'), 'srt');
|
||||||
@@ -12,3 +12,13 @@ test('codecToExtension maps stream/web formats to ffmpeg extractable extensions'
|
|||||||
test('codecToExtension returns null for unsupported codecs', () => {
|
test('codecToExtension returns null for unsupported codecs', () => {
|
||||||
assert.equal(codecToExtension('unsupported-codec'), null);
|
assert.equal(codecToExtension('unsupported-codec'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getSubsyncConfig defaults replace to true', () => {
|
||||||
|
assert.equal(getSubsyncConfig(undefined).replace, true);
|
||||||
|
assert.equal(getSubsyncConfig({}).replace, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getSubsyncConfig respects explicit replace value', () => {
|
||||||
|
assert.equal(getSubsyncConfig({ replace: false }).replace, false);
|
||||||
|
assert.equal(getSubsyncConfig({ replace: true }).replace, true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface SubsyncResolvedConfig {
|
|||||||
alassPath: string;
|
alassPath: string;
|
||||||
ffsubsyncPath: string;
|
ffsubsyncPath: string;
|
||||||
ffmpegPath: string;
|
ffmpegPath: string;
|
||||||
|
replace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
|
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
|
||||||
@@ -55,6 +56,7 @@ export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncReso
|
|||||||
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
|
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
|
||||||
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
|
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
|
||||||
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
|
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
|
||||||
|
replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface SubsyncConfig {
|
|||||||
alass_path?: string;
|
alass_path?: string;
|
||||||
ffsubsync_path?: string;
|
ffsubsync_path?: string;
|
||||||
ffmpeg_path?: string;
|
ffmpeg_path?: string;
|
||||||
|
replace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartupWarmupsConfig {
|
export interface StartupWarmupsConfig {
|
||||||
@@ -289,6 +290,7 @@ export interface SubtitleStyleConfig {
|
|||||||
enableJlpt?: boolean;
|
enableJlpt?: boolean;
|
||||||
preserveLineBreaks?: boolean;
|
preserveLineBreaks?: boolean;
|
||||||
autoPauseVideoOnHover?: boolean;
|
autoPauseVideoOnHover?: boolean;
|
||||||
|
autoPauseVideoOnYomitanPopup?: boolean;
|
||||||
hoverTokenColor?: string;
|
hoverTokenColor?: string;
|
||||||
hoverTokenBackgroundColor?: string;
|
hoverTokenBackgroundColor?: string;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
@@ -841,6 +843,8 @@ export interface ElectronAPI {
|
|||||||
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
||||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||||
onOpenJimaku: (callback: () => void) => void;
|
onOpenJimaku: (callback: () => void) => void;
|
||||||
|
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||||
|
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
||||||
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
||||||
|
|||||||
37
vendor/yomitan/js/app/frontend.js
vendored
37
vendor/yomitan/js/app/frontend.js
vendored
@@ -28,6 +28,40 @@ import {TextSourceGenerator} from '../dom/text-source-generator.js';
|
|||||||
import {TextSourceRange} from '../dom/text-source-range.js';
|
import {TextSourceRange} from '../dom/text-source-range.js';
|
||||||
import {TextScanner} from '../language/text-scanner.js';
|
import {TextScanner} from '../language/text-scanner.js';
|
||||||
|
|
||||||
|
const SUBMINER_FRONTEND_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||||
|
const subminerFrontendInstances = new Set();
|
||||||
|
let subminerFrontendCommandBridgeRegistered = false;
|
||||||
|
|
||||||
|
function getActiveFrontendForSubminerCommand() {
|
||||||
|
/** @type {?Frontend} */
|
||||||
|
let fallback = null;
|
||||||
|
for (const frontend of subminerFrontendInstances) {
|
||||||
|
if (frontend._textScanner?.isEnabled?.()) {
|
||||||
|
return frontend;
|
||||||
|
}
|
||||||
|
if (fallback === null) {
|
||||||
|
fallback = frontend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSubminerFrontendCommandBridge() {
|
||||||
|
if (subminerFrontendCommandBridgeRegistered) { return; }
|
||||||
|
subminerFrontendCommandBridgeRegistered = true;
|
||||||
|
|
||||||
|
window.addEventListener(SUBMINER_FRONTEND_COMMAND_EVENT, (event) => {
|
||||||
|
const frontend = getActiveFrontendForSubminerCommand();
|
||||||
|
if (frontend === null) { return; }
|
||||||
|
const detail = event.detail;
|
||||||
|
if (typeof detail !== 'object' || detail === null) { return; }
|
||||||
|
|
||||||
|
if (detail.type === 'scanSelectedText') {
|
||||||
|
frontend._onApiScanSelectedText();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the main class responsible for scanning and handling webpage content.
|
* This is the main class responsible for scanning and handling webpage content.
|
||||||
*/
|
*/
|
||||||
@@ -158,6 +192,9 @@ export class Frontend {
|
|||||||
* Prepares the instance for use.
|
* Prepares the instance for use.
|
||||||
*/
|
*/
|
||||||
async prepare() {
|
async prepare() {
|
||||||
|
registerSubminerFrontendCommandBridge();
|
||||||
|
subminerFrontendInstances.add(this);
|
||||||
|
|
||||||
await this.updateOptions();
|
await this.updateOptions();
|
||||||
try {
|
try {
|
||||||
const {zoomFactor} = await this._application.api.getZoom();
|
const {zoomFactor} = await this._application.api.getZoom();
|
||||||
|
|||||||
84
vendor/yomitan/js/app/popup.js
vendored
84
vendor/yomitan/js/app/popup.js
vendored
@@ -28,6 +28,85 @@ import {loadStyle} from '../dom/style-util.js';
|
|||||||
import {checkPopupPreviewURL} from '../pages/settings/popup-preview-controller.js';
|
import {checkPopupPreviewURL} from '../pages/settings/popup-preview-controller.js';
|
||||||
import {ThemeController} from './theme-controller.js';
|
import {ThemeController} from './theme-controller.js';
|
||||||
|
|
||||||
|
const SUBMINER_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||||
|
const subminerPopupInstances = new Set();
|
||||||
|
let subminerPopupCommandBridgeRegistered = false;
|
||||||
|
|
||||||
|
function getActivePopupForSubminerCommand() {
|
||||||
|
/** @type {?Popup} */
|
||||||
|
let fallback = null;
|
||||||
|
for (const popup of subminerPopupInstances) {
|
||||||
|
if (!popup.isVisibleSync()) { continue; }
|
||||||
|
fallback = popup;
|
||||||
|
if (popup.isPointerOverSelfOrChildren()) {
|
||||||
|
return popup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSubminerPopupCommandBridge() {
|
||||||
|
if (subminerPopupCommandBridgeRegistered) { return; }
|
||||||
|
subminerPopupCommandBridgeRegistered = true;
|
||||||
|
window.addEventListener(SUBMINER_POPUP_COMMAND_EVENT, (event) => {
|
||||||
|
const popup = getActivePopupForSubminerCommand();
|
||||||
|
if (popup === null) { return; }
|
||||||
|
const detail = event.detail;
|
||||||
|
if (typeof detail !== 'object' || detail === null) { return; }
|
||||||
|
|
||||||
|
if (detail.type === 'simulateHotkey') {
|
||||||
|
const key = detail.key;
|
||||||
|
const rawModifiers = detail.modifiers;
|
||||||
|
if (typeof key !== 'string' || !Array.isArray(rawModifiers)) { return; }
|
||||||
|
const modifiers = rawModifiers.filter((modifier) => (
|
||||||
|
modifier === 'alt' ||
|
||||||
|
modifier === 'ctrl' ||
|
||||||
|
modifier === 'shift' ||
|
||||||
|
modifier === 'meta'
|
||||||
|
));
|
||||||
|
void popup._invokeSafe('displaySimulateHotkey', {key, modifiers});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.type === 'forwardKeyDown') {
|
||||||
|
const code = detail.code;
|
||||||
|
const key = detail.key;
|
||||||
|
const rawModifiers = detail.modifiers;
|
||||||
|
if (typeof code !== 'string' || typeof key !== 'string' || !Array.isArray(rawModifiers)) { return; }
|
||||||
|
const modifiers = rawModifiers.filter((modifier) => (
|
||||||
|
modifier === 'alt' ||
|
||||||
|
modifier === 'ctrl' ||
|
||||||
|
modifier === 'shift' ||
|
||||||
|
modifier === 'meta'
|
||||||
|
));
|
||||||
|
void popup._invokeSafe('displayForwardKeyDown', {
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
modifiers,
|
||||||
|
repeat: detail.repeat === true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.type === 'mineSelected') {
|
||||||
|
void popup._invokeSafe('displayMineSelected', void 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.type === 'cycleAudioSource') {
|
||||||
|
const direction = detail.direction === -1 ? -1 : 1;
|
||||||
|
void popup._invokeSafe('displayAudioCycleSource', {direction});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.type === 'setVisible') {
|
||||||
|
if (detail.visible === false) {
|
||||||
|
popup.hide(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is the container which hosts the display of search results.
|
* This class is the container which hosts the display of search results.
|
||||||
* @augments EventDispatcher<import('popup').Events>
|
* @augments EventDispatcher<import('popup').Events>
|
||||||
@@ -209,6 +288,8 @@ export class Popup extends EventDispatcher {
|
|||||||
* Prepares the popup for use.
|
* Prepares the popup for use.
|
||||||
*/
|
*/
|
||||||
prepare() {
|
prepare() {
|
||||||
|
registerSubminerPopupCommandBridge();
|
||||||
|
subminerPopupInstances.add(this);
|
||||||
this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this));
|
this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this));
|
||||||
this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this));
|
this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this));
|
||||||
this._frame.addEventListener('mousedown', (e) => e.stopPropagation());
|
this._frame.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||||
@@ -471,6 +552,7 @@ export class Popup extends EventDispatcher {
|
|||||||
*/
|
*/
|
||||||
_onFrameMouseOver() {
|
_onFrameMouseOver() {
|
||||||
this._isPointerOverPopup = true;
|
this._isPointerOverPopup = true;
|
||||||
|
window.dispatchEvent(new CustomEvent('yomitan-popup-mouse-enter'));
|
||||||
|
|
||||||
this.stopHideDelayed();
|
this.stopHideDelayed();
|
||||||
this.trigger('mouseOver', {});
|
this.trigger('mouseOver', {});
|
||||||
@@ -486,6 +568,7 @@ export class Popup extends EventDispatcher {
|
|||||||
*/
|
*/
|
||||||
_onFrameMouseOut() {
|
_onFrameMouseOut() {
|
||||||
this._isPointerOverPopup = false;
|
this._isPointerOverPopup = false;
|
||||||
|
window.dispatchEvent(new CustomEvent('yomitan-popup-mouse-leave'));
|
||||||
|
|
||||||
this.trigger('mouseOut', {});
|
this.trigger('mouseOut', {});
|
||||||
|
|
||||||
@@ -836,6 +919,7 @@ export class Popup extends EventDispatcher {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_onExtensionUnloaded() {
|
_onExtensionUnloaded() {
|
||||||
|
subminerPopupInstances.delete(this);
|
||||||
this._invokeWindow('displayExtensionUnloaded', void 0);
|
this._invokeWindow('displayExtensionUnloaded', void 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
109
vendor/yomitan/js/display/display-audio.js
vendored
109
vendor/yomitan/js/display/display-audio.js
vendored
@@ -69,6 +69,10 @@ export class DisplayAudio {
|
|||||||
]);
|
]);
|
||||||
/** @type {?boolean} */
|
/** @type {?boolean} */
|
||||||
this._enableDefaultAudioSources = null;
|
this._enableDefaultAudioSources = null;
|
||||||
|
/** @type {?number} */
|
||||||
|
this._audioCycleSourceIndex = null;
|
||||||
|
/** @type {Map<number, number>} */
|
||||||
|
this._audioCycleAudioInfoIndexMap = new Map();
|
||||||
/** @type {(event: MouseEvent) => void} */
|
/** @type {(event: MouseEvent) => void} */
|
||||||
this._onAudioPlayButtonClickBind = this._onAudioPlayButtonClick.bind(this);
|
this._onAudioPlayButtonClickBind = this._onAudioPlayButtonClick.bind(this);
|
||||||
/** @type {(event: MouseEvent) => void} */
|
/** @type {(event: MouseEvent) => void} */
|
||||||
@@ -96,6 +100,7 @@ export class DisplayAudio {
|
|||||||
]);
|
]);
|
||||||
this._display.registerDirectMessageHandlers([
|
this._display.registerDirectMessageHandlers([
|
||||||
['displayAudioClearAutoPlayTimer', this._onMessageClearAutoPlayTimer.bind(this)],
|
['displayAudioClearAutoPlayTimer', this._onMessageClearAutoPlayTimer.bind(this)],
|
||||||
|
['displayAudioCycleSource', this._onMessageCycleAudioSource.bind(this)],
|
||||||
]);
|
]);
|
||||||
/* eslint-enable @stylistic/no-multi-spaces */
|
/* eslint-enable @stylistic/no-multi-spaces */
|
||||||
this._display.on('optionsUpdated', this._onOptionsUpdated.bind(this));
|
this._display.on('optionsUpdated', this._onOptionsUpdated.bind(this));
|
||||||
@@ -186,6 +191,8 @@ export class DisplayAudio {
|
|||||||
/** @type {Map<string, import('display-audio').AudioSource[]>} */
|
/** @type {Map<string, import('display-audio').AudioSource[]>} */
|
||||||
const nameMap = new Map();
|
const nameMap = new Map();
|
||||||
this._audioSources.length = 0;
|
this._audioSources.length = 0;
|
||||||
|
this._audioCycleSourceIndex = null;
|
||||||
|
this._audioCycleAudioInfoIndexMap.clear();
|
||||||
for (const {type, url, voice} of sources) {
|
for (const {type, url, voice} of sources) {
|
||||||
this._addAudioSourceInfo(type, url, voice, true, nameMap);
|
this._addAudioSourceInfo(type, url, voice, true, nameMap);
|
||||||
requiredAudioSources.delete(type);
|
requiredAudioSources.delete(type);
|
||||||
@@ -204,6 +211,8 @@ export class DisplayAudio {
|
|||||||
_onContentClear() {
|
_onContentClear() {
|
||||||
this._entriesToken = {};
|
this._entriesToken = {};
|
||||||
this._cache.clear();
|
this._cache.clear();
|
||||||
|
this._audioCycleSourceIndex = null;
|
||||||
|
this._audioCycleAudioInfoIndexMap.clear();
|
||||||
this.clearAutoPlayTimer();
|
this.clearAutoPlayTimer();
|
||||||
this._eventListeners.removeAllEventListeners();
|
this._eventListeners.removeAllEventListeners();
|
||||||
}
|
}
|
||||||
@@ -273,6 +282,73 @@ export class DisplayAudio {
|
|||||||
this.clearAutoPlayTimer();
|
this.clearAutoPlayTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{direction?: number}} details
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async _onMessageCycleAudioSource({direction}) {
|
||||||
|
/** @type {import('display-audio').AudioSource[]} */
|
||||||
|
const configuredSources = this._audioSources.filter((source) => source.isInOptions);
|
||||||
|
const sources = configuredSources.length > 0 ? configuredSources : this._audioSources;
|
||||||
|
if (sources.length === 0) { return false; }
|
||||||
|
|
||||||
|
const dictionaryEntryIndex = this._display.selectedIndex;
|
||||||
|
const headwordIndex = 0;
|
||||||
|
const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex);
|
||||||
|
if (headword === null) { return false; }
|
||||||
|
const {term, reading} = headword;
|
||||||
|
|
||||||
|
const primaryCardAudio = this._getPrimaryCardAudio(term, reading);
|
||||||
|
let source = null;
|
||||||
|
if (primaryCardAudio !== null) {
|
||||||
|
source = sources.find((item) => item.index === primaryCardAudio.index) ?? null;
|
||||||
|
}
|
||||||
|
if (source === null) {
|
||||||
|
const fallbackIndex = (
|
||||||
|
this._audioCycleSourceIndex !== null &&
|
||||||
|
this._audioCycleSourceIndex >= 0 &&
|
||||||
|
this._audioCycleSourceIndex < sources.length
|
||||||
|
) ? this._audioCycleSourceIndex : 0;
|
||||||
|
source = sources[fallbackIndex] ?? null;
|
||||||
|
this._audioCycleSourceIndex = fallbackIndex;
|
||||||
|
}
|
||||||
|
if (source === null) { return false; }
|
||||||
|
|
||||||
|
const infoList = await this._getSourceAudioInfoList(source, term, reading);
|
||||||
|
const infoListLength = infoList.length;
|
||||||
|
if (infoListLength === 0) { return false; }
|
||||||
|
|
||||||
|
const step = direction === -1 ? -1 : 1;
|
||||||
|
let currentSubIndex = this._audioCycleAudioInfoIndexMap.get(source.index);
|
||||||
|
if (
|
||||||
|
typeof currentSubIndex !== 'number' &&
|
||||||
|
primaryCardAudio !== null &&
|
||||||
|
primaryCardAudio.index === source.index &&
|
||||||
|
primaryCardAudio.subIndex !== null
|
||||||
|
) {
|
||||||
|
currentSubIndex = primaryCardAudio.subIndex;
|
||||||
|
}
|
||||||
|
if (typeof currentSubIndex !== 'number') {
|
||||||
|
currentSubIndex = step > 0 ? -1 : infoListLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < infoListLength; ++i) {
|
||||||
|
currentSubIndex = (currentSubIndex + step + infoListLength) % infoListLength;
|
||||||
|
const {valid} = await this._playAudio(
|
||||||
|
dictionaryEntryIndex,
|
||||||
|
headwordIndex,
|
||||||
|
[source],
|
||||||
|
currentSubIndex,
|
||||||
|
);
|
||||||
|
if (valid) {
|
||||||
|
this._audioCycleAudioInfoIndexMap.set(source.index, currentSubIndex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('settings').AudioSourceType} type
|
* @param {import('settings').AudioSourceType} type
|
||||||
* @param {string} url
|
* @param {string} url
|
||||||
@@ -691,6 +767,39 @@ export class DisplayAudio {
|
|||||||
return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null}));
|
return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('display-audio').AudioSource} source
|
||||||
|
* @param {string} term
|
||||||
|
* @param {string} reading
|
||||||
|
* @returns {Promise<import('display-audio').AudioInfoList>}
|
||||||
|
*/
|
||||||
|
async _getSourceAudioInfoList(source, term, reading) {
|
||||||
|
const cacheItem = this._getCacheItem(term, reading, true);
|
||||||
|
if (typeof cacheItem === 'undefined') { return []; }
|
||||||
|
const {sourceMap} = cacheItem;
|
||||||
|
|
||||||
|
let cacheUpdated = false;
|
||||||
|
let sourceInfo = sourceMap.get(source.index);
|
||||||
|
if (typeof sourceInfo === 'undefined') {
|
||||||
|
const infoListPromise = this._getTermAudioInfoList(source, term, reading);
|
||||||
|
sourceInfo = {infoListPromise, infoList: null};
|
||||||
|
sourceMap.set(source.index, sourceInfo);
|
||||||
|
cacheUpdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {infoList} = sourceInfo;
|
||||||
|
if (infoList === null) {
|
||||||
|
infoList = await sourceInfo.infoListPromise;
|
||||||
|
sourceInfo.infoList = infoList;
|
||||||
|
cacheUpdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheUpdated) {
|
||||||
|
this._updateOpenMenu();
|
||||||
|
}
|
||||||
|
return infoList;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} dictionaryEntryIndex
|
* @param {number} dictionaryEntryIndex
|
||||||
* @param {number} headwordIndex
|
* @param {number} headwordIndex
|
||||||
|
|||||||
54
vendor/yomitan/js/display/display.js
vendored
54
vendor/yomitan/js/display/display.js
vendored
@@ -224,6 +224,9 @@ export class Display extends EventDispatcher {
|
|||||||
['displaySetContentScale', this._onMessageSetContentScale.bind(this)],
|
['displaySetContentScale', this._onMessageSetContentScale.bind(this)],
|
||||||
['displayConfigure', this._onMessageConfigure.bind(this)],
|
['displayConfigure', this._onMessageConfigure.bind(this)],
|
||||||
['displayVisibilityChanged', this._onMessageVisibilityChanged.bind(this)],
|
['displayVisibilityChanged', this._onMessageVisibilityChanged.bind(this)],
|
||||||
|
['displaySimulateHotkey', this._onMessageSimulateHotkey.bind(this)],
|
||||||
|
['displayForwardKeyDown', this._onMessageForwardKeyDown.bind(this)],
|
||||||
|
['displayMineSelected', this._onMessageMineSelected.bind(this)],
|
||||||
]);
|
]);
|
||||||
this.registerWindowMessageHandlers([
|
this.registerWindowMessageHandlers([
|
||||||
['displayExtensionUnloaded', this._onMessageExtensionUnloaded.bind(this)],
|
['displayExtensionUnloaded', this._onMessageExtensionUnloaded.bind(this)],
|
||||||
@@ -785,6 +788,57 @@ export class Display extends EventDispatcher {
|
|||||||
this.trigger('frameVisibilityChange', {value});
|
this.trigger('frameVisibilityChange', {value});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{key: string, modifiers: unknown[]}} details
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_onMessageSimulateHotkey({key, modifiers}) {
|
||||||
|
if (typeof key !== 'string' || !Array.isArray(modifiers)) { return false; }
|
||||||
|
const normalizedModifiers = modifiers.filter((modifier) => (
|
||||||
|
modifier === 'alt' ||
|
||||||
|
modifier === 'ctrl' ||
|
||||||
|
modifier === 'shift' ||
|
||||||
|
modifier === 'meta'
|
||||||
|
));
|
||||||
|
return this._hotkeyHandler.simulate(key, normalizedModifiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{key: string, code: string, modifiers: unknown[], repeat?: boolean}} details
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_onMessageForwardKeyDown({key, code, modifiers, repeat = false}) {
|
||||||
|
if (typeof key !== 'string' || typeof code !== 'string' || !Array.isArray(modifiers)) { return false; }
|
||||||
|
const normalizedModifiers = modifiers.filter((modifier) => (
|
||||||
|
modifier === 'alt' ||
|
||||||
|
modifier === 'ctrl' ||
|
||||||
|
modifier === 'shift' ||
|
||||||
|
modifier === 'meta'
|
||||||
|
));
|
||||||
|
const eventInit = {
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
repeat,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
altKey: normalizedModifiers.includes('alt'),
|
||||||
|
ctrlKey: normalizedModifiers.includes('ctrl'),
|
||||||
|
shiftKey: normalizedModifiers.includes('shift'),
|
||||||
|
metaKey: normalizedModifiers.includes('meta'),
|
||||||
|
};
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', eventInit));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_onMessageMineSelected() {
|
||||||
|
document.dispatchEvent(new CustomEvent('subminer-display-mine-selected'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {import('display').WindowApiHandler<'displayExtensionUnloaded'>} */
|
/** @type {import('display').WindowApiHandler<'displayExtensionUnloaded'>} */
|
||||||
_onMessageExtensionUnloaded() {
|
_onMessageExtensionUnloaded() {
|
||||||
this._application.webExtension.triggerUnloaded();
|
this._application.webExtension.triggerUnloaded();
|
||||||
|
|||||||
55
vendor/yomitan/js/display/popup-main.js
vendored
55
vendor/yomitan/js/display/popup-main.js
vendored
@@ -47,6 +47,61 @@ await Application.main(true, async (application) => {
|
|||||||
const displayResizer = new DisplayResizer(display);
|
const displayResizer = new DisplayResizer(display);
|
||||||
displayResizer.prepare();
|
displayResizer.prepare();
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.defaultPrevented) { return; }
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey) { return; }
|
||||||
|
|
||||||
|
const target = /** @type {?Element} */ (event.target instanceof Element ? event.target : null);
|
||||||
|
if (target !== null) {
|
||||||
|
if (target.closest('input, textarea, select, [contenteditable="true"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = event.code;
|
||||||
|
const isPopupScrollKey =
|
||||||
|
code === 'KeyJ' ||
|
||||||
|
code === 'KeyK' ||
|
||||||
|
code === 'ArrowDown' ||
|
||||||
|
code === 'ArrowUp';
|
||||||
|
if (isPopupScrollKey) {
|
||||||
|
const scanningOptions = display.getOptions()?.scanning;
|
||||||
|
const scale = Number.isFinite(scanningOptions?.reducedMotionScrollingScale)
|
||||||
|
? scanningOptions.reducedMotionScrollingScale
|
||||||
|
: 1;
|
||||||
|
display._scrollByPopupHeight(
|
||||||
|
code === 'KeyJ' || code === 'ArrowDown' ? 1 : -1,
|
||||||
|
scale,
|
||||||
|
);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'KeyM') {
|
||||||
|
if (event.repeat) { return; }
|
||||||
|
displayAnki._hotkeySaveAnkiNoteForSelectedEntry('0');
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'KeyP') {
|
||||||
|
if (event.repeat) { return; }
|
||||||
|
void displayAudio.playAudio(display.selectedIndex, 0);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'BracketLeft' || code === 'BracketRight') {
|
||||||
|
if (event.repeat) { return; }
|
||||||
|
displayAudio._onMessageCycleAudioSource({direction: code === 'BracketLeft' ? 1 : -1});
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('subminer-display-mine-selected', () => {
|
||||||
|
displayAnki._hotkeySaveAnkiNoteForSelectedEntry('0');
|
||||||
|
});
|
||||||
|
|
||||||
display.initializeState();
|
display.initializeState();
|
||||||
|
|
||||||
document.documentElement.dataset.loaded = 'true';
|
document.documentElement.dataset.loaded = 'true';
|
||||||
|
|||||||
Reference in New Issue
Block a user