feat: auto-load youtube subtitles before manual picker

This commit is contained in:
2026-03-23 14:13:53 -07:00
parent b7e0026d48
commit 0c21e36e30
48 changed files with 1564 additions and 356 deletions

View File

@@ -0,0 +1,57 @@
---
id: TASK-221
title: 'Assess and address PR #31 latest CodeRabbit review'
status: Done
assignee: []
created_date: '2026-03-23 07:53'
updated_date: '2026-03-23 08:20'
labels:
- pr-review
- coderabbit
dependencies: []
references:
- >-
PR #31 feat: add app-owned YouTube subtitle flow with absPlayer-style
parsing
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Inspect the latest CodeRabbit review on PR #31, evaluate each actionable comment against the current branch, implement valid fixes, verify the changes, and prepare PR thread updates.
<!-- SECTION:DESCRIPTION:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect latest CodeRabbit review on PR #31 and separate valid action items from non-blocking suggestions.
2. Add regression coverage for any real bugs before changing production code.
3. Implement the minimal fixes for confirmed issues in runtime, renderer modal flow, and test fixtures.
4. Run targeted tests plus repo-native verification lanes.
5. Update PR threads with fix status and rationale for any comments not actioned yet.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented and pushed commit 207151db to PR #31.
Replied in-thread to the CodeRabbit comments for YouTube host matching, duplicate picker submissions, and missing MediaDetailView test fixture videoId fields.
Follow-up scope added: update release-facing docs/changelog for the YouTube subtitle picker work and run a release-readiness gate before handoff.
Added release-facing docs/changelog updates in commit b7e0026d and pushed them to PR #31.
Ran the release-readiness gate: changelog:lint, changelog:pr-check, verify:config-example, typecheck, test:fast, test:env, build, test:smoke:dist, docs:test, docs:build.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Assessed the latest CodeRabbit review on PR #31 and applied the confirmed fixes. Tightened `isYoutubeMediaPath()` to match only exact YouTube hosts or subdomains with a regression test for `notyoutube.com`, added an in-flight guard plus temporary control disabling to the YouTube track picker with a duplicate-submit regression test, replaced the picker empty-state `innerHTML` fallback with explicit DOM construction, and added the missing `videoId` fields to the `MediaDetailView` test fixtures. Verified with targeted Bun tests and the `runtime-compat` verification lane (`build`, `test:runtime:compat`, `test:smoke:dist`).
Updated `README.md`, `docs-site/usage.md`, and `changes/2026-03-23-immersion-youtube.md` so the PR is release-facing and user-visible surfaces describe the YouTube subtitle picker flow plus its latest hardening.
Release-readiness checks passed locally: `bun run changelog:lint`, `bun run changelog:pr-check`, `bun run verify:config-example`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`, `bun run docs:test`, and `bun run docs:build`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,55 @@
---
id: TASK-222
title: Fix YouTube overlay keybindings in subtitle path
status: Done
assignee:
- codex
created_date: '2026-03-23 08:32'
updated_date: '2026-03-23 08:38'
labels:
- bug
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/src/main/runtime
- /Users/sudacode/projects/japanese/SubMiner/src/core/services
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Users watching video through the YouTube subtitle path cannot use some overlay keyboard controls such as quit and pause/play. Restore expected overlay keybinding behavior for that playback path without regressing other overlay input handling.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Overlay quit and pause/play keybindings work while using the YouTube subtitle path.
- [x] #2 Existing overlay keybinding behavior for non-YouTube playback remains unchanged.
- [x] #3 Regression coverage exercises the YouTube subtitle path keyboard handling.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a regression test around YouTube track-picker close to verify it requests main-process main-window focus restoration before returning overlay focus locally.
2. Update the YouTube track-picker close flow to call `window.electronAPI.focusMainWindow()` alongside the existing `window.focus()` and `overlay.focus()` restoration.
3. Run targeted tests for the picker/keyboard paths to verify YouTube playback regains overlay keybindings without regressing existing overlay behavior.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Investigated overlay input path. Renderer already maps Space/KeyQ to mpv commands, but YouTube track-picker close only restores DOM focus (`window.focus` + `overlay.focus`) and does not invoke main-process window focus recovery, unlike the keyboard-mode focus reclaim path. Suspected root cause: overlay BrowserWindow focus is not restored after the YouTube picker closes, so playback keybindings stop reaching renderer keydown handlers.
User approved implementation plan on 2026-03-23. Proceeding with TDD: add failing regression first, then minimal fix, then targeted verification.
Implemented fix in the YouTube track-picker close path: request main-process `focusMainWindow()` before restoring renderer window/overlay focus so overlay keydown handlers regain input after YouTube subtitle selection.
Verification: `bun test src/renderer/modals/youtube-track-picker.test.ts` and `bun test src/renderer/handlers/keyboard.test.ts` both pass.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored overlay keyboard focus after closing the YouTube subtitle picker by invoking the main-process `focusMainWindow()` recovery path before local window/overlay focus restoration. Added regression coverage to the YouTube picker modal test and verified existing keyboard handler coverage for YouTube picker passthrough keys (`Space`, `KeyQ`) remains green.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,108 @@
---
id: TASK-223
title: Fix YouTube overlay Anki initialization regression
status: In Progress
assignee:
- codex
created_date: '2026-03-23 08:41'
updated_date: '2026-03-23 18:24'
labels:
- bug
- youtube
- anki
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-runtime-init.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/cli-command-runtime-handler.ts
documentation:
- /Users/sudacode/projects/japanese/SubMiner/docs/workflow/verification.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Restore Anki-backed lookup and known-word behavior during YouTube playback. Recent startup changes appear to let the YouTube flow initialize the overlay before runtime prerequisites exist, leaving the Anki integration unavailable for popup Mine actions and known-word highlighting.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 YouTube playback initializes Anki integration once overlay startup prerequisites are available so lookup can offer card-add actions again
- [x] #2 Known-word / N+1 state is available during YouTube playback when the user has Anki-backed known-word highlighting enabled
- [x] #3 Regression coverage fails before the fix and passes after it for the YouTube startup path
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a regression test covering the YouTube playback command path and assert overlay startup prerequisites are established before the flow runs.
2. Reuse the overlay startup prerequisite bootstrap for the YouTube playback path so Anki integration sees subtitle tracker, mpv client, and runtime options manager before initialization.
3. Verify with focused runtime/CLI tests, then run the cheapest sufficient verification lane for the touched files.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Identified regression path in CLI command runtime: YouTube playback commands could reach overlay initialization without first materializing overlay startup prerequisites, leaving Anki integration unavailable during the initial startup attempt.
Added a regression test at src/main/runtime/cli-command-runtime-handler.test.ts covering youtubePlay command dispatch outside texthooker-only mode.
Verified with bun test src/main/runtime/cli-command-runtime-handler.test.ts src/main/runtime/cli-command-prechecks.test.ts src/main/runtime/cli-command-prechecks-main-deps.test.ts and bun test src/core/services/cli-command.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/cli-command-prechecks-main-deps.test.ts src/main/runtime/cli-command-runtime-handler.test.ts.
Removed the YouTube-only ensureOverlayRuntimeReady path from src/main.ts after confirming regular app startup already loads Yomitan and the shared CLI overlay pre-dispatch bootstrap now covers overlay prerequisites.
Moved overlay bootstrap into the generic initial-args startup path for any initial command that needs overlay runtime, so overlay prerequisites and overlay initialization happen before CLI dispatch instead of inside a YouTube-only or last-moment command path.
Additional verification passed: bun test src/main/runtime/initial-args-runtime-handler.test.ts src/main/runtime/initial-args-handler.test.ts src/main/runtime/initial-args-main-deps.test.ts, bun run typecheck, bun run test:runtime:compat
Follow-up regression: subtitle picker was still auto-submitting the default selection during YouTube startup. Investigating renderer-side immediate Enter key bleed-through on picker open.
Root cause for remaining picker regression: the YouTube track picker accepted Enter immediately on open, so the launch keypress could auto-submit the default track selection before the modal was visible to the user.
Added renderer regression coverage in src/renderer/modals/youtube-track-picker.test.ts proving immediate Enter after open is ignored and a later Enter still submits normally.
Implemented a 200ms open-key guard in src/renderer/modals/youtube-track-picker.ts for Enter-based submission only; Escape/click behavior unchanged.
New follow-up regression report: YouTube subtitle picker can open before the mpv playback window is ready, leaving the picker behind the overlay after geometry snaps into place. Investigating picker-open gating and modal-targeting timing.
Identified likely cause of picker-behind-overlay regression: YouTube picker open logic mixed overlay targets. First attempt preferred the visible main overlay, timeout retry switched to the dedicated modal window, allowing a late first open to cover the modal.
Extracted picker-open policy into src/main/runtime/youtube-picker-open.ts and changed YouTube picker startup to always target the dedicated modal window, including retries. This keeps the picker on a single window path and lets overlay-runtime hide/click-through the main overlay while the modal is active.
Added regression tests in src/main/runtime/youtube-picker-open.test.ts covering dedicated modal first-open, dedicated-modal retry, and failure when no modal target is available.
User reports overlay flow still feels wrong: YouTube path appears to preload subtitles before mandatory selection and may open the picker before mpv window readiness. Re-evaluating flow design against regular video startup before further implementation.
New follow-up regression report: duplicate overlay windows appear during YouTube playback and only one window shows subtitles. Investigating main-overlay versus dedicated modal-window handoff/cleanup.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed the YouTube Anki initialization regression by making CLI commands that require overlay runtime bootstrap overlay startup prerequisites before command dispatch when not in texthooker-only mode. This ensures the YouTube playback flow has the mpv client, runtime options manager, and subtitle timing tracker ready before overlay/Anki initialization runs, restoring Mine actions and known-word-backed behavior.
Added a regression test covering youtubePlay command dispatch in src/main/runtime/cli-command-runtime-handler.test.ts.
Verification:
- bun test src/main/runtime/cli-command-runtime-handler.test.ts src/main/runtime/cli-command-prechecks.test.ts src/main/runtime/cli-command-prechecks-main-deps.test.ts
- bun test src/core/services/cli-command.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/cli-command-prechecks-main-deps.test.ts src/main/runtime/cli-command-runtime-handler.test.ts
Updated the fix to avoid a YouTube-specific startup path: removed the dedicated ensureOverlayRuntimeReady helper from src/main.ts and relied on the shared CLI overlay prerequisite bootstrap instead.
Additional verification: bun run typecheck
Follow-up adjustment: initial overlay-runtime commands now bootstrap overlay prerequisites and initialize overlay during the shared initial-args startup path, rather than waiting for command dispatch. This keeps YouTube on the regular startup path while preserving earlier overlay availability.
Additional verification: bun test src/main/runtime/initial-args-runtime-handler.test.ts src/main/runtime/initial-args-handler.test.ts src/main/runtime/initial-args-main-deps.test.ts; bun run test:runtime:compat
Follow-up fix: the YouTube subtitle picker now ignores immediate Enter key bleed-through right after opening, preventing the startup keypress from auto-submitting the default track selection before the modal is visible.
Added renderer regression coverage for immediate Enter suppression and verified with bun test src/renderer/modals/youtube-track-picker.test.ts plus the runtime-compat verification lane for the touched files.
Follow-up fix: YouTube subtitle picker startup now uses a dedicated modal-window path consistently instead of mixing main-overlay first-open with modal-window retry. That prevents late overlay opens from covering the interactive picker while mpv/window tracking settles.
Verified with bun test src/main/runtime/youtube-picker-open.test.ts, bun test src/renderer/modals/youtube-track-picker.test.ts, and the runtime-compat verification lane for src/main.ts plus the touched picker files.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,60 @@
---
id: TASK-224
title: >-
Auto-load default YouTube subtitles at playback start and make picker
manual-only
status: Done
assignee:
- Codex
created_date: '2026-03-23 18:51'
updated_date: '2026-03-23 19:14'
labels:
- youtube
- mpv
- overlay
- keybindings
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/youtube-flow.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/youtube-track-picker.ts
- /Users/sudacode/projects/japanese/SubMiner/src/config/definitions/shared.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the mandatory YouTube subtitle picker startup flow with automatic default-track loading. On YouTube playback start, attempt to load the default primary subtitle and best-effort secondary subtitle without prompting. Gate playback only on primary subtitle load/tokenization readiness. If primary subtitle probing/download/loading fails, resume playback and report the failure through the configured notification/output path. Keep the YouTube subtitle picker as a regular overlay modal opened by a new default keybinding during active YouTube playback.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Opening a YouTube URL auto-selects and attempts to load the default primary subtitle without opening the picker modal.
- [x] #2 Opening a YouTube URL also attempts to load the default secondary subtitle when available, but playback never waits on secondary success.
- [x] #3 Playback remains gated only until the primary subtitle is loaded and tokenization is ready; primary failure resumes playback immediately.
- [x] #4 Primary auto-load failures report through the existing configured notification/output path and keep playback running.
- [x] #5 The YouTube subtitle picker can be opened manually during active YouTube playback via a new default keybinding.
- [x] #6 Regression tests cover startup auto-load success, primary failure fallback, and the manual picker keybinding flow.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing tests for YouTube startup auto-load success, primary failure fallback, and manual picker keybinding flow.
2. Refactor the YouTube runtime to auto-select default tracks on startup, gate playback only on primary subtitle/tokenization readiness, and route failures through the configured notification/output path.
3. Add a new default keybinding and command path to open the YouTube picker manually during active YouTube playback.
4. Run targeted tests, then SubMiner verification lanes for launcher/runtime changes; update docs/changelog if required by the final behavior change.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Verification blocker outside this change: `bun run test:fast` still fails at `scripts/update-aur-package.test.ts` on macOS because `scripts/update-aur-package.sh` uses `mapfile`, which is unavailable in the system Bash 3.x environment used here.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Reworked app-owned YouTube playback to auto-load the default primary subtitle plus a best-effort secondary subtitle at startup instead of forcing the picker modal first. Playback now waits only on primary subtitle load/tokenization readiness, routes startup primary-failure messaging through the configured notification output path, and keeps the YouTube subtitle picker available on demand via a new default `Ctrl+Shift+J` keybinding during active YouTube playback. Updated the runtime/IPC/config plumbing, user-facing help/docs, and added regression coverage for startup auto-load, primary-failure fallback, and manual picker invocation.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,40 @@
---
id: TASK-225
title: Fix frozen primary YouTube subtitle display after auto-load startup
status: Done
assignee: []
created_date: '2026-03-23 20:07'
updated_date: '2026-03-23 20:15'
labels:
- bug
- youtube
- subtitles
dependencies:
- TASK-224
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
After the new YouTube auto-load startup flow, the primary subtitle overlay can stay stuck on an older line while the subtitle sidebar continues advancing. Investigate startup suppression / subtitle refresh timing and restore live primary overlay updates after auto-loaded subtitles are injected.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 When YouTube auto-load succeeds, the visible primary subtitle continues advancing after playback resumes.
- [x] #2 Startup suppression does not leave the primary subtitle display stuck on a stale line.
- [x] #3 A regression test covers the startup path that previously froze the visible primary subtitle while sidebar timing continued advancing.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: applyStartupState seeded youtubePlaybackFlowPending from initialArgs.youtubePlay, and runYoutubePlaybackFlowMain restored that preexisting true value after startup auto-load. Result: primary subtitle events stayed suppressed for startup-launched YouTube playback while sidebar timing still advanced.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Stopped pre-seeding youtubePlaybackFlowPending from startup CLI args so only the actual YouTube playback bootstrap window suppresses subtitle events. Added a regression test covering startup YouTube args and re-ran targeted YouTube/runtime subtitle tests plus typecheck.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,41 @@
---
id: TASK-226
title: Restore subtitle sidebar cues for auto-loaded YouTube subtitles
status: Done
assignee: []
created_date: '2026-03-23 20:21'
updated_date: '2026-03-23 20:25'
labels:
- bug
- youtube
- subtitle-sidebar
dependencies:
- TASK-224
- TASK-225
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
After fixing startup subtitle event suppression, the primary subtitle overlay updates for auto-loaded YouTube playback but the subtitle sidebar reports no parsed subtitle cues available. Investigate parsed subtitle source registration / refresh for auto-loaded YouTube subtitle files and restore sidebar cue population.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 When YouTube auto-load succeeds, the subtitle sidebar receives parsed cues for the active primary subtitle source.
- [x] #2 Auto-loaded YouTube subtitle source changes refresh the sidebar snapshot without requiring manual picker interaction.
- [x] #3 A regression test covers the startup auto-load path where live primary subtitles render but sidebar cues remain empty.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: successful YouTube auto-load refreshed visible primary subtitle state, but did not explicitly initialize parsed subtitle cues from the resolved downloaded primary subtitle file. Sidebar cue population depended on later mpv source rediscovery, which could leave snapshots empty.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added a regression test for YouTube auto-load sidebar cue refresh and wired the YouTube subtitle flow to explicitly refresh parsed subtitle cues from the resolved primary subtitle path after a successful load. Verified with targeted YouTube/sidebar/runtime tests plus typecheck.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,9 @@
type: changed
area: launcher
- Changed app-owned YouTube playback to auto-load the default primary subtitle and a best-effort secondary subtitle at startup instead of forcing the picker modal first.
- Kept startup playback gating tied only to primary subtitle load and tokenization readiness; secondary failures no longer block resume.
- Added a default `Ctrl+Shift+J` keybinding that opens the YouTube subtitle picker manually during active YouTube playback.
- Routed primary YouTube subtitle auto-load failures through the configured notification output path so failed startup still resumes cleanly.
- Fixed startup-launched YouTube playback so primary subtitle overlay updates continue after auto-load completes.
- Fixed auto-loaded YouTube primary subtitles so parsed cues appear in the subtitle sidebar without needing a manual picker retry.

View File

@@ -1,7 +1,8 @@
# Changelog
## v0.9.0 (2026-03-22)
- Added an app-owned YouTube subtitle picker flow that boots mpv paused, opens an overlay picker, and downloads selected subtitles into external files before playback resumes.
- Added an app-owned YouTube subtitle flow that boots mpv paused, auto-loads the default primary subtitle plus a best-effort secondary subtitle, and resumes once the primary subtitle is loaded and tokenized.
- Added a manual YouTube subtitle picker on `Ctrl+Shift+J` so subtitle selection can be retried on demand during active YouTube playback.
- Added explicit launcher/app YouTube subtitle modes `download` and `generate`, with `download` as the default path.
- Disabled mpv native YouTube subtitle auto-loading for the app-owned flow so injected external subtitle files stay authoritative.
- Added OSD status updates covering YouTube playback startup, subtitle acquisition, and subtitle loading.

View File

@@ -469,6 +469,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `Ctrl+Shift+KeyJ` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
@@ -1359,9 +1360,10 @@ Set defaults used by the `subminer` launcher for YouTube subtitle generation:
Launcher behavior:
- For YouTube URLs, subtitle generation now runs before mpv launch.
- For YouTube URLs, SubMiner auto-loads the default primary subtitle plus a best-effort secondary subtitle during startup.
- SubMiner probes manual/native YouTube subtitle tracks first.
- Missing tracks fall back to local `whisper.cpp`.
- Playback waits only for the primary subtitle to load and tokenize; startup secondary failures never block playback.
- English secondary subtitles can use whisper translate fallback when no manual track exists.
- If `fixWithAi` is enabled, only whisper-generated `.srt` output is post-processed with the shared top-level `ai` provider.

View File

@@ -228,12 +228,13 @@ If you also use Yomitan in a browser, configure that browser profile separately;
### YouTube Playback
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets.
For YouTube playback, SubMiner now resolves subtitle tracks before mpv starts playback: it pauses at startup, opens an overlay subtitle picker, resolves the selected tracks, then resumes with the downloaded subtitle files attached.
For YouTube playback, SubMiner now resolves its default startup subtitle selection before mpv starts regular playback: it pauses at startup, auto-selects the default primary subtitle track plus a best-effort secondary track, then resumes with any downloaded subtitle files attached. Playback waits only for the primary subtitle to load and tokenize. If the primary subtitle cannot be loaded, playback resumes and SubMiner reports the failure through the configured notification path.
Notes:
- Install `yt-dlp` so mpv can resolve YouTube streams and subtitle tracks reliably.
- For YouTube URLs, the overlay picker lets you choose the primary and optional secondary subtitle tracks before playback resumes.
- For YouTube URLs, startup no longer requires the overlay picker.
- Press `Ctrl+Shift+J` during active YouTube playback to open the manual YouTube subtitle picker and retry track selection.
- For YouTube URLs, `subminer` generates only the missing tracks after probing YouTube's native/manual subtitle inventory.
- It probes manual/native YouTube subtitle tracks first, then falls back to local `whisper.cpp` only for missing tracks.
- Primary subtitle target languages come from `youtubeSubgen.primarySubLanguages` (defaults to `["ja","jpn"]`).

View File

@@ -13,7 +13,7 @@ ${B}Session${R}
--background Start in tray/background mode
--start Connect to mpv and launch overlay
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
--youtube-play ${D}URL${R} Open YouTube subtitle picker flow for a URL
--youtube-play ${D}URL${R} Start app-owned YouTube subtitle auto-load flow for a URL
--youtube-mode ${D}download|generate${R} Subtitle acquisition mode for YouTube flow
--stop Stop the running instance
--stats Open the stats dashboard in your browser

View File

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

View File

@@ -46,6 +46,7 @@ export const SPECIAL_COMMANDS = {
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
} as const;
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
@@ -64,6 +65,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
key: 'Shift+BracketLeft',
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
},
{ key: 'Ctrl+Shift+KeyJ', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
{ key: 'KeyQ', command: ['quit'] },

View File

@@ -15,6 +15,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
},
triggerSubsyncFromConfig: () => {
calls.push('subsync');
@@ -22,6 +23,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openRuntimeOptionsPalette: () => {
calls.push('runtime-options');
},
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
},
runtimeOptionsCycle: () => ({ ok: true }),
showMpvOsd: (text) => {
osd.push(text);
@@ -98,6 +102,14 @@ test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command',
assert.deepEqual(osd, []);
});
test('handleMpvCommandFromIpc dispatches special youtube picker open command', () => {
const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__youtube-picker-open'], options);
assert.deepEqual(calls, ['youtube-picker']);
assert.deepEqual(sentCommands, []);
assert.deepEqual(osd, []);
});
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
const { options, sentCommands, osd } = createOptions({
isMpvConnected: () => false,

View File

@@ -14,9 +14,11 @@ export interface HandleMpvCommandFromIpcOptions {
PLAY_NEXT_SUBTITLE: string;
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
YOUTUBE_PICKER_OPEN: string;
};
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
mpvReplaySubtitle: () => void;
@@ -90,6 +92,11 @@ export function handleMpvCommandFromIpc(
return;
}
if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) {
void options.openYoutubeTrackPicker();
return;
}
if (
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START

View File

@@ -200,6 +200,44 @@ test('Windows visible overlay stays click-through and does not steal focus while
assert.ok(!calls.includes('focus'));
});
test('visible overlay stays hidden while a modal window is active', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
modalActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('update-bounds'));
});
test('macOS tracked visible overlay stays visible without passively stealing focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {

View File

@@ -4,6 +4,7 @@ import { WindowGeometry } from '../../types';
export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null;
@@ -28,6 +29,12 @@ export function updateVisibleOverlayVisibility(args: {
const mainWindow = args.mainWindow;
if (args.modalActive) {
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
const showPassiveVisibleOverlay = (): void => {
const forceMousePassthrough = args.forceMousePassthrough === true;
if (args.isWindowsPlatform || forceMousePassthrough) {

View File

@@ -394,6 +394,7 @@ import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import type { OverlayHostedModal } from './shared/ipc/contracts';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
@@ -754,6 +755,7 @@ process.on('SIGTERM', () => {
const overlayManager = createOverlayManager();
let overlayModalInputExclusive = false;
let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {};
let syncOverlayVisibilityForModal: () => void = () => {};
const handleModalInputStateChange = (isActive: boolean): void => {
if (overlayModalInputExclusive === isActive) return;
@@ -770,6 +772,7 @@ const handleModalInputStateChange = (isActive: boolean): void => {
}
}
syncOverlayShortcutsForModal(isActive);
syncOverlayVisibilityForModal();
};
const buildOverlayContentMeasurementStoreMainDepsHandler =
@@ -820,27 +823,15 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
return result.path;
},
openPicker: async (payload) => {
const preferDedicatedModalWindow = false;
const sendPickerOpen = (preferModalWindow: boolean): boolean =>
overlayModalRuntime.sendToActiveOverlayWindow('youtube:picker-open', payload, {
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow,
});
if (!sendPickerOpen(preferDedicatedModalWindow)) {
return false;
}
if (await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500)) {
return true;
}
logger.warn(
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying on visible overlay.',
return await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) =>
overlayModalRuntime.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions),
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
logWarn: (message) => logger.warn(message),
},
payload,
);
if (!sendPickerOpen(!preferDedicatedModalWindow)) {
return false;
}
return await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500);
},
pauseMpv: () => {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
@@ -859,6 +850,9 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
refreshCurrentSubtitle: (text: string) => {
subtitleProcessingController.refreshCurrentSubtitle(text);
},
refreshSubtitleSidebarSource: async (sourcePath: string) => {
await refreshSubtitleSidebarFromSource(sourcePath);
},
startTokenizationWarmups: async () => {
await startTokenizationWarmups();
},
@@ -889,14 +883,30 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
waitForPlaybackWindowReady: async () => {
const deadline = Date.now() + 4000;
let stableGeometry: WindowGeometry | null = null;
let stableSinceMs = 0;
while (Date.now() < deadline) {
const tracker = appState.windowTracker;
if (tracker && tracker.isTracking() && tracker.getGeometry()) {
return;
const trackerGeometry = tracker?.getGeometry() ?? null;
const mediaPath =
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
const trackerFocused = tracker?.isTargetWindowFocused() ?? false;
if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) {
if (!geometryMatches(stableGeometry, trackerGeometry)) {
stableGeometry = trackerGeometry;
stableSinceMs = Date.now();
} else if (Date.now() - stableSinceMs >= 200) {
return;
}
} else {
stableGeometry = null;
stableSinceMs = 0;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
logger.warn('Timed out waiting for tracked playback window before opening YouTube subtitle picker.');
logger.warn(
'Timed out waiting for tracked playback window focus/media readiness before opening YouTube subtitle picker.',
);
},
waitForOverlayGeometryReady: async () => {
const deadline = Date.now() + 4000;
@@ -924,6 +934,7 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
}
},
showMpvOsd: (text: string) => showMpvOsd(text),
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
warn: (message: string) => logger.warn(message),
log: (message: string) => logger.info(message),
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
@@ -958,7 +969,6 @@ async function runYoutubePlaybackFlowMain(request: {
if (!appState.mpvClient?.connected) {
appState.mpvClient?.connect();
}
await ensureOverlayRuntimeReady();
try {
await youtubeFlowRuntime.runYoutubePlaybackFlow({
url: request.url,
@@ -973,11 +983,6 @@ async function runYoutubePlaybackFlowMain(request: {
}
}
async function ensureOverlayRuntimeReady(): Promise<void> {
await ensureYomitanExtensionLoaded();
initializeOverlayRuntime();
}
let firstRunSetupMessage: string | null = null;
const resolveWindowsMpvShortcutRuntimePaths = () =>
resolveWindowsMpvShortcutPaths({
@@ -1239,6 +1244,33 @@ function isYoutubePlaybackActiveNow(): boolean {
);
}
function reportYoutubeSubtitleFailure(message: string): void {
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
if (type === 'osd' || type === 'both') {
showMpvOsd(message);
}
if (type === 'system' || type === 'both') {
try {
showDesktopNotification('SubMiner', { body: message });
} catch {
logger.warn(`Unable to show desktop notification: ${message}`);
}
}
}
async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
const currentMediaPath =
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
if (!isYoutubePlaybackActiveNow() || !currentMediaPath) {
showMpvOsd('YouTube subtitle picker is only available during YouTube playback.');
return;
}
await youtubeFlowRuntime.openManualPicker({
url: currentMediaPath,
mode: 'download',
});
}
function maybeSignalPluginAutoplayReady(
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
@@ -1416,6 +1448,18 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
},
});
async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise<void> {
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
if (!normalizedSourcePath) {
return;
}
await subtitlePrefetchInitController.initSubtitlePrefetch(
normalizedSourcePath,
lastObservedTimePos,
normalizedSourcePath,
);
}
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
const client = appState.mpvClient;
if (!client?.connected) {
@@ -1860,6 +1904,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
getModalActive: () => overlayModalInputExclusive,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getWindowTracker: () => appState.windowTracker,
@@ -1921,6 +1966,9 @@ const buildRestorePreviousSecondarySubVisibilityMainDepsHandler =
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({
getMpvClient: () => appState.mpvClient,
});
syncOverlayVisibilityForModal = () => {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
};
const restorePreviousSecondarySubVisibilityMainDeps =
buildRestorePreviousSecondarySubVisibilityMainDepsHandler();
const restorePreviousSecondarySubVisibilityHandler =
@@ -3377,39 +3425,7 @@ void initializeDiscordPresenceService();
const handleCliCommand = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
ensureOverlayStartupPrereqs: () => {
if (appState.subtitlePosition === null) {
loadSubtitlePosition();
}
if (appState.keybindings.length === 0) {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
}
if (!appState.mpvClient) {
appState.mpvClient = createMpvClientRuntimeService();
}
if (!appState.runtimeOptionsManager) {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
onOptionsChanged: () => {
subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
);
}
if (!appState.subtitleTimingTracker) {
appState.subtitleTimingTracker = new SubtitleTimingTracker();
}
},
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
setTexthookerOnlyMode: (enabled) => {
appState.texthookerOnlyMode = enabled;
},
@@ -3422,6 +3438,40 @@ const handleCliCommand = createCliCommandRuntimeHandler({
handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
});
function ensureOverlayStartupPrereqs(): void {
if (appState.subtitlePosition === null) {
loadSubtitlePosition();
}
if (appState.keybindings.length === 0) {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
}
if (!appState.mpvClient) {
appState.mpvClient = createMpvClientRuntimeService();
}
if (!appState.runtimeOptionsManager) {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
onOptionsChanged: () => {
subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
);
}
if (!appState.subtitleTimingTracker) {
appState.subtitleTimingTracker = new SubtitleTimingTracker();
}
}
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
getInitialArgs: () => appState.initialArgs,
isBackgroundMode: () => appState.backgroundMode,
@@ -3431,6 +3481,10 @@ const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
hasImmersionTracker: () => Boolean(appState.immersionTracker),
getMpvClient: () => appState.mpvClient,
commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntime: () => initializeOverlayRuntime(),
logInfo: (message) => logger.info(message),
handleCliCommand: (args, source) => handleCliCommand(args, source),
});
@@ -4387,6 +4441,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
cycleRuntimeOption: (id, direction) => {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' };

View File

@@ -191,6 +191,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
runtimeOptionsCycle: HandleMpvCommandFromIpcOptions['runtimeOptionsCycle'];
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
@@ -354,6 +355,7 @@ export function createMpvCommandRuntimeServiceDeps(
specialCommands: params.specialCommands,
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd,
mpvReplaySubtitle: params.mpvReplaySubtitle,

View File

@@ -12,6 +12,7 @@ type MpvPropertyClientLike = {
export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
replayCurrentSubtitle: () => void;
@@ -33,6 +34,7 @@ export function handleMpvCommandFromIpcRuntime(
specialCommands: SPECIAL_COMMANDS,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd,
mpvReplaySubtitle: deps.replayCurrentSubtitle,

View File

@@ -8,6 +8,7 @@ const OVERLAY_LOADING_OSD_COOLDOWN_MS = 30_000;
export interface OverlayVisibilityRuntimeDeps {
getMainWindow: () => BrowserWindow | null;
getModalActive: () => boolean;
getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
@@ -37,6 +38,7 @@ export function createOverlayVisibilityRuntimeService(
updateVisibleOverlayVisibility(): void {
updateVisibleOverlayVisibility({
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
modalActive: deps.getModalActive(),
forceMousePassthrough: deps.getForceMousePassthrough(),
mainWindow: deps.getMainWindow(),
windowTracker: deps.getWindowTracker(),

View File

@@ -33,3 +33,28 @@ test('cli command runtime handler applies precheck and forwards command with con
'cli:initial:ctx',
]);
});
test('cli command runtime handler prepares overlay prerequisites before overlay runtime commands', () => {
const calls: string[] = [];
const handler = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => false,
setTexthookerOnlyMode: () => calls.push('set-mode'),
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`log:${message}`),
},
createCliCommandContext: () => {
calls.push('context');
return { id: 'ctx' };
},
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
calls.push(`cli:${source}:${context.id}`);
},
});
handler({ youtubePlay: 'https://www.youtube.com/watch?v=test' } as never);
assert.deepEqual(calls, ['prereqs', 'context', 'cli:initial:ctx']);
});

View File

@@ -23,6 +23,12 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
return (args: CliArgs, source: CliCommandSource = 'initial'): void => {
handleTexthookerOnlyModeTransitionHandler(args);
if (
!deps.handleTexthookerOnlyModeTransitionMainDeps.isTexthookerOnlyMode() &&
deps.handleTexthookerOnlyModeTransitionMainDeps.commandNeedsOverlayRuntime(args)
) {
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
}
const cliContext = deps.createCliCommandContext();
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
};

View File

@@ -10,6 +10,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
mpvCommandMainDeps: {
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

View File

@@ -13,6 +13,10 @@ test('initial args handler no-ops without initial args', () => {
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {
handled = true;
@@ -36,6 +40,10 @@ test('initial args handler ensures tray in background mode', () => {
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
@@ -61,6 +69,10 @@ test('initial args handler auto-connects mpv when needed', () => {
connectCalls += 1;
},
}),
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {
logged = true;
},
@@ -83,6 +95,14 @@ test('initial args handler forwards args to cli handler', () => {
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {
seenSources.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
seenSources.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
seenSources.push(source);
@@ -93,6 +113,37 @@ test('initial args handler forwards args to cli handler', () => {
assert.deepEqual(seenSources, ['initial']);
});
test('initial args handler bootstraps overlay before initial overlay-runtime commands', () => {
const calls: string[] = [];
const args = { youtubePlay: 'https://youtube.com/watch?v=abc' } as never;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => args,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: (inputArgs) => inputArgs === args,
ensureOverlayStartupPrereqs: () => {
calls.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
calls.push(`cli:${source}`);
},
});
handleInitialArgs();
assert.deepEqual(calls, ['prereqs', 'init-overlay', 'cli:initial']);
});
test('initial args handler can ensure tray outside background mode when requested', () => {
let ensuredTray = false;
const handleInitialArgs = createHandleInitialArgsHandler({
@@ -106,6 +157,10 @@ test('initial args handler can ensure tray outside background mode when requeste
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
@@ -133,6 +188,10 @@ test('initial args handler skips tray and mpv auto-connect for headless refresh'
connectCalls += 1;
},
}),
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});

View File

@@ -14,6 +14,10 @@ export function createHandleInitialArgsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
getMpvClient: () => MpvClientLike | null;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
logInfo: (message: string) => void;
handleCliCommand: (args: CliArgs, source: 'initial') => void;
}) {
@@ -39,6 +43,13 @@ export function createHandleInitialArgsHandler(deps: {
mpvClient.connect();
}
if (!runHeadless && deps.commandNeedsOverlayRuntime(initialArgs)) {
deps.ensureOverlayStartupPrereqs();
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
}
deps.handleCliCommand(initialArgs, 'initial');
};
}

View File

@@ -15,6 +15,10 @@ test('initial args main deps builder maps runtime callbacks and state readers',
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => mpvClient,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`info:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
})();
@@ -26,9 +30,13 @@ test('initial args main deps builder maps runtime callbacks and state readers',
assert.equal(deps.isTexthookerOnlyMode(), false);
assert.equal(deps.hasImmersionTracker(), true);
assert.equal(deps.getMpvClient(), mpvClient);
assert.equal(deps.commandNeedsOverlayRuntime(args), true);
assert.equal(deps.isOverlayRuntimeInitialized(), false);
deps.ensureTray();
deps.ensureOverlayStartupPrereqs();
deps.initializeOverlayRuntime();
deps.logInfo('x');
deps.handleCliCommand(args, 'initial');
assert.deepEqual(calls, ['ensure-tray', 'info:x', 'cli:initial']);
assert.deepEqual(calls, ['ensure-tray', 'prereqs', 'init-overlay', 'info:x', 'cli:initial']);
});

View File

@@ -9,6 +9,10 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
getMpvClient: () => { connected: boolean; connect: () => void } | null;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
logInfo: (message: string) => void;
handleCliCommand: (args: CliArgs, source: 'initial') => void;
}) {
@@ -21,6 +25,10 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
hasImmersionTracker: () => deps.hasImmersionTracker(),
getMpvClient: () => deps.getMpvClient(),
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
initializeOverlayRuntime: () => deps.initializeOverlayRuntime(),
logInfo: (message: string) => deps.logInfo(message),
handleCliCommand: (args: CliArgs, source: 'initial') => deps.handleCliCommand(args, source),
});

View File

@@ -16,6 +16,10 @@ test('initial args runtime handler composes main deps and runs initial command f
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
@@ -44,6 +48,10 @@ test('initial args runtime handler skips mpv auto-connect for stats mode', () =>
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
@@ -67,6 +75,10 @@ test('initial args runtime handler skips tray and mpv auto-connect for headless
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { SubtitleData } from '../../types';
export function createHandleMpvSubtitleChangeHandler(deps: {
shouldSuppressSubtitleEvents?: () => boolean;
setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
@@ -10,6 +11,10 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
}) {
return ({ text }: { text: string }): void => {
deps.setCurrentSubText(text);
if (deps.shouldSuppressSubtitleEvents?.()) {
deps.refreshDiscordPresence();
return;
}
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
if (immediatePayload) {
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
@@ -25,19 +30,27 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
}
export function createHandleMpvSubtitleAssChangeHandler(deps: {
shouldSuppressSubtitleEvents?: () => boolean;
setCurrentSubAssText: (text: string) => void;
broadcastSubtitleAss: (text: string) => void;
}) {
return ({ text }: { text: string }): void => {
deps.setCurrentSubAssText(text);
if (deps.shouldSuppressSubtitleEvents?.()) {
return;
}
deps.broadcastSubtitleAss(text);
};
}
export function createHandleMpvSecondarySubtitleChangeHandler(deps: {
shouldSuppressSubtitleEvents?: () => boolean;
broadcastSecondarySubtitle: (text: string) => void;
}) {
return ({ text }: { text: string }): void => {
if (deps.shouldSuppressSubtitleEvents?.()) {
return;
}
deps.broadcastSecondarySubtitle(text);
};
}

View File

@@ -26,6 +26,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
calls.push('post-watch');
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
shouldSuppressSubtitleEvents: () => false,
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
@@ -93,3 +94,67 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('flush-playback'));
});
test('main mpv event binder suppresses subtitle broadcasts while youtube flow is pending', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
hasInitialJellyfinPlayArg: () => false,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
isMpvConnected: () => false,
quitApp: () => {},
recordImmersionSubtitleLine: () => {},
hasSubtitleTimingTracker: () => false,
recordSubtitleTiming: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
shouldSuppressSubtitleEvents: () => true,
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
refreshDiscordPresence: () => calls.push('presence-refresh'),
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
notifyImmersionTitleUpdate: () => {},
recordPlaybackPosition: () => {},
recordMediaDuration: () => {},
reportJellyfinRemoteProgress: () => {},
recordPauseState: () => {},
updateSubtitleRenderMetrics: () => {},
setPreviousSecondarySubVisibility: () => {},
});
bind({
on: (event, handler) => {
handlers.set(event, handler as (payload: unknown) => void);
},
});
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('subtitle-ass-change')?.({ text: 'ass' });
handlers.get('secondary-subtitle-change')?.({ text: 'sec' });
assert.ok(calls.includes('set-sub:line'));
assert.ok(calls.includes('set-ass:ass'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(!calls.includes('broadcast-sub:line'));
assert.ok(!calls.includes('subtitle-change:line'));
assert.ok(!calls.includes('broadcast-ass:ass'));
assert.ok(!calls.includes('broadcast-secondary:sec'));
});

View File

@@ -35,6 +35,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordSubtitleTiming: (text: string, start: number, end: number) => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void;
shouldSuppressSubtitleEvents?: () => boolean;
setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
@@ -99,6 +100,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
logError: (message, error) => deps.logSubtitleTimingError(message, error),
});
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
shouldSuppressSubtitleEvents: () => deps.shouldSuppressSubtitleEvents?.() ?? false,
setCurrentSubText: (text) => deps.setCurrentSubText(text),
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
emitImmediateSubtitle: (payload) => deps.emitImmediateSubtitle?.(payload),
@@ -107,10 +109,12 @@ export function createBindMpvMainEventHandlersHandler(deps: {
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
shouldSuppressSubtitleEvents: () => deps.shouldSuppressSubtitleEvents?.() ?? false,
setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text),
broadcastSubtitleAss: (text) => deps.broadcastSubtitleAss(text),
});
const handleMpvSecondarySubtitleChange = createHandleMpvSecondarySubtitleChangeHandler({
shouldSuppressSubtitleEvents: () => deps.shouldSuppressSubtitleEvents?.() ?? false,
broadcastSecondarySubtitle: (text) => deps.broadcastSecondarySubtitle(text),
});
const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({

View File

@@ -75,6 +75,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.recordSubtitleTiming('y', 0, 1);
await deps.maybeRunAnilistPostWatchUpdate();
deps.logSubtitleTimingError('err', new Error('boom'));
assert.equal(deps.shouldSuppressSubtitleEvents?.(), false);
deps.setCurrentSubText('sub');
deps.broadcastSubtitle({ text: 'sub', tokens: null });
deps.onSubtitleChange('sub');
@@ -117,3 +118,45 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-sidebar-layout'));
});
test('mpv main event main deps suppress subtitle events while youtube flow is pending', () => {
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
youtubePlaybackFlowPending: true,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
assert.equal(deps.shouldSuppressSubtitleEvents?.(), true);
});

View File

@@ -120,6 +120,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
logSubtitleTimingError: (message: string, error: unknown) =>
deps.logSubtitleTimingError(message, error),
shouldSuppressSubtitleEvents: () => deps.appState.youtubePlaybackFlowPending,
setCurrentSubText: (text: string) => {
deps.appState.currentSubText = text;
},

View File

@@ -12,6 +12,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => mainWindow,
getModalActive: () => true,
getVisibleOverlayVisible: () => true,
getForceMousePassthrough: () => true,
getWindowTracker: () => tracker,
@@ -32,6 +33,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
})();
assert.equal(deps.getMainWindow(), mainWindow);
assert.equal(deps.getModalActive(), true);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getForceMousePassthrough(), true);
assert.equal(deps.getTrackerNotReadyWarningShown(), false);

View File

@@ -7,6 +7,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
) {
return (): OverlayVisibilityRuntimeDeps => ({
getMainWindow: () => deps.getMainWindow(),
getModalActive: () => deps.getModalActive(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getWindowTracker: () => deps.getWindowTracker(),

View File

@@ -19,14 +19,12 @@ const secondaryTrack: YoutubeTrackOption = {
label: 'English (manual)',
};
test('youtube flow clears internal tracks and binds external primary+secondary subtitles', async () => {
test('youtube flow auto-loads default primary+secondary subtitles without opening the picker', async () => {
const commands: Array<Array<string | number>> = [];
const osdMessages: string[] = [];
const order: string[] = [];
const refreshedSubtitles: string[] = [];
const waits: number[] = [];
const focusOverlayCalls: string[] = [];
let pickerPayload: YoutubePickerOpenPayload | null = null;
let trackListRequests = 0;
const runtime = createYoutubeFlowRuntime({
@@ -66,27 +64,19 @@ test('youtube flow clears internal tracks and binds external primary+secondary s
order.push('wait-anki-ready');
},
waitForPlaybackWindowReady: async () => {
order.push('wait-window-ready');
throw new Error('startup auto-load should not wait for modal window readiness');
},
waitForOverlayGeometryReady: async () => {
order.push('wait-overlay-geometry');
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async (payload) => {
assert.deepEqual(waits, [150]);
order.push('open-picker');
pickerPayload = payload;
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: secondaryTrack.id,
});
});
return true;
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: () => {
throw new Error('startup auto-load should not report failure on success');
},
pauseMpv: () => {
commands.push(['set_property', 'pause', 'yes']);
@@ -97,7 +87,7 @@ test('youtube flow clears internal tracks and binds external primary+secondary s
sendMpvCommand: (command) => {
commands.push(command);
},
requestMpvProperty: async (name) => {
requestMpvProperty: async (name: string) => {
if (name === 'sub-text') {
return '字幕です';
}
@@ -128,13 +118,11 @@ test('youtube flow clears internal tracks and binds external primary+secondary s
refreshCurrentSubtitle: (text) => {
refreshedSubtitles.push(text);
},
wait: async (ms) => {
waits.push(ms);
},
wait: async () => {},
showMpvOsd: (text) => {
osdMessages.push(text);
},
warn: (message) => {
warn: (message: string) => {
throw new Error(message);
},
log: () => {},
@@ -142,24 +130,24 @@ test('youtube flow clears internal tracks and binds external primary+secondary s
});
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.ok(pickerPayload);
assert.deepEqual(order, [
'start-tokenization-warmups',
'wait-window-ready',
'wait-overlay-geometry',
'open-picker',
'wait-tokenization-ready',
'wait-anki-ready',
]);
assert.deepEqual(osdMessages, [
'Opening YouTube video',
'Getting subtitles...',
'Downloading subtitles...',
'Loading subtitles...',
'Primary and secondary subtitles loaded.',
]);
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'sub-auto', 'no'],
['set_property', 'sid', 'no'],
['set_property', 'secondary-sid', 'no'],
['set_property', 'sub-visibility', 'no'],
['set_property', 'secondary-sub-visibility', 'no'],
['set_property', 'sub-delay', 0],
['set_property', 'sid', 'no'],
['set_property', 'secondary-sid', 'no'],
@@ -174,8 +162,10 @@ test('youtube flow clears internal tracks and binds external primary+secondary s
assert.deepEqual(refreshedSubtitles, ['字幕です']);
});
test('youtube flow can cancel active picker session', async () => {
const focusOverlayCalls: string[] = [];
test('youtube flow refreshes parsed subtitle cues from the resolved primary subtitle path after auto-load', async () => {
const refreshedSidebarSources: string[] = [];
let trackListRequests = 0;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
@@ -183,48 +173,57 @@ test('youtube flow can cancel active picker session', async () => {
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('should not batch download after cancel');
throw new Error('single-track auto-load should not batch acquire');
},
acquireYoutubeSubtitleTrack: async () => {
throw new Error('should not download after cancel');
},
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async () => '/tmp/auto-ja-orig_retimed.vtt',
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async (payload) => {
queueMicrotask(() => {
assert.equal(runtime.cancelActivePicker(), true);
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
focusOverlayWindow: () => {},
openPicker: async () => false,
reportSubtitleFailure: () => {
throw new Error('primary subtitle should load successfully');
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: () => {},
requestMpvProperty: async () => null,
requestMpvProperty: async (name: string) => {
if (name === 'sub-text') {
return '字幕です';
}
assert.equal(name, 'track-list');
trackListRequests += 1;
return [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig_retimed.vtt',
},
];
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async (sourcePath: string) => {
refreshedSidebarSources.push(sourcePath);
},
wait: async () => {},
showMpvOsd: () => {},
warn: () => {},
warn: (message: string) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
} as never);
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.equal(runtime.hasActiveSession(), false);
assert.equal(runtime.cancelActivePicker(), false);
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
assert.equal(trackListRequests > 0, true);
assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig_retimed.vtt']);
});
test('youtube flow retries secondary after partial batch subtitle failure', async () => {
@@ -257,21 +256,20 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
waitForPlaybackWindowReady: async () => {
throw new Error('startup auto-load should not wait for modal window readiness');
},
waitForOverlayGeometryReady: async () => {
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: secondaryTrack.id,
});
});
return true;
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: () => {
throw new Error('secondary retry should not report primary failure');
},
pauseMpv: () => {},
resumeMpv: () => {},
@@ -332,7 +330,7 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.deepEqual(acquireSingleCalls, [secondaryTrack.id]);
assert.ok(waits.includes(150));
assert.ok(waits.includes(350));
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
assert.deepEqual(refreshedSubtitles, ['字幕です']);
assert.ok(
@@ -377,21 +375,20 @@ test('youtube flow waits for tokenization readiness before releasing playback',
waitForAnkiReady: async () => {
releaseOrder.push('wait-anki-ready');
},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
waitForPlaybackWindowReady: async () => {
throw new Error('startup auto-load should not wait for modal window readiness');
},
waitForOverlayGeometryReady: async () => {
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
focusOverlayWindow: () => {
releaseOrder.push('focus-overlay');
},
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: () => {
throw new Error('successful auto-load should not report failure');
},
pauseMpv: () => {},
resumeMpv: () => {
@@ -450,10 +447,10 @@ test('youtube flow waits for tokenization readiness before releasing playback',
]);
});
test('youtube flow cleans up paused picker state when opening the picker throws', async () => {
test('youtube flow reports primary auto-load failure through the configured reporter when the primary subtitle never binds', async () => {
const commands: Array<Array<string | number>> = [];
const warns: string[] = [];
const focusOverlayCalls: string[] = [];
const reportedFailures: string[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
@@ -465,78 +462,22 @@ test('youtube flow cleans up paused picker state when opening the picker throws'
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForTokenizationReady: async () => {
throw new Error('bind failure should not wait for tokenization readiness');
},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
waitForPlaybackWindowReady: async () => {
throw new Error('startup auto-load should not wait for modal window readiness');
},
openPicker: async () => {
throw new Error('picker boom');
waitForOverlayGeometryReady: async () => {
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
pauseMpv: () => {
commands.push(['set_property', 'pause', 'yes']);
},
resumeMpv: () => {
commands.push(['set_property', 'pause', 'no']);
},
sendMpvCommand: (command) => {
commands.push(command);
},
requestMpvProperty: async () => null,
refreshCurrentSubtitle: () => {},
wait: async () => {},
showMpvOsd: () => {},
warn: (message) => {
warns.push(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['script-message', 'subminer-autoplay-ready'],
['set_property', 'pause', 'no'],
]);
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
assert.equal(warns.some((message) => message.includes('picker boom')), true);
assert.equal(runtime.hasActiveSession(), false);
});
test('youtube flow reports failure when the primary subtitle never binds', async () => {
const commands: Array<Array<string | number>> = [];
const osdMessages: string[] = [];
const warns: string[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => new Map<string, string>(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: (message) => {
reportedFailures.push(message);
},
pauseMpv: () => {},
resumeMpv: () => {},
@@ -553,9 +494,7 @@ test('youtube flow reports failure when the primary subtitle never binds', async
throw new Error('should not refresh subtitle text on bind failure');
},
wait: async () => {},
showMpvOsd: (text) => {
osdMessages.push(text);
},
showMpvOsd: () => {},
warn: (message) => {
warns.push(message);
},
@@ -569,6 +508,129 @@ test('youtube flow reports failure when the primary subtitle never binds', async
commands.some((command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] !== 'no'),
false,
);
assert.deepEqual(osdMessages.slice(-1), ['Primary subtitles failed to load.']);
assert.deepEqual(reportedFailures, [
'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.',
]);
assert.equal(warns.some((message) => message.includes('Unable to bind downloaded primary subtitle track')), true);
});
test('youtube flow can open a manual picker session and load the selected subtitles', async () => {
const commands: Array<Array<string | number>> = [];
const focusOverlayCalls: string[] = [];
const osdMessages: string[] = [];
const openedPayloads: YoutubePickerOpenPayload[] = [];
const waits: number[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack, secondaryTrack],
}),
acquireYoutubeSubtitleTracks: async ({ tracks }) => {
assert.deepEqual(
tracks.map((track) => track.id),
[primaryTrack.id, secondaryTrack.id],
);
return new Map<string, string>([
[primaryTrack.id, '/tmp/auto-ja-orig.vtt'],
[secondaryTrack.id, '/tmp/manual-en.vtt'],
]);
},
acquireYoutubeSubtitleTrack: async ({ track }) => ({ path: `/tmp/${track.id}.vtt` }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`,
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {
waits.push(1);
},
waitForOverlayGeometryReady: async () => {
waits.push(2);
},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async (payload) => {
openedPayloads.push(payload);
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: secondaryTrack.id,
});
});
return true;
},
reportSubtitleFailure: () => {
throw new Error('manual picker success should not report failure');
},
pauseMpv: () => {
throw new Error('manual picker should not pause playback');
},
resumeMpv: () => {
throw new Error('manual picker should not resume playback');
},
sendMpvCommand: (command) => {
commands.push(command);
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
return [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig.vtt.retimed',
},
{
type: 'sub',
id: 6,
lang: 'en',
title: 'secondary',
external: true,
'external-filename': '/tmp/manual-en.vtt',
},
];
},
refreshCurrentSubtitle: () => {},
wait: async (ms) => {
waits.push(ms);
},
showMpvOsd: (text) => {
osdMessages.push(text);
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com', mode: 'download' });
assert.equal(openedPayloads.length, 1);
assert.equal(openedPayloads[0]?.defaultPrimaryTrackId, primaryTrack.id);
assert.equal(openedPayloads[0]?.defaultSecondaryTrackId, secondaryTrack.id);
assert.ok(waits.includes(150));
assert.deepEqual(osdMessages, [
'Getting subtitles...',
'Downloading subtitles...',
'Loading subtitles...',
'Primary and secondary subtitles loaded.',
]);
assert.ok(
commands.some(
(command) =>
command[0] === 'sub-add' &&
command[1] === '/tmp/auto-ja-orig.vtt.retimed' &&
command[2] === 'select',
),
);
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
});

View File

@@ -39,6 +39,7 @@ type YoutubeFlowDeps = {
sendMpvCommand: (command: Array<string | number>) => void;
requestMpvProperty: (name: string) => Promise<unknown>;
refreshCurrentSubtitle: (text: string) => void;
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
startTokenizationWarmups: () => Promise<void>;
waitForTokenizationReady: () => Promise<void>;
waitForAnkiReady: () => Promise<void>;
@@ -47,6 +48,7 @@ type YoutubeFlowDeps = {
waitForOverlayGeometryReady: () => Promise<void>;
focusOverlayWindow: () => void;
showMpvOsd: (text: string) => void;
reportSubtitleFailure: (message: string) => void;
warn: (message: string) => void;
log: (message: string) => void;
getYoutubeOutputDir: () => string;
@@ -109,6 +111,14 @@ function releasePlaybackGate(deps: YoutubeFlowDeps): void {
deps.resumeMpv();
}
function suppressYoutubeSubtitleState(deps: YoutubeFlowDeps): void {
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
}
function restoreOverlayInputFocus(deps: YoutubeFlowDeps): void {
deps.focusOverlayWindow();
}
@@ -259,7 +269,6 @@ async function injectDownloadedSubtitles(
}
if (primaryTrackId === null) {
deps.showMpvOsd('Primary subtitles failed to load.');
return false;
}
@@ -385,6 +394,182 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
activeSession = null;
});
const reportPrimarySubtitleFailure = (): void => {
deps.reportSubtitleFailure(
'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.',
);
};
const buildOpenPayload = (
input: {
url: string;
mode: YoutubeFlowMode;
},
probe: YoutubeTrackProbeResult,
): YoutubePickerOpenPayload => {
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
return {
sessionId: createSessionId(),
url: input.url,
mode: input.mode,
tracks: probe.tracks,
defaultPrimaryTrackId: defaults.primaryTrackId,
defaultSecondaryTrackId: defaults.secondaryTrackId,
hasTracks: probe.tracks.length > 0,
};
};
const loadTracksIntoMpv = async (input: {
url: string;
mode: YoutubeFlowMode;
outputDir: string;
primaryTrack: YoutubeTrackOption;
secondaryTrack: YoutubeTrackOption | null;
secondaryFailureLabel: string;
tokenizationWarmupPromise?: Promise<void>;
showDownloadProgress: boolean;
}): Promise<boolean> => {
const osdProgress = input.showDownloadProgress
? createYoutubeFlowOsdProgress(deps.showMpvOsd)
: null;
if (osdProgress) {
osdProgress.setMessage('Downloading subtitles...');
}
try {
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
outputDir: input.outputDir,
primaryTrack: input.primaryTrack,
secondaryTrack: input.secondaryTrack,
mode: input.mode,
secondaryFailureLabel: input.secondaryFailureLabel,
});
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
targetUrl: input.url,
primaryTrack: input.primaryTrack,
primaryPath: acquired.primaryPath,
secondaryTrack: input.secondaryTrack,
secondaryPath: acquired.secondaryPath,
});
deps.showMpvOsd('Loading subtitles...');
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
deps,
input.primaryTrack,
resolvedPrimaryPath,
input.secondaryTrack,
acquired.secondaryPath,
);
if (!refreshedActiveSubtitle) {
return false;
}
try {
await deps.refreshSubtitleSidebarSource?.(resolvedPrimaryPath);
} catch (error) {
deps.warn(
`Failed to refresh parsed subtitle cues for sidebar: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (input.tokenizationWarmupPromise) {
await input.tokenizationWarmupPromise;
}
await deps.waitForTokenizationReady();
await deps.waitForAnkiReady();
return true;
} finally {
osdProgress?.stop();
}
};
const openManualPicker = async (input: {
url: string;
mode: YoutubeFlowMode;
}): Promise<void> => {
let probe: YoutubeTrackProbeResult;
try {
probe = await deps.probeYoutubeTracks(input.url);
} catch (error) {
deps.warn(
`Failed to probe YouTube subtitle tracks: ${
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
restoreOverlayInputFocus(deps);
return;
}
const openPayload = buildOpenPayload(input, probe);
await deps.waitForPlaybackWindowReady();
await deps.waitForOverlayGeometryReady();
await deps.wait(YOUTUBE_PICKER_SETTLE_DELAY_MS);
const pickerSelection = createPickerSelectionPromise(openPayload.sessionId);
void pickerSelection.catch(() => undefined);
let opened = false;
try {
opened = await deps.openPicker(openPayload);
} catch (error) {
activeSession?.reject(error instanceof Error ? error : new Error(String(error)));
deps.warn(
`Unable to open YouTube subtitle picker: ${
error instanceof Error ? error.message : String(error)
}`,
);
restoreOverlayInputFocus(deps);
return;
}
if (!opened) {
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
activeSession = null;
deps.warn('Unable to open YouTube subtitle picker.');
restoreOverlayInputFocus(deps);
return;
}
const request = await pickerSelection;
if (request.action === 'continue-without-subtitles') {
restoreOverlayInputFocus(deps);
return;
}
const primaryTrack = getTrackById(probe.tracks, request.primaryTrackId);
if (!primaryTrack) {
deps.warn('No primary YouTube subtitle track selected.');
restoreOverlayInputFocus(deps);
return;
}
const selected = normalizeYoutubeTrackSelection({
primaryTrackId: primaryTrack.id,
secondaryTrackId: request.secondaryTrackId,
});
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
try {
deps.showMpvOsd('Getting subtitles...');
await loadTracksIntoMpv({
url: input.url,
mode: input.mode,
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
primaryTrack,
secondaryTrack,
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
showDownloadProgress: true,
});
} catch (error) {
deps.warn(
`Failed to download primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
} finally {
restoreOverlayInputFocus(deps);
}
};
async function runYoutubePlaybackFlow(input: {
url: string;
mode: YoutubeFlowMode;
@@ -399,6 +584,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
});
deps.pauseMpv();
suppressYoutubeSubtitleState(deps);
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
let probe: YoutubeTrackProbeResult;
@@ -410,123 +596,17 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
const sessionId = createSessionId();
const openPayload: YoutubePickerOpenPayload = {
sessionId,
url: input.url,
mode: input.mode,
tracks: probe.tracks,
defaultPrimaryTrackId: defaults.primaryTrackId,
defaultSecondaryTrackId: defaults.secondaryTrackId,
hasTracks: probe.tracks.length > 0,
};
if (input.mode === 'download') {
await deps.waitForPlaybackWindowReady();
await deps.waitForOverlayGeometryReady();
await deps.wait(YOUTUBE_PICKER_SETTLE_DELAY_MS);
deps.showMpvOsd('Getting subtitles...');
const pickerSelection = createPickerSelectionPromise(sessionId);
void pickerSelection.catch(() => undefined);
let opened = false;
try {
opened = await deps.openPicker(openPayload);
} catch (error) {
activeSession?.reject(
error instanceof Error ? error : new Error(String(error)),
);
deps.warn(
`Unable to open YouTube subtitle picker: ${
error instanceof Error ? error.message : String(error)
}`,
);
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
if (!opened) {
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
activeSession = null;
deps.warn('Unable to open YouTube subtitle picker; continuing without subtitles.');
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
const request = await pickerSelection;
if (request.action === 'continue-without-subtitles') {
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
const osdProgress = createYoutubeFlowOsdProgress(deps.showMpvOsd);
osdProgress.setMessage('Downloading subtitles...');
try {
const primaryTrack = getTrackById(probe.tracks, request.primaryTrackId);
if (!primaryTrack) {
deps.warn('No primary YouTube subtitle track selected; continuing without subtitles.');
return;
}
const selected = normalizeYoutubeTrackSelection({
primaryTrackId: primaryTrack.id,
secondaryTrackId: request.secondaryTrackId,
});
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
outputDir,
primaryTrack,
secondaryTrack,
mode: input.mode,
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
});
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
targetUrl: input.url,
primaryTrack,
primaryPath: acquired.primaryPath,
secondaryTrack,
secondaryPath: acquired.secondaryPath,
});
osdProgress.setMessage('Loading subtitles...');
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
deps,
primaryTrack,
resolvedPrimaryPath,
secondaryTrack,
acquired.secondaryPath,
);
await tokenizationWarmupPromise;
if (refreshedActiveSubtitle) {
await deps.waitForTokenizationReady();
}
await deps.waitForAnkiReady();
} catch (error) {
deps.warn(
`Failed to download primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
osdProgress.stop();
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
}
return;
}
const primaryTrack = getTrackById(probe.tracks, defaults.primaryTrackId);
const secondaryTrack = getTrackById(probe.tracks, defaults.secondaryTrackId);
if (!primaryTrack) {
deps.showMpvOsd('No usable YouTube subtitles found.');
reportPrimarySubtitleFailure();
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
@@ -534,40 +614,31 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
try {
deps.showMpvOsd('Getting subtitles...');
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
const loaded = await loadTracksIntoMpv({
url: input.url,
mode: input.mode,
outputDir,
primaryTrack,
secondaryTrack,
mode: input.mode,
secondaryFailureLabel: 'Failed to generate secondary YouTube subtitle track',
secondaryFailureLabel:
input.mode === 'generate'
? 'Failed to generate secondary YouTube subtitle track'
: 'Failed to download secondary YouTube subtitle track',
tokenizationWarmupPromise,
showDownloadProgress: false,
});
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
targetUrl: input.url,
primaryTrack,
primaryPath: acquired.primaryPath,
secondaryTrack,
secondaryPath: acquired.secondaryPath,
});
deps.showMpvOsd('Loading subtitles...');
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
deps,
primaryTrack,
resolvedPrimaryPath,
secondaryTrack,
acquired.secondaryPath,
);
await tokenizationWarmupPromise;
if (refreshedActiveSubtitle) {
await deps.waitForTokenizationReady();
if (!loaded) {
reportPrimarySubtitleFailure();
}
await deps.waitForAnkiReady();
} catch (error) {
deps.warn(
`Failed to generate primary YouTube subtitle track: ${
`Failed to ${
input.mode === 'generate' ? 'generate' : 'download'
} primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
} finally {
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
@@ -576,6 +647,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
return {
runYoutubePlaybackFlow,
openManualPicker,
resolveActivePicker,
cancelActivePicker,
hasActiveSession: () => Boolean(activeSession),

View File

@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openYoutubeTrackPicker } from './youtube-picker-open';
import type { YoutubePickerOpenPayload } from '../../types';
const payload: YoutubePickerOpenPayload = {
sessionId: 'yt-1',
url: 'https://example.com/watch?v=abc',
mode: 'download',
tracks: [],
defaultPrimaryTrackId: null,
defaultSecondaryTrackId: null,
hasTracks: false,
};
test('youtube picker open prefers dedicated modal window on first attempt', async () => {
const sends: Array<{
channel: string;
payload: YoutubePickerOpenPayload;
options: {
restoreOnModalClose: 'youtube-track-picker';
preferModalWindow: boolean;
};
}> = [];
const opened = await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: (channel, nextPayload, options) => {
sends.push({
channel,
payload: nextPayload as YoutubePickerOpenPayload,
options: options as {
restoreOnModalClose: 'youtube-track-picker';
preferModalWindow: boolean;
},
});
return true;
},
waitForModalOpen: async () => true,
logWarn: () => {},
},
payload,
);
assert.equal(opened, true);
assert.deepEqual(sends, [
{
channel: 'youtube:picker-open',
payload,
options: {
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow: true,
},
},
]);
});
test('youtube picker open retries on the dedicated modal window after open timeout', async () => {
const preferModalWindowValues: boolean[] = [];
const warns: string[] = [];
let waitCalls = 0;
const opened = await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: (_channel, _payload, options) => {
preferModalWindowValues.push(Boolean(options?.preferModalWindow));
return true;
},
waitForModalOpen: async () => {
waitCalls += 1;
return waitCalls === 2;
},
logWarn: (message) => {
warns.push(message);
},
},
payload,
);
assert.equal(opened, true);
assert.deepEqual(preferModalWindowValues, [true, true]);
assert.equal(
warns.includes(
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
),
true,
);
});
test('youtube picker open fails when the dedicated modal window cannot be targeted', async () => {
const opened = await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: () => false,
waitForModalOpen: async () => true,
logWarn: () => {},
},
payload,
);
assert.equal(opened, false);
});

View File

@@ -0,0 +1,42 @@
import type { YoutubePickerOpenPayload } from '../../types';
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
const YOUTUBE_PICKER_MODAL: OverlayHostedModal = 'youtube-track-picker';
const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500;
export async function openYoutubeTrackPicker(
deps: {
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
},
payload: YoutubePickerOpenPayload,
): Promise<boolean> {
const sendPickerOpen = (): boolean =>
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
preferModalWindow: true,
});
if (!sendPickerOpen()) {
return false;
}
if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) {
return true;
}
deps.logWarn(
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
);
if (!sendPickerOpen()) {
return false;
}
return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS);
}

View File

@@ -1,7 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs } from '../cli/args';
import {
applyStartupState,
createAppState,
createInitialAnilistMediaGuessRuntimeState,
createInitialAnilistUpdateInFlightState,
transitionAnilistClientSecretState,
@@ -91,3 +94,22 @@ test('transitionAnilistUpdateInFlightState updates inFlight only', () => {
assert.deepEqual(transitioned, { inFlight: true });
assert.notEqual(transitioned, current);
});
test('applyStartupState does not mark youtube playback flow pending from startup args alone', () => {
const appState = createAppState({
mpvSocketPath: '/tmp/mpv.sock',
texthookerPort: 4000,
});
applyStartupState(appState, {
initialArgs: parseArgs(['--youtube-play', 'https://www.youtube.com/watch?v=video123']),
mpvSocketPath: '/tmp/mpv.sock',
texthookerPort: 4000,
backendOverride: null,
autoStartOverlay: false,
texthookerOnlyMode: false,
backgroundMode: false,
});
assert.equal(appState.youtubePlaybackFlowPending, false);
});

View File

@@ -293,7 +293,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
export function applyStartupState(appState: AppState, startupState: StartupState): void {
appState.initialArgs = startupState.initialArgs;
appState.youtubePlaybackFlowPending = Boolean(startupState.initialArgs.youtubePlay);
appState.youtubePlaybackFlowPending = false;
appState.mpvSocketPath = startupState.mpvSocketPath;
appState.texthookerPort = startupState.texthookerPort;
appState.backendOverride = startupState.backendOverride;

View File

@@ -53,6 +53,7 @@ function createFakeElement() {
test('youtube track picker close restores focus and mouse-ignore state', () => {
const overlayFocusCalls: number[] = [];
const windowFocusCalls: number[] = [];
const focusMainWindowCalls: number[] = [];
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const notifications: string[] = [];
const frontendCommands: unknown[] = [];
@@ -92,6 +93,9 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
notifyOverlayModalClosed: (modal: string) => {
notifications.push(modal);
},
focusMainWindow: async () => {
focusMainWindowCalls.push(1);
},
youtubePickerResolve: async () => ({ ok: true, message: '' }),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
@@ -160,6 +164,7 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
assert.deepEqual(notifications, ['youtube-track-picker']);
assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]);
assert.equal(overlay.classList.contains('interactive'), false);
assert.equal(focusMainWindowCalls.length > 0, true);
assert.equal(overlayFocusCalls.length > 0, true);
assert.equal(windowFocusCalls.length > 0, true);
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
@@ -556,3 +561,131 @@ test('youtube track picker only consumes handled keys', async () => {
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
test('youtube track picker ignores immediate Enter after open before allowing keyboard submit', async () => {
const resolveCalls: Array<{
sessionId: string;
action: string;
primaryTrackId: string | null;
secondaryTrackId: string | null;
}> = [];
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalDateNow = Date.now;
let now = 10_000;
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createFakeElement(),
},
});
Date.now = () => now;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
dispatchEvent: () => true,
focus: () => {},
electronAPI: {
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: () => {},
youtubePickerResolve: async (payload: {
sessionId: string;
action: string;
primaryTrackId: string | null;
secondaryTrackId: string | null;
}) => {
resolveCalls.push(payload);
return { ok: true, message: '' };
},
setIgnoreMouseEvents: () => {},
},
},
});
try {
const state = createRendererState();
const dom = {
overlay: {
classList: createClassList(),
focus: () => {},
},
youtubePickerModal: createFakeElement(),
youtubePickerTitle: createFakeElement(),
youtubePickerPrimarySelect: createFakeElement(),
youtubePickerSecondarySelect: createFakeElement(),
youtubePickerTracks: createFakeElement(),
youtubePickerStatus: createFakeElement(),
youtubePickerContinueButton: createFakeElement(),
youtubePickerCloseButton: createFakeElement(),
};
const modal = createYoutubeTrackPickerModal(
{
state,
dom,
platform: {
shouldToggleMouseIgnore: false,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => true },
restorePointerInteractionState: () => {},
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.openYoutubePickerModal({
sessionId: 'yt-1',
url: 'https://example.com',
mode: 'download',
tracks: [
{
id: 'auto:ja-orig',
language: 'ja',
sourceLanguage: 'ja-orig',
kind: 'auto',
label: 'Japanese (auto)',
},
],
defaultPrimaryTrackId: 'auto:ja-orig',
defaultSecondaryTrackId: null,
hasTracks: true,
});
assert.equal(
modal.handleYoutubePickerKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent),
true,
);
await Promise.resolve();
assert.deepEqual(resolveCalls, []);
assert.equal(state.youtubePickerModalOpen, true);
now += 250;
assert.equal(
modal.handleYoutubePickerKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent),
true,
);
await Promise.resolve();
assert.deepEqual(resolveCalls, [
{
sessionId: 'yt-1',
action: 'use-selected',
primaryTrackId: 'auto:ja-orig',
secondaryTrackId: null,
},
]);
} finally {
Date.now = originalDateNow;
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});

View File

@@ -17,7 +17,9 @@ export function createYoutubeTrackPickerModal(
syncSettingsModalSubtitleSuppression: () => void;
},
) {
const OPEN_KEY_GUARD_MS = 200;
let resolveSelectionInFlight = false;
let keyboardSubmitEnabledAtMs = 0;
function setStatus(message: string, isError = false): void {
ctx.state.youtubePickerStatus = message;
@@ -162,6 +164,7 @@ export function createYoutubeTrackPickerModal(
}
function openYoutubePickerModal(payload: YoutubePickerOpenPayload): void {
keyboardSubmitEnabledAtMs = Date.now() + OPEN_KEY_GUARD_MS;
if (ctx.state.youtubePickerModalOpen) {
options.syncSettingsModalSubtitleSuppression();
applyPayload(payload);
@@ -199,6 +202,7 @@ export function createYoutubeTrackPickerModal(
ctx.dom.overlay.classList.remove('interactive');
}
options.restorePointerInteractionState();
void window.electronAPI.focusMainWindow();
if (typeof ctx.dom.overlay.focus === 'function') {
ctx.dom.overlay.focus({ preventScroll: true });
}
@@ -223,6 +227,9 @@ export function createYoutubeTrackPickerModal(
if (e.key === 'Enter') {
e.preventDefault();
if (Date.now() < keyboardSubmitEnabledAtMs) {
return true;
}
void resolveSelection(
ctx.state.youtubePickerPayload?.hasTracks ? 'use-selected' : 'continue-without-subtitles',
);