7 Commits

Author SHA1 Message Date
6ae3888b53 feat: add playlist browser overlay modal
- Add overlay modal for browsing sibling video files and live mpv queue
- Add IPC commands for playlist operations (add, remove, move, play)
- Add playlist-browser-runtime and playlist-browser-sort modules
- Add keyboard handler and preload bindings for playlist browser
- Add default Ctrl+Alt+P keybinding to open the modal
- Add HTML structure, renderer wiring, and state for the modal
- Add changelog fragment and docs updates
2026-03-30 01:50:38 -07:00
6e041bc68e style: fix modal background colors to use Catppuccin Macchiato values
- Playlist browser outer container: Base (#24273a) → Mantle (#1e2030)
  gradient instead of near-black values
- Playlist browser panes: Surface0 (#363a4f) → Base gradient for visible
  layering against the outer background
- Playlist browser list: Mantle (#1e2030) instead of near-black (#0c0e18)
2026-03-30 01:46:53 -07:00
8db89c2239 style: apply Catppuccin Macchiato palette to all modals
- Add full Catppuccin Macchiato CSS variables to :root (--ctp-*)
- Replace all generic rgba(255,255,255,...) and ad-hoc accent colors
  in modal components with shared --ctp-* variables
- Affected modals: jimaku, kiku, subsync, runtime options, controller
  select/config/debug, session help, YouTube picker, playlist browser,
  subtitle sidebar
- Shared button classes (.kiku-confirm-button, .kiku-cancel-button,
  .btn-learn, .btn-secondary) also updated
2026-03-30 01:40:25 -07:00
f9a4039ad2 chore: add backlog task records 2026-03-30 00:30:18 -07:00
8e5c21b443 fix: restore integrated texthooker startup 2026-03-30 00:25:30 -07:00
55b350c3a2 Fix AniList token persistence and AVIF timing
- Defer AniList setup prompts until app-ready and reuse stored tokens
- Add AVIF lead-in padding so motion stays aligned with sentence audio
2026-03-29 22:07:15 -07:00
54324df3be fix(release): make AUR publish best-effort 2026-03-29 16:51:22 -07:00
69 changed files with 3370 additions and 224 deletions

View File

@@ -409,33 +409,64 @@ jobs:
id: version id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Validate AUR SSH secret - name: Check AUR publish prerequisites
id: aur_prereqs
env: env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
echo "Missing required secret: AUR_SSH_PRIVATE_KEY" echo "::warning::Missing AUR_SSH_PRIVATE_KEY; skipping automated AUR publish."
exit 1 echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Configure SSH for AUR - name: Configure SSH for AUR
id: aur_ssh
if: steps.aur_prereqs.outputs.skip != 'true'
env: env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: | run: |
set -euo pipefail set -euo pipefail
install -dm700 ~/.ssh if install -dm700 ~/.ssh \
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur && printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur \
chmod 600 ~/.ssh/aur && chmod 600 ~/.ssh/aur \
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts && ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts \
chmod 644 ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts; then
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::warning::Unable to configure SSH for AUR; skipping automated AUR publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
- name: Clone AUR repo - name: Clone AUR repo
id: aur_clone
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true'
env: env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
run: git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin run: |
set -euo pipefail
attempts=3
for attempt in $(seq 1 "$attempts"); do
if git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin; then
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
rm -rf aur-subminer-bin
if [ "$attempt" -lt "$attempts" ]; then
sleep $((attempt * 15))
fi
done
echo "::warning::Unable to clone subminer-bin from AUR after ${attempts} attempts; skipping automated AUR publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
- name: Download release assets for AUR - name: Download release assets for AUR
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
@@ -449,6 +480,7 @@ jobs:
--pattern "subminer-assets.tar.gz" --pattern "subminer-assets.tar.gz"
- name: Update AUR packaging metadata - name: Update AUR packaging metadata
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
run: | run: |
set -euo pipefail set -euo pipefail
version_no_v="${{ steps.version.outputs.VERSION }}" version_no_v="${{ steps.version.outputs.VERSION }}"
@@ -463,6 +495,7 @@ jobs:
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz" --assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
- name: Commit and push AUR update - name: Commit and push AUR update
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
working-directory: aur-subminer-bin working-directory: aur-subminer-bin
env: env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
@@ -476,4 +509,16 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO git add PKGBUILD .SRCINFO
git commit -m "Update to ${{ steps.version.outputs.VERSION }}" git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
git push origin HEAD:master
attempts=3
for attempt in $(seq 1 "$attempts"); do
if git push origin HEAD:master; then
exit 0
fi
if [ "$attempt" -lt "$attempts" ]; then
sleep $((attempt * 15))
fi
done
echo "::warning::Unable to push the AUR update after ${attempts} attempts; GitHub release is published, but subminer-bin needs manual follow-up."

View File

@@ -63,6 +63,12 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
<br> <br>
### Playlist Browser
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
<br>
### Integrations ### Integrations
<table> <table>

View File

@@ -0,0 +1,35 @@
---
id: TASK-252
title: Harden AUR publish release step against transient SSH failures
status: Done
assignee: []
created_date: '2026-03-29 23:46'
updated_date: '2026-03-29 23:49'
labels:
- release
- ci
- aur
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make tagged releases resilient when the automated AUR update hits transient SSH disconnects from GitHub-hosted runners. The GitHub Release should still complete successfully, while AUR publish should retry a few times and downgrade persistent AUR failures to warnings instead of failing the entire release workflow.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Tagged release workflow retries the AUR clone/push path with bounded backoff when AUR SSH disconnects transiently.
- [x] #2 Persistent AUR publish failure does not fail the overall tagged release workflow or block GitHub Release publication.
- [x] #3 Release documentation notes that AUR publish is best-effort and may need manual follow-up when retries are exhausted.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Updated .github/workflows/release.yml so AUR secret/configure/clone/push failures downgrade to warnings, clone/push retry three times with linear backoff, and the GitHub Release path remains green.
Documented AUR publish as best-effort in docs/RELEASING.md and added changes/253-aur-release-best-effort.md for PR changelog compliance.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,68 @@
---
id: TASK-253
title: Fix animated AVIF lead-in alignment with sentence audio
status: Done
assignee:
- codex
created_date: '2026-03-30 01:59'
updated_date: '2026-03-30 02:03'
labels: []
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/anki-integration/animated-image-sync.ts
- /Users/sudacode/projects/japanese/SubMiner/src/anki-integration.ts
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/stats-server.ts
- /Users/sudacode/projects/japanese/SubMiner/src/media-generator.ts
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Animated AVIF cards currently freeze only for the existing word-audio duration. Because generated sentence audio starts with configured audio padding before the spoken subtitle begins, animation motion can begin early instead of lining up with the spoken sentence. Update the shared lead-in calculation so animated motion begins when sentence speech begins after the chosen word audio finishes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Animated AVIF lead-in calculation includes both the chosen word-audio duration and the generated sentence-audio start offset so motion begins with spoken sentence audio
- [x] #2 Shared animated-image sync behavior is applied consistently across the Anki note update, card creation, and stats server media-generation paths
- [x] #3 Regression tests cover the corrected lead-in timing calculation and fail before the fix
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Approved plan:
1. Add a failing unit test proving animated-image lead-in must include sentence-audio start offset in addition to chosen word-audio duration.
2. Update shared animated-image lead-in resolution to add the configured sentence-audio offset used by generated sentence audio.
3. Thread the shared calculation through note update, card creation, and stats-server generation paths without duplicating timing logic.
4. Run targeted tests first, then the relevant fast verification lane for touched files.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
User approved implementation on 2026-03-29 local time. Root cause: lead-in omitted sentence-audio padding offset, so AVIF motion began before spoken sentence audio.
Implemented shared animated-image lead-in fix in src/anki-integration/animated-image-sync.ts by adding the same sentence-audio start offset used by generated audio (`audioPadding`) after summing the chosen word-audio durations.
Added regression coverage in src/anki-integration/animated-image-sync.test.ts for explicit `audioPadding` lead-in alignment and kept the zero-padding case covered.
Verification passed: `bun test src/anki-integration/animated-image-sync.test.ts src/anki-integration/note-update-workflow.test.ts src/media-generator.test.ts`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed animated AVIF lead-in alignment so motion starts when the spoken sentence starts, not at the padded beginning of the generated sentence-audio clip. The shared resolver in `src/anki-integration/animated-image-sync.ts` now adds the configured/default `audioPadding` offset after summing the selected word-audio durations, which keeps note update, card creation, and stats-server generation paths aligned through the same logic.
Added regression coverage in `src/anki-integration/animated-image-sync.test.ts` for both zero-padding and explicit padding cases to prove the lead-in math matches sentence-audio timing.
Verification:
- `bun test src/anki-integration/animated-image-sync.test.ts src/anki-integration/note-update-workflow.test.ts src/media-generator.test.ts`
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
- `bun run test:smoke:dist`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,59 @@
---
id: TASK-254
title: Fix AniList token persistence when safe storage is unavailable
status: Done
assignee:
- codex
created_date: '2026-03-30 02:10'
updated_date: '2026-03-30 02:20'
labels:
- bug
- anilist
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/core/services/anilist/anilist-token-store.ts
- /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/anilist-setup.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/anilist-token-refresh.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
AniList login currently appears to succeed during setup, but some environments cannot persist the token because Electron safeStorage is unavailable or unusable. On the next app start, AniList tracking cannot load the token and re-prompts the user to set up AniList again. Align AniList token persistence with the intended login UX so a token the user already saved is reused on later sessions.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Saved encrypted AniList token is reused on app-ready startup without reopening setup.
- [x] #2 AniList startup no longer attempts to open the setup BrowserWindow before Electron is ready.
- [x] #3 AniList auth/runtime tests cover stored-token reuse and the missing-token startup path that previously triggered pre-ready setup attempts.
- [x] #4 AniList token storage remains encrypted-only; no plaintext fallback is introduced.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add regression tests for AniList startup auth refresh so a stored encrypted token is reused without opening setup, and for the missing-token path so setup opening is deferred safely until the app can actually show a window.
2. Update AniList startup/auth runtime to separate token resolution from setup-window prompting, and gate prompting on app readiness instead of attempting BrowserWindow creation during early startup.
3. Preserve encrypted-only storage semantics in anilist-token-store; do not add plaintext fallback. If stored-token load fails, keep logging/diagnostics intact.
4. Run targeted AniList runtime/token tests, then summarize root cause and verification results.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Investigated AniList auth persistence flow. Current setup path treats callback token as saved even when anilist-token-store refuses persistence because safeStorage is unavailable. Jellyfin token store already uses plaintext fallback in this environment class, which is a likely model for the AniList fix.
Confirmed from local logs that safeStorage was explicitly unavailable on 2026-03-23 due macOS Keychain lookup failure with NSOSStatusErrorDomain Code=-128 userCanceledErr. Current environment also has an encrypted AniList token file at /Users/sudacode/.config/SubMiner/anilist-token-store.json updated 2026-03-29 18:49, so safeStorage did work recently for save. Repeated AniList setup prompts on 2026-03-29/30 correlate more strongly with startup auth flow deciding no token is available and opening setup immediately; logs show repeated 'Loaded AniList manual token entry page' and several 'Failed to refresh AniList client secret state during startup' errors with 'Cannot create BrowserWindow before app is ready'. No recent log lines indicate safeStorage.isEncryptionAvailable() false after 2026-03-23.
Implemented encrypted-only startup fix by adding an allowSetupPrompt control to AniList token refresh and disabling setup-window prompting for the early pre-ready startup refresh in main.ts. App-ready reloadConfig still performs the normal prompt-capable refresh after Electron is ready. Added regression tests for stored-token reuse and prompt suppression when startup explicitly disables prompting.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Root cause was a redundant early AniList auth refresh during startup. The app refreshed AniList auth once before Electron was ready and again during app-ready config reload. When the early refresh could not resolve a token, it tried to open the AniList setup window immediately, which produced the observed 'Cannot create BrowserWindow before app is ready' failures and repeated setup prompts. The fix keeps token storage encrypted-only, teaches AniList auth refresh to optionally suppress setup-window prompting, and uses that suppression for the early startup refresh. App-ready startup still performs the normal prompt-capable refresh once Electron is ready, so saved encrypted tokens are reused without reopening setup and missing-token setup only happens at a safe point. Verified with targeted AniList auth tests, typecheck, test:fast, test:env, build, and test:smoke:dist.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,61 @@
---
id: TASK-255
title: Add overlay playlist browser modal for sibling video files and mpv queue
status: In Progress
assignee:
- codex
created_date: '2026-03-30 05:46'
updated_date: '2026-03-30 08:34'
labels:
- feature
- overlay
- mpv
- launcher
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add an in-session overlay modal that opens from a keybinding during active playback and lets the user browse video files from the current file's parent directory alongside the active mpv playlist. The modal should sort local files in best-effort episode order, highlight the current item, and allow keyboard/mouse interaction to add files into the mpv queue, remove queued items, and reorder queued items without leaving playback.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 An overlay modal can be opened during active playback from a dedicated keybinding and closed without disrupting existing modal behavior.
- [ ] #2 The modal shows video files from the current media file's parent directory in best-effort episode order and highlights the current file when present.
- [ ] #3 The modal shows the active mpv playlist/queue with enough metadata to identify the current item and queued order.
- [ ] #4 The user can add a directory file to the mpv playlist, remove playlist items, and reorder playlist items from the modal using both mouse and keyboard interactions.
- [ ] #5 Modal state stays in sync after playlist mutations so the rendered queue reflects mpv's current playlist order.
- [ ] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add playlist-browser domain types, IPC channels, overlay modal registration, special command, and default keybinding for Ctrl+Alt+P.
2. Write failing tests for best-effort episode sorting and main playlist-browser runtime snapshot/mutation behavior.
3. Implement playlist-browser main/runtime helpers for local sibling video discovery, mpv playlist normalization, and append/play/remove/move operations with refreshed snapshots.
4. Wire preload and main-process IPC handlers that expose snapshot and mutation methods to the renderer.
5. Write failing renderer and keyboard tests for modal open/close, split-pane interaction, keyboard controls, and degraded states.
6. Implement playlist-browser modal markup, DOM/state, renderer composition, keyboard routing, and session-help labeling.
7. Run targeted test lanes first, then the maintained verification gate relevant to the touched surfaces; update task notes/criteria as checks pass.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented overlay playlist browser modal with split directory/playlist panes, Ctrl+Alt+P keybinding, main/preload IPC, mpv queue mutations, and best-effort sibling episode sorting.
Added tests for sort/runtime logic, IPC wiring, keyboard routing, and playlist-browser modal behavior.
Verification: `bun run typecheck` passed; targeted playlist-browser and IPC tests passed; `bun run build` passed; `bun run test:smoke:dist` passed.
Repo gate blockers outside this feature: `bun run test:fast` hits existing Bun `node:test` NotImplementedError cases plus unrelated immersion-tracker failures; `bun run test:env` fails in existing immersion-tracker sqlite tests.
2026-03-30: Fixed playlist-browser local playback regression where subtitle track IDs leaked across episode jumps. `playPlaylistBrowserIndexRuntime` now reapplies local subtitle auto-selection defaults (`sub-auto=fuzzy`, `sid=auto`, `secondary-sid=auto`) before `playlist-play-index` for local filesystem targets only; remote playlist entries remain untouched. Added runtime regression tests for both paths.
2026-03-30: Follow-up subtitle regression fix. Pre-jump `sid=auto` was ineffective because mpv resolved it against the current episode before `playlist-play-index`. Local playlist jumps now set `sub-auto=fuzzy`, switch episodes, then schedule a delayed rearm of `sid=auto` and `secondary-sid=auto` so selection happens against the new file's tracks. Added failing-first runtime coverage for delayed local rearm and remote no-op behavior.
2026-03-30: Cleaned up playlist-browser runtime local-play subtitle-rearm flow by extracting focused helpers without changing behavior. Added public docs/readme coverage for the default `Ctrl+Alt+P` playlist browser keybinding and modal, plus changelog fragment `changes/260-playlist-browser.md`. Verification: `bun test src/main/runtime/playlist-browser-runtime.test.ts`, `bun run typecheck`, `bun run docs:test`, `bun run docs:build`, `bun run changelog:lint`, `bun run build`.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,56 @@
---
id: TASK-256
title: Fix texthooker page live websocket connect/send regression
status: Done
assignee:
- codex
created_date: '2026-03-30 06:04'
updated_date: '2026-03-30 06:12'
labels:
- bug
- texthooker
- websocket
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate why the bundled texthooker page loads at the local HTTP endpoint but does not reliably connect to the configured websocket feed or receive/display live subtitle lines. Identify the regression in the SubMiner startup/bootstrap or vendored texthooker client path, restore live line delivery, and cover the fix with focused regression tests and any required docs updates.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Bundled texthooker connects to the intended websocket endpoint on launch using the configured/default SubMiner startup path.
- [x] #2 Incoming subtitle or annotation websocket messages are accepted by the bundled texthooker and rendered as live lines.
- [x] #3 Regression coverage fails before the fix and passes after the fix for the identified breakage.
- [x] #4 Relevant docs/config notes are updated if user-facing behavior or troubleshooting guidance changes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused CLI regression test covering `--texthooker` startup when the runtime has a resolved websocket URL, proving the handler currently starts texthooker without that URL.
2. Extend CLI texthooker dependencies/runtime wiring so the handler can retrieve the resolved texthooker websocket URL from current config/runtime state.
3. Update the CLI texthooker flow to pass the resolved websocket URL into texthooker startup instead of starting the HTTP server with only a port.
4. Run focused tests for CLI command handling and texthooker bootstrap behavior; update task notes/final summary with the verified root cause and fix.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: the CLI `--texthooker` path started the HTTP texthooker server with only the port, so the served page never received `bannou-texthooker-websocketUrl` and fell back to the vendored default `ws://localhost:6677`. In environments where the regular websocket was skipped or the annotation websocket should have been used, the page stayed on `Connecting...` and never received lines.
Fix: added a shared `resolveTexthookerWebsocketUrl(...)` helper for websocket selection, reused it in both app-ready startup and CLI texthooker context wiring, and threaded the resolved websocket URL through `handleCliCommand` into `Texthooker.start(...)`.
Verification: `bun run typecheck`; focused Bun tests for texthooker bootstrap, startup, CLI command handling, and CLI context wiring; browser-level repro against a throwaway source-backed texthooker server confirmed the page bootstraps `ws://127.0.0.1:6678`, connects successfully, and renders live sample lines (`テスト一`, `テスト二`).
Docs: no user-facing behavior change beyond restoring the intended existing behavior, so no docs update was required.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored texthooker live line delivery for the CLI/startup path that launched the page without a resolved websocket URL. Shared websocket URL resolution between app-ready startup and CLI texthooker context, forwarded that URL into `Texthooker.start(...)`, added regression coverage for the CLI path, and verified both by focused tests and a browser-level throwaway server that connected on `ws://127.0.0.1:6678` and rendered live sample lines.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,57 @@
---
id: TASK-257
title: Fix texthooker-only mode startup to initialize websocket pipeline
status: Done
assignee:
- codex
created_date: '2026-03-30 06:15'
updated_date: '2026-03-30 06:17'
labels:
- bug
- texthooker
- websocket
- startup
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix `--texthooker` / `subminer texthooker` startup so it launches the texthooker page without the overlay window but still initializes the runtime pieces required for live subtitle delivery. Today texthooker-only mode serves the page yet skips mpv client and websocket startup, leaving the page pointed at `ws://127.0.0.1:6678` with no listener behind it.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `--texthooker` mode starts the texthooker page without opening the overlay window and still initializes the websocket path needed for live subtitle delivery.
- [x] #2 Texthooker-only startup creates the mpv/websocket runtime needed for the configured annotation or subtitle websocket feed.
- [x] #3 Regression coverage fails before the fix and passes after the fix for texthooker-only startup.
- [x] #4 Docs/help text remain accurate for texthooker-only behavior; update docs only if wording needs correction.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Replace the existing texthooker-only startup regression test so it asserts websocket/mpv startup still happens while overlay window initialization stays skipped.
2. Remove or narrow the early texthooker-only short-circuit in app-ready startup so runtime config, mpv client, subtitle websocket, and annotation websocket still initialize.
3. Run focused tests plus a local process check proving `--texthooker` now opens the websocket listener expected by the served page.
4. Update task notes/final summary with the live-process root cause (`--texthooker` serving HTML on 5174 with no 6678 listener).
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Live-process repro on the user's machine: `ps` showed the active process as `/tmp/.mount_SubMin.../SubMiner --texthooker --port 5174`. `lsof` showed 5174 listening but no listener on 6678/6677, while `curl http://127.0.0.1:5174/` confirmed the served page was correctly bootstrapped to `ws://127.0.0.1:6678`. That proved the remaining failure was startup mode, not page injection.
Root cause: `runAppReadyRuntime(...)` had an early `texthookerOnlyMode` return that reloaded config and handled initial args, but skipped `createMpvClient()`, subtitle websocket startup, annotation websocket startup, subtitle timing tracker creation, and the later texthooker-only branch that only skips the overlay window.
Fix: removed the early texthooker-only short-circuit so texthooker-only mode now runs the normal startup pipeline, then falls through to the existing `Texthooker-only mode enabled; skipping overlay window.` branch.
Verification: `bun run typecheck`; focused Bun tests for app-ready startup, startup bootstrap, CLI texthooker startup, and CLI context wiring. Existing local live-binary repro still reflects the old mounted AppImage until rebuilt/restarted. Current-binary workaround is to launch normal startup / `--start --texthooker` instead of plain `--texthooker`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed the second texthooker regression: plain `--texthooker` mode was serving the page but skipping mpv/websocket initialization, so the page pointed at `ws://127.0.0.1:6678` with no listener. Removed the early texthooker-only startup return, kept the later overlay-skip behavior, updated the startup regression test to require websocket/mpv initialization in texthooker-only mode, and re-verified with typecheck plus focused test coverage.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,57 @@
---
id: TASK-258
title: Stop plugin auto-start from spawning separate texthooker helper
status: Done
assignee:
- codex
created_date: '2026-03-30 06:25'
updated_date: '2026-03-30 06:26'
labels:
- bug
- texthooker
- launcher
- plugin
- startup
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Change the mpv/plugin auto-start path so normal SubMiner startup owns texthooker and websocket startup inside the main `--start` app instance. Keep standalone `subminer texthooker` / plain `--texthooker` available for explicit external use, but stop the plugin from spawning a second helper subprocess during regular auto-start.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Plugin auto-start includes texthooker on the main `--start` command when texthooker is enabled.
- [x] #2 Plugin auto-start no longer spawns a separate standalone `--texthooker` helper subprocess during normal startup.
- [x] #3 Regression coverage fails before the fix and passes after the fix for the plugin auto-start path.
- [x] #4 Standalone external `subminer texthooker` / plain `--texthooker` entrypoints remain available for explicit helper use.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Flip the mpv/plugin start-gate regression so enabled texthooker is folded into the main `--start` command and standalone helper subprocesses are rejected.
2. Update plugin process command construction so `start` includes `--texthooker` when enabled and the separate helper-launch path becomes a no-op for normal auto-start.
3. Run plugin Lua regressions, adjacent launcher tests, and typecheck to verify behavior and preserve explicit standalone `--texthooker` entrypoints.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Design approved by user: normal in-app startup should own texthooker/websocket; `texthookerOnlyMode` should stay explicit external-only.
Root cause path: mpv/plugin auto-start in `plugin/subminer/process.lua` launched `binary_path --start ...` and then separately spawned `binary_path --texthooker --port ...`. That created the standalone helper process observed live (`SubMiner --texthooker --port 5174`) instead of relying on the normal app instance.
Fix: `build_command_args('start', overrides)` now appends `--texthooker` when texthooker is enabled, and the old helper-launch path is reduced to a no-op so normal auto-start remains single-process.
Verification: `lua scripts/test-plugin-start-gate.lua`, `lua scripts/test-plugin-process-start-retries.lua`, `bun test launcher/mpv.test.ts launcher/commands/playback-command.test.ts launcher/config/args-normalizer.test.ts`, and `bun run typecheck`. Standalone launcher/app entrypoints for explicit `subminer texthooker` / plain `--texthooker` were left untouched.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Stopped the mpv/plugin auto-start path from spawning a second standalone texthooker helper. Texthooker now rides on the main `--start` app instance for normal startup, with Lua regressions updated to require `--texthooker` on the main start command and reject separate helper subprocesses. Explicit standalone `subminer texthooker` / plain `--texthooker` entrypoints remain available.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,33 @@
---
id: TASK-259
title: Fix integrated --start --texthooker startup skipping texthooker server
status: Done
assignee: []
created_date: '2026-03-30 06:48'
updated_date: '2026-03-30 06:56'
labels:
- bug
- texthooker
- startup
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Integrated overlay startup with `--start --texthooker` currently takes the minimal-startup path because startup mode flags treat any `args.texthooker` as texthooker-only. That skips app-ready texthooker service startup, so no server binds on port 5174 during normal SubMiner playback launches.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `--start --texthooker` uses full app-ready startup instead of minimal texthooker-only startup
- [x] #2 Integrated playback launch starts the texthooker server on the configured/default port
- [x] #3 Regression tests cover the startup-mode classification and integrated startup behavior
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Narrowed texthooker-only startup classification so integrated `--start --texthooker` no longer takes the minimal-startup path. Added CLI arg regression coverage, rebuilt the AppImage, installed it to `~/.local/bin/SubMiner.AppImage` with a timestamped backup, restarted against `/tmp/subminer-socket`, and verified listeners on 5174/6677/6678 plus browser connection state `Connected with ws://127.0.0.1:6678`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,5 @@
type: internal
area: release
- Retried AUR clone and push operations in the tagged release workflow.
- Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.

View File

@@ -0,0 +1,5 @@
type: fixed
area: main
- Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
- Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.

View File

@@ -0,0 +1,5 @@
type: added
area: overlay
- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
- Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.

View File

@@ -471,6 +471,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| `Space` | `["cycle", "pause"]` | Toggle pause | | `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | | `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | | `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | | `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
@@ -507,7 +508,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "Space", "command": null } { "key": "Space", "command": null }
``` ```
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value. **Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) **Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)

View File

@@ -40,6 +40,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Space` | Toggle mpv pause | | `Space` | Toggle mpv pause |
| `J` | Cycle primary subtitle track | | `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track | | `Shift+J` | Cycle secondary subtitle track |
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
| `ArrowRight` | Seek forward 5 seconds | | `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds | | `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds | | `ArrowUp` | Seek forward 60 seconds |
@@ -56,7 +57,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Right-click + drag` | Reposition subtitles (on subtitle area) | | `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | | `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
These keybindings can be overridden or disabled via the `keybindings` config array. These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
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 on leave).

View File

@@ -295,6 +295,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config. `Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
::: :::
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
Hovering over subtitle text pauses mpv by default; leaving resumes it. Disable with `subtitleStyle.autoPauseVideoOnHover: false`. To also pause while the Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup: true`. Hovering over subtitle text pauses mpv by default; leaving resumes it. Disable with `subtitleStyle.autoPauseVideoOnHover: false`. To also pause while the Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup: true`.
### Drag-and-Drop ### Drag-and-Drop

View File

@@ -34,4 +34,5 @@ Notes:
- Do not tag while `changes/*.md` fragments still exist. - Do not tag while `changes/*.md` fragments still exist.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`. - If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication. - Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.

View File

@@ -191,6 +191,14 @@ function M.create(ctx)
else else
table.insert(args, "--hide-visible-overlay") table.insert(args, "--hide-visible-overlay")
end end
local texthooker_enabled = overrides.texthooker_enabled
if texthooker_enabled == nil then
texthooker_enabled = opts.texthooker_enabled
end
if texthooker_enabled then
table.insert(args, "--texthooker")
end
end end
return args return args
@@ -242,50 +250,10 @@ function M.create(ctx)
return overrides return overrides
end end
local function build_texthooker_args()
local args = { state.binary_path, "--texthooker", "--port", tostring(opts.texthooker_port) }
local log_level = normalize_log_level(opts.log_level)
if log_level ~= "info" then
table.insert(args, "--log-level")
table.insert(args, log_level)
end
return args
end
local function ensure_texthooker_running(callback) local function ensure_texthooker_running(callback)
if not opts.texthooker_enabled then if callback then
callback() callback()
return
end end
if state.texthooker_running then
callback()
return
end
local args = build_texthooker_args()
subminer_log("info", "texthooker", "Starting texthooker process: " .. table.concat(args, " "))
state.texthooker_running = true
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
if not success or (result and result.status ~= 0) then
state.texthooker_running = false
subminer_log(
"warn",
"texthooker",
"Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error")
)
end
end)
-- Start overlay immediately; overlay start path retries on readiness failures.
callback()
end end
local function start_overlay(overrides) local function start_overlay(overrides)

View File

@@ -664,8 +664,8 @@ do
fire_event(recorded, "file-loaded") fire_event(recorded, "file-loaded")
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")
local texthooker_call = find_texthooker_call(recorded.async_calls) assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
assert_true(texthooker_call ~= nil, "auto-start should issue texthooker helper command when enabled") assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
assert_true( assert_true(
call_has_arg(start_call, "--show-visible-overlay"), call_has_arg(start_call, "--show-visible-overlay"),
"auto-start with visible overlay enabled should include --show-visible-overlay on --start" "auto-start with visible overlay enabled should include --show-visible-overlay on --start"
@@ -678,10 +678,6 @@ do
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil, find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command" "auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
) )
assert_true(
find_call_index(recorded.async_calls, start_call) < find_call_index(recorded.async_calls, texthooker_call),
"auto-start should launch --start before separate --texthooker helper startup"
)
assert_true( assert_true(
not has_property_set(recorded.property_sets, "pause", true), not has_property_set(recorded.property_sets, "pause", true),
"auto-start visible overlay should not force pause without explicit pause-until-ready option" "auto-start visible overlay should not force pause without explicit pause-until-ready option"

View File

@@ -19,6 +19,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
media: { media: {
imageType: 'avif', imageType: 'avif',
syncAnimatedImageToWordAudio: true, syncAnimatedImageToWordAudio: true,
audioPadding: 0,
}, },
}, },
noteInfo: { noteInfo: {
@@ -49,6 +50,46 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
assert.equal(leadInSeconds, 1.25); assert.equal(leadInSeconds, 1.25);
}); });
test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
audio: 'ExpressionAudio',
},
media: {
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
audioPadding: 0.5,
},
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: {
value: '[sound:word.mp3][sound:alt.ogg]',
},
},
},
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
for (const preferredName of preferredNames) {
if (!preferredName) continue;
const resolved = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
);
if (resolved) return resolved;
}
return null;
},
retrieveMediaFileBase64: async (filename) =>
filename === 'word.mp3' ? 'd29yZA==' : filename === 'alt.ogg' ? 'YWx0' : '',
probeAudioDurationSeconds: async (_buffer, filename) =>
filename === 'word.mp3' ? 0.41 : filename === 'alt.ogg' ? 0.84 : null,
logWarn: () => undefined,
});
assert.equal(leadInSeconds, 1.75);
});
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => { test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({ const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: { config: {

View File

@@ -39,6 +39,14 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false; return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
} }
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): number {
const configuredPadding = config.media?.audioPadding;
if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) {
return configuredPadding;
}
return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding;
}
export async function probeAudioDurationSeconds( export async function probeAudioDurationSeconds(
buffer: Buffer, buffer: Buffer,
filename: string, filename: string,
@@ -127,5 +135,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
totalLeadInSeconds += durationSeconds; totalLeadInSeconds += durationSeconds;
} }
return totalLeadInSeconds; return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
} }

View File

@@ -5,6 +5,7 @@ import {
commandNeedsOverlayRuntime, commandNeedsOverlayRuntime,
hasExplicitCommand, hasExplicitCommand,
isHeadlessInitialCommand, isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs, parseArgs,
shouldRunSettingsOnlyStartup, shouldRunSettingsOnlyStartup,
shouldStartApp, shouldStartApp,
@@ -79,6 +80,14 @@ test('youtube playback does not use generic overlay-runtime bootstrap classifica
assert.equal(commandNeedsOverlayStartupPrereqs(args), true); assert.equal(commandNeedsOverlayStartupPrereqs(args), true);
}); });
test('standalone texthooker classification excludes integrated start flow', () => {
const standalone = parseArgs(['--texthooker']);
const integrated = parseArgs(['--start', '--texthooker']);
assert.equal(isStandaloneTexthookerCommand(standalone), true);
assert.equal(isStandaloneTexthookerCommand(integrated), false);
});
test('parseArgs handles jellyfin item listing controls', () => { test('parseArgs handles jellyfin item listing controls', () => {
const args = parseArgs([ const args = parseArgs([
'--jellyfin-items', '--jellyfin-items',

View File

@@ -397,6 +397,54 @@ export function isHeadlessInitialCommand(args: CliArgs): boolean {
return args.refreshKnownWords; return args.refreshKnownWords;
} }
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
return (
args.texthooker &&
!args.background &&
!args.start &&
!Boolean(args.youtubePlay) &&
!args.launchMpv &&
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.settings &&
!args.setup &&
!args.show &&
!args.hide &&
!args.showVisibleOverlay &&
!args.hideVisibleOverlay &&
!args.copySubtitle &&
!args.copySubtitleMultiple &&
!args.mineSentence &&
!args.mineSentenceMultiple &&
!args.updateLastCardFromClipboard &&
!args.refreshKnownWords &&
!args.toggleSecondarySub &&
!args.triggerFieldGrouping &&
!args.triggerSubsync &&
!args.markAudioCard &&
!args.openRuntimeOptions &&
!args.anilistStatus &&
!args.anilistLogout &&
!args.anilistSetup &&
!args.anilistRetryQueue &&
!args.dictionary &&
!args.stats &&
!args.jellyfin &&
!args.jellyfinLogin &&
!args.jellyfinLogout &&
!args.jellyfinLibraries &&
!args.jellyfinItems &&
!args.jellyfinSubtitles &&
!args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig
);
}
export function shouldStartApp(args: CliArgs): boolean { export function shouldStartApp(args: CliArgs): boolean {
if (args.stop && !args.start) return false; if (args.stop && !args.start) return false;
if ( if (

View File

@@ -80,6 +80,7 @@ test('default keybindings include primary and secondary subtitle track cycling o
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']); assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']); assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']); assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']);
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']);
}); });
test('default keybindings include fullscreen on F', () => { test('default keybindings include fullscreen on F', () => {

View File

@@ -47,6 +47,7 @@ export const SPECIAL_COMMANDS = {
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line', SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line', SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
} as const; } as const;
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
@@ -66,6 +67,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START], command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
}, },
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] }, { key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] }, { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] }, { key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
{ key: 'KeyQ', command: ['quit'] }, { key: 'KeyQ', command: ['quit'] },

View File

@@ -176,7 +176,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs')); assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
}); });
test('runAppReadyRuntime uses minimal startup for texthooker-only mode', async () => { test('runAppReadyRuntime keeps websocket startup in texthooker-only mode but skips overlay window', async () => {
const { deps, calls } = makeDeps({ const { deps, calls } = makeDeps({
texthookerOnlyMode: true, texthookerOnlyMode: true,
reloadConfig: () => calls.push('reloadConfig'), reloadConfig: () => calls.push('reloadConfig'),
@@ -185,7 +185,16 @@ test('runAppReadyRuntime uses minimal startup for texthooker-only mode', async (
await runAppReadyRuntime(deps); await runAppReadyRuntime(deps);
assert.deepEqual(calls, ['ensureDefaultConfigBootstrap', 'reloadConfig', 'handleInitialArgs']); assert.ok(calls.includes('reloadConfig'));
assert.ok(calls.includes('createMpvClient'));
assert.ok(calls.includes('startAnnotationWebsocket:6678'));
assert.ok(calls.includes('startTexthooker:5174:ws://127.0.0.1:6678'));
assert.ok(calls.includes('createSubtitleTimingTracker'));
assert.ok(calls.includes('handleFirstRunSetup'));
assert.ok(calls.includes('handleInitialArgs'));
assert.ok(calls.includes('log:Texthooker-only mode enabled; skipping overlay window.'));
assert.equal(calls.includes('initializeOverlayRuntime'), false);
assert.equal(calls.includes('setVisibleOverlayVisible:true'), false);
}); });
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => { test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {

View File

@@ -62,6 +62,7 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
let mpvSocketPath = '/tmp/subminer.sock'; let mpvSocketPath = '/tmp/subminer.sock';
let texthookerPort = 5174; let texthookerPort = 5174;
const osd: string[] = []; const osd: string[] = [];
let texthookerWebsocketUrl: string | undefined;
const deps: CliCommandServiceDeps = { const deps: CliCommandServiceDeps = {
getMpvSocketPath: () => mpvSocketPath, getMpvSocketPath: () => mpvSocketPath,
@@ -82,9 +83,10 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push(`setTexthookerPort:${port}`); calls.push(`setTexthookerPort:${port}`);
}, },
getTexthookerPort: () => texthookerPort, getTexthookerPort: () => texthookerPort,
getTexthookerWebsocketUrl: () => texthookerWebsocketUrl,
shouldOpenTexthookerBrowser: () => true, shouldOpenTexthookerBrowser: () => true,
ensureTexthookerRunning: (port) => { ensureTexthookerRunning: (port, websocketUrl) => {
calls.push(`ensureTexthookerRunning:${port}`); calls.push(`ensureTexthookerRunning:${port}:${websocketUrl ?? ''}`);
}, },
openTexthookerInBrowser: (url) => { openTexthookerInBrowser: (url) => {
calls.push(`openTexthookerInBrowser:${url}`); calls.push(`openTexthookerInBrowser:${url}`);
@@ -354,10 +356,20 @@ test('handleCliCommand runs texthooker flow with browser open', () => {
handleCliCommand(args, 'initial', deps); handleCliCommand(args, 'initial', deps);
assert.ok(calls.includes('ensureTexthookerRunning:5174')); assert.ok(calls.includes('ensureTexthookerRunning:5174:'));
assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174')); assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174'));
}); });
test('handleCliCommand forwards resolved websocket url to texthooker startup', () => {
const { deps, calls } = createDeps({
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
});
handleCliCommand(makeArgs({ texthooker: true }), 'initial', deps);
assert.ok(calls.includes('ensureTexthookerRunning:5174:ws://127.0.0.1:6678'));
});
test('handleCliCommand reports async mine errors to OSD', async () => { test('handleCliCommand reports async mine errors to OSD', async () => {
const { deps, calls, osd } = createDeps({ const { deps, calls, osd } = createDeps({
mineSentenceCard: async () => { mineSentenceCard: async () => {

View File

@@ -10,8 +10,9 @@ export interface CliCommandServiceDeps {
isTexthookerRunning: () => boolean; isTexthookerRunning: () => boolean;
setTexthookerPort: (port: number) => void; setTexthookerPort: (port: number) => void;
getTexthookerPort: () => number; getTexthookerPort: () => number;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenTexthookerBrowser: () => boolean; shouldOpenTexthookerBrowser: () => boolean;
ensureTexthookerRunning: (port: number) => void; ensureTexthookerRunning: (port: number, websocketUrl?: string) => void;
openTexthookerInBrowser: (url: string) => void; openTexthookerInBrowser: (url: string) => void;
stopApp: () => void; stopApp: () => void;
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
@@ -84,7 +85,7 @@ interface MpvClientLike {
interface TexthookerServiceLike { interface TexthookerServiceLike {
isRunning: () => boolean; isRunning: () => boolean;
start: (port: number) => void; start: (port: number, websocketUrl?: string) => void;
} }
interface MpvCliRuntime { interface MpvCliRuntime {
@@ -98,6 +99,7 @@ interface TexthookerCliRuntime {
service: TexthookerServiceLike; service: TexthookerServiceLike;
getPort: () => number; getPort: () => number;
setPort: (port: number) => void; setPort: (port: number) => void;
getWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean; shouldOpenBrowser: () => boolean;
openInBrowser: (url: string) => void; openInBrowser: (url: string) => void;
} }
@@ -194,10 +196,11 @@ export function createCliCommandDepsRuntime(
isTexthookerRunning: () => options.texthooker.service.isRunning(), isTexthookerRunning: () => options.texthooker.service.isRunning(),
setTexthookerPort: options.texthooker.setPort, setTexthookerPort: options.texthooker.setPort,
getTexthookerPort: options.texthooker.getPort, getTexthookerPort: options.texthooker.getPort,
getTexthookerWebsocketUrl: options.texthooker.getWebsocketUrl,
shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser, shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser,
ensureTexthookerRunning: (port) => { ensureTexthookerRunning: (port, websocketUrl) => {
if (!options.texthooker.service.isRunning()) { if (!options.texthooker.service.isRunning()) {
options.texthooker.service.start(port); options.texthooker.service.start(port, websocketUrl);
} }
}, },
openTexthookerInBrowser: options.texthooker.openInBrowser, openTexthookerInBrowser: options.texthooker.openInBrowser,
@@ -473,7 +476,7 @@ export function handleCliCommand(
); );
} else if (args.texthooker) { } else if (args.texthooker) {
const texthookerPort = deps.getTexthookerPort(); const texthookerPort = deps.getTexthookerPort();
deps.ensureTexthookerRunning(texthookerPort); deps.ensureTexthookerRunning(texthookerPort, deps.getTexthookerWebsocketUrl());
if (deps.shouldOpenTexthookerBrowser()) { if (deps.shouldOpenTexthookerBrowser()) {
deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`); deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`);
} }

View File

@@ -16,6 +16,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line', SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line', SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
}, },
triggerSubsyncFromConfig: () => { triggerSubsyncFromConfig: () => {
calls.push('subsync'); calls.push('subsync');
@@ -26,6 +27,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube-picker'); calls.push('youtube-picker');
}, },
openPlaylistBrowser: () => {
calls.push('playlist-browser');
},
runtimeOptionsCycle: () => ({ ok: true }), runtimeOptionsCycle: () => ({ ok: true }),
showMpvOsd: (text) => { showMpvOsd: (text) => {
osd.push(text); osd.push(text);
@@ -110,6 +114,14 @@ test('handleMpvCommandFromIpc dispatches special youtube picker open command', (
assert.deepEqual(osd, []); assert.deepEqual(osd, []);
}); });
test('handleMpvCommandFromIpc dispatches special playlist browser open command', () => {
const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
assert.deepEqual(calls, ['playlist-browser']);
assert.deepEqual(sentCommands, []);
assert.deepEqual(osd, []);
});
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => { test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
const { options, sentCommands, osd } = createOptions({ const { options, sentCommands, osd } = createOptions({
isMpvConnected: () => false, isMpvConnected: () => false,

View File

@@ -15,10 +15,12 @@ export interface HandleMpvCommandFromIpcOptions {
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string; SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string; SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
YOUTUBE_PICKER_OPEN: string; YOUTUBE_PICKER_OPEN: string;
PLAYLIST_BROWSER_OPEN: string;
}; };
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
mpvReplaySubtitle: () => void; mpvReplaySubtitle: () => void;
@@ -97,6 +99,11 @@ export function handleMpvCommandFromIpc(
return; return;
} }
if (first === options.specialCommands.PLAYLIST_BROWSER_OPEN) {
void options.openPlaylistBrowser();
return;
}
if ( if (
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START || first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START

View File

@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc'; import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { SubtitleSidebarSnapshot } from '../../types'; import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types';
interface FakeIpcRegistrar { interface FakeIpcRegistrar {
on: Map<string, (event: unknown, ...args: unknown[]) => void>; on: Map<string, (event: unknown, ...args: unknown[]) => void>;
@@ -148,6 +148,19 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
getPlaylistBrowserSnapshot: async () => ({
directoryPath: null,
directoryAvailable: false,
directoryStatus: '',
directoryItems: [],
playlistItems: [],
playingIndex: null,
currentFilePath: null,
}),
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
immersionTracker: null, immersionTracker: null,
...overrides, ...overrides,
@@ -241,6 +254,19 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
return { ok: true, message: 'done' }; return { ok: true, message: 'done' };
}, },
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
getPlaylistBrowserSnapshot: async () => ({
directoryPath: '/tmp',
directoryAvailable: true,
directoryStatus: '/tmp',
directoryItems: [],
playlistItems: [],
playingIndex: 0,
currentFilePath: '/tmp/current.mkv',
}),
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'append' }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'play' }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'remove' }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'move' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}); });
@@ -256,10 +282,83 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
ok: true, ok: true,
message: 'done', message: 'done',
}); });
assert.equal((await deps.getPlaylistBrowserSnapshot()).directoryAvailable, true);
assert.deepEqual(await deps.appendPlaylistBrowserFile('/tmp/new.mkv'), {
ok: true,
message: 'append',
});
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']); assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
assert.equal(deps.getPlaybackPaused(), true); assert.equal(deps.getPlaybackPaused(), true);
}); });
test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<[string, unknown[]]> = [];
registerIpcHandlers(
createRegisterIpcDeps({
getPlaylistBrowserSnapshot: async () => ({
directoryPath: '/tmp/videos',
directoryAvailable: true,
directoryStatus: '/tmp/videos',
directoryItems: [],
playlistItems: [],
playingIndex: 1,
currentFilePath: '/tmp/videos/ep2.mkv',
}),
appendPlaylistBrowserFile: async (filePath) => {
calls.push(['append', [filePath]]);
return { ok: true, message: 'append-ok' };
},
playPlaylistBrowserIndex: async (index) => {
calls.push(['play', [index]]);
return { ok: true, message: 'play-ok' };
},
removePlaylistBrowserIndex: async (index) => {
calls.push(['remove', [index]]);
return { ok: true, message: 'remove-ok' };
},
movePlaylistBrowserIndex: async (index, direction) => {
calls.push(['move', [index, direction]]);
return { ok: true, message: 'move-ok' };
},
}),
registrar,
);
const snapshot = (await handlers.handle.get(IPC_CHANNELS.request.getPlaylistBrowserSnapshot)?.(
{},
)) as PlaylistBrowserSnapshot | undefined;
const append = await handlers.handle.get(IPC_CHANNELS.request.appendPlaylistBrowserFile)?.(
{},
'/tmp/videos/ep3.mkv',
);
const play = await handlers.handle.get(IPC_CHANNELS.request.playPlaylistBrowserIndex)?.({}, 2);
const remove = await handlers.handle.get(IPC_CHANNELS.request.removePlaylistBrowserIndex)?.(
{},
2,
);
const move = await handlers.handle.get(IPC_CHANNELS.request.movePlaylistBrowserIndex)?.(
{},
2,
-1,
);
assert.equal(snapshot?.playingIndex, 1);
assert.deepEqual(append, { ok: true, message: 'append-ok' });
assert.deepEqual(play, { ok: true, message: 'play-ok' });
assert.deepEqual(remove, { ok: true, message: 'remove-ok' });
assert.deepEqual(move, { ok: true, message: 'move-ok' });
assert.deepEqual(calls, [
['append', ['/tmp/videos/ep3.mkv']],
['play', [2]],
['remove', [2]],
['move', [2, -1]],
]);
});
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => { test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<{ id: string; value: unknown }> = []; const calls: Array<{ id: string; value: unknown }> = [];
@@ -311,6 +410,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
getPlaylistBrowserSnapshot: async () => ({
directoryPath: null,
directoryAvailable: false,
directoryStatus: '',
directoryItems: [],
playlistItems: [],
playingIndex: null,
currentFilePath: null,
}),
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,
@@ -618,6 +730,19 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
getPlaylistBrowserSnapshot: async () => ({
directoryPath: null,
directoryAvailable: false,
directoryStatus: '',
directoryItems: [],
playlistItems: [],
playingIndex: null,
currentFilePath: null,
}),
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,
@@ -685,6 +810,19 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
getPlaylistBrowserSnapshot: async () => ({
directoryPath: null,
directoryAvailable: false,
directoryStatus: '',
directoryItems: [],
playlistItems: [],
playingIndex: null,
currentFilePath: null,
}),
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,
@@ -755,6 +893,19 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
getPlaylistBrowserSnapshot: async () => ({
directoryPath: null,
directoryAvailable: false,
directoryStatus: '',
directoryItems: [],
playlistItems: [],
playingIndex: null,
currentFilePath: null,
}),
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,

View File

@@ -2,6 +2,8 @@ import electron from 'electron';
import type { IpcMainEvent } from 'electron'; import type { IpcMainEvent } from 'electron';
import type { import type {
ControllerConfigUpdate, ControllerConfigUpdate,
PlaylistBrowserMutationResult,
PlaylistBrowserSnapshot,
ControllerPreferenceUpdate, ControllerPreferenceUpdate,
ResolvedControllerConfig, ResolvedControllerConfig,
RuntimeOptionId, RuntimeOptionId,
@@ -78,6 +80,14 @@ export interface IpcServiceDeps {
getAnilistQueueStatus: () => unknown; getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string }; appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
removePlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
movePlaylistBrowserIndex: (
index: number,
direction: 1 | -1,
) => Promise<PlaylistBrowserMutationResult>;
immersionTracker?: { immersionTracker?: {
recordYomitanLookup: () => void; recordYomitanLookup: () => void;
getSessionSummaries: (limit?: number) => Promise<unknown>; getSessionSummaries: (limit?: number) => Promise<unknown>;
@@ -183,6 +193,14 @@ export interface IpcDepsRuntimeOptions {
getAnilistQueueStatus: () => unknown; getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string }; appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
removePlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
movePlaylistBrowserIndex: (
index: number,
direction: 1 | -1,
) => Promise<PlaylistBrowserMutationResult>;
getImmersionTracker?: () => IpcServiceDeps['immersionTracker']; getImmersionTracker?: () => IpcServiceDeps['immersionTracker'];
} }
@@ -246,6 +264,11 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getAnilistQueueStatus: options.getAnilistQueueStatus, getAnilistQueueStatus: options.getAnilistQueueStatus,
retryAnilistQueueNow: options.retryAnilistQueueNow, retryAnilistQueueNow: options.retryAnilistQueueNow,
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
removePlaylistBrowserIndex: options.removePlaylistBrowserIndex,
movePlaylistBrowserIndex: options.movePlaylistBrowserIndex,
get immersionTracker() { get immersionTracker() {
return options.getImmersionTracker?.() ?? null; return options.getImmersionTracker?.() ?? null;
}, },
@@ -510,6 +533,44 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.appendClipboardVideoToQueue(); return deps.appendClipboardVideoToQueue();
}); });
ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => {
return await deps.getPlaylistBrowserSnapshot();
});
ipc.handle(IPC_CHANNELS.request.appendPlaylistBrowserFile, async (_event, filePath: unknown) => {
if (typeof filePath !== 'string' || filePath.trim().length === 0) {
return { ok: false, message: 'Invalid playlist browser file path.' };
}
return await deps.appendPlaylistBrowserFile(filePath);
});
ipc.handle(IPC_CHANNELS.request.playPlaylistBrowserIndex, async (_event, index: unknown) => {
if (!Number.isSafeInteger(index) || (index as number) < 0) {
return { ok: false, message: 'Invalid playlist browser index.' };
}
return await deps.playPlaylistBrowserIndex(index as number);
});
ipc.handle(IPC_CHANNELS.request.removePlaylistBrowserIndex, async (_event, index: unknown) => {
if (!Number.isSafeInteger(index) || (index as number) < 0) {
return { ok: false, message: 'Invalid playlist browser index.' };
}
return await deps.removePlaylistBrowserIndex(index as number);
});
ipc.handle(
IPC_CHANNELS.request.movePlaylistBrowserIndex,
async (_event, index: unknown, direction: unknown) => {
if (!Number.isSafeInteger(index) || (index as number) < 0) {
return { ok: false, message: 'Invalid playlist browser index.' };
}
if (direction !== 1 && direction !== -1) {
return { ok: false, message: 'Invalid playlist browser move direction.' };
}
return await deps.movePlaylistBrowserIndex(index as number, direction as 1 | -1);
},
);
// Stats request handlers // Stats request handlers
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => { ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
const tracker = deps.immersionTracker; const tracker = deps.immersionTracker;

View File

@@ -98,6 +98,13 @@ interface AppReadyConfigLike {
}; };
} }
type TexthookerWebsocketConfigLike = Pick<AppReadyConfigLike, 'annotationWebsocket' | 'websocket'>;
type TexthookerWebsocketDefaults = {
defaultWebsocketPort: number;
defaultAnnotationWebsocketPort: number;
};
export interface AppReadyRuntimeDeps { export interface AppReadyRuntimeDeps {
ensureDefaultConfigBootstrap: () => void; ensureDefaultConfigBootstrap: () => void;
loadSubtitlePosition: () => void; loadSubtitlePosition: () => void;
@@ -169,6 +176,29 @@ function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] {
return errors; return errors;
} }
export function resolveTexthookerWebsocketUrl(
config: TexthookerWebsocketConfigLike,
defaults: TexthookerWebsocketDefaults,
hasMpvWebsocketPlugin: boolean,
): string | undefined {
const wsConfig = config.websocket || {};
const wsEnabled = wsConfig.enabled ?? 'auto';
const wsPort = wsConfig.port || defaults.defaultWebsocketPort;
const annotationWsConfig = config.annotationWebsocket || {};
const annotationWsEnabled = annotationWsConfig.enabled !== false;
const annotationWsPort = annotationWsConfig.port || defaults.defaultAnnotationWebsocketPort;
if (annotationWsEnabled) {
return `ws://127.0.0.1:${annotationWsPort}`;
}
if (wsEnabled === true || (wsEnabled === 'auto' && !hasMpvWebsocketPlugin)) {
return `ws://127.0.0.1:${wsPort}`;
}
return undefined;
}
export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean { export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean {
return config.auto_start_overlay === true; return config.auto_start_overlay === true;
} }
@@ -201,12 +231,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
return; return;
} }
if (deps.texthookerOnlyMode) {
deps.reloadConfig();
deps.handleInitialArgs();
return;
}
if (deps.shouldUseMinimalStartup?.()) { if (deps.shouldUseMinimalStartup?.()) {
deps.reloadConfig(); deps.reloadConfig();
deps.handleInitialArgs(); deps.handleInitialArgs();
@@ -262,7 +286,14 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const annotationWsEnabled = annotationWsConfig.enabled !== false; const annotationWsEnabled = annotationWsConfig.enabled !== false;
const annotationWsPort = annotationWsConfig.port || deps.defaultAnnotationWebsocketPort; const annotationWsPort = annotationWsConfig.port || deps.defaultAnnotationWebsocketPort;
const texthookerPort = deps.defaultTexthookerPort; const texthookerPort = deps.defaultTexthookerPort;
let texthookerWebsocketUrl: string | undefined; const texthookerWebsocketUrl = resolveTexthookerWebsocketUrl(
config,
{
defaultWebsocketPort: deps.defaultWebsocketPort,
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
},
deps.hasMpvWebsocketPlugin(),
);
if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) { if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
deps.startSubtitleWebsocket(wsPort); deps.startSubtitleWebsocket(wsPort);
@@ -272,9 +303,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (annotationWsEnabled) { if (annotationWsEnabled) {
deps.startAnnotationWebsocket(annotationWsPort); deps.startAnnotationWebsocket(annotationWsPort);
texthookerWebsocketUrl = `ws://127.0.0.1:${annotationWsPort}`;
} else if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
texthookerWebsocketUrl = `ws://127.0.0.1:${wsPort}`;
} }
if (config.texthooker?.launchAtStartup !== false) { if (config.texthooker?.launchAtStartup !== false) {

View File

@@ -75,7 +75,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
} { } {
return { return {
shouldUseMinimalStartup: Boolean( shouldUseMinimalStartup: Boolean(
initialArgs?.texthooker || (initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
(initialArgs?.stats && (initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)), (initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
), ),
@@ -128,6 +128,7 @@ import {
commandNeedsOverlayStartupPrereqs, commandNeedsOverlayStartupPrereqs,
commandNeedsOverlayRuntime, commandNeedsOverlayRuntime,
isHeadlessInitialCommand, isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs, parseArgs,
shouldRunSettingsOnlyStartup, shouldRunSettingsOnlyStartup,
shouldStartApp, shouldStartApp,
@@ -426,6 +427,13 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import {
appendPlaylistBrowserFileRuntime,
getPlaylistBrowserSnapshotRuntime,
movePlaylistBrowserIndexRuntime,
playPlaylistBrowserIndexRuntime,
removePlaylistBrowserIndexRuntime,
} from './main/runtime/playlist-browser-runtime';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import { import {
createFrequencyDictionaryRuntimeService, createFrequencyDictionaryRuntimeService,
@@ -1928,6 +1936,19 @@ function openRuntimeOptionsPalette(): void {
overlayVisibilityComposer.openRuntimeOptionsPalette(); overlayVisibilityComposer.openRuntimeOptionsPalette();
} }
function openPlaylistBrowser(): void {
if (!appState.mpvClient?.connected) {
showMpvOsd('Playlist browser requires active playback.');
return;
}
const opened = sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, {
restoreOnModalClose: 'playlist-browser',
});
if (!opened) {
showMpvOsd('Playlist browser overlay unavailable.');
}
}
function getResolvedConfig() { function getResolvedConfig() {
return configService.getConfig(); return configService.getConfig();
} }
@@ -2591,6 +2612,7 @@ const {
function refreshAnilistClientSecretStateIfEnabled(options?: { function refreshAnilistClientSecretStateIfEnabled(options?: {
force?: boolean; force?: boolean;
allowSetupPrompt?: boolean;
}): Promise<string | null> { }): Promise<string | null> {
if (!isAnilistTrackingEnabled(getResolvedConfig())) { if (!isAnilistTrackingEnabled(getResolvedConfig())) {
return Promise.resolve(null); return Promise.resolve(null);
@@ -4107,11 +4129,16 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text) => showMpvOsd(text),
}); });
const playlistBrowserRuntimeDeps = {
getMpvClient: () => appState.mpvClient,
};
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: { mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => { cycleRuntimeOption: (id, direction) => {
if (!appState.runtimeOptionsManager) { if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' }; return { ok: false, error: 'Runtime options manager unavailable' };
@@ -4288,6 +4315,16 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
getPlaylistBrowserSnapshot: () =>
getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
appendPlaylistBrowserFile: (filePath) =>
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
playPlaylistBrowserIndex: (index) =>
playPlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
removePlaylistBrowserIndex: (index) =>
removePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
movePlaylistBrowserIndex: (index, direction) =>
movePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index, direction),
getImmersionTracker: () => appState.immersionTracker, getImmersionTracker: () => appState.immersionTracker,
}, },
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({
@@ -4333,6 +4370,9 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
setLogLevel: (level) => setLogLevel(level, 'cli'), setLogLevel: (level) => setLogLevel(level, 'cli'),
texthookerService, texthookerService,
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
openExternal: (url: string) => shell.openExternal(url), openExternal: (url: string) => shell.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) => logBrowserOpenError: (url: string, error: unknown) =>
logger.error(`Failed to open browser for texthooker URL: ${url}`, error), logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
@@ -4480,7 +4520,10 @@ const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup;
const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup; const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup;
if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartup)) { if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartup)) {
if (isAnilistTrackingEnabled(getResolvedConfig())) { if (isAnilistTrackingEnabled(getResolvedConfig())) {
void refreshAnilistClientSecretStateIfEnabled({ force: true }).catch((error) => { void refreshAnilistClientSecretStateIfEnabled({
force: true,
allowSetupPrompt: false,
}).catch((error) => {
logger.error('Failed to refresh AniList client secret state during startup', error); logger.error('Failed to refresh AniList client secret state during startup', error);
}); });
anilistStateRuntime.refreshRetryQueueState(); anilistStateRuntime.refreshRetryQueueState();

View File

@@ -13,6 +13,7 @@ export interface CliCommandRuntimeServiceContext {
showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd']; showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd'];
getTexthookerPort: () => number; getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void; setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean; shouldOpenBrowser: () => boolean;
openInBrowser: (url: string) => void; openInBrowser: (url: string) => void;
isOverlayInitialized: () => boolean; isOverlayInitialized: () => boolean;
@@ -71,6 +72,7 @@ function createCliCommandDepsFromContext(
service: context.texthookerService, service: context.texthookerService,
getPort: context.getTexthookerPort, getPort: context.getTexthookerPort,
setPort: context.setTexthookerPort, setPort: context.setTexthookerPort,
getWebsocketUrl: context.getTexthookerWebsocketUrl,
shouldOpenBrowser: context.shouldOpenBrowser, shouldOpenBrowser: context.shouldOpenBrowser,
openInBrowser: context.openInBrowser, openInBrowser: context.openInBrowser,
}, },

View File

@@ -93,6 +93,11 @@ export interface MainIpcRuntimeServiceDepsParams {
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
removePlaylistBrowserIndex: IpcDepsRuntimeOptions['removePlaylistBrowserIndex'];
movePlaylistBrowserIndex: IpcDepsRuntimeOptions['movePlaylistBrowserIndex'];
getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker']; getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker'];
} }
@@ -132,6 +137,7 @@ export interface CliCommandRuntimeServiceDepsParams {
service: CliCommandDepsRuntimeOptions['texthooker']['service']; service: CliCommandDepsRuntimeOptions['texthooker']['service'];
getPort: CliCommandDepsRuntimeOptions['texthooker']['getPort']; getPort: CliCommandDepsRuntimeOptions['texthooker']['getPort'];
setPort: CliCommandDepsRuntimeOptions['texthooker']['setPort']; setPort: CliCommandDepsRuntimeOptions['texthooker']['setPort'];
getWebsocketUrl: CliCommandDepsRuntimeOptions['texthooker']['getWebsocketUrl'];
shouldOpenBrowser: CliCommandDepsRuntimeOptions['texthooker']['shouldOpenBrowser']; shouldOpenBrowser: CliCommandDepsRuntimeOptions['texthooker']['shouldOpenBrowser'];
openInBrowser: CliCommandDepsRuntimeOptions['texthooker']['openInBrowser']; openInBrowser: CliCommandDepsRuntimeOptions['texthooker']['openInBrowser'];
}; };
@@ -192,6 +198,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig']; triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette']; openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
@@ -246,6 +253,11 @@ export function createMainIpcRuntimeServiceDeps(
getAnilistQueueStatus: params.getAnilistQueueStatus, getAnilistQueueStatus: params.getAnilistQueueStatus,
retryAnilistQueueNow: params.retryAnilistQueueNow, retryAnilistQueueNow: params.retryAnilistQueueNow,
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
removePlaylistBrowserIndex: params.removePlaylistBrowserIndex,
movePlaylistBrowserIndex: params.movePlaylistBrowserIndex,
getImmersionTracker: params.getImmersionTracker, getImmersionTracker: params.getImmersionTracker,
}; };
} }
@@ -293,6 +305,7 @@ export function createCliCommandRuntimeServiceDeps(
service: params.texthooker.service, service: params.texthooker.service,
getPort: params.texthooker.getPort, getPort: params.texthooker.getPort,
setPort: params.texthooker.setPort, setPort: params.texthooker.setPort,
getWebsocketUrl: params.texthooker.getWebsocketUrl,
shouldOpenBrowser: params.texthooker.shouldOpenBrowser, shouldOpenBrowser: params.texthooker.shouldOpenBrowser,
openInBrowser: params.texthooker.openInBrowser, openInBrowser: params.texthooker.openInBrowser,
}, },
@@ -356,6 +369,7 @@ export function createMpvCommandRuntimeServiceDeps(
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig, triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette, openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
openYoutubeTrackPicker: params.openYoutubeTrackPicker, openYoutubeTrackPicker: params.openYoutubeTrackPicker,
openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle, runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd, showMpvOsd: params.showMpvOsd,
mpvReplaySubtitle: params.mpvReplaySubtitle, mpvReplaySubtitle: params.mpvReplaySubtitle,

View File

@@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
replayCurrentSubtitle: () => void; replayCurrentSubtitle: () => void;
@@ -35,6 +36,7 @@ export function handleMpvCommandFromIpcRuntime(
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker, openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption, runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd, showMpvOsd: deps.showMpvOsd,
mpvReplaySubtitle: deps.replayCurrentSubtitle, mpvReplaySubtitle: deps.replayCurrentSubtitle,

View File

@@ -82,7 +82,42 @@ test('refresh handler prefers cached token when not forced', async () => {
assert.equal(loadCalls, 0); assert.equal(loadCalls, 0);
}); });
test('refresh handler falls back to stored token then opens setup when missing', async () => { test('refresh handler falls back to stored token without opening setup', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
const states: Array<{ status: string; source: string }> = [];
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: () => {},
loadStoredToken: () => ' stored-token ',
setClientSecretState: (state) => {
states.push({ status: state.status, source: state.source });
},
getAnilistSetupPageOpened: () => opened,
setAnilistSetupPageOpened: (next) => {
opened = next;
},
openAnilistSetupWindow: () => {
openCalls += 1;
},
now: () => 400,
});
const token = await refresh({ force: true });
assert.equal(token, 'stored-token');
assert.equal(cached, 'stored-token');
assert.equal(opened, false);
assert.equal(openCalls, 0);
assert.deepEqual(states, [{ status: 'resolved', source: 'stored' }]);
});
test('refresh handler opens setup when missing token and prompting allowed', async () => {
let cached: string | null = null; let cached: string | null = null;
let opened = false; let opened = false;
let openCalls = 0; let openCalls = 0;
@@ -111,3 +146,44 @@ test('refresh handler falls back to stored token then opens setup when missing',
assert.equal(cached, null); assert.equal(cached, null);
assert.equal(openCalls, 1); assert.equal(openCalls, 1);
}); });
test('refresh handler skips setup open when missing token and prompting disabled', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
const states: Array<{ status: string; source: string; message: string | null }> = [];
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: () => {},
loadStoredToken: () => '',
setClientSecretState: (state) => {
states.push({ status: state.status, source: state.source, message: state.message });
},
getAnilistSetupPageOpened: () => opened,
setAnilistSetupPageOpened: (next) => {
opened = next;
},
openAnilistSetupWindow: () => {
openCalls += 1;
},
now: () => 500,
});
const token = await refresh({ force: true, allowSetupPrompt: false });
assert.equal(token, null);
assert.equal(cached, null);
assert.equal(opened, false);
assert.equal(openCalls, 0);
assert.deepEqual(states, [
{
status: 'error',
source: 'none',
message: 'cannot authenticate without anilist.accessToken',
},
]);
});

View File

@@ -27,7 +27,10 @@ export function createRefreshAnilistClientSecretStateHandler<
openAnilistSetupWindow: () => void; openAnilistSetupWindow: () => void;
now: () => number; now: () => number;
}) { }) {
return async (options?: { force?: boolean }): Promise<string | null> => { return async (options?: {
force?: boolean;
allowSetupPrompt?: boolean;
}): Promise<string | null> => {
const resolved = deps.getResolvedConfig(); const resolved = deps.getResolvedConfig();
const now = deps.now(); const now = deps.now();
if (!deps.isAnilistTrackingEnabled(resolved)) { if (!deps.isAnilistTrackingEnabled(resolved)) {
@@ -87,7 +90,11 @@ export function createRefreshAnilistClientSecretStateHandler<
resolvedAt: null, resolvedAt: null,
errorAt: now, errorAt: now,
}); });
if (deps.isAnilistTrackingEnabled(resolved) && !deps.getAnilistSetupPageOpened()) { if (
options?.allowSetupPrompt !== false &&
deps.isAnilistTrackingEnabled(resolved) &&
!deps.getAnilistSetupPageOpened()
) {
deps.openAnilistSetupWindow(); deps.openAnilistSetupWindow();
} }
return null; return null;

View File

@@ -12,6 +12,7 @@ test('build cli command context deps maps handlers and values', () => {
texthookerService: { start: () => null, status: () => ({ running: false }) } as never, texthookerService: { start: () => null, status: () => ({ running: false }) } as never,
getTexthookerPort: () => 5174, getTexthookerPort: () => 5174,
setTexthookerPort: (port) => calls.push(`port:${port}`), setTexthookerPort: (port) => calls.push(`port:${port}`),
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
shouldOpenBrowser: () => true, shouldOpenBrowser: () => true,
openExternal: async (url) => calls.push(`open:${url}`), openExternal: async (url) => calls.push(`open:${url}`),
logBrowserOpenError: (url) => calls.push(`open-error:${url}`), logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
@@ -82,6 +83,7 @@ test('build cli command context deps maps handlers and values', () => {
const deps = buildDeps(); const deps = buildDeps();
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.getTexthookerPort(), 5174); assert.equal(deps.getTexthookerPort(), 5174);
assert.equal(deps.getTexthookerWebsocketUrl(), 'ws://127.0.0.1:6678');
assert.equal(deps.shouldOpenBrowser(), true); assert.equal(deps.shouldOpenBrowser(), true);
assert.equal(deps.isOverlayInitialized(), true); assert.equal(deps.isOverlayInitialized(), true);
assert.equal(deps.hasMainWindow(), true); assert.equal(deps.hasMainWindow(), true);

View File

@@ -10,6 +10,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
texthookerService: CliCommandContextFactoryDeps['texthookerService']; texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getTexthookerPort: () => number; getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void; setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean; shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>; openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void; logBrowserOpenError: (url: string, error: unknown) => void;
@@ -58,6 +59,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
texthookerService: deps.texthookerService, texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort, getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort, setTexthookerPort: deps.setTexthookerPort,
getTexthookerWebsocketUrl: deps.getTexthookerWebsocketUrl,
shouldOpenBrowser: deps.shouldOpenBrowser, shouldOpenBrowser: deps.shouldOpenBrowser,
openExternal: deps.openExternal, openExternal: deps.openExternal,
logBrowserOpenError: deps.logBrowserOpenError, logBrowserOpenError: deps.logBrowserOpenError,

View File

@@ -14,7 +14,13 @@ test('cli command context factory composes main deps and context handlers', () =
const createContext = createCliCommandContextFactory({ const createContext = createCliCommandContextFactory({
appState, appState,
texthookerService: { isRunning: () => false, start: () => null }, texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }), getResolvedConfig: () => ({
texthooker: { openBrowser: true },
annotationWebsocket: { enabled: true, port: 6678 },
}),
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async () => {}, openExternal: async () => {},
logBrowserOpenError: () => {}, logBrowserOpenError: () => {},
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),

View File

@@ -14,7 +14,13 @@ test('cli command context main deps builder maps state and callbacks', async ()
const build = createBuildCliCommandContextMainDepsHandler({ const build = createBuildCliCommandContextMainDepsHandler({
appState, appState,
texthookerService: { isRunning: () => false, start: () => null }, texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }), getResolvedConfig: () => ({
texthooker: { openBrowser: true },
annotationWebsocket: { enabled: true, port: 6678 },
}),
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async (url) => { openExternal: async (url) => {
calls.push(`open:${url}`); calls.push(`open:${url}`);
}, },
@@ -110,6 +116,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
assert.equal(deps.getTexthookerPort(), 5174); assert.equal(deps.getTexthookerPort(), 5174);
deps.setTexthookerPort(5175); deps.setTexthookerPort(5175);
assert.equal(appState.texthookerPort, 5175); assert.equal(appState.texthookerPort, 5175);
assert.equal(deps.getTexthookerWebsocketUrl(), 'ws://127.0.0.1:6678');
assert.equal(deps.shouldOpenBrowser(), true); assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello'); deps.showOsd('hello');
deps.initializeOverlay(); deps.initializeOverlay();

View File

@@ -1,4 +1,5 @@
import type { CliArgs } from '../../cli/args'; import type { CliArgs } from '../../cli/args';
import { resolveTexthookerWebsocketUrl } from '../../core/services/startup';
import type { CliCommandContextFactoryDeps } from './cli-command-context'; import type { CliCommandContextFactoryDeps } from './cli-command-context';
type CliCommandContextMainState = { type CliCommandContextMainState = {
@@ -12,7 +13,14 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: CliCommandContextMainState; appState: CliCommandContextMainState;
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void; setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService']; texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } }; getResolvedConfig: () => {
texthooker?: { openBrowser?: boolean };
websocket?: { enabled?: boolean | 'auto'; port?: number };
annotationWebsocket?: { enabled?: boolean; port?: number };
};
defaultWebsocketPort: number;
defaultAnnotationWebsocketPort: number;
hasMpvWebsocketPlugin: () => boolean;
openExternal: (url: string) => Promise<unknown>; openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void; logBrowserOpenError: (url: string, error: unknown) => void;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
@@ -68,6 +76,15 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
setTexthookerPort: (port: number) => { setTexthookerPort: (port: number) => {
deps.appState.texthookerPort = port; deps.appState.texthookerPort = port;
}, },
getTexthookerWebsocketUrl: () =>
resolveTexthookerWebsocketUrl(
deps.getResolvedConfig(),
{
defaultWebsocketPort: deps.defaultWebsocketPort,
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
},
deps.hasMpvWebsocketPlugin(),
),
shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false, shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false,
openExternal: (url: string) => deps.openExternal(url), openExternal: (url: string) => deps.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error), logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error),

View File

@@ -18,6 +18,7 @@ function createDeps() {
texthookerService: {} as never, texthookerService: {} as never,
getTexthookerPort: () => 6677, getTexthookerPort: () => 6677,
setTexthookerPort: () => {}, setTexthookerPort: () => {},
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
shouldOpenBrowser: () => true, shouldOpenBrowser: () => true,
openExternal: async () => {}, openExternal: async () => {},
logBrowserOpenError: (url: string) => browserErrors.push(url), logBrowserOpenError: (url: string) => browserErrors.push(url),

View File

@@ -15,6 +15,7 @@ export type CliCommandContextFactoryDeps = {
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService']; texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
getTexthookerPort: () => number; getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void; setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean; shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>; openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void; logBrowserOpenError: (url: string, error: unknown) => void;
@@ -67,6 +68,7 @@ export function createCliCommandContext(
texthookerService: deps.texthookerService, texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort, getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort, setTexthookerPort: deps.setTexthookerPort,
getTexthookerWebsocketUrl: deps.getTexthookerWebsocketUrl,
shouldOpenBrowser: deps.shouldOpenBrowser, shouldOpenBrowser: deps.shouldOpenBrowser,
openInBrowser: (url: string) => { openInBrowser: (url: string) => {
void deps.openExternal(url).catch((error) => { void deps.openExternal(url).catch((error) => {

View File

@@ -11,6 +11,9 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
setLogLevel: () => {}, setLogLevel: () => {},
texthookerService: {} as never, texthookerService: {} as never,
getResolvedConfig: () => ({}) as never, getResolvedConfig: () => ({}) as never,
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async () => {}, openExternal: async () => {},
logBrowserOpenError: () => {}, logBrowserOpenError: () => {},
showMpvOsd: () => {}, showMpvOsd: () => {},

View File

@@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},
@@ -68,6 +69,20 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnilistQueueStatus: () => ({}) as never, getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
getPlaylistBrowserSnapshot: async () =>
({
directoryPath: null,
directoryAvailable: false,
directoryStatus: '',
directoryItems: [],
playlistItems: [],
playingIndex: null,
currentFilePath: null,
}) as never,
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
ankiJimakuDeps: { ankiJimakuDeps: {

View File

@@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},

View File

@@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
triggerSubsyncFromConfig: () => {}, triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},

View File

@@ -10,6 +10,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube-picker'); calls.push('youtube-picker');
}, },
openPlaylistBrowser: () => {
calls.push('playlist-browser');
},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
replayCurrentSubtitle: () => calls.push('replay'), replayCurrentSubtitle: () => calls.push('replay'),
@@ -26,6 +29,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.triggerSubsyncFromConfig(); deps.triggerSubsyncFromConfig();
deps.openRuntimeOptionsPalette(); deps.openRuntimeOptionsPalette();
void deps.openYoutubeTrackPicker(); void deps.openYoutubeTrackPicker();
void deps.openPlaylistBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
deps.showMpvOsd('hello'); deps.showMpvOsd('hello');
deps.replayCurrentSubtitle(); deps.replayCurrentSubtitle();
@@ -39,6 +43,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'subsync', 'subsync',
'palette', 'palette',
'youtube-picker', 'youtube-picker',
'playlist-browser',
'osd:hello', 'osd:hello',
'replay', 'replay',
'next', 'next',

View File

@@ -7,6 +7,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(), openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
showMpvOsd: (text: string) => deps.showMpvOsd(text), showMpvOsd: (text: string) => deps.showMpvOsd(text),
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),

View File

@@ -0,0 +1,326 @@
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 type { PlaylistBrowserQueueItem } from '../../types';
import {
appendPlaylistBrowserFileRuntime,
getPlaylistBrowserSnapshotRuntime,
movePlaylistBrowserIndexRuntime,
playPlaylistBrowserIndexRuntime,
removePlaylistBrowserIndexRuntime,
} from './playlist-browser-runtime';
type FakePlaylistEntry = {
current?: boolean;
playing?: boolean;
filename: string;
title?: string;
id?: number;
};
function createTempVideoDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-playlist-browser-'));
}
function createFakeMpvClient(options: {
currentVideoPath: string;
playlist: FakePlaylistEntry[];
connected?: boolean;
}) {
let playlist = options.playlist.map((item, index) => ({
id: item.id ?? index + 1,
current: item.current ?? false,
playing: item.playing ?? item.current ?? false,
filename: item.filename,
title: item.title ?? null,
}));
const commands: Array<(string | number)[]> = [];
const syncFlags = (): void => {
let playingIndex = playlist.findIndex((item) => item.current || item.playing);
if (playingIndex < 0 && playlist.length > 0) {
playingIndex = 0;
}
playlist = playlist.map((item, index) => ({
...item,
current: index === playingIndex,
playing: index === playingIndex,
}));
};
syncFlags();
return {
connected: options.connected ?? true,
currentVideoPath: options.currentVideoPath,
async requestProperty(name: string): Promise<unknown> {
if (name === 'playlist') {
return playlist;
}
if (name === 'playlist-playing-pos') {
return playlist.findIndex((item) => item.current || item.playing);
}
if (name === 'path') {
return this.currentVideoPath;
}
throw new Error(`Unexpected property: ${name}`);
},
send(payload: { command: unknown[] }): boolean {
const command = payload.command as (string | number)[];
commands.push(command);
const [action, first, second] = command;
if (action === 'loadfile' && typeof first === 'string' && second === 'append') {
playlist.push({
id: playlist.length + 1,
filename: first,
title: null,
current: false,
playing: false,
});
syncFlags();
return true;
}
if (action === 'playlist-play-index' && typeof first === 'number' && playlist[first]) {
playlist = playlist.map((item, index) => ({
...item,
current: index === first,
playing: index === first,
}));
this.currentVideoPath = playlist[first]!.filename;
return true;
}
if (action === 'playlist-remove' && typeof first === 'number' && playlist[first]) {
const removingCurrent = playlist[first]!.current || playlist[first]!.playing;
playlist.splice(first, 1);
if (removingCurrent) {
syncFlags();
this.currentVideoPath =
playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath;
}
return true;
}
if (
action === 'playlist-move' &&
typeof first === 'number' &&
typeof second === 'number' &&
playlist[first]
) {
const [moved] = playlist.splice(first, 1);
playlist.splice(second, 0, moved!);
syncFlags();
return true;
}
return true;
},
getCommands(): Array<(string | number)[]> {
return commands;
},
};
}
test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async () => {
const dir = createTempVideoDir();
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const special = path.join(dir, 'Show - Special.mp4');
const ignored = path.join(dir, 'notes.txt');
fs.writeFileSync(episode2, '');
fs.writeFileSync(episode1, '');
fs.writeFileSync(special, '');
fs.writeFileSync(ignored, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode2,
playlist: [
{ filename: episode1, current: false, playing: false, title: 'Episode 1' },
{ filename: episode2, current: true, playing: true, title: 'Episode 2' },
],
});
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
assert.equal(snapshot.directoryAvailable, true);
assert.equal(snapshot.directoryPath, dir);
assert.equal(snapshot.currentFilePath, episode2);
assert.equal(snapshot.playingIndex, 1);
assert.deepEqual(
snapshot.directoryItems.map((item) => [item.basename, item.isCurrentFile]),
[
['Show - S01E01.mkv', false],
['Show - S01E02.mkv', true],
['Show - Special.mp4', false],
],
);
assert.deepEqual(
snapshot.playlistItems.map((item) => ({
index: item.index,
displayLabel: item.displayLabel,
current: item.current,
})),
[
{ index: 0, displayLabel: 'Episode 1', current: false },
{ index: 1, displayLabel: 'Episode 2', current: true },
],
);
});
test('getPlaylistBrowserSnapshotRuntime degrades directory pane for remote media', async () => {
const mpvClient = createFakeMpvClient({
currentVideoPath: 'https://example.com/video.m3u8',
playlist: [{ filename: 'https://example.com/video.m3u8', current: true }],
});
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
assert.equal(snapshot.directoryAvailable, false);
assert.equal(snapshot.directoryItems.length, 0);
assert.match(snapshot.directoryStatus, /local filesystem/i);
assert.equal(snapshot.playlistItems.length, 1);
});
test('playlist-browser mutation runtimes mutate queue and return refreshed snapshots', async () => {
const dir = createTempVideoDir();
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode3 = path.join(dir, 'Show - S01E03.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
fs.writeFileSync(episode3, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true, title: 'Episode 1' },
{ filename: episode2, title: 'Episode 2' },
],
});
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
const deps = {
getMpvClient: () => mpvClient,
schedule: (callback: () => void, delayMs: number) => {
scheduled.push({ callback, delayMs });
},
};
const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3);
assert.equal(appendResult.ok, true);
assert.deepEqual(mpvClient.getCommands().at(-1), ['loadfile', episode3, 'append']);
assert.deepEqual(
appendResult.snapshot?.playlistItems.map((item) => item.path),
[episode1, episode2, episode3],
);
const moveResult = await movePlaylistBrowserIndexRuntime(deps, 2, -1);
assert.equal(moveResult.ok, true);
assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-move', 2, 1]);
assert.deepEqual(
moveResult.snapshot?.playlistItems.map((item) => item.path),
[episode1, episode3, episode2],
);
const playResult = await playPlaylistBrowserIndexRuntime(deps, 1);
assert.equal(playResult.ok, true);
assert.deepEqual(mpvClient.getCommands().slice(-2), [
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1],
]);
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
scheduled[0]?.callback();
assert.deepEqual(mpvClient.getCommands().slice(-2), [
['set_property', 'sid', 'auto'],
['set_property', 'secondary-sid', 'auto'],
]);
assert.equal(playResult.snapshot?.playingIndex, 1);
const removeResult = await removePlaylistBrowserIndexRuntime(deps, 2);
assert.equal(removeResult.ok, true);
assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-remove', 2]);
assert.deepEqual(
removeResult.snapshot?.playlistItems.map((item) => item.path),
[episode1, episode3],
);
});
test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', async () => {
const dir = createTempVideoDir();
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true },
{ filename: episode2 },
],
});
const deps = {
getMpvClient: () => mpvClient,
};
const moveUp = await movePlaylistBrowserIndexRuntime(deps, 0, -1);
assert.deepEqual(moveUp, {
ok: false,
message: 'Playlist item is already at the top.',
});
const moveDown = await movePlaylistBrowserIndexRuntime(deps, 1, 1);
assert.deepEqual(moveDown, {
ok: false,
message: 'Playlist item is already at the bottom.',
});
});
test('getPlaylistBrowserSnapshotRuntime normalizes playlist labels from title then filename', async () => {
const dir = createTempVideoDir();
const episode1 = path.join(dir, 'Show - S01E01.mkv');
fs.writeFileSync(episode1, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [{ filename: episode1, current: true, title: '' }],
});
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
const item = snapshot.playlistItems[0] as PlaylistBrowserQueueItem;
assert.equal(item.displayLabel, 'Show - S01E01.mkv');
assert.equal(item.path, episode1);
});
test('playPlaylistBrowserIndexRuntime skips local subtitle reset for remote playlist entries', async () => {
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
const mpvClient = createFakeMpvClient({
currentVideoPath: 'https://example.com/video-1.m3u8',
playlist: [
{ filename: 'https://example.com/video-1.m3u8', current: true, title: 'Episode 1' },
{ filename: 'https://example.com/video-2.m3u8', title: 'Episode 2' },
],
});
const result = await playPlaylistBrowserIndexRuntime(
{
getMpvClient: () => mpvClient,
schedule: (callback, delayMs) => {
scheduled.push({ callback, delayMs });
},
},
1,
);
assert.equal(result.ok, true);
assert.deepEqual(mpvClient.getCommands().slice(-1), [['playlist-play-index', 1]]);
assert.equal(scheduled.length, 0);
});

View File

@@ -0,0 +1,314 @@
import fs from 'node:fs';
import path from 'node:path';
import type {
PlaylistBrowserDirectoryItem,
PlaylistBrowserMutationResult,
PlaylistBrowserQueueItem,
PlaylistBrowserSnapshot,
} from '../../types';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { hasVideoExtension } from '../../shared/video-extensions';
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
type PlaylistLike = {
filename?: unknown;
title?: unknown;
id?: unknown;
current?: unknown;
playing?: unknown;
};
type MpvPlaylistBrowserClientLike = {
connected: boolean;
currentVideoPath?: string | null;
requestProperty?: (name: string) => Promise<unknown>;
send: (payload: { command: unknown[]; request_id?: number }) => boolean;
};
export type PlaylistBrowserRuntimeDeps = {
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
schedule?: (callback: () => void, delayMs: number) => void;
};
function trimToNull(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
async function readProperty(
client: MpvPlaylistBrowserClientLike | null,
name: string,
): Promise<unknown> {
if (!client?.requestProperty) return null;
try {
return await client.requestProperty(name);
} catch {
return null;
}
}
async function resolveCurrentFilePath(
client: MpvPlaylistBrowserClientLike | null,
): Promise<string | null> {
const currentVideoPath = trimToNull(client?.currentVideoPath);
if (currentVideoPath) return currentVideoPath;
return trimToNull(await readProperty(client, 'path'));
}
function resolveDirectorySnapshot(
currentFilePath: string | null,
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
if (!currentFilePath) {
return {
directoryAvailable: false,
directoryItems: [],
directoryPath: null,
directoryStatus: 'Current media path is unavailable.',
};
}
if (isRemoteMediaPath(currentFilePath)) {
return {
directoryAvailable: false,
directoryItems: [],
directoryPath: null,
directoryStatus: 'Directory browser requires a local filesystem video.',
};
}
const resolvedPath = path.resolve(currentFilePath);
const directoryPath = path.dirname(resolvedPath);
try {
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
const videoPaths = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => hasVideoExtension(path.extname(name)))
.map((name) => path.join(directoryPath, name));
const directoryItems: PlaylistBrowserDirectoryItem[] = sortPlaylistBrowserDirectoryItems(
videoPaths,
).map((item) => ({
...item,
isCurrentFile: item.path === resolvedPath,
}));
return {
directoryAvailable: true,
directoryItems,
directoryPath,
directoryStatus: directoryPath,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
directoryAvailable: false,
directoryItems: [],
directoryPath,
directoryStatus: `Could not read parent directory: ${message}`,
};
}
}
function normalizePlaylistItems(raw: unknown): PlaylistBrowserQueueItem[] {
if (!Array.isArray(raw)) return [];
return raw.map((entry, index) => {
const item = (entry ?? {}) as PlaylistLike;
const filename = trimToNull(item.filename) ?? '';
const title = trimToNull(item.title);
const normalizedPath =
filename && !isRemoteMediaPath(filename) ? path.resolve(filename) : trimToNull(filename);
return {
index,
id: typeof item.id === 'number' ? item.id : null,
filename,
title,
displayLabel:
title ?? (path.basename(filename || '') || filename || `Playlist item ${index + 1}`),
current: item.current === true,
playing: item.playing === true,
path: normalizedPath,
};
});
}
function ensureConnectedClient(
deps: PlaylistBrowserRuntimeDeps,
): MpvPlaylistBrowserClientLike | { ok: false; message: string } {
const client = deps.getMpvClient();
if (!client?.connected) {
return {
ok: false,
message: 'MPV is not connected.',
};
}
return client;
}
async function getPlaylistItemsFromClient(
client: MpvPlaylistBrowserClientLike | null,
): Promise<PlaylistBrowserQueueItem[]> {
return normalizePlaylistItems(await readProperty(client, 'playlist'));
}
export async function getPlaylistBrowserSnapshotRuntime(
deps: PlaylistBrowserRuntimeDeps,
): Promise<PlaylistBrowserSnapshot> {
const client = deps.getMpvClient();
const currentFilePath = await resolveCurrentFilePath(client);
const [playlistItems, playingPosValue] = await Promise.all([
getPlaylistItemsFromClient(client),
readProperty(client, 'playlist-playing-pos'),
]);
const playingIndex =
typeof playingPosValue === 'number' && Number.isInteger(playingPosValue)
? playingPosValue
: playlistItems.findIndex((item) => item.current || item.playing);
return {
...resolveDirectorySnapshot(currentFilePath),
playlistItems,
playingIndex: playingIndex >= 0 ? playingIndex : null,
currentFilePath,
};
}
async function validatePlaylistIndex(
deps: PlaylistBrowserRuntimeDeps,
index: number,
): Promise<
| { ok: false; message: string }
| { ok: true; client: MpvPlaylistBrowserClientLike; playlistItems: PlaylistBrowserQueueItem[] }
> {
const client = ensureConnectedClient(deps);
if ('ok' in client) {
return client;
}
const playlistItems = await getPlaylistItemsFromClient(client);
if (!Number.isInteger(index) || index < 0 || index >= playlistItems.length) {
return {
ok: false,
message: 'Playlist item not found.',
};
}
return {
ok: true,
client,
playlistItems,
};
}
async function buildMutationResult(
message: string,
deps: PlaylistBrowserRuntimeDeps,
): Promise<PlaylistBrowserMutationResult> {
return {
ok: true,
message,
snapshot: await getPlaylistBrowserSnapshotRuntime(deps),
};
}
function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void {
client.send({ command: ['set_property', 'sid', 'auto'] });
client.send({ command: ['set_property', 'secondary-sid', 'auto'] });
}
function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
client.send({ command: ['set_property', 'sub-auto', 'fuzzy'] });
}
function isLocalPlaylistItem(item: PlaylistBrowserQueueItem | null | undefined): item is PlaylistBrowserQueueItem {
return Boolean(item?.path && !isRemoteMediaPath(item.path));
}
function scheduleLocalSubtitleSelectionRearm(
deps: PlaylistBrowserRuntimeDeps,
client: MpvPlaylistBrowserClientLike,
): void {
(deps.schedule ?? setTimeout)(() => {
rearmLocalSubtitleSelection(client);
}, 400);
}
export async function appendPlaylistBrowserFileRuntime(
deps: PlaylistBrowserRuntimeDeps,
filePath: string,
): Promise<PlaylistBrowserMutationResult> {
const client = ensureConnectedClient(deps);
if ('ok' in client) {
return client;
}
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
return {
ok: false,
message: 'Playlist browser file is not readable.',
};
}
client.send({ command: ['loadfile', resolvedPath, 'append'] });
return buildMutationResult(`Queued ${path.basename(resolvedPath)}`, deps);
}
export async function playPlaylistBrowserIndexRuntime(
deps: PlaylistBrowserRuntimeDeps,
index: number,
): Promise<PlaylistBrowserMutationResult> {
const result = await validatePlaylistIndex(deps, index);
if (!result.ok) {
return result;
}
const targetItem = result.playlistItems[index] ?? null;
if (isLocalPlaylistItem(targetItem)) {
prepareLocalSubtitleAutoload(result.client);
}
result.client.send({ command: ['playlist-play-index', index] });
if (isLocalPlaylistItem(targetItem)) {
scheduleLocalSubtitleSelectionRearm(deps, result.client);
}
return buildMutationResult(`Playing playlist item ${index + 1}`, deps);
}
export async function removePlaylistBrowserIndexRuntime(
deps: PlaylistBrowserRuntimeDeps,
index: number,
): Promise<PlaylistBrowserMutationResult> {
const result = await validatePlaylistIndex(deps, index);
if (!result.ok) {
return result;
}
result.client.send({ command: ['playlist-remove', index] });
return buildMutationResult(`Removed playlist item ${index + 1}`, deps);
}
export async function movePlaylistBrowserIndexRuntime(
deps: PlaylistBrowserRuntimeDeps,
index: number,
direction: -1 | 1,
): Promise<PlaylistBrowserMutationResult> {
const result = await validatePlaylistIndex(deps, index);
if (!result.ok) {
return result;
}
const targetIndex = index + direction;
if (targetIndex < 0) {
return {
ok: false,
message: 'Playlist item is already at the top.',
};
}
if (targetIndex >= result.playlistItems.length) {
return {
ok: false,
message: 'Playlist item is already at the bottom.',
};
}
result.client.send({ command: ['playlist-move', index, targetIndex] });
return buildMutationResult(`Moved playlist item ${index + 1}`, deps);
}

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
test('sortPlaylistBrowserDirectoryItems prefers parsed season and episode order', () => {
const root = '/library/show';
const items = sortPlaylistBrowserDirectoryItems([
path.join(root, 'Show - S01E10.mkv'),
path.join(root, 'Show - S01E02.mkv'),
path.join(root, 'Show - S01E01.mkv'),
path.join(root, 'Show - Episode 7.mkv'),
path.join(root, 'Show - 01x03.mkv'),
]);
assert.deepEqual(
items.map((item) => item.basename),
[
'Show - S01E01.mkv',
'Show - S01E02.mkv',
'Show - 01x03.mkv',
'Show - Episode 7.mkv',
'Show - S01E10.mkv',
],
);
assert.deepEqual(
items.map((item) => item.episodeLabel),
['S1E1', 'S1E2', 'S1E3', 'E7', 'S1E10'],
);
});
test('sortPlaylistBrowserDirectoryItems falls back to deterministic natural ordering', () => {
const root = '/library/show';
const items = sortPlaylistBrowserDirectoryItems([
path.join(root, 'Show Part 10.mkv'),
path.join(root, 'Show Part 2.mkv'),
path.join(root, 'Show Part 1.mkv'),
path.join(root, 'Show Special.mkv'),
]);
assert.deepEqual(
items.map((item) => item.basename),
['Show Part 1.mkv', 'Show Part 2.mkv', 'Show Part 10.mkv', 'Show Special.mkv'],
);
assert.deepEqual(
items.map((item) => item.episodeLabel),
[null, null, null, null],
);
});

View File

@@ -0,0 +1,129 @@
import path from 'node:path';
type ParsedEpisodeKey = {
season: number | null;
episode: number;
};
type SortToken = string | number;
export type PlaylistBrowserSortedDirectoryItem = {
path: string;
basename: string;
episodeLabel: string | null;
};
const COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
function parseEpisodeKey(basename: string): ParsedEpisodeKey | null {
const name = basename.replace(/\.[^.]+$/, '');
const seasonEpisode = name.match(/(?:^|[^a-z0-9])s(\d{1,2})\s*e(\d{1,3})(?:$|[^a-z0-9])/i);
if (seasonEpisode) {
return {
season: Number(seasonEpisode[1]),
episode: Number(seasonEpisode[2]),
};
}
const seasonByX = name.match(/(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:$|[^a-z0-9])/i);
if (seasonByX) {
return {
season: Number(seasonByX[1]),
episode: Number(seasonByX[2]),
};
}
const namedEpisode = name.match(
/(?:^|[^a-z0-9])(?:ep|episode|第)\s*(\d{1,3})(?:\s*(?:話|episode|ep))?(?:$|[^a-z0-9])/i,
);
if (namedEpisode) {
return {
season: null,
episode: Number(namedEpisode[1]),
};
}
return null;
}
function buildEpisodeLabel(parsed: ParsedEpisodeKey | null): string | null {
if (!parsed) return null;
if (parsed.season !== null) {
return `S${parsed.season}E${parsed.episode}`;
}
return `E${parsed.episode}`;
}
function tokenizeNaturalSort(basename: string): SortToken[] {
return basename
.toLowerCase()
.split(/(\d+)/)
.filter((token) => token.length > 0)
.map((token) => (/^\d+$/.test(token) ? Number(token) : token));
}
function compareNaturalTokens(left: SortToken[], right: SortToken[]): number {
const maxLength = Math.max(left.length, right.length);
for (let index = 0; index < maxLength; index += 1) {
const a = left[index];
const b = right[index];
if (a === undefined) return -1;
if (b === undefined) return 1;
if (typeof a === 'number' && typeof b === 'number') {
if (a !== b) return a - b;
continue;
}
const comparison = COLLATOR.compare(String(a), String(b));
if (comparison !== 0) return comparison;
}
return 0;
}
export function sortPlaylistBrowserDirectoryItems(
paths: string[],
): PlaylistBrowserSortedDirectoryItem[] {
return paths
.map((pathValue) => {
const basename = path.basename(pathValue);
const parsed = parseEpisodeKey(basename);
return {
path: pathValue,
basename,
parsed,
episodeLabel: buildEpisodeLabel(parsed),
naturalTokens: tokenizeNaturalSort(basename),
};
})
.sort((left, right) => {
if (left.parsed && right.parsed) {
if (
left.parsed.season !== null &&
right.parsed.season !== null &&
left.parsed.season !== right.parsed.season
) {
return left.parsed.season - right.parsed.season;
}
if (left.parsed.episode !== right.parsed.episode) {
return left.parsed.episode - right.parsed.episode;
}
} else if (left.parsed && !right.parsed) {
return -1;
} else if (!left.parsed && right.parsed) {
return 1;
}
const naturalComparison = compareNaturalTokens(left.naturalTokens, right.naturalTokens);
if (naturalComparison !== 0) {
return naturalComparison;
}
return COLLATOR.compare(left.basename, right.basename);
})
.map(({ path: itemPath, basename, episodeLabel }) => ({
path: itemPath,
basename,
episodeLabel,
}));
}

View File

@@ -38,6 +38,8 @@ import type {
SubsyncManualRunRequest, SubsyncManualRunRequest,
SubsyncResult, SubsyncResult,
ClipboardAppendResult, ClipboardAppendResult,
PlaylistBrowserMutationResult,
PlaylistBrowserSnapshot,
KikuFieldGroupingRequestData, KikuFieldGroupingRequestData,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
KikuMergePreviewRequest, KikuMergePreviewRequest,
@@ -126,6 +128,7 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<Youtube
IPC_CHANNELS.event.youtubePickerOpen, IPC_CHANNELS.event.youtubePickerOpen,
(payload) => payload as YoutubePickerOpenPayload, (payload) => payload as YoutubePickerOpenPayload,
); );
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener( const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.youtubePickerCancel, IPC_CHANNELS.event.youtubePickerCancel,
); );
@@ -322,11 +325,25 @@ const electronAPI: ElectronAPI = {
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent, onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
onOpenJimaku: onOpenJimakuEvent, onOpenJimaku: onOpenJimakuEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> => appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
getPlaylistBrowserSnapshot: (): Promise<PlaylistBrowserSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot),
appendPlaylistBrowserFile: (pathValue: string): Promise<PlaylistBrowserMutationResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendPlaylistBrowserFile, pathValue),
playPlaylistBrowserIndex: (index: number): Promise<PlaylistBrowserMutationResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.playPlaylistBrowserIndex, index),
removePlaylistBrowserIndex: (index: number): Promise<PlaylistBrowserMutationResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.removePlaylistBrowserIndex, index),
movePlaylistBrowserIndex: (
index: number,
direction: 1 | -1,
): Promise<PlaylistBrowserMutationResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.movePlaylistBrowserIndex, index, direction),
youtubePickerResolve: ( youtubePickerResolve: (
request: YoutubePickerResolveRequest, request: YoutubePickerResolveRequest,
): Promise<YoutubePickerResolveResult> => ): Promise<YoutubePickerResolveResult> =>

View File

@@ -294,6 +294,7 @@ function createKeyboardHandlerHarness() {
let controllerSelectOpenCount = 0; let controllerSelectOpenCount = 0;
let controllerDebugOpenCount = 0; let controllerDebugOpenCount = 0;
let controllerSelectKeydownCount = 0; let controllerSelectKeydownCount = 0;
let playlistBrowserKeydownCount = 0;
const createWordNode = (left: number) => ({ const createWordNode = (left: number) => ({
classList: createClassList(), classList: createClassList(),
@@ -333,6 +334,10 @@ function createKeyboardHandlerHarness() {
}, },
handleControllerDebugKeydown: () => false, handleControllerDebugKeydown: () => false,
handleYoutubePickerKeydown: () => false, handleYoutubePickerKeydown: () => false,
handlePlaylistBrowserKeydown: () => {
playlistBrowserKeydownCount += 1;
return true;
},
handleSessionHelpKeydown: () => false, handleSessionHelpKeydown: () => false,
openSessionHelpModal: () => {}, openSessionHelpModal: () => {},
appendClipboardVideoToQueue: () => {}, appendClipboardVideoToQueue: () => {},
@@ -352,6 +357,7 @@ function createKeyboardHandlerHarness() {
controllerSelectOpenCount: () => controllerSelectOpenCount, controllerSelectOpenCount: () => controllerSelectOpenCount,
controllerDebugOpenCount: () => controllerDebugOpenCount, controllerDebugOpenCount: () => controllerDebugOpenCount,
controllerSelectKeydownCount: () => controllerSelectKeydownCount, controllerSelectKeydownCount: () => controllerSelectKeydownCount,
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
setWordCount: (count: number) => { setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70)); wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
}, },
@@ -623,6 +629,30 @@ test('keyboard mode: controller select modal handles arrow keys before yomitan p
} }
}); });
test('keyboard mode: playlist browser modal handles arrow keys before yomitan popup', async () => {
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.playlistBrowserModalOpen = true;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
assert.equal(playlistBrowserKeydownCount(), 1);
assert.equal(
testGlobals.commandEvents.some(
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
),
false,
);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: configured stats toggle works even while popup is open', async () => { test('keyboard mode: configured stats toggle works even while popup is open', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness(); const { handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -16,6 +16,7 @@ export function createKeyboardHandlers(
handleKikuKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean; handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
@@ -841,6 +842,11 @@ export function createKeyboardHandlers(
return; return;
} }
} }
if (ctx.state.playlistBrowserModalOpen) {
if (options.handlePlaylistBrowserKeydown(e)) {
return;
}
}
if (ctx.state.controllerSelectModalOpen) { if (ctx.state.controllerSelectModalOpen) {
options.handleControllerSelectKeydown(e); options.handleControllerSelectKeydown(e);
return; return;

View File

@@ -320,6 +320,35 @@
</div> </div>
</div> </div>
</div> </div>
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
<div class="modal-content playlist-browser-content">
<div class="modal-header">
<div class="modal-title">Playlist Browser</div>
<button id="playlistBrowserClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body playlist-browser-body">
<div id="playlistBrowserTitle" class="playlist-browser-title"></div>
<div id="playlistBrowserStatus" class="playlist-browser-status"></div>
<div class="playlist-browser-grid">
<div class="playlist-browser-pane">
<div class="playlist-browser-pane-title">Directory</div>
<ul id="playlistBrowserDirectoryList" class="playlist-browser-list"></ul>
</div>
<div class="playlist-browser-pane">
<div class="playlist-browser-pane-title">Playlist</div>
<ul id="playlistBrowserPlaylistList" class="playlist-browser-list"></ul>
</div>
</div>
<div class="playlist-browser-footer">
<span>Tab switch pane</span>
<span>Enter activate</span>
<span>Delete remove</span>
<span>Ctrl/Cmd+Arrows reorder</span>
<span>Esc close</span>
</div>
</div>
</div>
</div>
</div> </div>
<script type="module" src="renderer.js"></script> <script type="module" src="renderer.js"></script>
</body> </body>

View File

@@ -0,0 +1,430 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI, PlaylistBrowserSnapshot } from '../../types';
import { createRendererState } from '../state.js';
import { createPlaylistBrowserModal } from './playlist-browser.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
toggle: (entry: string, force?: boolean) => {
if (force === true) tokens.add(entry);
else if (force === false) tokens.delete(entry);
else if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
},
};
}
function createFakeElement() {
const attributes = new Map<string, string>();
return {
textContent: '',
innerHTML: '',
children: [] as unknown[],
listeners: new Map<string, Array<(event?: unknown) => void>>(),
classList: createClassList(['hidden']),
appendChild(child: unknown) {
this.children.push(child);
return child;
},
append(...children: unknown[]) {
this.children.push(...children);
},
replaceChildren(...children: unknown[]) {
this.children = [...children];
},
addEventListener(type: string, listener: (event?: unknown) => void) {
const bucket = this.listeners.get(type) ?? [];
bucket.push(listener);
this.listeners.set(type, bucket);
},
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
getAttribute(name: string) {
return attributes.get(name) ?? null;
},
focus() {},
};
}
function createPlaylistRow() {
return {
className: '',
classList: createClassList(),
dataset: {} as Record<string, string>,
textContent: '',
children: [] as unknown[],
listeners: new Map<string, Array<(event?: unknown) => void>>(),
append(...children: unknown[]) {
this.children.push(...children);
},
appendChild(child: unknown) {
this.children.push(child);
return child;
},
addEventListener(type: string, listener: (event?: unknown) => void) {
const bucket = this.listeners.get(type) ?? [];
bucket.push(listener);
this.listeners.set(type, bucket);
},
setAttribute() {},
};
}
function createListStub() {
return {
innerHTML: '',
children: [] as ReturnType<typeof createPlaylistRow>[],
appendChild(child: ReturnType<typeof createPlaylistRow>) {
this.children.push(child);
return child;
},
replaceChildren(...children: ReturnType<typeof createPlaylistRow>[]) {
this.children = [...children];
},
};
}
function createSnapshot(): PlaylistBrowserSnapshot {
return {
directoryPath: '/tmp/show',
directoryAvailable: true,
directoryStatus: '/tmp/show',
currentFilePath: '/tmp/show/Show - S01E02.mkv',
playingIndex: 1,
directoryItems: [
{
path: '/tmp/show/Show - S01E01.mkv',
basename: 'Show - S01E01.mkv',
episodeLabel: 'S1E1',
isCurrentFile: false,
},
{
path: '/tmp/show/Show - S01E02.mkv',
basename: 'Show - S01E02.mkv',
episodeLabel: 'S1E2',
isCurrentFile: true,
},
],
playlistItems: [
{
index: 0,
id: 1,
filename: '/tmp/show/Show - S01E01.mkv',
title: 'Episode 1',
displayLabel: 'Episode 1',
current: false,
playing: false,
path: '/tmp/show/Show - S01E01.mkv',
},
{
index: 1,
id: 2,
filename: '/tmp/show/Show - S01E02.mkv',
title: 'Episode 2',
displayLabel: 'Episode 2',
current: true,
playing: true,
path: '/tmp/show/Show - S01E02.mkv',
},
],
};
}
test('playlist browser modal opens with playlist-focused current item selection', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const notifications: string[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getPlaylistBrowserSnapshot: async () => createSnapshot(),
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
focusMainWindow: async () => {},
setIgnoreMouseEvents: () => {},
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
} as unknown as ElectronAPI,
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createPlaylistRow(),
},
});
try {
const state = createRendererState();
const directoryList = createListStub();
const playlistList = createListStub();
const ctx = {
state,
platform: {
shouldToggleMouseIgnore: false,
},
dom: {
overlay: {
classList: createClassList(),
focus: () => {},
},
playlistBrowserModal: createFakeElement(),
playlistBrowserTitle: createFakeElement(),
playlistBrowserStatus: createFakeElement(),
playlistBrowserDirectoryList: directoryList,
playlistBrowserPlaylistList: playlistList,
playlistBrowserClose: createFakeElement(),
},
};
const modal = createPlaylistBrowserModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
await modal.openPlaylistBrowserModal();
assert.equal(state.playlistBrowserModalOpen, true);
assert.equal(state.playlistBrowserActivePane, 'playlist');
assert.equal(state.playlistBrowserSelectedPlaylistIndex, 1);
assert.equal(state.playlistBrowserSelectedDirectoryIndex, 1);
assert.equal(directoryList.children.length, 2);
assert.equal(playlistList.children.length, 2);
assert.equal(directoryList.children[0]?.children.length, 2);
assert.equal(playlistList.children[0]?.children.length, 2);
assert.deepEqual(notifications, ['open:playlist-browser']);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('playlist browser modal keydown routes append, remove, reorder, tab switch, and play', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const calls: Array<[string, unknown[]]> = [];
const notifications: string[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getPlaylistBrowserSnapshot: async () => createSnapshot(),
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
focusMainWindow: async () => {},
setIgnoreMouseEvents: () => {},
appendPlaylistBrowserFile: async (filePath: string) => {
calls.push(['append', [filePath]]);
return { ok: true, message: 'append-ok', snapshot: createSnapshot() };
},
playPlaylistBrowserIndex: async (index: number) => {
calls.push(['play', [index]]);
return { ok: true, message: 'play-ok', snapshot: createSnapshot() };
},
removePlaylistBrowserIndex: async (index: number) => {
calls.push(['remove', [index]]);
return { ok: true, message: 'remove-ok', snapshot: createSnapshot() };
},
movePlaylistBrowserIndex: async (index: number, direction: -1 | 1) => {
calls.push(['move', [index, direction]]);
return { ok: true, message: 'move-ok', snapshot: createSnapshot() };
},
} as unknown as ElectronAPI,
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createPlaylistRow(),
},
});
try {
const state = createRendererState();
const ctx = {
state,
platform: {
shouldToggleMouseIgnore: false,
},
dom: {
overlay: {
classList: createClassList(),
focus: () => {},
},
playlistBrowserModal: createFakeElement(),
playlistBrowserTitle: createFakeElement(),
playlistBrowserStatus: createFakeElement(),
playlistBrowserDirectoryList: createListStub(),
playlistBrowserPlaylistList: createListStub(),
playlistBrowserClose: createFakeElement(),
},
};
const modal = createPlaylistBrowserModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
await modal.openPlaylistBrowserModal();
const preventDefault = () => {};
state.playlistBrowserActivePane = 'directory';
state.playlistBrowserSelectedDirectoryIndex = 0;
await modal.handlePlaylistBrowserKeydown({
key: 'Enter',
code: 'Enter',
preventDefault,
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
await modal.handlePlaylistBrowserKeydown({
key: 'Tab',
code: 'Tab',
preventDefault,
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
assert.equal(state.playlistBrowserActivePane, 'playlist');
await modal.handlePlaylistBrowserKeydown({
key: 'ArrowDown',
code: 'ArrowDown',
preventDefault,
ctrlKey: true,
metaKey: false,
shiftKey: false,
} as never);
await modal.handlePlaylistBrowserKeydown({
key: 'Delete',
code: 'Delete',
preventDefault,
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
await modal.handlePlaylistBrowserKeydown({
key: 'Enter',
code: 'Enter',
preventDefault,
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
assert.deepEqual(calls, [
['append', ['/tmp/show/Show - S01E01.mkv']],
['move', [1, 1]],
['remove', [1]],
['play', [1]],
]);
assert.equal(state.playlistBrowserModalOpen, false);
assert.deepEqual(notifications, ['open:playlist-browser', 'close:playlist-browser']);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('playlist browser keeps modal open when playing selected queue item fails', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const notifications: string[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getPlaylistBrowserSnapshot: async () => createSnapshot(),
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
focusMainWindow: async () => {},
setIgnoreMouseEvents: () => {},
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
playPlaylistBrowserIndex: async () => ({ ok: false, message: 'play failed' }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
} as unknown as ElectronAPI,
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createPlaylistRow(),
},
});
try {
const state = createRendererState();
const playlistBrowserStatus = createFakeElement();
const ctx = {
state,
platform: {
shouldToggleMouseIgnore: false,
},
dom: {
overlay: {
classList: createClassList(),
focus: () => {},
},
playlistBrowserModal: createFakeElement(),
playlistBrowserTitle: createFakeElement(),
playlistBrowserStatus,
playlistBrowserDirectoryList: createListStub(),
playlistBrowserPlaylistList: createListStub(),
playlistBrowserClose: createFakeElement(),
},
};
const modal = createPlaylistBrowserModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
await modal.openPlaylistBrowserModal();
assert.equal(state.playlistBrowserModalOpen, true);
await modal.handlePlaylistBrowserKeydown({
key: 'Enter',
code: 'Enter',
preventDefault: () => {},
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
assert.equal(state.playlistBrowserModalOpen, true);
assert.equal(playlistBrowserStatus.textContent, 'play failed');
assert.equal(playlistBrowserStatus.classList.contains('error'), true);
assert.deepEqual(notifications, ['open:playlist-browser']);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});

View File

@@ -0,0 +1,419 @@
import type {
PlaylistBrowserDirectoryItem,
PlaylistBrowserMutationResult,
PlaylistBrowserQueueItem,
PlaylistBrowserSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
function clampIndex(index: number, length: number): number {
if (length <= 0) return 0;
return Math.min(Math.max(index, 0), length - 1);
}
function createActionButton(label: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.className = 'playlist-browser-action';
button.addEventListener('click', (event) => {
event.stopPropagation();
onClick();
});
return button;
}
function buildDefaultStatus(snapshot: PlaylistBrowserSnapshot): string {
const directoryCount = snapshot.directoryItems.length;
const playlistCount = snapshot.playlistItems.length;
if (!snapshot.directoryAvailable) {
return `${snapshot.directoryStatus} ${playlistCount > 0 ? `· ${playlistCount} queued` : ''}`.trim();
}
return `${directoryCount} sibling videos · ${playlistCount} queued`;
}
export function createPlaylistBrowserModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setStatus(message: string, isError = false): void {
ctx.state.playlistBrowserStatus = message;
ctx.dom.playlistBrowserStatus.textContent = message;
ctx.dom.playlistBrowserStatus.classList.toggle('error', isError);
}
function getSnapshot(): PlaylistBrowserSnapshot | null {
return ctx.state.playlistBrowserSnapshot;
}
function syncSelection(snapshot: PlaylistBrowserSnapshot): void {
const directoryIndex = snapshot.directoryItems.findIndex((item) => item.isCurrentFile);
const playlistIndex =
snapshot.playingIndex ?? snapshot.playlistItems.findIndex((item) => item.current || item.playing);
ctx.state.playlistBrowserSelectedDirectoryIndex = clampIndex(
directoryIndex >= 0 ? directoryIndex : 0,
snapshot.directoryItems.length,
);
ctx.state.playlistBrowserSelectedPlaylistIndex = clampIndex(
playlistIndex >= 0 ? playlistIndex : 0,
snapshot.playlistItems.length,
);
}
function renderDirectoryRow(item: PlaylistBrowserDirectoryItem, index: number): HTMLElement {
const row = document.createElement('li');
row.className = 'playlist-browser-row';
if (item.isCurrentFile) row.classList.add('current');
if (
ctx.state.playlistBrowserActivePane === 'directory' &&
ctx.state.playlistBrowserSelectedDirectoryIndex === index
) {
row.classList.add('active');
}
const main = document.createElement('div');
main.className = 'playlist-browser-row-main';
const label = document.createElement('div');
label.className = 'playlist-browser-row-label';
label.textContent = item.basename;
const meta = document.createElement('div');
meta.className = 'playlist-browser-row-meta';
meta.textContent = item.isCurrentFile
? item.episodeLabel
? `${item.episodeLabel} · Current file`
: 'Current file'
: item.episodeLabel ?? 'Video file';
main.append(label, meta);
const trailing = document.createElement('div');
trailing.className = 'playlist-browser-row-trailing';
if (item.episodeLabel) {
const badge = document.createElement('div');
badge.className = 'playlist-browser-chip';
badge.textContent = item.episodeLabel;
trailing.appendChild(badge);
}
trailing.appendChild(
createActionButton('Add', () => {
void appendDirectoryItem(item.path);
}),
);
row.append(main, trailing);
row.addEventListener('click', () => {
ctx.state.playlistBrowserActivePane = 'directory';
ctx.state.playlistBrowserSelectedDirectoryIndex = index;
render();
});
row.addEventListener('dblclick', () => {
ctx.state.playlistBrowserSelectedDirectoryIndex = index;
void appendDirectoryItem(item.path);
});
return row;
}
function renderPlaylistRow(item: PlaylistBrowserQueueItem, index: number): HTMLElement {
const row = document.createElement('li');
row.className = 'playlist-browser-row';
if (item.current || item.playing) row.classList.add('current');
if (
ctx.state.playlistBrowserActivePane === 'playlist' &&
ctx.state.playlistBrowserSelectedPlaylistIndex === index
) {
row.classList.add('active');
}
const main = document.createElement('div');
main.className = 'playlist-browser-row-main';
const label = document.createElement('div');
label.className = 'playlist-browser-row-label';
label.textContent = `${index + 1}. ${item.displayLabel}`;
const meta = document.createElement('div');
meta.className = 'playlist-browser-row-meta';
meta.textContent = item.current || item.playing ? 'Playing now' : 'Queued';
const submeta = document.createElement('div');
submeta.className = 'playlist-browser-row-submeta';
submeta.textContent = item.filename;
main.append(label, meta, submeta);
const trailing = document.createElement('div');
trailing.className = 'playlist-browser-row-actions';
trailing.append(
createActionButton('Play', () => {
void playPlaylistItem(item.index);
}),
createActionButton('Up', () => {
void movePlaylistItem(item.index, -1);
}),
createActionButton('Down', () => {
void movePlaylistItem(item.index, 1);
}),
createActionButton('Remove', () => {
void removePlaylistItem(item.index);
}),
);
row.append(main, trailing);
row.addEventListener('click', () => {
ctx.state.playlistBrowserActivePane = 'playlist';
ctx.state.playlistBrowserSelectedPlaylistIndex = index;
render();
});
row.addEventListener('dblclick', () => {
ctx.state.playlistBrowserSelectedPlaylistIndex = index;
void playPlaylistItem(item.index);
});
return row;
}
function render(): void {
const snapshot = getSnapshot();
if (!snapshot) {
ctx.dom.playlistBrowserDirectoryList.replaceChildren();
ctx.dom.playlistBrowserPlaylistList.replaceChildren();
return;
}
ctx.dom.playlistBrowserTitle.textContent = snapshot.directoryPath ?? 'Playlist Browser';
ctx.dom.playlistBrowserStatus.textContent =
ctx.state.playlistBrowserStatus || buildDefaultStatus(snapshot);
ctx.dom.playlistBrowserDirectoryList.replaceChildren(
...snapshot.directoryItems.map((item, index) => renderDirectoryRow(item, index)),
);
ctx.dom.playlistBrowserPlaylistList.replaceChildren(
...snapshot.playlistItems.map((item, index) => renderPlaylistRow(item, index)),
);
}
function applySnapshot(snapshot: PlaylistBrowserSnapshot): void {
ctx.state.playlistBrowserSnapshot = snapshot;
syncSelection(snapshot);
render();
}
async function refreshSnapshot(): Promise<void> {
const snapshot = await window.electronAPI.getPlaylistBrowserSnapshot();
ctx.state.playlistBrowserStatus = '';
applySnapshot(snapshot);
setStatus(
buildDefaultStatus(snapshot),
!snapshot.directoryAvailable && snapshot.directoryStatus.length > 0,
);
}
async function handleMutation(
action: Promise<PlaylistBrowserMutationResult>,
fallbackMessage: string,
): Promise<void> {
const result = await action;
if (!result.ok) {
setStatus(result.message, true);
return;
}
setStatus(result.message || fallbackMessage, false);
if (result.snapshot) {
applySnapshot(result.snapshot);
return;
}
await refreshSnapshot();
}
async function appendDirectoryItem(filePath: string): Promise<void> {
await handleMutation(window.electronAPI.appendPlaylistBrowserFile(filePath), 'Queued file');
}
async function playPlaylistItem(index: number): Promise<void> {
const result = await window.electronAPI.playPlaylistBrowserIndex(index);
if (!result.ok) {
setStatus(result.message, true);
return;
}
closePlaylistBrowserModal();
}
async function removePlaylistItem(index: number): Promise<void> {
await handleMutation(window.electronAPI.removePlaylistBrowserIndex(index), 'Removed queue item');
}
async function movePlaylistItem(index: number, direction: 1 | -1): Promise<void> {
await handleMutation(
window.electronAPI.movePlaylistBrowserIndex(index, direction),
'Moved queue item',
);
}
async function openPlaylistBrowserModal(): Promise<void> {
if (ctx.state.playlistBrowserModalOpen) {
await refreshSnapshot();
return;
}
ctx.state.playlistBrowserModalOpen = true;
ctx.state.playlistBrowserActivePane = 'playlist';
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.playlistBrowserModal.classList.remove('hidden');
ctx.dom.playlistBrowserModal.setAttribute('aria-hidden', 'false');
window.electronAPI.notifyOverlayModalOpened('playlist-browser');
try {
await refreshSnapshot();
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
}
function closePlaylistBrowserModal(): void {
if (!ctx.state.playlistBrowserModalOpen) return;
ctx.state.playlistBrowserModalOpen = false;
ctx.state.playlistBrowserSnapshot = null;
ctx.state.playlistBrowserStatus = '';
ctx.dom.playlistBrowserModal.classList.add('hidden');
ctx.dom.playlistBrowserModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('playlist-browser');
options.syncSettingsModalSubtitleSuppression();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
function moveSelection(delta: number): void {
const snapshot = getSnapshot();
if (!snapshot) return;
if (ctx.state.playlistBrowserActivePane === 'directory') {
ctx.state.playlistBrowserSelectedDirectoryIndex = clampIndex(
ctx.state.playlistBrowserSelectedDirectoryIndex + delta,
snapshot.directoryItems.length,
);
} else {
ctx.state.playlistBrowserSelectedPlaylistIndex = clampIndex(
ctx.state.playlistBrowserSelectedPlaylistIndex + delta,
snapshot.playlistItems.length,
);
}
render();
}
function jumpSelection(target: 'start' | 'end'): void {
const snapshot = getSnapshot();
if (!snapshot) return;
const length =
ctx.state.playlistBrowserActivePane === 'directory'
? snapshot.directoryItems.length
: snapshot.playlistItems.length;
const nextIndex = target === 'start' ? 0 : Math.max(0, length - 1);
if (ctx.state.playlistBrowserActivePane === 'directory') {
ctx.state.playlistBrowserSelectedDirectoryIndex = nextIndex;
} else {
ctx.state.playlistBrowserSelectedPlaylistIndex = nextIndex;
}
render();
}
function activateSelection(): void {
const snapshot = getSnapshot();
if (!snapshot) return;
if (ctx.state.playlistBrowserActivePane === 'directory') {
const item = snapshot.directoryItems[ctx.state.playlistBrowserSelectedDirectoryIndex];
if (item) {
void appendDirectoryItem(item.path);
}
return;
}
const item = snapshot.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
if (item) {
void playPlaylistItem(item.index);
}
}
function handlePlaylistBrowserKeydown(event: KeyboardEvent): boolean {
if (!ctx.state.playlistBrowserModalOpen) return false;
if (event.key === 'Escape') {
event.preventDefault();
closePlaylistBrowserModal();
return true;
}
if (event.key === 'Tab') {
event.preventDefault();
ctx.state.playlistBrowserActivePane =
ctx.state.playlistBrowserActivePane === 'directory' ? 'playlist' : 'directory';
render();
return true;
}
if (event.key === 'Home') {
event.preventDefault();
jumpSelection('start');
return true;
}
if (event.key === 'End') {
event.preventDefault();
jumpSelection('end');
return true;
}
if (event.key === 'ArrowUp' && (event.ctrlKey || event.metaKey)) {
if (ctx.state.playlistBrowserActivePane === 'playlist') {
event.preventDefault();
const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
if (item) {
void movePlaylistItem(item.index, -1);
}
return true;
}
}
if (event.key === 'ArrowDown' && (event.ctrlKey || event.metaKey)) {
if (ctx.state.playlistBrowserActivePane === 'playlist') {
event.preventDefault();
const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
if (item) {
void movePlaylistItem(item.index, 1);
}
return true;
}
}
if (event.key === 'ArrowUp') {
event.preventDefault();
moveSelection(-1);
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
moveSelection(1);
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
activateSelection();
return true;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
if (ctx.state.playlistBrowserActivePane === 'playlist') {
event.preventDefault();
const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
if (item) {
void removePlaylistItem(item.index);
}
return true;
}
}
return false;
}
function wireDomEvents(): void {
ctx.dom.playlistBrowserClose.addEventListener('click', () => {
closePlaylistBrowserModal();
});
}
return {
openPlaylistBrowserModal,
closePlaylistBrowserModal,
handlePlaylistBrowserKeydown,
refreshSnapshot,
wireDomEvents,
};
}

View File

@@ -130,6 +130,7 @@ function describeCommand(command: (string | number)[]): string {
} }
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls'; if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options'; if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle'; if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
@@ -164,6 +165,7 @@ function sectionForCommand(command: (string | number)[]): string {
if ( if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN || first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX) first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) { ) {
return 'Runtime settings'; return 'Runtime settings';

View File

@@ -33,6 +33,7 @@ import { createControllerDebugModal } from './modals/controller-debug.js';
import { createControllerSelectModal } from './modals/controller-select.js'; import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js'; import { createJimakuModal } from './modals/jimaku.js';
import { createKikuModal } from './modals/kiku.js'; import { createKikuModal } from './modals/kiku.js';
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createSessionHelpModal } from './modals/session-help.js'; import { createSessionHelpModal } from './modals/session-help.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js'; import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js'; import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
@@ -71,7 +72,8 @@ function isAnySettingsModalOpen(): boolean {
ctx.state.kikuModalOpen || ctx.state.kikuModalOpen ||
ctx.state.jimakuModalOpen || ctx.state.jimakuModalOpen ||
ctx.state.youtubePickerModalOpen || ctx.state.youtubePickerModalOpen ||
ctx.state.sessionHelpModalOpen ctx.state.sessionHelpModalOpen ||
ctx.state.playlistBrowserModalOpen
); );
} }
@@ -85,6 +87,7 @@ function isAnyModalOpen(): boolean {
ctx.state.subsyncModalOpen || ctx.state.subsyncModalOpen ||
ctx.state.youtubePickerModalOpen || ctx.state.youtubePickerModalOpen ||
ctx.state.sessionHelpModalOpen || ctx.state.sessionHelpModalOpen ||
ctx.state.playlistBrowserModalOpen ||
ctx.state.subtitleSidebarModalOpen ctx.state.subtitleSidebarModalOpen
); );
} }
@@ -153,12 +156,17 @@ const youtubePickerModal = createYoutubeTrackPickerModal(ctx, {
restorePointerInteractionState: mouseHandlers.restorePointerInteractionState, restorePointerInteractionState: mouseHandlers.restorePointerInteractionState,
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const keyboardHandlers = createKeyboardHandlers(ctx, { const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown, handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown, handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown, handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown, handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown, handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown, handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
@@ -209,6 +217,7 @@ function getActiveModal(): string | null {
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar'; if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
if (ctx.state.jimakuModalOpen) return 'jimaku'; if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker'; if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker';
if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser';
if (ctx.state.kikuModalOpen) return 'kiku'; if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options'; if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
if (ctx.state.subsyncModalOpen) return 'subsync'; if (ctx.state.subsyncModalOpen) return 'subsync';
@@ -232,6 +241,9 @@ function dismissActiveUiAfterError(): void {
if (ctx.state.youtubePickerModalOpen) { if (ctx.state.youtubePickerModalOpen) {
youtubePickerModal.closeYoutubePickerModal(); youtubePickerModal.closeYoutubePickerModal();
} }
if (ctx.state.playlistBrowserModalOpen) {
playlistBrowserModal.closePlaylistBrowserModal();
}
if (ctx.state.runtimeOptionsModalOpen) { if (ctx.state.runtimeOptionsModalOpen) {
runtimeOptionsModal.closeRuntimeOptionsModal(); runtimeOptionsModal.closeRuntimeOptionsModal();
} }
@@ -439,6 +451,11 @@ function registerModalOpenHandlers(): void {
youtubePickerModal.openYoutubePickerModal(payload); youtubePickerModal.openYoutubePickerModal(payload);
}); });
}); });
window.electronAPI.onOpenPlaylistBrowser(() => {
runGuardedAsync('playlist-browser:open', async () => {
await playlistBrowserModal.openPlaylistBrowserModal();
});
});
window.electronAPI.onCancelYoutubeTrackPicker(() => { window.electronAPI.onCancelYoutubeTrackPicker(() => {
runGuarded('youtube:picker-cancel', () => { runGuarded('youtube:picker-cancel', () => {
youtubePickerModal.closeYoutubePickerModal(); youtubePickerModal.closeYoutubePickerModal();
@@ -518,6 +535,11 @@ async function init(): Promise<void> {
runGuarded('subtitle-position:update', () => { runGuarded('subtitle-position:update', () => {
positioning.applyStoredSubtitlePosition(position, 'media-change'); positioning.applyStoredSubtitlePosition(position, 'media-change');
measurementReporter.schedule(); measurementReporter.schedule();
if (ctx.state.playlistBrowserModalOpen) {
runGuardedAsync('playlist-browser:refresh-on-media-change', async () => {
await playlistBrowserModal.refreshSnapshot();
});
}
}); });
}); });
@@ -572,6 +594,7 @@ async function init(): Promise<void> {
jimakuModal.wireDomEvents(); jimakuModal.wireDomEvents();
youtubePickerModal.wireDomEvents(); youtubePickerModal.wireDomEvents();
playlistBrowserModal.wireDomEvents();
kikuModal.wireDomEvents(); kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents(); runtimeOptionsModal.wireDomEvents();
subsyncModal.wireDomEvents(); subsyncModal.wireDomEvents();

View File

@@ -1,4 +1,5 @@
import type { import type {
PlaylistBrowserSnapshot,
ControllerButtonSnapshot, ControllerButtonSnapshot,
ControllerDeviceInfo, ControllerDeviceInfo,
ResolvedControllerConfig, ResolvedControllerConfig,
@@ -78,6 +79,12 @@ export type RendererState = {
sessionHelpModalOpen: boolean; sessionHelpModalOpen: boolean;
sessionHelpSelectedIndex: number; sessionHelpSelectedIndex: number;
playlistBrowserModalOpen: boolean;
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
playlistBrowserStatus: string;
playlistBrowserActivePane: 'directory' | 'playlist';
playlistBrowserSelectedDirectoryIndex: number;
playlistBrowserSelectedPlaylistIndex: number;
subtitleSidebarCues: SubtitleCue[]; subtitleSidebarCues: SubtitleCue[];
subtitleSidebarActiveCueIndex: number; subtitleSidebarActiveCueIndex: number;
subtitleSidebarToggleKey: string; subtitleSidebarToggleKey: string;
@@ -175,6 +182,12 @@ export function createRendererState(): RendererState {
sessionHelpModalOpen: false, sessionHelpModalOpen: false,
sessionHelpSelectedIndex: 0, sessionHelpSelectedIndex: 0,
playlistBrowserModalOpen: false,
playlistBrowserSnapshot: null,
playlistBrowserStatus: '',
playlistBrowserActivePane: 'playlist',
playlistBrowserSelectedDirectoryIndex: 0,
playlistBrowserSelectedPlaylistIndex: 0,
subtitleSidebarCues: [], subtitleSidebarCues: [],
subtitleSidebarActiveCueIndex: -1, subtitleSidebarActiveCueIndex: -1,
subtitleSidebarToggleKey: 'Backslash', subtitleSidebarToggleKey: 'Backslash',

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,13 @@ export type RendererDom = {
sessionHelpStatus: HTMLDivElement; sessionHelpStatus: HTMLDivElement;
sessionHelpFilter: HTMLInputElement; sessionHelpFilter: HTMLInputElement;
sessionHelpContent: HTMLDivElement; sessionHelpContent: HTMLDivElement;
playlistBrowserModal: HTMLDivElement;
playlistBrowserTitle: HTMLDivElement;
playlistBrowserStatus: HTMLDivElement;
playlistBrowserDirectoryList: HTMLUListElement;
playlistBrowserPlaylistList: HTMLUListElement;
playlistBrowserClose: HTMLButtonElement;
}; };
function getRequiredElement<T extends HTMLElement>(id: string): T { function getRequiredElement<T extends HTMLElement>(id: string): T {
@@ -211,5 +218,12 @@ export function resolveRendererDom(): RendererDom {
sessionHelpStatus: getRequiredElement<HTMLDivElement>('sessionHelpStatus'), sessionHelpStatus: getRequiredElement<HTMLDivElement>('sessionHelpStatus'),
sessionHelpFilter: getRequiredElement<HTMLInputElement>('sessionHelpFilter'), sessionHelpFilter: getRequiredElement<HTMLInputElement>('sessionHelpFilter'),
sessionHelpContent: getRequiredElement<HTMLDivElement>('sessionHelpContent'), sessionHelpContent: getRequiredElement<HTMLDivElement>('sessionHelpContent'),
playlistBrowserModal: getRequiredElement<HTMLDivElement>('playlistBrowserModal'),
playlistBrowserTitle: getRequiredElement<HTMLDivElement>('playlistBrowserTitle'),
playlistBrowserStatus: getRequiredElement<HTMLDivElement>('playlistBrowserStatus'),
playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>('playlistBrowserDirectoryList'),
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>('playlistBrowserPlaylistList'),
playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'),
}; };
} }

View File

@@ -6,6 +6,7 @@ export const OVERLAY_HOSTED_MODALS = [
'subsync', 'subsync',
'jimaku', 'jimaku',
'youtube-track-picker', 'youtube-track-picker',
'playlist-browser',
'kiku', 'kiku',
'controller-select', 'controller-select',
'controller-debug', 'controller-debug',
@@ -67,6 +68,11 @@ export const IPC_CHANNELS = {
getAnilistQueueStatus: 'anilist:get-queue-status', getAnilistQueueStatus: 'anilist:get-queue-status',
retryAnilistNow: 'anilist:retry-now', retryAnilistNow: 'anilist:retry-now',
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue', appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',
getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot',
appendPlaylistBrowserFile: 'playlist-browser:append-file',
playPlaylistBrowserIndex: 'playlist-browser:play-index',
removePlaylistBrowserIndex: 'playlist-browser:remove-index',
movePlaylistBrowserIndex: 'playlist-browser:move-index',
jimakuGetMediaInfo: 'jimaku:get-media-info', jimakuGetMediaInfo: 'jimaku:get-media-info',
jimakuSearchEntries: 'jimaku:search-entries', jimakuSearchEntries: 'jimaku:search-entries',
jimakuListFiles: 'jimaku:list-files', jimakuListFiles: 'jimaku:list-files',
@@ -100,6 +106,7 @@ export const IPC_CHANNELS = {
jimakuOpen: 'jimaku:open', jimakuOpen: 'jimaku:open',
youtubePickerOpen: 'youtube:picker-open', youtubePickerOpen: 'youtube:picker-open',
youtubePickerCancel: 'youtube:picker-cancel', youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested',
configHotReload: 'config:hot-reload', configHotReload: 'config:hot-reload',

View File

@@ -76,6 +76,40 @@ export interface SubsyncResult {
message: string; message: string;
} }
export interface PlaylistBrowserDirectoryItem {
path: string;
basename: string;
episodeLabel?: string | null;
isCurrentFile: boolean;
}
export interface PlaylistBrowserQueueItem {
index: number;
id: number | null;
filename: string;
title: string | null;
displayLabel: string;
current: boolean;
playing: boolean;
path: string | null;
}
export interface PlaylistBrowserSnapshot {
directoryPath: string | null;
directoryAvailable: boolean;
directoryStatus: string;
directoryItems: PlaylistBrowserDirectoryItem[];
playlistItems: PlaylistBrowserQueueItem[];
playingIndex: number | null;
currentFilePath: string | null;
}
export interface PlaylistBrowserMutationResult {
ok: boolean;
message: string;
snapshot?: PlaylistBrowserSnapshot;
}
export type ControllerButtonBinding = export type ControllerButtonBinding =
| 'none' | 'none'
| 'select' | 'select'
@@ -354,10 +388,19 @@ export interface ElectronAPI {
onOpenRuntimeOptions: (callback: () => void) => void; onOpenRuntimeOptions: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void;
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenPlaylistBrowser: (callback: () => void) => void;
onCancelYoutubeTrackPicker: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>; appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (path: string) => Promise<PlaylistBrowserMutationResult>;
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
removePlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
movePlaylistBrowserIndex: (
index: number,
direction: 1 | -1,
) => Promise<PlaylistBrowserMutationResult>;
youtubePickerResolve: ( youtubePickerResolve: (
request: YoutubePickerResolveRequest, request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>; ) => Promise<YoutubePickerResolveResult>;
@@ -367,6 +410,7 @@ export interface ElectronAPI {
| 'subsync' | 'subsync'
| 'jimaku' | 'jimaku'
| 'youtube-track-picker' | 'youtube-track-picker'
| 'playlist-browser'
| 'kiku' | 'kiku'
| 'controller-select' | 'controller-select'
| 'controller-debug' | 'controller-debug'
@@ -378,6 +422,7 @@ export interface ElectronAPI {
| 'subsync' | 'subsync'
| 'jimaku' | 'jimaku'
| 'youtube-track-picker' | 'youtube-track-picker'
| 'playlist-browser'
| 'kiku' | 'kiku'
| 'controller-select' | 'controller-select'
| 'controller-debug' | 'controller-debug'