diff --git a/changes/animated-avif-padding-freeze.md b/changes/animated-avif-padding-freeze.md new file mode 100644 index 00000000..f95b7d8b --- /dev/null +++ b/changes/animated-avif-padding-freeze.md @@ -0,0 +1,5 @@ +type: fixed +area: anki + +- Made sentence-audio padding opt-in by default, and kept animated AVIF motion aligned when padding is configured by freezing the first frame during leading audio padding. +- Kept multi-line sentence mining aligned when repeated subtitle text appears in the selected history range. diff --git a/changes/fix-animated-avif-word-audio-sync.md b/changes/fix-animated-avif-word-audio-sync.md new file mode 100644 index 00000000..b30d5d0e --- /dev/null +++ b/changes/fix-animated-avif-word-audio-sync.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Fixed animated AVIF word-audio sync so the frozen lead-in matches the word audio duration without adding sentence audio padding a second time. diff --git a/changes/fix-multiline-copy-overlay-focus.md b/changes/fix-multiline-copy-overlay-focus.md new file mode 100644 index 00000000..94b1e02c --- /dev/null +++ b/changes/fix-multiline-copy-overlay-focus.md @@ -0,0 +1,4 @@ +type: fixed +area: shortcuts + +- Focus the visible overlay when entering multi-line copy/mine selection so number keys choose the line count on macOS and Windows. diff --git a/changes/fix-youtube-manual-card-update-media.md b/changes/fix-youtube-manual-card-update-media.md new file mode 100644 index 00000000..88773dba --- /dev/null +++ b/changes/fix-youtube-manual-card-update-media.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Fixed manual clipboard card updates from YouTube playback so generated audio and images use mpv's resolved stream URLs instead of the YouTube page URL. diff --git a/changes/fix-youtube-sidebar-primary-cues.md b/changes/fix-youtube-sidebar-primary-cues.md new file mode 100644 index 00000000..bd81b3bf --- /dev/null +++ b/changes/fix-youtube-sidebar-primary-cues.md @@ -0,0 +1,4 @@ +type: fixed +area: youtube + +- Downloaded selected YouTube primary subtitles to temporary local files so the primary bar and sidebar read the same subtitle source, with temp-file cleanup on reload and quit. Suppressed stale failure notifications by re-checking live mpv subtitle state before reporting primary subtitle load failures. diff --git a/changes/youtube-hyprland-tray.md b/changes/youtube-hyprland-tray.md new file mode 100644 index 00000000..2d8f48d5 --- /dev/null +++ b/changes/youtube-hyprland-tray.md @@ -0,0 +1,7 @@ +type: fixed +area: linux + +- Suppressed false YouTube primary subtitle failure notifications after SubMiner confirms the selected primary track loaded successfully. +- Ensured launcher-managed playback commands create the tray icon even when they attach to an already-running SubMiner process. +- Prevented app-owned YouTube playback from letting the mpv plugin start a second SubMiner process after the launcher already started one. +- Logged Linux tray registration failures with a StatusNotifier/AppIndicator hint and documented the Hyprland tray-host requirement. diff --git a/config.example.jsonc b/config.example.jsonc index a5751221..31444de7 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -515,7 +515,7 @@ "animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio. "animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files. "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false - "audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio. + "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. }, // Media setting. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 33de7fd3..21f029b4 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -154,13 +154,13 @@ SubMiner uses FFmpeg to generate audio and image media from the video. FFmpeg mu ### Audio -Audio is extracted from the video file using the subtitle's start and end timestamps, with configurable padding added before and after. +Audio is extracted from the video file using the subtitle's start and end timestamps. Padding is opt-in; keep it at `0` when you want sentence audio to start exactly at the mined sentence. ```jsonc "ankiConnect": { "media": { "generateAudio": true, - "audioPadding": 0.5, // seconds before and after subtitle timing + "audioPadding": 0, // optional seconds before and after subtitle timing "maxMediaDuration": 30 // cap total duration in seconds } } @@ -342,7 +342,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead "imageType": "static", "imageFormat": "jpg", "imageQuality": 92, - "audioPadding": 0.5, + "audioPadding": 0, "maxMediaDuration": 30, }, "behavior": { diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 28b378b5..d3d1e7ce 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -922,7 +922,7 @@ Enable automatic Anki card creation and updates with media generation: "animatedMaxWidth": 640, "animatedMaxHeight": 360, "animatedCrf": 35, - "audioPadding": 0.5, + "audioPadding": 0, "fallbackDuration": 3, "maxMediaDuration": 30 }, @@ -984,7 +984,7 @@ This example is intentionally compact. The option table below documents availabl | `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | | `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | | `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | -| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) | +| `media.audioPadding` | number (seconds) | Optional padding around audio clip timing (default: `0`). Animated AVIF clips freeze the first frame during leading audio padding. | | `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | | `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated sentence audio (default: `true`) | diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index 1544fbab..8ec85ca5 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -25,7 +25,7 @@ This is the most common flow. Yomitan creates a card in Anki, and SubMiner enric - **Polling mode** (fallback, when the proxy is disabled): detects new cards via AnkiConnect polling (`ankiConnect.pollingRate`, default 3 seconds). 4. SubMiner updates the card with: - **Sentence**: The current subtitle line. - - **Audio**: Extracted from the video using the subtitle's start/end timing (plus configurable padding). + - **Audio**: Extracted from the video using the subtitle's start/end timing (plus optional configured padding). - **Image**: A screenshot or animated clip from the current playback position. - **Translation**: From the secondary subtitle track, or generated via AI if configured. - **MiscInfo**: Metadata like filename and timestamp. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index a5751221..31444de7 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -515,7 +515,7 @@ "animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio. "animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files. "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false - "audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio. + "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. }, // Media setting. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index ea91e122..071fc216 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -37,7 +37,7 @@ These work when the overlay window has focus. | `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` | | `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` | -The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine. +The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin. ## Overlay Controls diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 4663a289..309d0495 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -315,6 +315,7 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati - **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors are not supported — both mpv and SubMiner must run under X11 or Xwayland instead. - **X11 / Xwayland**: Requires `xdotool` and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway — both mpv and SubMiner must be running under X11/Xwayland for window tracking to work. +- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host. - **Mouse passthrough**: On Linux, Electron's mouse passthrough is unreliable. SubMiner keeps pointer events enabled, meaning you may need to toggle the overlay off to interact with mpv controls underneath. ### Hyprland diff --git a/docs-site/usage.md b/docs-site/usage.md index 31cf0679..9e75eb3b 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -156,6 +156,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta - `--background` defaults to quieter logging (`warn`) unless `--log-level` is set. - `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows). - Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`). +- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host. - On Linux, the app now defaults `safeStorage` to `gnome-libsecret` for encrypted token persistence. Launcher pass-through commands also support `--password-store=` and forward it to the app when present. Override with e.g. `--password-store=basic_text`. diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index c6102186..3fb69e7a 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -148,6 +148,50 @@ test('youtube playback launches overlay with app-owned youtube flow args', async assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true); }); +test('youtube app-owned playback disables mpv plugin auto-start', async () => { + const context = createContext(); + context.pluginRuntimeConfig = { + ...context.pluginRuntimeConfig, + autoStart: true, + autoStartVisibleOverlay: true, + autoStartPauseUntilReady: true, + }; + const receivedStartMpvOptions: Record[] = []; + + await runPlaybackCommandWithDeps(context, { + ensurePlaybackSetupReady: async () => {}, + chooseTarget: async () => ({ target: context.args.target, kind: 'url' }), + checkDependencies: () => {}, + registerCleanup: () => {}, + startMpv: async ( + _target, + _targetKind, + _args, + _socketPath, + _appPath, + _preloadedSubtitles, + options, + ) => { + if (options) { + receivedStartMpvOptions.push(options as Record); + } + }, + waitForUnixSocketReady: async () => true, + startOverlay: async () => {}, + launchAppCommandDetached: () => {}, + log: () => {}, + cleanupPlaybackSession: async () => {}, + getMpvProc: () => null, + }); + + const runtimeConfig = receivedStartMpvOptions[0]?.runtimePluginConfig as + | { autoStart?: boolean; autoStartVisibleOverlay?: boolean; autoStartPauseUntilReady?: boolean } + | undefined; + assert.equal(runtimeConfig?.autoStart, false); + assert.equal(runtimeConfig?.autoStartVisibleOverlay, false); + assert.equal(runtimeConfig?.autoStartPauseUntilReady, false); +}); + test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => { const context = createContext(); context.args = { diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index db216989..2b546fa3 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -258,6 +258,13 @@ export async function runPlaybackCommandWithDeps( runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }), runtimePluginConfig: { ...effectivePluginRuntimeConfig, + ...(isAppOwnedYoutubeFlow + ? { + autoStart: false, + autoStartVisibleOverlay: false, + autoStartPauseUntilReady: false, + } + : {}), backend: args.backend, texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled, }, diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index 47de9f81..194aec56 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -134,7 +134,10 @@ function M.create(ctx) elseif action_id == "copySubtitle" then return { "--copy-subtitle" } elseif action_id == "copySubtitleMultiple" then - return { "--copy-subtitle-count", tostring(payload and payload.count or 1) } + if payload and payload.count then + return { "--copy-subtitle-count", tostring(payload.count) } + end + return { "--copy-subtitle-multiple" } elseif action_id == "updateLastCardFromClipboard" then return { "--update-last-card-from-clipboard" } elseif action_id == "triggerFieldGrouping" then @@ -144,7 +147,10 @@ function M.create(ctx) elseif action_id == "mineSentence" then return { "--mine-sentence" } elseif action_id == "mineSentenceMultiple" then - return { "--mine-sentence-count", tostring(payload and payload.count or 1) } + if payload and payload.count then + return { "--mine-sentence-count", tostring(payload.count) } + end + return { "--mine-sentence-multiple" } elseif action_id == "toggleSecondarySub" then return { "--toggle-secondary-sub" } elseif action_id == "toggleSubtitleSidebar" then @@ -232,73 +238,6 @@ function M.create(ctx) end) end - local function clear_numeric_selection(show_cancelled) - if state.session_numeric_selection and state.session_numeric_selection.timeout then - state.session_numeric_selection.timeout:kill() - end - state.session_numeric_selection = nil - remove_binding_names(state.session_numeric_binding_names) - if show_cancelled then - show_osd("Cancelled") - end - end - - local function build_modifier_prefixes(modifiers) - local prefixes = { "" } - if type(modifiers) ~= "table" then - return prefixes - end - - for _, modifier in ipairs(modifiers) do - local mapped = MODIFIER_MAP[modifier] - if mapped then - local existing_count = #prefixes - for index = 1, existing_count do - prefixes[#prefixes + 1] = prefixes[index] .. mapped .. "+" - end - end - end - return prefixes - end - - local function start_numeric_selection(action_id, timeout_ms, starter_modifiers) - clear_numeric_selection(false) - local modifier_prefixes = build_modifier_prefixes(starter_modifiers) - for digit = 1, 9 do - local digit_string = tostring(digit) - for _, prefix in ipairs(modifier_prefixes) do - local key_name = prefix .. digit_string - local modifier_name = prefix:gsub("[^%w]", "-") - local name = "subminer-session-digit-" .. modifier_name .. digit_string - state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name - mp.add_forced_key_binding(key_name, name, function() - clear_numeric_selection(false) - invoke_cli_action(action_id, { count = digit }) - end) - end - end - - state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = - "subminer-session-digit-cancel" - mp.add_forced_key_binding("ESC", "subminer-session-digit-cancel", function() - clear_numeric_selection(true) - end) - - state.session_numeric_selection = { - action_id = action_id, - timeout = mp.add_timeout((timeout_ms or 3000) / 1000, function() - clear_numeric_selection(false) - show_osd(action_id == "copySubtitleMultiple" and "Copy timeout" or "Mine timeout") - end), - } - - show_osd( - action_id == "copySubtitleMultiple" - and "Copy how many lines? Press 1-9 (Esc to cancel)" - or "Mine how many lines? Press 1-9 (Esc to cancel)" - ) - end - local function execute_mpv_command(command) if type(command) ~= "table" or command[1] == nil then return @@ -306,17 +245,12 @@ function M.create(ctx) mp.commandv(unpack_fn(command)) end - local function handle_binding(binding, numeric_selection_timeout_ms) + local function handle_binding(binding) if binding.actionType == "mpv-command" then execute_mpv_command(binding.command) return end - if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then - start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers) - return - end - invoke_cli_action(binding.actionId, binding.payload) end @@ -339,7 +273,6 @@ function M.create(ctx) end local function clear_bindings() - clear_numeric_selection(false) remove_binding_names(state.session_binding_names) end @@ -350,21 +283,18 @@ function M.create(ctx) return false end - clear_numeric_selection(false) - local previous_binding_names = state.session_binding_names local next_binding_names = {} state.session_binding_generation = (state.session_binding_generation or 0) + 1 local generation = state.session_binding_generation - local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000 for index, binding in ipairs(artifact.bindings) do local key_name = key_spec_to_mpv_binding(binding.key) if key_name then local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index) next_binding_names[#next_binding_names + 1] = name mp.add_forced_key_binding(key_name, name, function() - handle_binding(binding, timeout_ms) + handle_binding(binding) end) else subminer_log( diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index 69d800e4..437cfa6a 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -351,21 +351,10 @@ assert_true( starter.fn() -local modified_digit = nil -for _, binding in ipairs(recorded.bindings) do - if binding.keys == "Ctrl+Shift+3" then - modified_digit = binding - break - end -end -assert_true(modified_digit ~= nil, "numeric selection should bind Ctrl+Shift+3") - -modified_digit.fn() - local call = recorded.async_calls[#recorded.async_calls] -assert_true(call ~= nil, "modified digit should invoke CLI action") +assert_true(call ~= nil, "multi-line shortcut should invoke CLI action") assert_true(call[1] == "/tmp/subminer", "CLI action should use configured binary") -assert_true(call[2] == "--mine-sentence-count", "CLI action should mine sentence count") -assert_true(call[3] == "3", "CLI action should pass selected count") +assert_true(call[2] == "--mine-sentence-multiple", "CLI action should enter mine sentence count selector") +assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count") print("plugin session binding regression tests: OK") diff --git a/src/anki-integration/animated-image-sync.test.ts b/src/anki-integration/animated-image-sync.test.ts index a34225fa..6f18cba5 100644 --- a/src/anki-integration/animated-image-sync.test.ts +++ b/src/anki-integration/animated-image-sync.test.ts @@ -50,7 +50,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for assert.equal(leadInSeconds, 1.25); }); -test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => { +test('resolveAnimatedImageLeadInSeconds does not double-count sentence audio padding', async () => { const leadInSeconds = await resolveAnimatedImageLeadInSeconds({ config: { fields: { @@ -87,7 +87,7 @@ test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audi logWarn: () => undefined, }); - assert.equal(leadInSeconds, 1.75); + assert.equal(leadInSeconds, 1.25); }); test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => { diff --git a/src/anki-integration/animated-image-sync.ts b/src/anki-integration/animated-image-sync.ts index 3931dfbc..25282873 100644 --- a/src/anki-integration/animated-image-sync.ts +++ b/src/anki-integration/animated-image-sync.ts @@ -39,14 +39,6 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick): number { - const configuredPadding = config.media?.audioPadding; - if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) { - return configuredPadding; - } - return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding; -} - export async function probeAudioDurationSeconds( buffer: Buffer, filename: string, @@ -135,5 +127,5 @@ export async function resolveAnimatedImageLeadInSeconds { + const audioPaths: string[] = []; + const imagePaths: string[] = []; + const edlSource = [ + 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', + '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', + '!global_tags,title=test', + ].join(';'); + + const { service, updatedFields, storedMedia } = createManualUpdateService({ + getConfig: () => + ({ + deck: 'Mining', + fields: { + word: 'Expression', + sentence: 'Sentence', + audio: 'ExpressionAudio', + image: 'Picture', + }, + media: { + generateAudio: true, + generateImage: true, + imageFormat: 'jpg', + maxMediaDuration: 30, + }, + behavior: { + overwriteAudio: false, + overwriteImage: false, + }, + ai: false, + }) as AnkiConnectConfig, + getTimingTracker: () => + ({ + findTiming: (text: string) => { + if (text === '一行目') return { startTime: 10, endTime: 12 }; + if (text === '二行目') return { startTime: 12.5, endTime: 14 }; + return null; + }, + }) as never, + getMpvClient: () => + ({ + currentVideoPath: 'https://www.youtube.com/watch?v=abc123', + currentTimePos: 13, + currentAudioStreamIndex: 0, + requestProperty: async (name: string) => { + assert.equal(name, 'stream-open-filename'); + return edlSource; + }, + }) as never, + client: { + addNote: async () => 0, + addTags: async () => undefined, + notesInfo: async () => [ + { + noteId: 42, + fields: { + Expression: { value: '単語' }, + Sentence: { value: '' }, + ExpressionAudio: { value: '[sound:auto-expression.mp3]' }, + SentenceAudio: { value: '[sound:auto-sentence.mp3]' }, + Picture: { value: '' }, + }, + }, + ], + updateNoteFields: async (_noteId, fields) => { + updatedFields.push(fields); + }, + storeMediaFile: async (filename) => { + storedMedia.push(filename); + }, + findNotes: async () => [42], + retrieveMediaFile: async () => '', + }, + mediaGenerator: { + generateAudio: async (path) => { + audioPaths.push(path); + return Buffer.from('audio'); + }, + generateScreenshot: async (path) => { + imagePaths.push(path); + return Buffer.from('image'); + }, + generateAnimatedImage: async () => null, + }, + }); + + await service.updateLastAddedFromClipboard('一行目\n\n二行目'); + + assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']); + assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']); + assert.equal(storedMedia.length, 2); + assert.equal(updatedFields.length, 1); + assert.equal(updatedFields[0]?.Sentence, '一行目 二行目'); + assert.match(updatedFields[0]?.Picture ?? '', /^$/); +}); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index a5349fd8..9de9bac0 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -237,14 +237,19 @@ export class CardCreationService { `Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`, ); + const audioSourcePath = this.deps.getConfig().media?.generateAudio + ? await resolveMediaGenerationInputPath(mpvClient, 'audio') + : null; + const videoPath = this.deps.getConfig().media?.generateImage + ? await resolveMediaGenerationInputPath(mpvClient, 'video') + : null; + if (this.deps.getConfig().media?.generateAudio) { try { const audioFilename = this.generateAudioFilename(); - const audioBuffer = await this.mediaGenerateAudio( - mpvClient.currentVideoPath, - rangeStart, - rangeEnd, - ); + const audioBuffer = audioSourcePath + ? await this.mediaGenerateAudio(audioSourcePath, rangeStart, rangeEnd) + : null; if (audioBuffer) { await this.deps.client.storeMediaFile(audioFilename, audioBuffer); @@ -271,12 +276,14 @@ export class CardCreationService { try { const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const imageFilename = this.generateImageFilename(); - const imageBuffer = await this.generateImageBuffer( - mpvClient.currentVideoPath, - rangeStart, - rangeEnd, - animatedLeadInSeconds, - ); + const imageBuffer = videoPath + ? await this.generateImageBuffer( + videoPath, + rangeStart, + rangeEnd, + animatedLeadInSeconds, + ) + : null; if (imageBuffer) { await this.deps.client.storeMediaFile(imageFilename, imageBuffer); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 4dcb29fc..51d6fe59 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -61,6 +61,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.texthooker.launchAtStartup, false); assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true); assert.deepEqual(config.ankiConnect.tags, ['SubMiner']); + assert.equal(config.ankiConnect.media.audioPadding, 0); assert.equal(config.anilist.enabled, false); assert.equal(config.anilist.characterDictionary.enabled, false); assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168); diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 4fc597c9..1815daec 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -51,7 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< animatedMaxHeight: 0, animatedCrf: 35, syncAnimatedImageToWordAudio: true, - audioPadding: 0.5, + audioPadding: 0, fallbackDuration: 3.0, maxMediaDuration: 30, }, diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts index 19f54d59..431a1332 100644 --- a/src/core/services/mining.test.ts +++ b/src/core/services/mining.test.ts @@ -6,6 +6,7 @@ import { handleMultiCopyDigit, mineSentenceCard, } from './mining'; +import { SubtitleTimingTracker } from '../../subtitle-timing-tracker'; test('copyCurrentSubtitle reports tracker and subtitle guards', () => { const osd: string[] = []; @@ -207,3 +208,76 @@ test('handleMineSentenceDigit increments successful card count', async () => { assert.equal(cardsMined, 1); }); + +test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => { + const created: Array<{ sentence: string; startTime: number; endTime: number }> = []; + const tracker = new SubtitleTimingTracker(); + + try { + tracker.recordSubtitle('same', 1, 2); + tracker.recordSubtitle('other', 3, 4); + tracker.recordSubtitle('same', 5, 6); + + handleMineSentenceDigit(3, { + subtitleTimingTracker: tracker, + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async (sentence, startTime, endTime) => { + created.push({ sentence, startTime, endTime }); + return true; + }, + }, + getCurrentSecondarySubText: () => undefined, + showMpvOsd: () => {}, + logError: () => {}, + }); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(created, [{ sentence: 'same other same', startTime: 1, endTime: 6 }]); + } finally { + tracker.destroy(); + } +}); + +test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => { + const created: Array<{ sentence: string; secondarySub?: string }> = []; + const tracker = new SubtitleTimingTracker(); + const recordSubtitleWithSecondary = tracker.recordSubtitle as ( + text: string, + startTime: number, + endTime: number, + secondaryText?: string, + ) => void; + + try { + recordSubtitleWithSecondary.call(tracker, 'one', 1, 2, 'translation one'); + recordSubtitleWithSecondary.call(tracker, 'two', 3, 4, 'translation two'); + + handleMineSentenceDigit(2, { + subtitleTimingTracker: tracker, + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => { + created.push({ sentence, secondarySub }); + return true; + }, + }, + getCurrentSecondarySubText: () => 'current translation only', + showMpvOsd: () => {}, + logError: () => {}, + }); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(created, [ + { sentence: 'one two', secondarySub: 'translation one translation two' }, + ]); + } finally { + tracker.destroy(); + } +}); diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts index da643f96..3baa7275 100644 --- a/src/core/services/mining.ts +++ b/src/core/services/mining.ts @@ -1,5 +1,8 @@ +import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker'; + interface SubtitleTimingTrackerLike { getRecentBlocks: (count: number) => string[]; + getRecentEntries?: (count: number) => SubtitleTimingBlock[]; getCurrentSubtitle: () => string | null; findTiming: (text: string) => { startTime: number; endTime: number } | null; } @@ -79,6 +82,19 @@ function requireAnkiIntegration( return ankiIntegration; } +function getSecondarySubTextForMinedBlocks( + entries: SubtitleTimingBlock[] | undefined, + getCurrentSecondarySubText: () => string | undefined, +): string | undefined { + const secondaryBlocks = entries + ?.map((entry) => entry.secondaryText?.trim()) + .filter((text): text is string => Boolean(text)); + if (secondaryBlocks && secondaryBlocks.length > 0) { + return secondaryBlocks.join(' '); + } + return getCurrentSecondarySubText(); +} + export async function updateLastCardFromClipboard(deps: { ankiIntegration: AnkiIntegrationLike | null; readClipboardText: () => string; @@ -146,17 +162,20 @@ export function handleMineSentenceDigit( ): void { if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return; - const blocks = deps.subtitleTimingTracker.getRecentBlocks(count); + const entries = deps.subtitleTimingTracker.getRecentEntries?.(count); + const blocks = + entries?.map((entry) => entry.displayText) ?? deps.subtitleTimingTracker.getRecentBlocks(count); if (blocks.length === 0) { deps.showMpvOsd('No subtitle history available'); return; } - const timings: { startTime: number; endTime: number }[] = []; - for (const block of blocks) { - const timing = deps.subtitleTimingTracker.findTiming(block); - if (timing) timings.push(timing); - } + const timings: { startTime: number; endTime: number }[] = + entries ?? + blocks.flatMap((block) => { + const timing = deps.subtitleTimingTracker?.findTiming(block); + return timing ? [timing] : []; + }); if (timings.length === 0) { deps.showMpvOsd('Subtitle timing not found'); @@ -166,9 +185,13 @@ export function handleMineSentenceDigit( const rangeStart = Math.min(...timings.map((t) => t.startTime)); const rangeEnd = Math.max(...timings.map((t) => t.endTime)); const sentence = blocks.join(' '); + const secondarySubText = getSecondarySubTextForMinedBlocks( + entries, + deps.getCurrentSecondarySubText, + ); const cardsToMine = 1; deps.ankiIntegration - .createSentenceCard(sentence, rangeStart, rangeEnd, deps.getCurrentSecondarySubText()) + .createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText) .then((created) => { if (created) { deps.onCardsMined?.(cardsToMine); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index ee9d9c63..c6aac6d9 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -843,7 +843,7 @@ export function createStatsApp( const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765'); const mediaGen = new MediaGenerator(); - const audioPadding = ankiConfig.media?.audioPadding ?? 0.5; + const audioPadding = ankiConfig.media?.audioPadding ?? 0; const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30; const startSec = startMs / 1000; diff --git a/src/main.ts b/src/main.ts index c0f51430..f9781bbb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -464,6 +464,7 @@ import { composeStartupLifecycleHandlers, } from './main/runtime/composers'; import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers'; +import { tryBeginVisibleOverlayNumericSelection } from './main/runtime/overlay-numeric-selection'; import { createStartupBootstrapRuntimeDeps } from './main/startup'; import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle'; import { @@ -547,7 +548,12 @@ import { createCreateJellyfinSetupWindowHandler, } from './main/runtime/setup-window-factory'; import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime'; -import { isYoutubePlaybackActive } from './main/runtime/youtube-playback'; +import { + isSameYoutubeMediaPath, + isYoutubeMediaPath, + isYoutubePlaybackActive, + shouldUseCachedYoutubeParsedCues, +} from './main/runtime/youtube-playback'; import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; @@ -988,8 +994,8 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({ refreshCurrentSubtitle: (text: string) => { subtitleProcessingController.refreshCurrentSubtitle(text); }, - refreshSubtitleSidebarSource: async (sourcePath: string) => { - await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath); + refreshSubtitleSidebarSource: async (sourcePath: string, mediaPath?: string) => { + await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath); }, startTokenizationWarmups: async () => { await startTokenizationWarmups(); @@ -1076,9 +1082,18 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({ }, showMpvOsd: (text: string) => showMpvOsd(text), reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message), + notifyPrimarySubtitleLoaded: () => + youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(), warn: (message: string) => logger.warn(message), log: (message: string) => logger.info(message), getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'), + createSubtitleTempDir: () => + fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-youtube-subtitles-')), + cleanupSubtitleTempDirs: (dirs) => { + for (const dir of dirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, }); const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({ requestPath: async () => { @@ -1545,6 +1560,20 @@ const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNo notifyFailure: (message) => reportYoutubeSubtitleFailure(message), schedule: (fn, delayMs) => setTimeout(fn, delayMs), clearSchedule: clearYoutubePrimarySubtitleNotificationTimer, + getCurrentSubtitleState: async () => { + const client = appState.mpvClient; + if (!client?.connected) { + return null; + } + const [sid, trackList] = await Promise.all([ + client.requestProperty('sid').catch(() => null), + client.requestProperty('track-list').catch(() => null), + ]); + return { + sid, + trackList: Array.isArray(trackList) ? trackList : null, + }; + }, }); function isYoutubePlaybackActiveNow(): boolean { @@ -1745,6 +1774,9 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ onParsedSubtitleCuesChanged: (cues, sourceKey) => { appState.activeParsedSubtitleCues = cues ?? []; appState.activeParsedSubtitleSource = sourceKey; + if (!cues?.length) { + appState.activeParsedSubtitleMediaPath = null; + } const mediaPath = getCurrentAutoplayMediaPath(); if (mediaPath && cues?.length) { void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => { @@ -1763,11 +1795,15 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), }); -async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise { +async function refreshSubtitleSidebarFromSource( + sourcePath: string, + mediaPath?: string, +): Promise { const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim()); if (!normalizedSourcePath) { return; } + appState.activeParsedSubtitleMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath(); await subtitlePrefetchInitController.initSubtitlePrefetch( normalizedSourcePath, lastObservedTimePos, @@ -1778,6 +1814,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler = createRefreshSubtitlePrefetchFromActiveTrackHandler({ getMpvClient: () => appState.mpvClient, getLastObservedTimePos: () => lastObservedTimePos, + shouldKeepExistingCuesOnMissingSource: (videoPath) => isYoutubeMediaPath(videoPath), subtitlePrefetchInitController, resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), }); @@ -1792,8 +1829,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { const subtitlePrefetchRuntime = { cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(), initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch, - refreshSubtitleSidebarFromSource: (sourcePath: string) => - refreshSubtitleSidebarFromSource(sourcePath), + refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) => + refreshSubtitleSidebarFromSource(sourcePath, mediaPath), refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs), clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(), @@ -3632,6 +3669,7 @@ const { appState.yomitanSettingsWindow = null; }, stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), + cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(), stopDiscordPresenceService: () => { void appState.discordPresenceService?.stop(); appState.discordPresenceService = null; @@ -4271,6 +4309,10 @@ const { updateCurrentMediaPath: (path) => { const normalizedPath = path.trim(); const previousPath = appState.currentMediaPath?.trim() || null; + const preserveParsedSubtitleCues = isSameYoutubeMediaPath( + normalizedPath, + appState.activeParsedSubtitleMediaPath, + ); if ((normalizedPath || null) !== previousPath) { const resetSubtitlePayload = { text: '', tokens: null }; const frequencyDictionary = getResolvedConfig().subtitleStyle.frequencyDictionary; @@ -4284,8 +4326,11 @@ const { appState.currentSubText = ''; appState.currentSubAssText = ''; appState.currentSubtitleData = null; - appState.activeParsedSubtitleCues = []; - appState.activeParsedSubtitleSource = null; + if (!preserveParsedSubtitleCues) { + appState.activeParsedSubtitleCues = []; + appState.activeParsedSubtitleSource = null; + appState.activeParsedSubtitleMediaPath = null; + } broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); @@ -4295,7 +4340,9 @@ const { managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); startupOsdSequencer.reset(); subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh(); - subtitlePrefetchRuntime.cancelPendingInit(); + if (!preserveParsedSubtitleCues) { + subtitlePrefetchRuntime.cancelPendingInit(); + } youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path); if (path) { ensureImmersionTrackerStarted(); @@ -4844,6 +4891,20 @@ const { numericSessions: { onMultiCopyDigit: (count) => handleMultiCopyDigit(count), onMineSentenceDigit: (count) => handleMineSentenceDigit(count), + tryBeginMultiCopyOverlaySelection: (timeoutMs) => + tryBeginVisibleOverlayNumericSelection({ + actionId: 'copySubtitleMultiple', + timeoutMs, + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }), + tryBeginMineSentenceOverlaySelection: (timeoutMs) => + tryBeginVisibleOverlayNumericSelection({ + actionId: 'mineSentenceMultiple', + timeoutMs, + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }), }, overlayShortcutsRuntimeMainDeps: { overlayShortcutsRuntime, @@ -5555,6 +5616,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ config, }; } + if ( + shouldUseCachedYoutubeParsedCues({ + videoPath, + cachedMediaPath: appState.activeParsedSubtitleMediaPath, + cachedCueCount: appState.activeParsedSubtitleCues.length, + }) + ) { + return { + cues: appState.activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({ currentExternalFilenameRaw, @@ -5586,6 +5661,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ const cues = parseSubtitleCues(content, resolvedSource.path); appState.activeParsedSubtitleCues = cues; appState.activeParsedSubtitleSource = resolvedSource.sourceKey; + appState.activeParsedSubtitleMediaPath = videoPath || null; return { cues, currentTimeSec, @@ -5793,6 +5869,11 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ startBackgroundWarmups: () => startBackgroundWarmups(), logInfo: (message: string) => logger.info(message), }, + ensureTrayForCommand: (args) => { + if (args.background || args.managedPlayback) { + ensureTray(); + } + }, handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) => handleCliCommandRuntimeServiceWithContext(args, source, cliContext), }, diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 2a7bc4b1..7946bf40 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -21,7 +21,7 @@ test('manual watched session action starts immersion tracker before marking watc ); }); -test('media path changes clear rendered subtitle state', () => { +test('media path changes clear rendered subtitle state without clearing same-youtube parsed cues', () => { const source = readMainSource(); const actionBlock = source.match( /updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/, @@ -31,8 +31,11 @@ test('media path changes clear rendered subtitle state', () => { assert.match(actionBlock, /appState\.currentSubText = '';/); assert.match(actionBlock, /appState\.currentSubAssText = '';/); assert.match(actionBlock, /appState\.currentSubtitleData = null;/); + assert.match(actionBlock, /isSameYoutubeMediaPath\(/); + assert.match(actionBlock, /if \(!preserveParsedSubtitleCues\)/); assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/); assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/); + assert.match(actionBlock, /appState\.activeParsedSubtitleMediaPath = null;/); assert.match(actionBlock, /lastObservedTimePos = 0;/); assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/); assert.match(actionBlock, /subtitleWsService\.broadcast\(/); @@ -52,3 +55,19 @@ test('main process uses one shared mpv plugin runtime config helper', () => { 0, ); }); + +test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active-source parsing', () => { + const source = readMainSource(); + const snapshotBlock = source.match( + /getSubtitleSidebarSnapshot:\s*async\s*\(\)\s*=>\s*\{(?[\s\S]*?const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler)/, + )?.groups?.body; + + assert.ok(snapshotBlock); + assert.match(snapshotBlock, /shouldUseCachedYoutubeParsedCues\(/); + assert.match(snapshotBlock, /cachedMediaPath:\s*appState\.activeParsedSubtitleMediaPath/); + assert.match(snapshotBlock, /cachedCueCount:\s*appState\.activeParsedSubtitleCues\.length/); + assert.ok( + snapshotBlock.indexOf('shouldUseCachedYoutubeParsedCues(') < + snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'), + ); +}); diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 9e858daf..eae95178 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -40,15 +40,17 @@ test('on will quit cleanup handler runs all cleanup steps', () => { destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'), clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), + cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); cleanup(); - assert.equal(calls.length, 30); + assert.equal(calls.length, 31); assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); + assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); }); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index cf9eb634..1978c571 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: { destroyYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void; stopJellyfinRemoteSession: () => void; + cleanupYoutubeSubtitleTempDirs: () => void; stopDiscordPresenceService: () => void; }) { return (): void => { @@ -60,6 +61,7 @@ export function createOnWillQuitCleanupHandler(deps: { deps.destroyYomitanSettingsWindow(); deps.clearYomitanSettingsWindow(); deps.stopJellyfinRemoteSession(); + deps.cleanupYoutubeSubtitleTempDirs(); deps.stopDiscordPresenceService(); }; } diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index 102fd992..0d57d6d9 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), + cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); @@ -89,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' assert.ok(calls.includes('destroy-first-run-window')); assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('stop-jellyfin-remote')); + assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.includes('stop-discord-presence')); assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); @@ -142,6 +144,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => { getYomitanSettingsWindow: () => null, clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: () => {}, + cleanupYoutubeSubtitleTempDirs: () => {}, stopDiscordPresenceService: () => {}, }); @@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () = getYomitanSettingsWindow: () => null, clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: () => {}, + cleanupYoutubeSubtitleTempDirs: () => {}, stopDiscordPresenceService: () => {}, }); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index 83943633..f6d42da2 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { clearYomitanSettingsWindow: () => void; stopJellyfinRemoteSession: () => void; + cleanupYoutubeSubtitleTempDirs: () => void; stopDiscordPresenceService: () => void; }) { return () => ({ @@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { }, clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), + cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), }); } diff --git a/src/main/runtime/cli-command-runtime-handler.test.ts b/src/main/runtime/cli-command-runtime-handler.test.ts index 960446b2..c5f28a90 100644 --- a/src/main/runtime/cli-command-runtime-handler.test.ts +++ b/src/main/runtime/cli-command-runtime-handler.test.ts @@ -83,3 +83,39 @@ test('cli command runtime handler skips generic overlay prerequisites for youtub assert.deepEqual(calls, ['context', 'cli:initial:ctx']); }); + +test('cli command runtime handler ensures tray for managed playback commands', () => { + const calls: string[] = []; + const handler = createCliCommandRuntimeHandler({ + handleTexthookerOnlyModeTransitionMainDeps: { + isTexthookerOnlyMode: () => false, + setTexthookerOnlyMode: () => calls.push('set-mode'), + commandNeedsOverlayStartupPrereqs: () => false, + ensureOverlayStartupPrereqs: () => calls.push('prereqs'), + startBackgroundWarmups: () => calls.push('warmups'), + logInfo: (message) => calls.push(`log:${message}`), + }, + ensureTrayForCommand: (args) => { + if (args.managedPlayback) { + calls.push('ensure-tray'); + } + }, + createCliCommandContext: () => { + calls.push('context'); + return { id: 'ctx' }; + }, + handleCliCommandRuntimeServiceWithContext: (_args, source, context) => { + calls.push(`cli:${source}:${context.id}`); + }, + }); + + handler( + { + managedPlayback: true, + youtubePlay: 'https://youtube.com/watch?v=abc', + } as never, + 'second-instance', + ); + + assert.deepEqual(calls, ['ensure-tray', 'context', 'cli:second-instance:ctx']); +}); diff --git a/src/main/runtime/cli-command-runtime-handler.ts b/src/main/runtime/cli-command-runtime-handler.ts index 8aa403f9..ed6d20b3 100644 --- a/src/main/runtime/cli-command-runtime-handler.ts +++ b/src/main/runtime/cli-command-runtime-handler.ts @@ -8,6 +8,7 @@ type HandleTexthookerOnlyModeTransitionMainDeps = Parameters< export function createCliCommandRuntimeHandler(deps: { handleTexthookerOnlyModeTransitionMainDeps: HandleTexthookerOnlyModeTransitionMainDeps; + ensureTrayForCommand?: (args: CliArgs, source: CliCommandSource) => void; createCliCommandContext: () => TCliContext; handleCliCommandRuntimeServiceWithContext: ( args: CliArgs, @@ -29,6 +30,7 @@ export function createCliCommandRuntimeHandler(deps: { ) { deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs(); } + deps.ensureTrayForCommand?.(args, source); const cliContext = deps.createCliCommandContext(); deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext); }; diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index fbb026ca..5bea06e4 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler getYomitanSettingsWindow: () => null, clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: async () => {}, + cleanupYoutubeSubtitleTempDirs: () => {}, stopDiscordPresenceService: () => {}, }, shouldRestoreWindowsOnActivateMainDeps: { diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index e1f33c9a..6eb6bb27 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -9,6 +9,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as overlayRuntimeInitialized: true, mpvClient: { connected: true, + currentSecondarySubText: 'secondary', currentTimePos: 12.25, requestProperty: async () => 18.75, }, @@ -20,7 +21,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`), }, subtitleTimingTracker: { - recordSubtitle: (text: string) => calls.push(`timing:${text}`), + recordSubtitle: (text: string, _start: number, _end: number, secondaryText?: string) => + calls.push(`timing:${text}:${secondaryText ?? ''}`), }, currentSubText: '', currentSubAssText: '', @@ -113,6 +115,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as assert.ok(calls.includes('remote-stopped')); assert.ok(calls.includes('sync-overlay-mpv-sub')); assert.ok(calls.includes('anilist-post-watch')); + assert.ok(calls.includes('timing:y:secondary')); assert.ok(calls.includes('ensure-immersion')); assert.ok(calls.includes('sync-immersion')); assert.ok(calls.includes('autoplay:/tmp/video')); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index c18c7a59..6515025f 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -32,7 +32,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { recordPauseState?: (paused: boolean) => void; } | null; subtitleTimingTracker: { - recordSubtitle?: (text: string, start: number, end: number) => void; + recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void; } | null; currentMediaPath?: string | null; currentSubText: string; @@ -132,7 +132,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { }, hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), recordSubtitleTiming: (text: string, start: number, end: number) => - deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end), + deps.appState.subtitleTimingTracker?.recordSubtitle?.( + text, + start, + end, + deps.appState.mpvClient?.currentSecondarySubText || undefined, + ), maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => deps.maybeRunAnilistPostWatchUpdate(options), logSubtitleTimingError: (message: string, error: unknown) => diff --git a/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts b/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts index 65f60137..f83230a9 100644 --- a/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts +++ b/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts @@ -33,3 +33,28 @@ test('numeric shortcut session runtime handlers compose cancel/start handlers', 'mine-sentence:digit:3', ]); }); + +test('numeric shortcut session runtime handlers prefer overlay digit selection when available', () => { + const calls: string[] = []; + const createSession = (name: string) => ({ + start: () => calls.push(`${name}:start`), + cancel: () => calls.push(`${name}:cancel`), + }); + + const runtime = createNumericShortcutSessionRuntimeHandlers({ + multiCopySession: createSession('multi-copy'), + mineSentenceSession: createSession('mine-sentence'), + onMultiCopyDigit: () => calls.push('multi-copy:digit'), + onMineSentenceDigit: () => calls.push('mine-sentence:digit'), + tryBeginMultiCopyOverlaySelection: (timeoutMs) => { + calls.push(`multi-copy:overlay:${timeoutMs}`); + return true; + }, + tryBeginMineSentenceOverlaySelection: () => false, + }); + + runtime.startPendingMultiCopy(500); + runtime.startPendingMineSentenceMultiple(700); + + assert.deepEqual(calls, ['multi-copy:overlay:500', 'mine-sentence:start']); +}); diff --git a/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts b/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts index 946ea77b..77a0932b 100644 --- a/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts +++ b/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts @@ -16,6 +16,8 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: { mineSentenceSession: CancelNumericShortcutSessionMainDeps['session']; onMultiCopyDigit: (count: number) => void; onMineSentenceDigit: (count: number) => void; + tryBeginMultiCopyOverlaySelection?: (timeoutMs: number) => boolean; + tryBeginMineSentenceOverlaySelection?: (timeoutMs: number) => boolean; }) { const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({ session: deps.multiCopySession, @@ -61,9 +63,14 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: { return { cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(), - startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopyHandler(timeoutMs), + startPendingMultiCopy: (timeoutMs: number) => { + if (deps.tryBeginMultiCopyOverlaySelection?.(timeoutMs)) return; + startPendingMultiCopyHandler(timeoutMs); + }, cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(), - startPendingMineSentenceMultiple: (timeoutMs: number) => - startPendingMineSentenceMultipleHandler(timeoutMs), + startPendingMineSentenceMultiple: (timeoutMs: number) => { + if (deps.tryBeginMineSentenceOverlaySelection?.(timeoutMs)) return; + startPendingMineSentenceMultipleHandler(timeoutMs); + }, }; } diff --git a/src/main/runtime/overlay-numeric-selection.test.ts b/src/main/runtime/overlay-numeric-selection.test.ts new file mode 100644 index 00000000..3332edf3 --- /dev/null +++ b/src/main/runtime/overlay-numeric-selection.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { tryBeginVisibleOverlayNumericSelection } from './overlay-numeric-selection'; + +function createWindowStub( + options: { + destroyed?: boolean; + visible?: boolean; + focused?: boolean; + webContentsFocused?: boolean; + } = {}, +) { + const calls: string[] = []; + return { + calls, + window: { + isDestroyed: () => options.destroyed === true, + isVisible: () => options.visible !== false, + isFocused: () => options.focused === true, + setIgnoreMouseEvents: (ignore: boolean) => { + calls.push(`mouse:${ignore}`); + }, + focus: () => { + calls.push('focus'); + }, + webContents: { + isFocused: () => options.webContentsFocused === true, + focus: () => { + calls.push('web-focus'); + }, + send: (channel: string, payload: unknown) => { + calls.push(`send:${channel}:${JSON.stringify(payload)}`); + }, + }, + }, + }; +} + +test('tryBeginVisibleOverlayNumericSelection focuses visible overlay and sends selector event', () => { + const { window, calls } = createWindowStub(); + + const handled = tryBeginVisibleOverlayNumericSelection({ + actionId: 'copySubtitleMultiple', + timeoutMs: 1234, + getMainWindow: () => window, + getVisibleOverlayVisible: () => true, + }); + + assert.equal(handled, true); + assert.deepEqual(calls, [ + 'mouse:false', + 'focus', + 'web-focus', + `send:${IPC_CHANNELS.event.sessionNumericSelectionStart}:{"actionId":"copySubtitleMultiple","timeoutMs":1234}`, + ]); +}); + +test('tryBeginVisibleOverlayNumericSelection skips hidden visible overlay', () => { + const { window, calls } = createWindowStub({ visible: false }); + + const handled = tryBeginVisibleOverlayNumericSelection({ + actionId: 'mineSentenceMultiple', + timeoutMs: 3000, + getMainWindow: () => window, + getVisibleOverlayVisible: () => true, + }); + + assert.equal(handled, false); + assert.deepEqual(calls, []); +}); diff --git a/src/main/runtime/overlay-numeric-selection.ts b/src/main/runtime/overlay-numeric-selection.ts new file mode 100644 index 00000000..19344e54 --- /dev/null +++ b/src/main/runtime/overlay-numeric-selection.ts @@ -0,0 +1,47 @@ +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import type { SessionNumericSelectionStartPayload } from '../../types/runtime'; + +type OverlayNumericSelectionWindow = { + isDestroyed: () => boolean; + isVisible: () => boolean; + isFocused?: () => boolean; + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; + focus: () => void; + webContents: { + isFocused?: () => boolean; + focus: () => void; + send: (channel: string, payload: SessionNumericSelectionStartPayload) => void; + }; +}; + +export function tryBeginVisibleOverlayNumericSelection(options: { + actionId: SessionNumericSelectionStartPayload['actionId']; + timeoutMs: number; + getMainWindow: () => OverlayNumericSelectionWindow | null; + getVisibleOverlayVisible: () => boolean; +}): boolean { + if (!options.getVisibleOverlayVisible()) { + return false; + } + + const mainWindow = options.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) { + return false; + } + + mainWindow.setIgnoreMouseEvents(false); + if (typeof mainWindow.isFocused !== 'function' || !mainWindow.isFocused()) { + mainWindow.focus(); + } + if ( + typeof mainWindow.webContents.isFocused !== 'function' || + !mainWindow.webContents.isFocused() + ) { + mainWindow.webContents.focus(); + } + mainWindow.webContents.send(IPC_CHANNELS.event.sessionNumericSelectionStart, { + actionId: options.actionId, + timeoutMs: options.timeoutMs, + }); + return true; +} diff --git a/src/main/runtime/subtitle-prefetch-runtime.test.ts b/src/main/runtime/subtitle-prefetch-runtime.test.ts index 07080896..0a022b1e 100644 --- a/src/main/runtime/subtitle-prefetch-runtime.test.ts +++ b/src/main/runtime/subtitle-prefetch-runtime.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createResolveActiveSubtitleSidebarSourceHandler } from './subtitle-prefetch-runtime'; +import { + createRefreshSubtitlePrefetchFromActiveTrackHandler, + createResolveActiveSubtitleSidebarSourceHandler, +} from './subtitle-prefetch-runtime'; test('subtitle prefetch runtime resolves direct external subtitle sources first', async () => { const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({ @@ -57,3 +60,43 @@ test('subtitle prefetch runtime extracts internal subtitle tracks into a stable cleanup: resolved?.cleanup, }); }); + +test('subtitle prefetch runtime preserves parsed cues when YouTube active track source is unresolved', async () => { + const calls: string[] = []; + const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'path') return 'https://www.youtube.com/watch?v=video123'; + if (name === 'track-list') { + return [ + { + type: 'sub', + id: 4, + lang: 'ja', + title: 'Japanese', + external: true, + }, + ]; + } + if (name === 'sid') return 4; + return null; + }, + }), + getLastObservedTimePos: () => 12, + subtitlePrefetchInitController: { + cancelPendingInit: () => { + calls.push('cancel'); + }, + initSubtitlePrefetch: async () => { + calls.push('init'); + }, + }, + resolveActiveSubtitleSidebarSource: async () => null, + shouldKeepExistingCuesOnMissingSource: (videoPath) => videoPath.includes('youtube.com'), + }); + + await refresh(); + + assert.deepEqual(calls, []); +}); diff --git a/src/main/runtime/subtitle-prefetch-runtime.ts b/src/main/runtime/subtitle-prefetch-runtime.ts index 27da41d4..99a113dc 100644 --- a/src/main/runtime/subtitle-prefetch-runtime.ts +++ b/src/main/runtime/subtitle-prefetch-runtime.ts @@ -126,6 +126,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: { requestProperty: (name: string) => Promise; } | null; getLastObservedTimePos: () => number; + shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean; subtitlePrefetchInitController: SubtitlePrefetchInitController; resolveActiveSubtitleSidebarSource: ( input: Parameters>[0], @@ -160,6 +161,9 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: { videoPath, }); if (!resolvedSource) { + if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) { + return; + } deps.subtitlePrefetchInitController.cancelPendingInit(); return; } diff --git a/src/main/runtime/tray-lifecycle.test.ts b/src/main/runtime/tray-lifecycle.test.ts index 1d6bbd9d..594e7efc 100644 --- a/src/main/runtime/tray-lifecycle.test.ts +++ b/src/main/runtime/tray-lifecycle.test.ts @@ -104,6 +104,55 @@ test('ensure tray creates new tray and binds click handler', () => { assert.ok(calls.includes('bind-click')); }); +test('ensure tray logs Linux tray registration failures without crashing startup', () => { + const calls: string[] = []; + let trayRef: unknown = null; + + const ensureTray = createEnsureTrayHandler({ + getTray: () => null, + setTray: (tray) => { + trayRef = tray; + calls.push('set-tray'); + }, + buildTrayMenu: () => ({ id: 'menu' }), + resolveTrayIconPath: () => '/tmp/icon.png', + createImageFromPath: () => + ({ + isEmpty: () => false, + resize: () => ({ + isEmpty: () => false, + resize: () => { + throw new Error('unexpected'); + }, + setTemplateImage: () => {}, + }), + setTemplateImage: () => {}, + }) as never, + createEmptyImage: () => + ({ + isEmpty: () => true, + resize: () => { + throw new Error('unexpected'); + }, + setTemplateImage: () => {}, + }) as never, + createTray: () => { + throw new Error('StatusNotifier watcher unavailable'); + }, + trayTooltip: 'SubMiner', + platform: 'linux', + logWarn: (message) => calls.push(`warn:${message}`), + ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'), + }); + + ensureTray(); + + assert.equal(trayRef, null); + assert.deepEqual(calls, [ + 'warn:Unable to create Linux tray icon. Ensure your desktop has a StatusNotifier/AppIndicator tray host. StatusNotifier watcher unavailable', + ]); +}); + test('destroy tray handler destroys active tray and clears ref', () => { const calls: string[] = []; let tray: { destroy: () => void } | null = { diff --git a/src/main/runtime/tray-lifecycle.ts b/src/main/runtime/tray-lifecycle.ts index e9b0b605..82fc2300 100644 --- a/src/main/runtime/tray-lifecycle.ts +++ b/src/main/runtime/tray-lifecycle.ts @@ -48,7 +48,20 @@ export function createEnsureTrayHandler(deps: { trayIcon = trayIcon.resize({ width: 20, height: 20 }); } - const tray = deps.createTray(trayIcon); + let tray: TrayLike; + try { + tray = deps.createTray(trayIcon); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + if (deps.platform === 'linux') { + deps.logWarn( + `Unable to create Linux tray icon. Ensure your desktop has a StatusNotifier/AppIndicator tray host. ${reason}`, + ); + } else { + deps.logWarn(`Unable to create tray icon. ${reason}`); + } + return; + } tray.setToolTip(deps.trayTooltip); tray.setContextMenu(deps.buildTrayMenu()); tray.on('click', () => { diff --git a/src/main/runtime/youtube-flow.test.ts b/src/main/runtime/youtube-flow.test.ts index 6e48b859..5150035a 100644 --- a/src/main/runtime/youtube-flow.test.ts +++ b/src/main/runtime/youtube-flow.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import path from 'node:path'; import test from 'node:test'; import { createYoutubeFlowRuntime } from './youtube-flow'; import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types'; @@ -306,6 +307,7 @@ test('youtube flow reports probe failure through the configured reporter in manu test('youtube flow does not report failure when subtitle track binds before cue text appears', async () => { const failures: string[] = []; + const loadedSignals: string[] = []; const runtime = createYoutubeFlowRuntime({ probeYoutubeTracks: async () => ({ @@ -358,6 +360,9 @@ test('youtube flow does not report failure when subtitle track binds before cue reportSubtitleFailure: (message) => { failures.push(message); }, + notifyPrimarySubtitleLoaded: () => { + loadedSignals.push('loaded'); + }, warn: (message) => { throw new Error(message); }, @@ -368,6 +373,7 @@ test('youtube flow does not report failure when subtitle track binds before cue await runtime.openManualPicker({ url: 'https://example.com' }); assert.deepEqual(failures, []); + assert.deepEqual(loadedSignals, ['loaded']); }); test('youtube flow does not fail when mpv reports sub-text as unavailable after track bind', async () => { @@ -781,11 +787,13 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af ); }); -test('youtube flow reuses existing manual youtube subtitle tracks when both requested languages already exist', async () => { +test('youtube flow injects downloaded primary while reusing existing manual secondary tracks', async () => { const commands: Array> = []; let selectedPrimarySid: number | null = null; let selectedSecondarySid: number | null = null; + let downloadedPrimaryAdded = false; const refreshedSidebarSources: string[] = []; + const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt'; const runtime = createYoutubeFlowRuntime({ probeYoutubeTracks: async () => ({ @@ -813,7 +821,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ }, acquireYoutubeSubtitleTrack: async ({ track }) => { if (track.language === 'ja') { - return { path: '/tmp/manual-ja.ja.srt' }; + return { path: downloadedPrimaryPath }; } throw new Error('should not download secondary track when manual english already exists'); }, @@ -832,6 +840,13 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ resumeMpv: () => {}, sendMpvCommand: (command) => { commands.push(command); + if ( + command[0] === 'sub-add' && + command[1] === downloadedPrimaryPath && + command[2] === 'select' + ) { + downloadedPrimaryAdded = true; + } if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') { selectedPrimarySid = command[2]; } @@ -853,7 +868,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ if (name === 'secondary-sid') { return selectedSecondarySid; } - return [ + const tracks: Array> = [ { type: 'sub', id: 1, @@ -887,6 +902,17 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ 'external-filename': null, }, ]; + if (downloadedPrimaryAdded) { + tracks.push({ + type: 'sub', + id: 9, + lang: 'ja', + title: path.basename(downloadedPrimaryPath), + external: true, + 'external-filename': downloadedPrimaryPath, + }); + } + return tracks; }, refreshCurrentSubtitle: () => {}, refreshSubtitleSidebarSource: async (sourcePath) => { @@ -912,24 +938,451 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ await runtime.openManualPicker({ url: 'https://example.com' }); - assert.equal(selectedPrimarySid, 2); + assert.equal(selectedPrimarySid, 9); assert.equal(selectedSecondarySid, 1); - assert.equal( - commands.some((command) => command[0] === 'sub-add'), - false, + assert.ok( + commands.some( + (command) => + command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select', + ), ); - assert.deepEqual(refreshedSidebarSources, ['/tmp/manual-ja.ja.srt']); + assert.deepEqual(refreshedSidebarSources, [downloadedPrimaryPath]); assert.equal( commands.some((command) => command[0] === 'sub-remove'), false, ); }); -test('youtube flow waits for manual youtube tracks to appear before falling back to injected copies', async () => { +test('youtube flow injects downloaded primary subtitles instead of reusing streamed youtube tracks', async () => { + const commands: Array> = []; + const refreshedSidebarSources: string[] = []; + let selectedPrimarySid: number | null = null; + let downloadedPrimaryAdded = false; + const downloadedPrimaryPath = '/tmp/subminer-youtube-subtitles-abc/manual-ja.ja.vtt'; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [ + { + ...primaryTrack, + id: 'manual:ja', + sourceLanguage: 'ja', + kind: 'manual', + title: 'Japanese', + }, + ], + }), + acquireYoutubeSubtitleTracks: async () => { + throw new Error('single primary selection should not batch download'); + }, + acquireYoutubeSubtitleTrack: async ({ track }) => { + assert.equal(track.id, 'manual:ja'); + return { path: downloadedPrimaryPath }; + }, + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: 'manual:ja', + secondaryTrackId: null, + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: (command) => { + commands.push(command); + if ( + command[0] === 'sub-add' && + command[1] === downloadedPrimaryPath && + command[2] === 'select' + ) { + downloadedPrimaryAdded = true; + } + if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') { + selectedPrimarySid = command[2]; + } + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + if (name === 'sid') { + return selectedPrimarySid; + } + return downloadedPrimaryAdded + ? [ + { + type: 'sub', + id: 2, + lang: 'ja', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/mpv-ytdl-track-ja.vtt', + }, + { + type: 'sub', + id: 9, + lang: 'ja', + title: path.basename(downloadedPrimaryPath), + external: true, + 'external-filename': downloadedPrimaryPath, + }, + ] + : [ + { + type: 'sub', + id: 2, + lang: 'ja', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/mpv-ytdl-track-ja.vtt', + }, + ]; + }, + refreshCurrentSubtitle: () => {}, + refreshSubtitleSidebarSource: async (sourcePath) => { + refreshedSidebarSources.push(sourcePath); + }, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: (message) => { + throw new Error(message); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' }); + + assert.equal(selectedPrimarySid, 9); + assert.deepEqual(refreshedSidebarSources, [downloadedPrimaryPath]); + assert.ok( + commands.some( + (command) => + command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select', + ), + ); +}); + +test('youtube flow confirms primary subtitle load before sidebar and tokenization waits', async () => { + const events: string[] = []; + let selectedPrimarySid: number | null = null; + let downloadedPrimaryAdded = false; + const downloadedPrimaryPath = '/tmp/subminer-youtube-subtitles-abc/auto-ja-orig.vtt'; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [primaryTrack], + }), + acquireYoutubeSubtitleTracks: async () => { + throw new Error('single primary selection should not batch download'); + }, + acquireYoutubeSubtitleTrack: async () => ({ path: downloadedPrimaryPath }), + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: primaryTrack.id, + secondaryTrackId: null, + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: (command) => { + if ( + command[0] === 'sub-add' && + command[1] === downloadedPrimaryPath && + command[2] === 'select' + ) { + downloadedPrimaryAdded = true; + } + if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') { + selectedPrimarySid = command[2]; + } + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + if (name === 'sid') { + return selectedPrimarySid; + } + return downloadedPrimaryAdded + ? [ + { + type: 'sub', + id: 9, + lang: 'ja-orig', + title: path.basename(downloadedPrimaryPath), + external: true, + 'external-filename': downloadedPrimaryPath, + }, + ] + : []; + }, + refreshCurrentSubtitle: () => {}, + refreshSubtitleSidebarSource: async () => { + events.push('sidebar'); + assert.ok( + events.includes('notify'), + 'primary load should be confirmed before sidebar parsing can delay', + ); + }, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => { + events.push('tokenization'); + assert.ok( + events.includes('notify'), + 'primary load should be confirmed before tokenization waits can delay', + ); + }, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: (message) => { + throw new Error(message); + }, + notifyPrimarySubtitleLoaded: () => { + events.push('notify'); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' }); + + assert.deepEqual(events, ['notify', 'sidebar', 'tokenization']); +}); + +test('youtube flow downloads subtitles into temporary dirs and exposes cleanup', async () => { + const outputDirs: string[] = []; + const cleanupCalls: string[][] = []; + let tempDirIndex = 0; + let selectedPrimarySid: number | null = null; + let addedSubtitlePath: string | null = null; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [primaryTrack], + }), + acquireYoutubeSubtitleTracks: async () => { + throw new Error('single primary selection should not batch download'); + }, + acquireYoutubeSubtitleTrack: async ({ outputDir }) => { + outputDirs.push(outputDir); + return { path: path.join(outputDir, 'auto-ja-orig.vtt') }; + }, + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: primaryTrack.id, + secondaryTrackId: null, + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: (command) => { + if (command[0] === 'sub-add' && typeof command[1] === 'string') { + addedSubtitlePath = command[1]; + } + if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') { + selectedPrimarySid = command[2]; + } + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + if (name === 'sid') { + return selectedPrimarySid; + } + return addedSubtitlePath + ? [ + { + type: 'sub', + id: 10 + tempDirIndex, + lang: 'ja-orig', + title: path.basename(addedSubtitlePath), + external: true, + 'external-filename': addedSubtitlePath, + }, + ] + : []; + }, + refreshCurrentSubtitle: () => {}, + refreshSubtitleSidebarSource: async () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: (message) => { + throw new Error(message); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp/unused-youtube-cache', + createSubtitleTempDir: async () => { + tempDirIndex += 1; + return `/tmp/subminer-youtube-subtitles-${tempDirIndex}`; + }, + cleanupSubtitleTempDirs: (dirs) => { + cleanupCalls.push([...dirs]); + }, + }); + + await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' }); + addedSubtitlePath = null; + selectedPrimarySid = null; + await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' }); + runtime.cleanupSubtitleTempDirs(); + runtime.cleanupSubtitleTempDirs(); + + assert.deepEqual(outputDirs, [ + '/tmp/subminer-youtube-subtitles-1', + '/tmp/subminer-youtube-subtitles-2', + ]); + assert.deepEqual(cleanupCalls, [ + ['/tmp/subminer-youtube-subtitles-1'], + ['/tmp/subminer-youtube-subtitles-2'], + ]); +}); + +test('youtube flow falls back to configured output dir when subtitle temp dir creation fails', async () => { + const outputDirs: string[] = []; + const warnings: string[] = []; + let selectedPrimarySid: number | null = null; + let addedSubtitlePath: string | null = null; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [primaryTrack], + }), + acquireYoutubeSubtitleTracks: async () => { + throw new Error('single primary selection should not batch download'); + }, + acquireYoutubeSubtitleTrack: async ({ outputDir }) => { + outputDirs.push(outputDir); + return { path: path.join(outputDir, 'auto-ja-orig.vtt') }; + }, + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: primaryTrack.id, + secondaryTrackId: null, + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: (command) => { + if (command[0] === 'sub-add' && typeof command[1] === 'string') { + addedSubtitlePath = command[1]; + } + if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') { + selectedPrimarySid = command[2]; + } + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + if (name === 'sid') { + return selectedPrimarySid; + } + return addedSubtitlePath + ? [ + { + type: 'sub', + id: 11, + lang: 'ja-orig', + title: path.basename(addedSubtitlePath), + external: true, + 'external-filename': addedSubtitlePath, + }, + ] + : []; + }, + refreshCurrentSubtitle: () => {}, + refreshSubtitleSidebarSource: async () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: (message) => { + throw new Error(message); + }, + warn: (message) => { + warnings.push(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp/youtube-cache', + createSubtitleTempDir: async () => { + throw new Error('tmp unavailable'); + }, + cleanupSubtitleTempDirs: () => {}, + }); + + await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' }); + + assert.deepEqual(outputDirs, ['/tmp/youtube-cache']); + assert.deepEqual(warnings, [ + 'Failed to create YouTube subtitle temp dir; using configured output dir: tmp unavailable', + ]); +}); + +test('youtube flow waits for manual secondary tracks while injecting downloaded primary', async () => { const commands: Array> = []; let selectedPrimarySid: number | null = null; let selectedSecondarySid: number | null = null; let trackListReads = 0; + let downloadedPrimaryAdded = false; + const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt'; const runtime = createYoutubeFlowRuntime({ probeYoutubeTracks: async () => ({ @@ -957,7 +1410,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back }, acquireYoutubeSubtitleTrack: async ({ track }) => { if (track.language === 'ja') { - return { path: '/tmp/manual-ja.ja.srt' }; + return { path: downloadedPrimaryPath }; } throw new Error('should not download secondary track when manual english appears in mpv'); }, @@ -976,6 +1429,13 @@ test('youtube flow waits for manual youtube tracks to appear before falling back resumeMpv: () => {}, sendMpvCommand: (command) => { commands.push(command); + if ( + command[0] === 'sub-add' && + command[1] === downloadedPrimaryPath && + command[2] === 'select' + ) { + downloadedPrimaryAdded = true; + } if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') { selectedPrimarySid = command[2]; } @@ -1001,7 +1461,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back if (trackListReads === 1) { return []; } - return [ + const tracks: Array> = [ { type: 'sub', id: 1, @@ -1035,6 +1495,17 @@ test('youtube flow waits for manual youtube tracks to appear before falling back 'external-filename': null, }, ]; + if (downloadedPrimaryAdded) { + tracks.push({ + type: 'sub', + id: 9, + lang: 'ja', + title: path.basename(downloadedPrimaryPath), + external: true, + 'external-filename': downloadedPrimaryPath, + }); + } + return tracks; }, refreshCurrentSubtitle: () => {}, startTokenizationWarmups: async () => {}, @@ -1057,18 +1528,22 @@ test('youtube flow waits for manual youtube tracks to appear before falling back await runtime.openManualPicker({ url: 'https://example.com' }); - assert.equal(selectedPrimarySid, 2); + assert.equal(selectedPrimarySid, 9); assert.equal(selectedSecondarySid, 1); - assert.equal( - commands.some((command) => command[0] === 'sub-add'), - false, + assert.ok( + commands.some( + (command) => + command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select', + ), ); }); -test('youtube flow reuses manual youtube tracks even when mpv exposes external filenames', async () => { +test('youtube flow injects downloaded primary even when reusable manual youtube tracks exist', async () => { const commands: Array> = []; let selectedPrimarySid: number | null = null; let selectedSecondarySid: number | null = null; + let downloadedPrimaryAdded = false; + const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt'; const runtime = createYoutubeFlowRuntime({ probeYoutubeTracks: async () => ({ @@ -1098,7 +1573,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f }, acquireYoutubeSubtitleTrack: async ({ track }) => { if (track.id === 'manual:ja') { - return { path: '/tmp/manual-ja.ja.srt' }; + return { path: downloadedPrimaryPath }; } throw new Error( 'should not download secondary track when existing manual english track is reusable', @@ -1109,6 +1584,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f resumeMpv: () => {}, sendMpvCommand: (command) => { commands.push(command); + if ( + command[0] === 'sub-add' && + command[1] === downloadedPrimaryPath && + command[2] === 'select' + ) { + downloadedPrimaryAdded = true; + } if (command[0] === 'set_property' && command[1] === 'sid') { selectedPrimarySid = Number(command[2]); } @@ -1118,7 +1600,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f }, requestMpvProperty: async (name) => { if (name === 'track-list') { - return [ + const tracks: Array> = [ { type: 'sub', id: 1, @@ -1144,6 +1626,17 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f 'external-filename': '/tmp/mpv-ytdl-track-ja-en.vtt', }, ]; + if (downloadedPrimaryAdded) { + tracks.push({ + type: 'sub', + id: 9, + lang: 'ja', + title: path.basename(downloadedPrimaryPath), + external: true, + 'external-filename': downloadedPrimaryPath, + }); + } + return tracks; } if (name === 'sid') { return selectedPrimarySid; @@ -1181,11 +1674,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f mode: 'download', }); - assert.equal(selectedPrimarySid, 2); + assert.equal(selectedPrimarySid, 9); assert.equal(selectedSecondarySid, 1); - assert.equal( - commands.some((command) => command[0] === 'sub-add'), - false, + assert.ok( + commands.some( + (command) => + command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select', + ), ); }); diff --git a/src/main/runtime/youtube-flow.ts b/src/main/runtime/youtube-flow.ts index d4f411e4..625619da 100644 --- a/src/main/runtime/youtube-flow.ts +++ b/src/main/runtime/youtube-flow.ts @@ -32,7 +32,7 @@ type YoutubeFlowDeps = { sendMpvCommand: (command: Array) => void; requestMpvProperty: (name: string) => Promise; refreshCurrentSubtitle: (text: string) => void; - refreshSubtitleSidebarSource?: (sourcePath: string) => Promise; + refreshSubtitleSidebarSource?: (sourcePath: string, mediaPath?: string) => Promise; startTokenizationWarmups: () => Promise; waitForTokenizationReady: () => Promise; waitForAnkiReady: () => Promise; @@ -42,9 +42,12 @@ type YoutubeFlowDeps = { focusOverlayWindow: () => void; showMpvOsd: (text: string) => void; reportSubtitleFailure: (message: string) => void; + notifyPrimarySubtitleLoaded?: () => void; warn: (message: string) => void; log: (message: string) => void; getYoutubeOutputDir: () => string; + createSubtitleTempDir?: () => Promise; + cleanupSubtitleTempDirs?: (dirs: string[]) => void; }; type YoutubeFlowSession = { @@ -349,7 +352,9 @@ async function injectDownloadedSubtitles( } let trackListRaw: unknown = await deps.requestMpvProperty('track-list'); - let primaryTrackId: number | null = primarySelection.existingTrackId; + let primaryTrackId: number | null = primarySelection.injectedPath + ? null + : primarySelection.existingTrackId; let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null; for (let attempt = 0; attempt < 12; attempt += 1) { if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) { @@ -423,6 +428,53 @@ async function injectDownloadedSubtitles( export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { let activeSession: YoutubeFlowSession | null = null; + const activeSubtitleTempDirs = new Set(); + + const cleanupSubtitleTempDirs = (): void => { + const dirs = [...activeSubtitleTempDirs]; + if (dirs.length === 0) { + return; + } + if (!deps.cleanupSubtitleTempDirs) { + activeSubtitleTempDirs.clear(); + return; + } + deps.cleanupSubtitleTempDirs(dirs); + for (const dir of dirs) { + activeSubtitleTempDirs.delete(dir); + } + }; + + const cleanupSubtitleTempDirsForNextLoad = (): void => { + try { + cleanupSubtitleTempDirs(); + } catch (error) { + deps.warn( + `Failed to cleanup YouTube subtitle temp files: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }; + + const prepareSubtitleOutputDir = async (fallbackOutputDir: string): Promise => { + if (!deps.createSubtitleTempDir || !deps.cleanupSubtitleTempDirs) { + return fallbackOutputDir; + } + cleanupSubtitleTempDirsForNextLoad(); + try { + const tempDir = await deps.createSubtitleTempDir(); + activeSubtitleTempDirs.add(tempDir); + return tempDir; + } catch (error) { + deps.warn( + `Failed to create YouTube subtitle temp dir; using configured output dir: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return fallbackOutputDir; + } + }; const acquireSelectedTracks = async (input: { targetUrl: string; @@ -567,6 +619,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { osdProgress.setMessage('Downloading subtitles...'); } try { + const outputDir = await prepareSubtitleOutputDir(input.outputDir); let initialTrackListRaw: unknown = null; let existingPrimaryTrackId: number | null = null; let existingSecondaryTrackId: number | null = null; @@ -602,19 +655,11 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { let primaryInjectedPath: string | null = null; let secondaryInjectedPath: string | null = null; - if (existingPrimaryTrackId !== null) { - primarySidebarPath = ( - await deps.acquireYoutubeSubtitleTrack({ - targetUrl: input.url, - outputDir: input.outputDir, - track: input.primaryTrack, - }) - ).path; - } else if (existingSecondaryTrackId !== null || !input.secondaryTrack) { + if (existingSecondaryTrackId !== null || !input.secondaryTrack) { primaryInjectedPath = ( await deps.acquireYoutubeSubtitleTrack({ targetUrl: input.url, - outputDir: input.outputDir, + outputDir, track: input.primaryTrack, }) ).path; @@ -622,7 +667,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { } else { const acquired = await acquireSelectedTracks({ targetUrl: input.url, - outputDir: input.outputDir, + outputDir, primaryTrack: input.primaryTrack, secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null, secondaryFailureLabel: input.secondaryFailureLabel, @@ -641,7 +686,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { secondaryInjectedPath = ( await deps.acquireYoutubeSubtitleTrack({ targetUrl: input.url, - outputDir: input.outputDir, + outputDir, track: input.secondaryTrack, }) ).path; @@ -685,8 +730,9 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { if (!refreshedActiveSubtitle) { return false; } + deps.notifyPrimarySubtitleLoaded?.(); try { - await deps.refreshSubtitleSidebarSource?.(primarySidebarPath); + await deps.refreshSubtitleSidebarSource?.(primarySidebarPath, input.url); } catch (error) { deps.warn( `Failed to refresh parsed subtitle cues for sidebar: ${ @@ -877,5 +923,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { resolveActivePicker, cancelActivePicker, hasActiveSession: () => Boolean(activeSession), + cleanupSubtitleTempDirs, }; } diff --git a/src/main/runtime/youtube-playback.test.ts b/src/main/runtime/youtube-playback.test.ts index e8b4fec6..c63f5691 100644 --- a/src/main/runtime/youtube-playback.test.ts +++ b/src/main/runtime/youtube-playback.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback'; +import { + isSameYoutubeMediaPath, + isYoutubeMediaPath, + isYoutubePlaybackActive, + shouldUseCachedYoutubeParsedCues, +} from './youtube-playback'; test('isYoutubeMediaPath detects youtube watch and short urls', () => { assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true); @@ -22,3 +27,49 @@ test('isYoutubePlaybackActive checks both current media and mpv video paths', () assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true); assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false); }); + +test('isSameYoutubeMediaPath matches equivalent youtube urls by video id', () => { + assert.equal( + isSameYoutubeMediaPath('https://www.youtube.com/watch?v=abc123&t=30', 'https://youtu.be/abc123'), + true, + ); + assert.equal( + isSameYoutubeMediaPath( + 'https://www.youtube.com/embed/abc123', + 'https://www.youtube-nocookie.com/embed/abc123', + ), + true, + ); + assert.equal( + isSameYoutubeMediaPath('https://www.youtube.com/watch?v=abc123', 'https://youtu.be/xyz789'), + false, + ); + assert.equal(isSameYoutubeMediaPath('/tmp/video.mkv', 'https://youtu.be/abc123'), false); +}); + +test('shouldUseCachedYoutubeParsedCues requires cached cues for the same youtube video', () => { + assert.equal( + shouldUseCachedYoutubeParsedCues({ + videoPath: 'https://www.youtube.com/watch?v=abc123&t=30', + cachedMediaPath: 'https://youtu.be/abc123', + cachedCueCount: 12, + }), + true, + ); + assert.equal( + shouldUseCachedYoutubeParsedCues({ + videoPath: 'https://www.youtube.com/watch?v=abc123', + cachedMediaPath: 'https://youtu.be/abc123', + cachedCueCount: 0, + }), + false, + ); + assert.equal( + shouldUseCachedYoutubeParsedCues({ + videoPath: 'https://www.youtube.com/watch?v=abc123', + cachedMediaPath: 'https://youtu.be/other', + cachedCueCount: 12, + }), + false, + ); +}); diff --git a/src/main/runtime/youtube-playback.ts b/src/main/runtime/youtube-playback.ts index 7e21ba55..55afadcf 100644 --- a/src/main/runtime/youtube-playback.ts +++ b/src/main/runtime/youtube-playback.ts @@ -10,6 +10,39 @@ function matchesYoutubeHost(hostname: string, expectedHost: string): boolean { return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`); } +function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null { + const normalized = trimToNull(mediaPath); + if (!normalized) { + return null; + } + + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + return null; + } + + const host = parsed.hostname.toLowerCase(); + if (matchesYoutubeHost(host, 'youtu.be')) { + return parsed.pathname.replace(/^\/+/, '').split('/')[0]?.trim() || null; + } + if ( + !matchesYoutubeHost(host, 'youtube.com') && + !matchesYoutubeHost(host, 'youtube-nocookie.com') + ) { + return null; + } + if (parsed.pathname === '/watch') { + return parsed.searchParams.get('v')?.trim() || null; + } + const pathSegments = parsed.pathname.replace(/^\/+/, '').split('/'); + if (pathSegments[0] === 'shorts' || pathSegments[0] === 'embed') { + return pathSegments[1]?.trim() || null; + } + return null; +} + export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean { const normalized = trimToNull(mediaPath); if (!normalized) { @@ -31,6 +64,26 @@ export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolea ); } +export function isSameYoutubeMediaPath( + left: string | null | undefined, + right: string | null | undefined, +): boolean { + const leftId = extractYoutubeVideoId(left); + const rightId = extractYoutubeVideoId(right); + return Boolean(leftId && rightId && leftId === rightId); +} + +export function shouldUseCachedYoutubeParsedCues(input: { + videoPath: string | null | undefined; + cachedMediaPath: string | null | undefined; + cachedCueCount: number; +}): boolean { + return ( + input.cachedCueCount > 0 && + isSameYoutubeMediaPath(input.videoPath, input.cachedMediaPath) + ); +} + export function isYoutubePlaybackActive( currentMediaPath: string | null | undefined, currentVideoPath: string | null | undefined, diff --git a/src/main/runtime/youtube-primary-subtitle-notification.test.ts b/src/main/runtime/youtube-primary-subtitle-notification.test.ts index 51a7d601..44f3a82c 100644 --- a/src/main/runtime/youtube-primary-subtitle-notification.test.ts +++ b/src/main/runtime/youtube-primary-subtitle-notification.test.ts @@ -7,9 +7,9 @@ import { function createTimerHarness() { let nextId = 1; - const timers = new Map void>(); + const timers = new Map void | Promise>(); return { - schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => { + schedule: (fn: () => void | Promise): YoutubePrimarySubtitleNotificationTimer => { const id = nextId++; timers.set(id, fn); return { id }; @@ -26,7 +26,14 @@ function createTimerHarness() { const pending = [...timers.values()]; timers.clear(); for (const fn of pending) { - fn(); + void fn(); + } + }, + runAllAsync: async () => { + const pending = [...timers.values()]; + timers.clear(); + for (const fn of pending) { + await fn(); } }, size: () => timers.size, @@ -195,3 +202,80 @@ test('notifier suppresses timer while app-owned youtube flow is still settling', 'Primary subtitle failed to download or load. Try again from the subtitle modal.', ]); }); + +test('notifier suppresses stale delayed failure after primary subtitle load is confirmed', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleSubtitleTrackChange(null); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 2, lang: 'en', title: 'English', external: true }, + ]); + runtime.markCurrentMediaPrimarySubtitleLoaded(); + + assert.equal(timers.size(), 0); + timers.runAll(); + assert.deepEqual(notifications, []); +}); + +test('notifier suppresses delayed failure when live mpv state has downloaded primary selected', async () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + let liveStateReads = 0; + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + getCurrentSubtitleState: async () => { + liveStateReads += 1; + return { + sid: 22, + trackList: [ + { + type: 'sub', + id: 1, + lang: 'en', + title: 'English', + external: true, + selected: true, + 'main-selection': 1, + }, + { + type: 'sub', + id: 22, + lang: 'ja', + title: 'manual-ja.ja.srt', + external: true, + selected: true, + 'main-selection': 0, + 'external-filename': '/tmp/subminer-youtube-subtitles-aahLWu/manual-ja.ja.srt', + }, + ], + }; + }, + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=uO2jfacqjYQ'); + runtime.handleSubtitleTrackChange(null); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 1, lang: 'en', title: 'English', external: true, selected: false }, + ]); + + assert.equal(timers.size(), 1); + await timers.runAllAsync(); + + assert.equal(liveStateReads, 1); + assert.deepEqual(notifications, []); +}); diff --git a/src/main/runtime/youtube-primary-subtitle-notification.ts b/src/main/runtime/youtube-primary-subtitle-notification.ts index df07e597..5c75664b 100644 --- a/src/main/runtime/youtube-primary-subtitle-notification.ts +++ b/src/main/runtime/youtube-primary-subtitle-notification.ts @@ -13,6 +13,11 @@ type SubtitleTrackEntry = { selected: boolean; }; +type CurrentSubtitleState = { + sid: unknown; + trackList: unknown[] | null; +}; + function parseTrackId(value: unknown): number | null { if (typeof value === 'number' && Number.isInteger(value)) { return value; @@ -101,8 +106,12 @@ function hasSelectedPrimarySubtitle( export function createYoutubePrimarySubtitleNotificationRuntime(deps: { getPrimarySubtitleLanguages: () => string[]; notifyFailure: (message: string) => void; - schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer; + schedule: ( + fn: () => void | Promise, + delayMs: number, + ) => YoutubePrimarySubtitleNotificationTimer; clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void; + getCurrentSubtitleState?: () => CurrentSubtitleState | null | Promise; delayMs?: number; }) { const delayMs = deps.delayMs ?? 5000; @@ -112,13 +121,35 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: { let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null; let lastReportedMediaPath: string | null = null; let appOwnedFlowInFlight = false; + let primarySubtitleLoadedForCurrentMedia = false; const clearPendingTimer = (): void => { deps.clearSchedule(pendingTimer); pendingTimer = null; }; - const maybeReportFailure = (): void => { + const refreshCurrentSubtitleState = async ( + preferredLanguages: Set, + ): Promise => { + const getCurrentSubtitleState = deps.getCurrentSubtitleState; + if (!getCurrentSubtitleState) { + return false; + } + let state: CurrentSubtitleState | null; + try { + state = await getCurrentSubtitleState(); + } catch { + state = null; + } + if (!state) { + return false; + } + currentSid = parseTrackId(state.sid); + currentTrackList = Array.isArray(state.trackList) ? state.trackList : null; + return hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages); + }; + + const maybeReportFailure = async (): Promise => { const mediaPath = currentMediaPath?.trim() || ''; if (!mediaPath || !isYoutubeMediaPath(mediaPath)) { return; @@ -126,13 +157,30 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: { if (lastReportedMediaPath === mediaPath) { return; } + if (appOwnedFlowInFlight) { + return; + } const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages()); if (preferredLanguages.size === 0) { return; } + if (primarySubtitleLoadedForCurrentMedia) { + return; + } if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) { return; } + if (deps.getCurrentSubtitleState && (await refreshCurrentSubtitleState(preferredLanguages))) { + clearPendingTimer(); + return; + } + if ( + currentMediaPath?.trim() !== mediaPath || + appOwnedFlowInFlight || + primarySubtitleLoadedForCurrentMedia + ) { + return; + } lastReportedMediaPath = mediaPath; deps.notifyFailure( 'Primary subtitle failed to download or load. Try again from the subtitle modal.', @@ -148,9 +196,12 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: { if (!mediaPath || !isYoutubeMediaPath(mediaPath)) { return; } - pendingTimer = deps.schedule(() => { + if (primarySubtitleLoadedForCurrentMedia) { + return; + } + pendingTimer = deps.schedule(async () => { pendingTimer = null; - maybeReportFailure(); + await maybeReportFailure(); }, delayMs); }; @@ -160,6 +211,7 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: { typeof path === 'string' && path.trim().length > 0 ? path.trim() : null; if (currentMediaPath !== normalizedPath) { lastReportedMediaPath = null; + primarySubtitleLoadedForCurrentMedia = false; } currentMediaPath = normalizedPath; currentSid = null; @@ -180,6 +232,14 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: { clearPendingTimer(); } }, + markCurrentMediaPrimarySubtitleLoaded: (): void => { + const mediaPath = currentMediaPath?.trim() || ''; + if (!mediaPath || !isYoutubeMediaPath(mediaPath)) { + return; + } + primarySubtitleLoadedForCurrentMedia = true; + clearPendingTimer(); + }, setAppOwnedFlowInFlight: (inFlight: boolean): void => { appOwnedFlowInFlight = inFlight; if (inFlight) { diff --git a/src/main/state.ts b/src/main/state.ts index 5771d981..34dcc707 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -163,6 +163,7 @@ export interface AppState { currentSubtitleData: SubtitleData | null; activeParsedSubtitleCues: SubtitleCue[]; activeParsedSubtitleSource: string | null; + activeParsedSubtitleMediaPath: string | null; windowTracker: BaseWindowTracker | null; subtitlePosition: SubtitlePosition | null; currentMediaPath: string | null; @@ -248,6 +249,7 @@ export function createAppState(values: AppStateInitialValues): AppState { currentSubtitleData: null, activeParsedSubtitleCues: [], activeParsedSubtitleSource: null, + activeParsedSubtitleMediaPath: null, windowTracker: null, subtitlePosition: null, currentMediaPath: null, diff --git a/src/media-generator.test.ts b/src/media-generator.test.ts index fb1cfab4..ef0b69b0 100644 --- a/src/media-generator.test.ts +++ b/src/media-generator.test.ts @@ -1,7 +1,60 @@ import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import test from 'node:test'; -import { buildAnimatedImageVideoFilter } from './media-generator'; +import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator'; + +async function withStubbedFfmpeg( + run: (generator: MediaGenerator, argsPath: string) => Promise, +): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-')); + const binDir = path.join(root, 'bin'); + const tempDir = path.join(root, 'media'); + const argsPath = path.join(root, 'ffmpeg-args.txt'); + fs.mkdirSync(binDir, { recursive: true }); + const ffmpegPath = path.join(binDir, 'ffmpeg'); + fs.writeFileSync( + ffmpegPath, + [ + '#!/bin/sh', + 'if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then', + ' echo " V..... libaom-av1"', + ' exit 0', + 'fi', + 'printf "%s\\n" "$@" > "$SUBMINER_TEST_FFMPEG_ARGS"', + 'out=""', + 'for arg in "$@"; do out="$arg"; done', + 'printf avif > "$out"', + ].join('\n'), + 'utf8', + ); + fs.chmodSync(ffmpegPath, 0o755); + + const originalPath = process.env.PATH; + const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS; + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; + process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath; + const generator = new MediaGenerator(tempDir); + + try { + await run(generator, argsPath); + } finally { + generator.cleanup(); + process.env.PATH = originalPath; + if (originalArgsPath === undefined) { + delete process.env.SUBMINER_TEST_FFMPEG_ARGS; + } else { + process.env.SUBMINER_TEST_FFMPEG_ARGS = originalArgsPath; + } + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function readFfmpegArgs(argsPath: string): string[] { + return fs.readFileSync(argsPath, 'utf8').trim().split('\n'); +} test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in is provided', () => { assert.equal( @@ -13,3 +66,89 @@ test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in i 'tpad=start_duration=1.25:start_mode=clone,fps=10,scale=w=640:h=-2', ); }); + +test('generateAnimatedImage freezes first frame for leading audio padding', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAnimatedImage('/video.mp4', 10, 12, 0.5, { + fps: 10, + maxWidth: 640, + }); + + const args = readFfmpegArgs(argsPath); + assert.equal(args[args.indexOf('-ss') + 1], '10'); + assert.equal(args[args.indexOf('-t') + 1], '2.5'); + assert.equal( + args[args.indexOf('-vf') + 1], + 'tpad=start_duration=0.5:start_mode=clone,fps=10,scale=w=640:h=-2', + ); + }); +}); + +test('generateAnimatedImage defaults to unpadded sentence timing', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAnimatedImage('/video.mp4', 10, 12, undefined, { + fps: 10, + maxWidth: 640, + }); + + const args = readFfmpegArgs(argsPath); + assert.equal(args[args.indexOf('-ss') + 1], '10'); + assert.equal(args[args.indexOf('-t') + 1], '2'); + assert.equal(args[args.indexOf('-vf') + 1], 'fps=10,scale=w=640:h=-2'); + }); +}); + +test('generateAnimatedImage adds audio lead padding to existing word-audio lead-in', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAnimatedImage('/video.mp4', 10, 12, 0.5, { + fps: 10, + maxWidth: 640, + leadingStillDuration: 1.25, + }); + + const args = readFfmpegArgs(argsPath); + assert.equal(args[args.indexOf('-ss') + 1], '10'); + assert.equal(args[args.indexOf('-t') + 1], '2.5'); + assert.equal( + args[args.indexOf('-vf') + 1], + 'tpad=start_duration=1.75:start_mode=clone,fps=10,scale=w=640:h=-2', + ); + }); +}); + +test('generateAnimatedImage clips leading audio padding at the start of media', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAnimatedImage('/video.mp4', 0.2, 1.2, 0.5, { + fps: 10, + maxWidth: 640, + }); + + const args = readFfmpegArgs(argsPath); + assert.equal(args[args.indexOf('-ss') + 1], '0.2'); + assert.equal(args[args.indexOf('-t') + 1], '1.5'); + assert.equal( + args[args.indexOf('-vf') + 1], + 'tpad=start_duration=0.2:start_mode=clone,fps=10,scale=w=640:h=-2', + ); + }); +}); + +test('generateAudio defaults to unpadded sentence timing', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAudio('/video.mp4', 10, 12); + + const args = readFfmpegArgs(argsPath); + assert.equal(args[args.indexOf('-ss') + 1], '10'); + assert.equal(args[args.indexOf('-t') + 1], '2'); + }); +}); + +test('generateAudio clips leading padding without adding it to trailing duration', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5); + + const args = readFfmpegArgs(argsPath); + assert.equal(args[args.indexOf('-ss') + 1], '0'); + assert.equal(args[args.indexOf('-t') + 1], '1.7'); + }); +}); diff --git a/src/media-generator.ts b/src/media-generator.ts index 479b98a3..38e15a56 100644 --- a/src/media-generator.ts +++ b/src/media-generator.ts @@ -158,11 +158,12 @@ export class MediaGenerator { videoPath: string, startTime: number, endTime: number, - padding: number = 0.5, + padding: number = 0, audioStreamIndex: number | null = null, ): Promise { - const start = Math.max(0, startTime - padding); - const duration = endTime - startTime + 2 * padding; + const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0; + const start = Math.max(0, startTime - safePadding); + const duration = endTime - start + safePadding; return new Promise((resolve, reject) => { const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`); @@ -310,7 +311,7 @@ export class MediaGenerator { videoPath: string, startTime: number, endTime: number, - padding: number = 0.5, + padding: number = 0, options: { fps?: number; maxWidth?: number; @@ -319,9 +320,12 @@ export class MediaGenerator { leadingStillDuration?: number; } = {}, ): Promise { - const start = Math.max(0, startTime - padding); - const duration = endTime - startTime + 2 * padding; const { fps = 10, maxWidth = 640, maxHeight, crf = 35, leadingStillDuration = 0 } = options; + const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0; + const start = Math.max(0, startTime); + const duration = endTime - startTime + safePadding; + const effectiveLeadingPadding = Math.min(safePadding, start); + const totalLeadingStillDuration = Math.max(0, leadingStillDuration) + effectiveLeadingPadding; const clampedCrf = Math.max(0, Math.min(63, crf)); @@ -359,7 +363,7 @@ export class MediaGenerator { fps, maxWidth, maxHeight, - leadingStillDuration, + leadingStillDuration: totalLeadingStillDuration, }), ...encoderArgs, '-y', diff --git a/src/preload.ts b/src/preload.ts index dce58d64..3622c31d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -54,6 +54,7 @@ import type { ControllerConfigUpdate, ControllerPreferenceUpdate, ResolvedControllerConfig, + SessionNumericSelectionStartPayload, YoutubePickerOpenPayload, YoutubePickerResolveRequest, YoutubePickerResolveResult, @@ -171,6 +172,11 @@ const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.pl const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener( IPC_CHANNELS.event.youtubePickerCancel, ); +const onSessionNumericSelectionStartEvent = + createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.sessionNumericSelectionStart, + (payload) => payload as SessionNumericSelectionStartPayload, + ); const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener( IPC_CHANNELS.event.keyboardModeToggleRequested, ); @@ -385,6 +391,7 @@ const electronAPI: ElectronAPI = { onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, + onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, appendClipboardVideoToQueue: (): Promise => diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 650eb8b0..e3b88908 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -670,6 +670,21 @@ test('numeric selection ignores non-digit keys instead of falling through to oth } }); +test('numeric selection start focuses overlay for follow-up digit keys', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.beginSessionNumericSelection('copySubtitleMultiple'); + + assert.equal(testGlobals.focusMainWindowCalls() > 0, true); + assert.equal(testGlobals.windowFocusCalls() > 0, true); + assert.equal(testGlobals.overlayFocusCalls.length > 0, true); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: left and right move token selection while popup remains open', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 585710e1..cefdd995 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -147,6 +147,7 @@ export function createKeyboardHandlers( function startPendingNumericSelection( actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple', + timeoutMs: number = ctx.state.sessionActionTimeoutMs, ): void { cancelPendingNumericSelection(false); const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout'; @@ -159,15 +160,17 @@ export function createKeyboardHandlers( timeout: setTimeout(() => { pendingNumericSelection = null; showSessionSelectionMessage(timeoutMessage); - }, ctx.state.sessionActionTimeoutMs), + }, timeoutMs), }; showSessionSelectionMessage(promptMessage); } function beginSessionNumericSelection( actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple', + timeoutMs?: number, ): void { - startPendingNumericSelection(actionId); + startPendingNumericSelection(actionId, timeoutMs); + restoreOverlayKeyboardFocus(); } function handlePendingNumericSelection(e: KeyboardEvent): boolean { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 380e3dea..84cc5e1c 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -530,6 +530,12 @@ function registerModalOpenHandlers(): void { } function registerKeyboardCommandHandlers(): void { + window.electronAPI.onSessionNumericSelectionStart((payload) => { + runGuarded('session:numeric-selection-start', () => { + keyboardHandlers.beginSessionNumericSelection(payload.actionId, payload.timeoutMs); + }); + }); + window.electronAPI.onKeyboardModeToggleRequested(() => { runGuarded('keyboard-mode-toggle:requested', () => { keyboardHandlers.handleKeyboardModeToggleRequested(); diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 816dda44..3f880cc4 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -123,6 +123,7 @@ export const IPC_CHANNELS = { youtubePickerOpen: 'youtube:picker-open', youtubePickerCancel: 'youtube:picker-cancel', playlistBrowserOpen: 'playlist-browser:open', + sessionNumericSelectionStart: 'session:numeric-selection-start', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested', sessionHelpOpen: 'session-help:open', diff --git a/src/subtitle-timing-tracker.ts b/src/subtitle-timing-tracker.ts index 22d475ac..21736130 100644 --- a/src/subtitle-timing-tracker.ts +++ b/src/subtitle-timing-tracker.ts @@ -27,9 +27,17 @@ interface HistoryEntry { timingKey: string; startTime: number; endTime: number; + secondaryText?: string; timestamp: number; } +export interface SubtitleTimingBlock { + displayText: string; + startTime: number; + endTime: number; + secondaryText?: string; +} + export class SubtitleTimingTracker { private timings = new Map(); private history: HistoryEntry[] = []; @@ -41,11 +49,12 @@ export class SubtitleTimingTracker { this.startCleanup(); } - recordSubtitle(text: string, startTime: number, endTime: number): void { + recordSubtitle(text: string, startTime: number, endTime: number, secondaryText?: string): void { const normalizedText = this.normalizeText(text); if (!normalizedText) return; const displayText = this.prepareDisplayText(text); + const displaySecondaryText = secondaryText ? this.prepareDisplayText(secondaryText) : undefined; const timingKey = normalizedText; this.timings.set(timingKey, { @@ -60,6 +69,7 @@ export class SubtitleTimingTracker { // Update timing to most recent occurrence lastEntry.startTime = startTime; lastEntry.endTime = endTime; + lastEntry.secondaryText = displaySecondaryText; lastEntry.timestamp = Date.now(); return; } @@ -69,6 +79,7 @@ export class SubtitleTimingTracker { timingKey, startTime, endTime, + secondaryText: displaySecondaryText, timestamp: Date.now(), }); @@ -106,6 +117,23 @@ export class SubtitleTimingTracker { return this.history.slice(-count).map((entry) => entry.displayText); } + /** + * Get recent subtitle blocks with their original event timings. + * Returns the last `count` subtitle events (oldest → newest). + */ + getRecentEntries(count: number): SubtitleTimingBlock[] { + if (count <= 0) return []; + if (count > this.history.length) { + count = this.history.length; + } + return this.history.slice(-count).map((entry) => ({ + displayText: entry.displayText, + startTime: entry.startTime, + endTime: entry.endTime, + secondaryText: entry.secondaryText, + })); + } + /** * Get display text for the most recent subtitle. */ diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 188cef24..4a1bb0ae 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -378,6 +378,11 @@ export interface CharacterDictionarySelectionResult { staleMediaIds: number[]; } +export interface SessionNumericSelectionStartPayload { + actionId: Extract; + timeoutMs: number; +} + export interface ElectronAPI { getOverlayLayer: () => 'visible' | 'modal' | null; onSubtitle: (callback: (data: SubtitleData) => void) => void; @@ -451,6 +456,9 @@ export interface ElectronAPI { onSubtitleSidebarToggle: (callback: () => void) => void; onPrimarySubtitleBarToggle: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void; + onSessionNumericSelectionStart: ( + callback: (payload: SessionNumericSelectionStartPayload) => void, + ) => void; onKeyboardModeToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void; appendClipboardVideoToQueue: () => Promise;