mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix: delegate multi-line digit selection to visible overlay (#78)
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -515,7 +515,7 @@
|
|||||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
"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.
|
"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
|
"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.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
|
|||||||
@@ -154,13 +154,13 @@ SubMiner uses FFmpeg to generate audio and image media from the video. FFmpeg mu
|
|||||||
|
|
||||||
### Audio
|
### 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
|
```jsonc
|
||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true,
|
"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
|
"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",
|
"imageType": "static",
|
||||||
"imageFormat": "jpg",
|
"imageFormat": "jpg",
|
||||||
"imageQuality": 92,
|
"imageQuality": 92,
|
||||||
"audioPadding": 0.5,
|
"audioPadding": 0,
|
||||||
"maxMediaDuration": 30,
|
"maxMediaDuration": 30,
|
||||||
},
|
},
|
||||||
"behavior": {
|
"behavior": {
|
||||||
|
|||||||
@@ -922,7 +922,7 @@ Enable automatic Anki card creation and updates with media generation:
|
|||||||
"animatedMaxWidth": 640,
|
"animatedMaxWidth": 640,
|
||||||
"animatedMaxHeight": 360,
|
"animatedMaxHeight": 360,
|
||||||
"animatedCrf": 35,
|
"animatedCrf": 35,
|
||||||
"audioPadding": 0.5,
|
"audioPadding": 0,
|
||||||
"fallbackDuration": 3,
|
"fallbackDuration": 3,
|
||||||
"maxMediaDuration": 30
|
"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.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||||
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
|
| `media.audioPadding` | number (seconds) | 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.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
| `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`) |
|
||||||
|
|||||||
@@ -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).
|
- **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:
|
4. SubMiner updates the card with:
|
||||||
- **Sentence**: The current subtitle line.
|
- **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.
|
- **Image**: A screenshot or animated clip from the current playback position.
|
||||||
- **Translation**: From the secondary subtitle track, or generated via AI if configured.
|
- **Translation**: From the secondary subtitle track, or generated via AI if configured.
|
||||||
- **MiscInfo**: Metadata like filename and timestamp.
|
- **MiscInfo**: Metadata like filename and timestamp.
|
||||||
|
|||||||
@@ -515,7 +515,7 @@
|
|||||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
"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.
|
"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
|
"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.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
|
|||||||
@@ -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+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` |
|
||||||
| `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` |
|
| `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
|
## Overlay Controls
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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
|
### Hyprland
|
||||||
|
|||||||
@@ -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` 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).
|
- `--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`).
|
- 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.
|
- On Linux, the app now defaults `safeStorage` to `gnome-libsecret` for encrypted token persistence.
|
||||||
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
||||||
Override with e.g. `--password-store=basic_text`.
|
Override with e.g. `--password-store=basic_text`.
|
||||||
|
|||||||
@@ -148,6 +148,50 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
|||||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
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<string, unknown>[] = [];
|
||||||
|
|
||||||
|
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<string, unknown>);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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 () => {
|
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args = {
|
context.args = {
|
||||||
|
|||||||
@@ -258,6 +258,13 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||||
runtimePluginConfig: {
|
runtimePluginConfig: {
|
||||||
...effectivePluginRuntimeConfig,
|
...effectivePluginRuntimeConfig,
|
||||||
|
...(isAppOwnedYoutubeFlow
|
||||||
|
? {
|
||||||
|
autoStart: false,
|
||||||
|
autoStartVisibleOverlay: false,
|
||||||
|
autoStartPauseUntilReady: false,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
backend: args.backend,
|
backend: args.backend,
|
||||||
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -134,7 +134,10 @@ function M.create(ctx)
|
|||||||
elseif action_id == "copySubtitle" then
|
elseif action_id == "copySubtitle" then
|
||||||
return { "--copy-subtitle" }
|
return { "--copy-subtitle" }
|
||||||
elseif action_id == "copySubtitleMultiple" then
|
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
|
elseif action_id == "updateLastCardFromClipboard" then
|
||||||
return { "--update-last-card-from-clipboard" }
|
return { "--update-last-card-from-clipboard" }
|
||||||
elseif action_id == "triggerFieldGrouping" then
|
elseif action_id == "triggerFieldGrouping" then
|
||||||
@@ -144,7 +147,10 @@ function M.create(ctx)
|
|||||||
elseif action_id == "mineSentence" then
|
elseif action_id == "mineSentence" then
|
||||||
return { "--mine-sentence" }
|
return { "--mine-sentence" }
|
||||||
elseif action_id == "mineSentenceMultiple" then
|
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
|
elseif action_id == "toggleSecondarySub" then
|
||||||
return { "--toggle-secondary-sub" }
|
return { "--toggle-secondary-sub" }
|
||||||
elseif action_id == "toggleSubtitleSidebar" then
|
elseif action_id == "toggleSubtitleSidebar" then
|
||||||
@@ -232,73 +238,6 @@ function M.create(ctx)
|
|||||||
end)
|
end)
|
||||||
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)
|
local function execute_mpv_command(command)
|
||||||
if type(command) ~= "table" or command[1] == nil then
|
if type(command) ~= "table" or command[1] == nil then
|
||||||
return
|
return
|
||||||
@@ -306,17 +245,12 @@ function M.create(ctx)
|
|||||||
mp.commandv(unpack_fn(command))
|
mp.commandv(unpack_fn(command))
|
||||||
end
|
end
|
||||||
|
|
||||||
local function handle_binding(binding, numeric_selection_timeout_ms)
|
local function handle_binding(binding)
|
||||||
if binding.actionType == "mpv-command" then
|
if binding.actionType == "mpv-command" then
|
||||||
execute_mpv_command(binding.command)
|
execute_mpv_command(binding.command)
|
||||||
return
|
return
|
||||||
end
|
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)
|
invoke_cli_action(binding.actionId, binding.payload)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -339,7 +273,6 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function clear_bindings()
|
local function clear_bindings()
|
||||||
clear_numeric_selection(false)
|
|
||||||
remove_binding_names(state.session_binding_names)
|
remove_binding_names(state.session_binding_names)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -350,21 +283,18 @@ function M.create(ctx)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
clear_numeric_selection(false)
|
|
||||||
|
|
||||||
local previous_binding_names = state.session_binding_names
|
local previous_binding_names = state.session_binding_names
|
||||||
local next_binding_names = {}
|
local next_binding_names = {}
|
||||||
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
||||||
local generation = state.session_binding_generation
|
local generation = state.session_binding_generation
|
||||||
|
|
||||||
local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000
|
|
||||||
for index, binding in ipairs(artifact.bindings) do
|
for index, binding in ipairs(artifact.bindings) do
|
||||||
local key_name = key_spec_to_mpv_binding(binding.key)
|
local key_name = key_spec_to_mpv_binding(binding.key)
|
||||||
if key_name then
|
if key_name then
|
||||||
local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index)
|
local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index)
|
||||||
next_binding_names[#next_binding_names + 1] = name
|
next_binding_names[#next_binding_names + 1] = name
|
||||||
mp.add_forced_key_binding(key_name, name, function()
|
mp.add_forced_key_binding(key_name, name, function()
|
||||||
handle_binding(binding, timeout_ms)
|
handle_binding(binding)
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
subminer_log(
|
subminer_log(
|
||||||
|
|||||||
@@ -351,21 +351,10 @@ assert_true(
|
|||||||
|
|
||||||
starter.fn()
|
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]
|
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[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[2] == "--mine-sentence-multiple", "CLI action should enter mine sentence count selector")
|
||||||
assert_true(call[3] == "3", "CLI action should pass selected count")
|
assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
|
||||||
|
|
||||||
print("plugin session binding regression tests: OK")
|
print("plugin session binding regression tests: OK")
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
|
|||||||
assert.equal(leadInSeconds, 1.25);
|
assert.equal(leadInSeconds, 1.25);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => {
|
test('resolveAnimatedImageLeadInSeconds does not double-count sentence audio padding', async () => {
|
||||||
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
|
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
|
||||||
config: {
|
config: {
|
||||||
fields: {
|
fields: {
|
||||||
@@ -87,7 +87,7 @@ test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audi
|
|||||||
logWarn: () => undefined,
|
logWarn: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(leadInSeconds, 1.75);
|
assert.equal(leadInSeconds, 1.25);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
|
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
|
||||||
|
|||||||
@@ -39,14 +39,6 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
|
|||||||
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
|
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): number {
|
|
||||||
const configuredPadding = config.media?.audioPadding;
|
|
||||||
if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) {
|
|
||||||
return configuredPadding;
|
|
||||||
}
|
|
||||||
return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function probeAudioDurationSeconds(
|
export async function probeAudioDurationSeconds(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
filename: string,
|
filename: string,
|
||||||
@@ -135,5 +127,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
|
|||||||
totalLeadInSeconds += durationSeconds;
|
totalLeadInSeconds += durationSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
|
return totalLeadInSeconds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,3 +175,99 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
|||||||
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
|
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
|
||||||
assert.equal(mergeCalls.length, 0);
|
assert.equal(mergeCalls.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||||
|
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 ?? '', /^<img src="image_\d+\.jpg">$/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -237,14 +237,19 @@ export class CardCreationService {
|
|||||||
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
`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) {
|
if (this.deps.getConfig().media?.generateAudio) {
|
||||||
try {
|
try {
|
||||||
const audioFilename = this.generateAudioFilename();
|
const audioFilename = this.generateAudioFilename();
|
||||||
const audioBuffer = await this.mediaGenerateAudio(
|
const audioBuffer = audioSourcePath
|
||||||
mpvClient.currentVideoPath,
|
? await this.mediaGenerateAudio(audioSourcePath, rangeStart, rangeEnd)
|
||||||
rangeStart,
|
: null;
|
||||||
rangeEnd,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||||
@@ -271,12 +276,14 @@ export class CardCreationService {
|
|||||||
try {
|
try {
|
||||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||||
const imageFilename = this.generateImageFilename();
|
const imageFilename = this.generateImageFilename();
|
||||||
const imageBuffer = await this.generateImageBuffer(
|
const imageBuffer = videoPath
|
||||||
mpvClient.currentVideoPath,
|
? await this.generateImageBuffer(
|
||||||
rangeStart,
|
videoPath,
|
||||||
rangeEnd,
|
rangeStart,
|
||||||
animatedLeadInSeconds,
|
rangeEnd,
|
||||||
);
|
animatedLeadInSeconds,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (imageBuffer) {
|
if (imageBuffer) {
|
||||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.texthooker.launchAtStartup, false);
|
assert.equal(config.texthooker.launchAtStartup, false);
|
||||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
||||||
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
||||||
|
assert.equal(config.ankiConnect.media.audioPadding, 0);
|
||||||
assert.equal(config.anilist.enabled, false);
|
assert.equal(config.anilist.enabled, false);
|
||||||
assert.equal(config.anilist.characterDictionary.enabled, false);
|
assert.equal(config.anilist.characterDictionary.enabled, false);
|
||||||
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
animatedMaxHeight: 0,
|
animatedMaxHeight: 0,
|
||||||
animatedCrf: 35,
|
animatedCrf: 35,
|
||||||
syncAnimatedImageToWordAudio: true,
|
syncAnimatedImageToWordAudio: true,
|
||||||
audioPadding: 0.5,
|
audioPadding: 0,
|
||||||
fallbackDuration: 3.0,
|
fallbackDuration: 3.0,
|
||||||
maxMediaDuration: 30,
|
maxMediaDuration: 30,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
handleMultiCopyDigit,
|
handleMultiCopyDigit,
|
||||||
mineSentenceCard,
|
mineSentenceCard,
|
||||||
} from './mining';
|
} from './mining';
|
||||||
|
import { SubtitleTimingTracker } from '../../subtitle-timing-tracker';
|
||||||
|
|
||||||
test('copyCurrentSubtitle reports tracker and subtitle guards', () => {
|
test('copyCurrentSubtitle reports tracker and subtitle guards', () => {
|
||||||
const osd: string[] = [];
|
const osd: string[] = [];
|
||||||
@@ -207,3 +208,76 @@ test('handleMineSentenceDigit increments successful card count', async () => {
|
|||||||
|
|
||||||
assert.equal(cardsMined, 1);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
||||||
|
|
||||||
interface SubtitleTimingTrackerLike {
|
interface SubtitleTimingTrackerLike {
|
||||||
getRecentBlocks: (count: number) => string[];
|
getRecentBlocks: (count: number) => string[];
|
||||||
|
getRecentEntries?: (count: number) => SubtitleTimingBlock[];
|
||||||
getCurrentSubtitle: () => string | null;
|
getCurrentSubtitle: () => string | null;
|
||||||
findTiming: (text: string) => { startTime: number; endTime: number } | null;
|
findTiming: (text: string) => { startTime: number; endTime: number } | null;
|
||||||
}
|
}
|
||||||
@@ -79,6 +82,19 @@ function requireAnkiIntegration(
|
|||||||
return ankiIntegration;
|
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: {
|
export async function updateLastCardFromClipboard(deps: {
|
||||||
ankiIntegration: AnkiIntegrationLike | null;
|
ankiIntegration: AnkiIntegrationLike | null;
|
||||||
readClipboardText: () => string;
|
readClipboardText: () => string;
|
||||||
@@ -146,17 +162,20 @@ export function handleMineSentenceDigit(
|
|||||||
): void {
|
): void {
|
||||||
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
|
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) {
|
if (blocks.length === 0) {
|
||||||
deps.showMpvOsd('No subtitle history available');
|
deps.showMpvOsd('No subtitle history available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timings: { startTime: number; endTime: number }[] = [];
|
const timings: { startTime: number; endTime: number }[] =
|
||||||
for (const block of blocks) {
|
entries ??
|
||||||
const timing = deps.subtitleTimingTracker.findTiming(block);
|
blocks.flatMap((block) => {
|
||||||
if (timing) timings.push(timing);
|
const timing = deps.subtitleTimingTracker?.findTiming(block);
|
||||||
}
|
return timing ? [timing] : [];
|
||||||
|
});
|
||||||
|
|
||||||
if (timings.length === 0) {
|
if (timings.length === 0) {
|
||||||
deps.showMpvOsd('Subtitle timing not found');
|
deps.showMpvOsd('Subtitle timing not found');
|
||||||
@@ -166,9 +185,13 @@ export function handleMineSentenceDigit(
|
|||||||
const rangeStart = Math.min(...timings.map((t) => t.startTime));
|
const rangeStart = Math.min(...timings.map((t) => t.startTime));
|
||||||
const rangeEnd = Math.max(...timings.map((t) => t.endTime));
|
const rangeEnd = Math.max(...timings.map((t) => t.endTime));
|
||||||
const sentence = blocks.join(' ');
|
const sentence = blocks.join(' ');
|
||||||
|
const secondarySubText = getSecondarySubTextForMinedBlocks(
|
||||||
|
entries,
|
||||||
|
deps.getCurrentSecondarySubText,
|
||||||
|
);
|
||||||
const cardsToMine = 1;
|
const cardsToMine = 1;
|
||||||
deps.ankiIntegration
|
deps.ankiIntegration
|
||||||
.createSentenceCard(sentence, rangeStart, rangeEnd, deps.getCurrentSecondarySubText())
|
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
|
||||||
.then((created) => {
|
.then((created) => {
|
||||||
if (created) {
|
if (created) {
|
||||||
deps.onCardsMined?.(cardsToMine);
|
deps.onCardsMined?.(cardsToMine);
|
||||||
|
|||||||
@@ -843,7 +843,7 @@ export function createStatsApp(
|
|||||||
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
||||||
const mediaGen = new MediaGenerator();
|
const mediaGen = new MediaGenerator();
|
||||||
|
|
||||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0.5;
|
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||||
|
|
||||||
const startSec = startMs / 1000;
|
const startSec = startMs / 1000;
|
||||||
|
|||||||
+90
-9
@@ -464,6 +464,7 @@ import {
|
|||||||
composeStartupLifecycleHandlers,
|
composeStartupLifecycleHandlers,
|
||||||
} from './main/runtime/composers';
|
} from './main/runtime/composers';
|
||||||
import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers';
|
import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers';
|
||||||
|
import { tryBeginVisibleOverlayNumericSelection } from './main/runtime/overlay-numeric-selection';
|
||||||
import { createStartupBootstrapRuntimeDeps } from './main/startup';
|
import { createStartupBootstrapRuntimeDeps } from './main/startup';
|
||||||
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
|
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
|
||||||
import {
|
import {
|
||||||
@@ -547,7 +548,12 @@ import {
|
|||||||
createCreateJellyfinSetupWindowHandler,
|
createCreateJellyfinSetupWindowHandler,
|
||||||
} from './main/runtime/setup-window-factory';
|
} from './main/runtime/setup-window-factory';
|
||||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
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 { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||||
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
|
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
|
||||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||||
@@ -988,8 +994,8 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
|||||||
refreshCurrentSubtitle: (text: string) => {
|
refreshCurrentSubtitle: (text: string) => {
|
||||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||||
},
|
},
|
||||||
refreshSubtitleSidebarSource: async (sourcePath: string) => {
|
refreshSubtitleSidebarSource: async (sourcePath: string, mediaPath?: string) => {
|
||||||
await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath);
|
await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath);
|
||||||
},
|
},
|
||||||
startTokenizationWarmups: async () => {
|
startTokenizationWarmups: async () => {
|
||||||
await startTokenizationWarmups();
|
await startTokenizationWarmups();
|
||||||
@@ -1076,9 +1082,18 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
|||||||
},
|
},
|
||||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||||
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
|
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
|
||||||
|
notifyPrimarySubtitleLoaded: () =>
|
||||||
|
youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(),
|
||||||
warn: (message: string) => logger.warn(message),
|
warn: (message: string) => logger.warn(message),
|
||||||
log: (message: string) => logger.info(message),
|
log: (message: string) => logger.info(message),
|
||||||
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
|
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({
|
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
requestPath: async () => {
|
requestPath: async () => {
|
||||||
@@ -1545,6 +1560,20 @@ const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNo
|
|||||||
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
|
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
|
||||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
||||||
clearSchedule: clearYoutubePrimarySubtitleNotificationTimer,
|
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 {
|
function isYoutubePlaybackActiveNow(): boolean {
|
||||||
@@ -1745,6 +1774,9 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
|||||||
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||||
appState.activeParsedSubtitleCues = cues ?? [];
|
appState.activeParsedSubtitleCues = cues ?? [];
|
||||||
appState.activeParsedSubtitleSource = sourceKey;
|
appState.activeParsedSubtitleSource = sourceKey;
|
||||||
|
if (!cues?.length) {
|
||||||
|
appState.activeParsedSubtitleMediaPath = null;
|
||||||
|
}
|
||||||
const mediaPath = getCurrentAutoplayMediaPath();
|
const mediaPath = getCurrentAutoplayMediaPath();
|
||||||
if (mediaPath && cues?.length) {
|
if (mediaPath && cues?.length) {
|
||||||
void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => {
|
void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => {
|
||||||
@@ -1763,11 +1795,15 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
|
|||||||
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
|
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise<void> {
|
async function refreshSubtitleSidebarFromSource(
|
||||||
|
sourcePath: string,
|
||||||
|
mediaPath?: string,
|
||||||
|
): Promise<void> {
|
||||||
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
|
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
|
||||||
if (!normalizedSourcePath) {
|
if (!normalizedSourcePath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
appState.activeParsedSubtitleMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||||
normalizedSourcePath,
|
normalizedSourcePath,
|
||||||
lastObservedTimePos,
|
lastObservedTimePos,
|
||||||
@@ -1778,6 +1814,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
|
|||||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
getLastObservedTimePos: () => lastObservedTimePos,
|
getLastObservedTimePos: () => lastObservedTimePos,
|
||||||
|
shouldKeepExistingCuesOnMissingSource: (videoPath) => isYoutubeMediaPath(videoPath),
|
||||||
subtitlePrefetchInitController,
|
subtitlePrefetchInitController,
|
||||||
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||||
});
|
});
|
||||||
@@ -1792,8 +1829,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
|||||||
const subtitlePrefetchRuntime = {
|
const subtitlePrefetchRuntime = {
|
||||||
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
|
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
|
||||||
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
|
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
|
||||||
refreshSubtitleSidebarFromSource: (sourcePath: string) =>
|
refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) =>
|
||||||
refreshSubtitleSidebarFromSource(sourcePath),
|
refreshSubtitleSidebarFromSource(sourcePath, mediaPath),
|
||||||
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
|
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
|
||||||
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
|
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
|
||||||
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
|
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
|
||||||
@@ -3632,6 +3669,7 @@ const {
|
|||||||
appState.yomitanSettingsWindow = null;
|
appState.yomitanSettingsWindow = null;
|
||||||
},
|
},
|
||||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||||
|
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
||||||
stopDiscordPresenceService: () => {
|
stopDiscordPresenceService: () => {
|
||||||
void appState.discordPresenceService?.stop();
|
void appState.discordPresenceService?.stop();
|
||||||
appState.discordPresenceService = null;
|
appState.discordPresenceService = null;
|
||||||
@@ -4271,6 +4309,10 @@ const {
|
|||||||
updateCurrentMediaPath: (path) => {
|
updateCurrentMediaPath: (path) => {
|
||||||
const normalizedPath = path.trim();
|
const normalizedPath = path.trim();
|
||||||
const previousPath = appState.currentMediaPath?.trim() || null;
|
const previousPath = appState.currentMediaPath?.trim() || null;
|
||||||
|
const preserveParsedSubtitleCues = isSameYoutubeMediaPath(
|
||||||
|
normalizedPath,
|
||||||
|
appState.activeParsedSubtitleMediaPath,
|
||||||
|
);
|
||||||
if ((normalizedPath || null) !== previousPath) {
|
if ((normalizedPath || null) !== previousPath) {
|
||||||
const resetSubtitlePayload = { text: '', tokens: null };
|
const resetSubtitlePayload = { text: '', tokens: null };
|
||||||
const frequencyDictionary = getResolvedConfig().subtitleStyle.frequencyDictionary;
|
const frequencyDictionary = getResolvedConfig().subtitleStyle.frequencyDictionary;
|
||||||
@@ -4284,8 +4326,11 @@ const {
|
|||||||
appState.currentSubText = '';
|
appState.currentSubText = '';
|
||||||
appState.currentSubAssText = '';
|
appState.currentSubAssText = '';
|
||||||
appState.currentSubtitleData = null;
|
appState.currentSubtitleData = null;
|
||||||
appState.activeParsedSubtitleCues = [];
|
if (!preserveParsedSubtitleCues) {
|
||||||
appState.activeParsedSubtitleSource = null;
|
appState.activeParsedSubtitleCues = [];
|
||||||
|
appState.activeParsedSubtitleSource = null;
|
||||||
|
appState.activeParsedSubtitleMediaPath = null;
|
||||||
|
}
|
||||||
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
||||||
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||||
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||||
@@ -4295,7 +4340,9 @@ const {
|
|||||||
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||||
startupOsdSequencer.reset();
|
startupOsdSequencer.reset();
|
||||||
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
||||||
subtitlePrefetchRuntime.cancelPendingInit();
|
if (!preserveParsedSubtitleCues) {
|
||||||
|
subtitlePrefetchRuntime.cancelPendingInit();
|
||||||
|
}
|
||||||
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
||||||
if (path) {
|
if (path) {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
@@ -4844,6 +4891,20 @@ const {
|
|||||||
numericSessions: {
|
numericSessions: {
|
||||||
onMultiCopyDigit: (count) => handleMultiCopyDigit(count),
|
onMultiCopyDigit: (count) => handleMultiCopyDigit(count),
|
||||||
onMineSentenceDigit: (count) => handleMineSentenceDigit(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: {
|
overlayShortcutsRuntimeMainDeps: {
|
||||||
overlayShortcutsRuntime,
|
overlayShortcutsRuntime,
|
||||||
@@ -5555,6 +5616,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
shouldUseCachedYoutubeParsedCues({
|
||||||
|
videoPath,
|
||||||
|
cachedMediaPath: appState.activeParsedSubtitleMediaPath,
|
||||||
|
cachedCueCount: appState.activeParsedSubtitleCues.length,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
|
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
|
||||||
currentExternalFilenameRaw,
|
currentExternalFilenameRaw,
|
||||||
@@ -5586,6 +5661,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
const cues = parseSubtitleCues(content, resolvedSource.path);
|
const cues = parseSubtitleCues(content, resolvedSource.path);
|
||||||
appState.activeParsedSubtitleCues = cues;
|
appState.activeParsedSubtitleCues = cues;
|
||||||
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
||||||
|
appState.activeParsedSubtitleMediaPath = videoPath || null;
|
||||||
return {
|
return {
|
||||||
cues,
|
cues,
|
||||||
currentTimeSec,
|
currentTimeSec,
|
||||||
@@ -5793,6 +5869,11 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
|||||||
startBackgroundWarmups: () => startBackgroundWarmups(),
|
startBackgroundWarmups: () => startBackgroundWarmups(),
|
||||||
logInfo: (message: string) => logger.info(message),
|
logInfo: (message: string) => logger.info(message),
|
||||||
},
|
},
|
||||||
|
ensureTrayForCommand: (args) => {
|
||||||
|
if (args.background || args.managedPlayback) {
|
||||||
|
ensureTray();
|
||||||
|
}
|
||||||
|
},
|
||||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
||||||
handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/,
|
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\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\.currentSubText = '';/);
|
||||||
assert.match(actionBlock, /appState\.currentSubAssText = '';/);
|
assert.match(actionBlock, /appState\.currentSubAssText = '';/);
|
||||||
assert.match(actionBlock, /appState\.currentSubtitleData = null;/);
|
assert.match(actionBlock, /appState\.currentSubtitleData = null;/);
|
||||||
|
assert.match(actionBlock, /isSameYoutubeMediaPath\(/);
|
||||||
|
assert.match(actionBlock, /if \(!preserveParsedSubtitleCues\)/);
|
||||||
assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/);
|
assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/);
|
||||||
assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/);
|
assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/);
|
||||||
|
assert.match(actionBlock, /appState\.activeParsedSubtitleMediaPath = null;/);
|
||||||
assert.match(actionBlock, /lastObservedTimePos = 0;/);
|
assert.match(actionBlock, /lastObservedTimePos = 0;/);
|
||||||
assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/);
|
assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/);
|
||||||
assert.match(actionBlock, /subtitleWsService\.broadcast\(/);
|
assert.match(actionBlock, /subtitleWsService\.broadcast\(/);
|
||||||
@@ -52,3 +55,19 @@ test('main process uses one shared mpv plugin runtime config helper', () => {
|
|||||||
0,
|
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*\{(?<body>[\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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,15 +40,17 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||||
|
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
assert.equal(calls.length, 30);
|
assert.equal(calls.length, 31);
|
||||||
assert.equal(calls[0], 'destroy-tray');
|
assert.equal(calls[0], 'destroy-tray');
|
||||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
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'));
|
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
destroyYomitanSettingsWindow: () => void;
|
destroyYomitanSettingsWindow: () => void;
|
||||||
clearYomitanSettingsWindow: () => void;
|
clearYomitanSettingsWindow: () => void;
|
||||||
stopJellyfinRemoteSession: () => void;
|
stopJellyfinRemoteSession: () => void;
|
||||||
|
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||||
stopDiscordPresenceService: () => void;
|
stopDiscordPresenceService: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (): void => {
|
return (): void => {
|
||||||
@@ -60,6 +61,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.destroyYomitanSettingsWindow();
|
deps.destroyYomitanSettingsWindow();
|
||||||
deps.clearYomitanSettingsWindow();
|
deps.clearYomitanSettingsWindow();
|
||||||
deps.stopJellyfinRemoteSession();
|
deps.stopJellyfinRemoteSession();
|
||||||
|
deps.cleanupYoutubeSubtitleTempDirs();
|
||||||
deps.stopDiscordPresenceService();
|
deps.stopDiscordPresenceService();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||||
|
|
||||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||||
|
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
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-first-run-window'));
|
||||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
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('stop-discord-presence'));
|
||||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
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,
|
getYomitanSettingsWindow: () => null,
|
||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: () => {},
|
stopJellyfinRemoteSession: () => {},
|
||||||
|
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||||
stopDiscordPresenceService: () => {},
|
stopDiscordPresenceService: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
|||||||
getYomitanSettingsWindow: () => null,
|
getYomitanSettingsWindow: () => null,
|
||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: () => {},
|
stopJellyfinRemoteSession: () => {},
|
||||||
|
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||||
stopDiscordPresenceService: () => {},
|
stopDiscordPresenceService: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
clearYomitanSettingsWindow: () => void;
|
clearYomitanSettingsWindow: () => void;
|
||||||
|
|
||||||
stopJellyfinRemoteSession: () => void;
|
stopJellyfinRemoteSession: () => void;
|
||||||
|
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||||
stopDiscordPresenceService: () => void;
|
stopDiscordPresenceService: () => void;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
},
|
},
|
||||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||||
|
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,3 +83,39 @@ test('cli command runtime handler skips generic overlay prerequisites for youtub
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['context', 'cli:initial:ctx']);
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type HandleTexthookerOnlyModeTransitionMainDeps = Parameters<
|
|||||||
|
|
||||||
export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
||||||
handleTexthookerOnlyModeTransitionMainDeps: HandleTexthookerOnlyModeTransitionMainDeps;
|
handleTexthookerOnlyModeTransitionMainDeps: HandleTexthookerOnlyModeTransitionMainDeps;
|
||||||
|
ensureTrayForCommand?: (args: CliArgs, source: CliCommandSource) => void;
|
||||||
createCliCommandContext: () => TCliContext;
|
createCliCommandContext: () => TCliContext;
|
||||||
handleCliCommandRuntimeServiceWithContext: (
|
handleCliCommandRuntimeServiceWithContext: (
|
||||||
args: CliArgs,
|
args: CliArgs,
|
||||||
@@ -29,6 +30,7 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
|||||||
) {
|
) {
|
||||||
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
|
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
|
||||||
}
|
}
|
||||||
|
deps.ensureTrayForCommand?.(args, source);
|
||||||
const cliContext = deps.createCliCommandContext();
|
const cliContext = deps.createCliCommandContext();
|
||||||
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
|
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
|||||||
getYomitanSettingsWindow: () => null,
|
getYomitanSettingsWindow: () => null,
|
||||||
clearYomitanSettingsWindow: () => {},
|
clearYomitanSettingsWindow: () => {},
|
||||||
stopJellyfinRemoteSession: async () => {},
|
stopJellyfinRemoteSession: async () => {},
|
||||||
|
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||||
stopDiscordPresenceService: () => {},
|
stopDiscordPresenceService: () => {},
|
||||||
},
|
},
|
||||||
shouldRestoreWindowsOnActivateMainDeps: {
|
shouldRestoreWindowsOnActivateMainDeps: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
overlayRuntimeInitialized: true,
|
overlayRuntimeInitialized: true,
|
||||||
mpvClient: {
|
mpvClient: {
|
||||||
connected: true,
|
connected: true,
|
||||||
|
currentSecondarySubText: 'secondary',
|
||||||
currentTimePos: 12.25,
|
currentTimePos: 12.25,
|
||||||
requestProperty: async () => 18.75,
|
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}`),
|
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
|
||||||
},
|
},
|
||||||
subtitleTimingTracker: {
|
subtitleTimingTracker: {
|
||||||
recordSubtitle: (text: string) => calls.push(`timing:${text}`),
|
recordSubtitle: (text: string, _start: number, _end: number, secondaryText?: string) =>
|
||||||
|
calls.push(`timing:${text}:${secondaryText ?? ''}`),
|
||||||
},
|
},
|
||||||
currentSubText: '',
|
currentSubText: '',
|
||||||
currentSubAssText: '',
|
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('remote-stopped'));
|
||||||
assert.ok(calls.includes('sync-overlay-mpv-sub'));
|
assert.ok(calls.includes('sync-overlay-mpv-sub'));
|
||||||
assert.ok(calls.includes('anilist-post-watch'));
|
assert.ok(calls.includes('anilist-post-watch'));
|
||||||
|
assert.ok(calls.includes('timing:y:secondary'));
|
||||||
assert.ok(calls.includes('ensure-immersion'));
|
assert.ok(calls.includes('ensure-immersion'));
|
||||||
assert.ok(calls.includes('sync-immersion'));
|
assert.ok(calls.includes('sync-immersion'));
|
||||||
assert.ok(calls.includes('autoplay:/tmp/video'));
|
assert.ok(calls.includes('autoplay:/tmp/video'));
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
recordPauseState?: (paused: boolean) => void;
|
recordPauseState?: (paused: boolean) => void;
|
||||||
} | null;
|
} | null;
|
||||||
subtitleTimingTracker: {
|
subtitleTimingTracker: {
|
||||||
recordSubtitle?: (text: string, start: number, end: number) => void;
|
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
|
||||||
} | null;
|
} | null;
|
||||||
currentMediaPath?: string | null;
|
currentMediaPath?: string | null;
|
||||||
currentSubText: string;
|
currentSubText: string;
|
||||||
@@ -132,7 +132,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
},
|
},
|
||||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
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) =>
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
||||||
deps.maybeRunAnilistPostWatchUpdate(options),
|
deps.maybeRunAnilistPostWatchUpdate(options),
|
||||||
logSubtitleTimingError: (message: string, error: unknown) =>
|
logSubtitleTimingError: (message: string, error: unknown) =>
|
||||||
|
|||||||
@@ -33,3 +33,28 @@ test('numeric shortcut session runtime handlers compose cancel/start handlers',
|
|||||||
'mine-sentence:digit:3',
|
'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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
|
|||||||
mineSentenceSession: CancelNumericShortcutSessionMainDeps['session'];
|
mineSentenceSession: CancelNumericShortcutSessionMainDeps['session'];
|
||||||
onMultiCopyDigit: (count: number) => void;
|
onMultiCopyDigit: (count: number) => void;
|
||||||
onMineSentenceDigit: (count: number) => void;
|
onMineSentenceDigit: (count: number) => void;
|
||||||
|
tryBeginMultiCopyOverlaySelection?: (timeoutMs: number) => boolean;
|
||||||
|
tryBeginMineSentenceOverlaySelection?: (timeoutMs: number) => boolean;
|
||||||
}) {
|
}) {
|
||||||
const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({
|
const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({
|
||||||
session: deps.multiCopySession,
|
session: deps.multiCopySession,
|
||||||
@@ -61,9 +63,14 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(),
|
cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(),
|
||||||
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopyHandler(timeoutMs),
|
startPendingMultiCopy: (timeoutMs: number) => {
|
||||||
|
if (deps.tryBeginMultiCopyOverlaySelection?.(timeoutMs)) return;
|
||||||
|
startPendingMultiCopyHandler(timeoutMs);
|
||||||
|
},
|
||||||
cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(),
|
cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(),
|
||||||
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
startPendingMineSentenceMultiple: (timeoutMs: number) => {
|
||||||
startPendingMineSentenceMultipleHandler(timeoutMs),
|
if (deps.tryBeginMineSentenceOverlaySelection?.(timeoutMs)) return;
|
||||||
|
startPendingMineSentenceMultipleHandler(timeoutMs);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, []);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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 () => {
|
test('subtitle prefetch runtime resolves direct external subtitle sources first', async () => {
|
||||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||||
@@ -57,3 +60,43 @@ test('subtitle prefetch runtime extracts internal subtitle tracks into a stable
|
|||||||
cleanup: resolved?.cleanup,
|
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, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
|||||||
requestProperty: (name: string) => Promise<unknown>;
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
} | null;
|
} | null;
|
||||||
getLastObservedTimePos: () => number;
|
getLastObservedTimePos: () => number;
|
||||||
|
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean;
|
||||||
subtitlePrefetchInitController: SubtitlePrefetchInitController;
|
subtitlePrefetchInitController: SubtitlePrefetchInitController;
|
||||||
resolveActiveSubtitleSidebarSource: (
|
resolveActiveSubtitleSidebarSource: (
|
||||||
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
||||||
@@ -160,6 +161,9 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
|||||||
videoPath,
|
videoPath,
|
||||||
});
|
});
|
||||||
if (!resolvedSource) {
|
if (!resolvedSource) {
|
||||||
|
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
deps.subtitlePrefetchInitController.cancelPendingInit();
|
deps.subtitlePrefetchInitController.cancelPendingInit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,55 @@ test('ensure tray creates new tray and binds click handler', () => {
|
|||||||
assert.ok(calls.includes('bind-click'));
|
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', () => {
|
test('destroy tray handler destroys active tray and clears ref', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let tray: { destroy: () => void } | null = {
|
let tray: { destroy: () => void } | null = {
|
||||||
|
|||||||
@@ -48,7 +48,20 @@ export function createEnsureTrayHandler(deps: {
|
|||||||
trayIcon = trayIcon.resize({ width: 20, height: 20 });
|
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.setToolTip(deps.trayTooltip);
|
||||||
tray.setContextMenu(deps.buildTrayMenu());
|
tray.setContextMenu(deps.buildTrayMenu());
|
||||||
tray.on('click', () => {
|
tray.on('click', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createYoutubeFlowRuntime } from './youtube-flow';
|
import { createYoutubeFlowRuntime } from './youtube-flow';
|
||||||
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
|
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 () => {
|
test('youtube flow does not report failure when subtitle track binds before cue text appears', async () => {
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
|
const loadedSignals: string[] = [];
|
||||||
|
|
||||||
const runtime = createYoutubeFlowRuntime({
|
const runtime = createYoutubeFlowRuntime({
|
||||||
probeYoutubeTracks: async () => ({
|
probeYoutubeTracks: async () => ({
|
||||||
@@ -358,6 +360,9 @@ test('youtube flow does not report failure when subtitle track binds before cue
|
|||||||
reportSubtitleFailure: (message) => {
|
reportSubtitleFailure: (message) => {
|
||||||
failures.push(message);
|
failures.push(message);
|
||||||
},
|
},
|
||||||
|
notifyPrimarySubtitleLoaded: () => {
|
||||||
|
loadedSignals.push('loaded');
|
||||||
|
},
|
||||||
warn: (message) => {
|
warn: (message) => {
|
||||||
throw new Error(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' });
|
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||||
|
|
||||||
assert.deepEqual(failures, []);
|
assert.deepEqual(failures, []);
|
||||||
|
assert.deepEqual(loadedSignals, ['loaded']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('youtube flow does not fail when mpv reports sub-text as unavailable after track bind', async () => {
|
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<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
let selectedPrimarySid: number | null = null;
|
let selectedPrimarySid: number | null = null;
|
||||||
let selectedSecondarySid: number | null = null;
|
let selectedSecondarySid: number | null = null;
|
||||||
|
let downloadedPrimaryAdded = false;
|
||||||
const refreshedSidebarSources: string[] = [];
|
const refreshedSidebarSources: string[] = [];
|
||||||
|
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||||
|
|
||||||
const runtime = createYoutubeFlowRuntime({
|
const runtime = createYoutubeFlowRuntime({
|
||||||
probeYoutubeTracks: async () => ({
|
probeYoutubeTracks: async () => ({
|
||||||
@@ -813,7 +821,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
|||||||
},
|
},
|
||||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
if (track.language === 'ja') {
|
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');
|
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: () => {},
|
resumeMpv: () => {},
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
commands.push(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') {
|
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||||
selectedPrimarySid = command[2];
|
selectedPrimarySid = command[2];
|
||||||
}
|
}
|
||||||
@@ -853,7 +868,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
|||||||
if (name === 'secondary-sid') {
|
if (name === 'secondary-sid') {
|
||||||
return selectedSecondarySid;
|
return selectedSecondarySid;
|
||||||
}
|
}
|
||||||
return [
|
const tracks: Array<Record<string, unknown>> = [
|
||||||
{
|
{
|
||||||
type: 'sub',
|
type: 'sub',
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -887,6 +902,17 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
|
|||||||
'external-filename': null,
|
'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: () => {},
|
refreshCurrentSubtitle: () => {},
|
||||||
refreshSubtitleSidebarSource: async (sourcePath) => {
|
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' });
|
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||||
|
|
||||||
assert.equal(selectedPrimarySid, 2);
|
assert.equal(selectedPrimarySid, 9);
|
||||||
assert.equal(selectedSecondarySid, 1);
|
assert.equal(selectedSecondarySid, 1);
|
||||||
assert.equal(
|
assert.ok(
|
||||||
commands.some((command) => command[0] === 'sub-add'),
|
commands.some(
|
||||||
false,
|
(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(
|
assert.equal(
|
||||||
commands.some((command) => command[0] === 'sub-remove'),
|
commands.some((command) => command[0] === 'sub-remove'),
|
||||||
false,
|
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<Array<string | number>> = [];
|
||||||
|
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<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
let selectedPrimarySid: number | null = null;
|
let selectedPrimarySid: number | null = null;
|
||||||
let selectedSecondarySid: number | null = null;
|
let selectedSecondarySid: number | null = null;
|
||||||
let trackListReads = 0;
|
let trackListReads = 0;
|
||||||
|
let downloadedPrimaryAdded = false;
|
||||||
|
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||||
|
|
||||||
const runtime = createYoutubeFlowRuntime({
|
const runtime = createYoutubeFlowRuntime({
|
||||||
probeYoutubeTracks: async () => ({
|
probeYoutubeTracks: async () => ({
|
||||||
@@ -957,7 +1410,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
|||||||
},
|
},
|
||||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
if (track.language === 'ja') {
|
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');
|
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: () => {},
|
resumeMpv: () => {},
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
commands.push(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') {
|
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
|
||||||
selectedPrimarySid = command[2];
|
selectedPrimarySid = command[2];
|
||||||
}
|
}
|
||||||
@@ -1001,7 +1461,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
|||||||
if (trackListReads === 1) {
|
if (trackListReads === 1) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [
|
const tracks: Array<Record<string, unknown>> = [
|
||||||
{
|
{
|
||||||
type: 'sub',
|
type: 'sub',
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -1035,6 +1495,17 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
|
|||||||
'external-filename': null,
|
'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: () => {},
|
refreshCurrentSubtitle: () => {},
|
||||||
startTokenizationWarmups: async () => {},
|
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' });
|
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||||
|
|
||||||
assert.equal(selectedPrimarySid, 2);
|
assert.equal(selectedPrimarySid, 9);
|
||||||
assert.equal(selectedSecondarySid, 1);
|
assert.equal(selectedSecondarySid, 1);
|
||||||
assert.equal(
|
assert.ok(
|
||||||
commands.some((command) => command[0] === 'sub-add'),
|
commands.some(
|
||||||
false,
|
(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<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
let selectedPrimarySid: number | null = null;
|
let selectedPrimarySid: number | null = null;
|
||||||
let selectedSecondarySid: number | null = null;
|
let selectedSecondarySid: number | null = null;
|
||||||
|
let downloadedPrimaryAdded = false;
|
||||||
|
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
|
||||||
|
|
||||||
const runtime = createYoutubeFlowRuntime({
|
const runtime = createYoutubeFlowRuntime({
|
||||||
probeYoutubeTracks: async () => ({
|
probeYoutubeTracks: async () => ({
|
||||||
@@ -1098,7 +1573,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
|||||||
},
|
},
|
||||||
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
if (track.id === 'manual:ja') {
|
if (track.id === 'manual:ja') {
|
||||||
return { path: '/tmp/manual-ja.ja.srt' };
|
return { path: downloadedPrimaryPath };
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'should not download secondary track when existing manual english track is reusable',
|
'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: () => {},
|
resumeMpv: () => {},
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
commands.push(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') {
|
if (command[0] === 'set_property' && command[1] === 'sid') {
|
||||||
selectedPrimarySid = Number(command[2]);
|
selectedPrimarySid = Number(command[2]);
|
||||||
}
|
}
|
||||||
@@ -1118,7 +1600,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
|||||||
},
|
},
|
||||||
requestMpvProperty: async (name) => {
|
requestMpvProperty: async (name) => {
|
||||||
if (name === 'track-list') {
|
if (name === 'track-list') {
|
||||||
return [
|
const tracks: Array<Record<string, unknown>> = [
|
||||||
{
|
{
|
||||||
type: 'sub',
|
type: 'sub',
|
||||||
id: 1,
|
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',
|
'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') {
|
if (name === 'sid') {
|
||||||
return selectedPrimarySid;
|
return selectedPrimarySid;
|
||||||
@@ -1181,11 +1674,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
|
|||||||
mode: 'download',
|
mode: 'download',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(selectedPrimarySid, 2);
|
assert.equal(selectedPrimarySid, 9);
|
||||||
assert.equal(selectedSecondarySid, 1);
|
assert.equal(selectedSecondarySid, 1);
|
||||||
assert.equal(
|
assert.ok(
|
||||||
commands.some((command) => command[0] === 'sub-add'),
|
commands.some(
|
||||||
false,
|
(command) =>
|
||||||
|
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type YoutubeFlowDeps = {
|
|||||||
sendMpvCommand: (command: Array<string | number>) => void;
|
sendMpvCommand: (command: Array<string | number>) => void;
|
||||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||||
refreshCurrentSubtitle: (text: string) => void;
|
refreshCurrentSubtitle: (text: string) => void;
|
||||||
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
|
refreshSubtitleSidebarSource?: (sourcePath: string, mediaPath?: string) => Promise<void>;
|
||||||
startTokenizationWarmups: () => Promise<void>;
|
startTokenizationWarmups: () => Promise<void>;
|
||||||
waitForTokenizationReady: () => Promise<void>;
|
waitForTokenizationReady: () => Promise<void>;
|
||||||
waitForAnkiReady: () => Promise<void>;
|
waitForAnkiReady: () => Promise<void>;
|
||||||
@@ -42,9 +42,12 @@ type YoutubeFlowDeps = {
|
|||||||
focusOverlayWindow: () => void;
|
focusOverlayWindow: () => void;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
reportSubtitleFailure: (message: string) => void;
|
reportSubtitleFailure: (message: string) => void;
|
||||||
|
notifyPrimarySubtitleLoaded?: () => void;
|
||||||
warn: (message: string) => void;
|
warn: (message: string) => void;
|
||||||
log: (message: string) => void;
|
log: (message: string) => void;
|
||||||
getYoutubeOutputDir: () => string;
|
getYoutubeOutputDir: () => string;
|
||||||
|
createSubtitleTempDir?: () => Promise<string>;
|
||||||
|
cleanupSubtitleTempDirs?: (dirs: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type YoutubeFlowSession = {
|
type YoutubeFlowSession = {
|
||||||
@@ -349,7 +352,9 @@ async function injectDownloadedSubtitles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let trackListRaw: unknown = await deps.requestMpvProperty('track-list');
|
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;
|
let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null;
|
||||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) {
|
if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) {
|
||||||
@@ -423,6 +428,53 @@ async function injectDownloadedSubtitles(
|
|||||||
|
|
||||||
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||||
let activeSession: YoutubeFlowSession | null = null;
|
let activeSession: YoutubeFlowSession | null = null;
|
||||||
|
const activeSubtitleTempDirs = new Set<string>();
|
||||||
|
|
||||||
|
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<string> => {
|
||||||
|
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: {
|
const acquireSelectedTracks = async (input: {
|
||||||
targetUrl: string;
|
targetUrl: string;
|
||||||
@@ -567,6 +619,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
osdProgress.setMessage('Downloading subtitles...');
|
osdProgress.setMessage('Downloading subtitles...');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const outputDir = await prepareSubtitleOutputDir(input.outputDir);
|
||||||
let initialTrackListRaw: unknown = null;
|
let initialTrackListRaw: unknown = null;
|
||||||
let existingPrimaryTrackId: number | null = null;
|
let existingPrimaryTrackId: number | null = null;
|
||||||
let existingSecondaryTrackId: number | null = null;
|
let existingSecondaryTrackId: number | null = null;
|
||||||
@@ -602,19 +655,11 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
let primaryInjectedPath: string | null = null;
|
let primaryInjectedPath: string | null = null;
|
||||||
let secondaryInjectedPath: string | null = null;
|
let secondaryInjectedPath: string | null = null;
|
||||||
|
|
||||||
if (existingPrimaryTrackId !== null) {
|
if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
|
||||||
primarySidebarPath = (
|
|
||||||
await deps.acquireYoutubeSubtitleTrack({
|
|
||||||
targetUrl: input.url,
|
|
||||||
outputDir: input.outputDir,
|
|
||||||
track: input.primaryTrack,
|
|
||||||
})
|
|
||||||
).path;
|
|
||||||
} else if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
|
|
||||||
primaryInjectedPath = (
|
primaryInjectedPath = (
|
||||||
await deps.acquireYoutubeSubtitleTrack({
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
targetUrl: input.url,
|
targetUrl: input.url,
|
||||||
outputDir: input.outputDir,
|
outputDir,
|
||||||
track: input.primaryTrack,
|
track: input.primaryTrack,
|
||||||
})
|
})
|
||||||
).path;
|
).path;
|
||||||
@@ -622,7 +667,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
} else {
|
} else {
|
||||||
const acquired = await acquireSelectedTracks({
|
const acquired = await acquireSelectedTracks({
|
||||||
targetUrl: input.url,
|
targetUrl: input.url,
|
||||||
outputDir: input.outputDir,
|
outputDir,
|
||||||
primaryTrack: input.primaryTrack,
|
primaryTrack: input.primaryTrack,
|
||||||
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
|
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
|
||||||
secondaryFailureLabel: input.secondaryFailureLabel,
|
secondaryFailureLabel: input.secondaryFailureLabel,
|
||||||
@@ -641,7 +686,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
secondaryInjectedPath = (
|
secondaryInjectedPath = (
|
||||||
await deps.acquireYoutubeSubtitleTrack({
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
targetUrl: input.url,
|
targetUrl: input.url,
|
||||||
outputDir: input.outputDir,
|
outputDir,
|
||||||
track: input.secondaryTrack,
|
track: input.secondaryTrack,
|
||||||
})
|
})
|
||||||
).path;
|
).path;
|
||||||
@@ -685,8 +730,9 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
if (!refreshedActiveSubtitle) {
|
if (!refreshedActiveSubtitle) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
deps.notifyPrimarySubtitleLoaded?.();
|
||||||
try {
|
try {
|
||||||
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath);
|
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath, input.url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.warn(
|
deps.warn(
|
||||||
`Failed to refresh parsed subtitle cues for sidebar: ${
|
`Failed to refresh parsed subtitle cues for sidebar: ${
|
||||||
@@ -877,5 +923,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
resolveActivePicker,
|
resolveActivePicker,
|
||||||
cancelActivePicker,
|
cancelActivePicker,
|
||||||
hasActiveSession: () => Boolean(activeSession),
|
hasActiveSession: () => Boolean(activeSession),
|
||||||
|
cleanupSubtitleTempDirs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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', () => {
|
test('isYoutubeMediaPath detects youtube watch and short urls', () => {
|
||||||
assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true);
|
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('https://www.youtube.com/watch?v=abc123', null), true);
|
||||||
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false);
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,39 @@ function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
|
|||||||
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
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 {
|
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
|
||||||
const normalized = trimToNull(mediaPath);
|
const normalized = trimToNull(mediaPath);
|
||||||
if (!normalized) {
|
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(
|
export function isYoutubePlaybackActive(
|
||||||
currentMediaPath: string | null | undefined,
|
currentMediaPath: string | null | undefined,
|
||||||
currentVideoPath: string | null | undefined,
|
currentVideoPath: string | null | undefined,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
|
|
||||||
function createTimerHarness() {
|
function createTimerHarness() {
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
const timers = new Map<number, () => void>();
|
const timers = new Map<number, () => void | Promise<void>>();
|
||||||
return {
|
return {
|
||||||
schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => {
|
schedule: (fn: () => void | Promise<void>): YoutubePrimarySubtitleNotificationTimer => {
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
timers.set(id, fn);
|
timers.set(id, fn);
|
||||||
return { id };
|
return { id };
|
||||||
@@ -26,7 +26,14 @@ function createTimerHarness() {
|
|||||||
const pending = [...timers.values()];
|
const pending = [...timers.values()];
|
||||||
timers.clear();
|
timers.clear();
|
||||||
for (const fn of pending) {
|
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,
|
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.',
|
'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, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type SubtitleTrackEntry = {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CurrentSubtitleState = {
|
||||||
|
sid: unknown;
|
||||||
|
trackList: unknown[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
function parseTrackId(value: unknown): number | null {
|
function parseTrackId(value: unknown): number | null {
|
||||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
return value;
|
return value;
|
||||||
@@ -101,8 +106,12 @@ function hasSelectedPrimarySubtitle(
|
|||||||
export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||||
getPrimarySubtitleLanguages: () => string[];
|
getPrimarySubtitleLanguages: () => string[];
|
||||||
notifyFailure: (message: string) => void;
|
notifyFailure: (message: string) => void;
|
||||||
schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer;
|
schedule: (
|
||||||
|
fn: () => void | Promise<void>,
|
||||||
|
delayMs: number,
|
||||||
|
) => YoutubePrimarySubtitleNotificationTimer;
|
||||||
clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void;
|
clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void;
|
||||||
|
getCurrentSubtitleState?: () => CurrentSubtitleState | null | Promise<CurrentSubtitleState | null>;
|
||||||
delayMs?: number;
|
delayMs?: number;
|
||||||
}) {
|
}) {
|
||||||
const delayMs = deps.delayMs ?? 5000;
|
const delayMs = deps.delayMs ?? 5000;
|
||||||
@@ -112,13 +121,35 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
|||||||
let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null;
|
let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null;
|
||||||
let lastReportedMediaPath: string | null = null;
|
let lastReportedMediaPath: string | null = null;
|
||||||
let appOwnedFlowInFlight = false;
|
let appOwnedFlowInFlight = false;
|
||||||
|
let primarySubtitleLoadedForCurrentMedia = false;
|
||||||
|
|
||||||
const clearPendingTimer = (): void => {
|
const clearPendingTimer = (): void => {
|
||||||
deps.clearSchedule(pendingTimer);
|
deps.clearSchedule(pendingTimer);
|
||||||
pendingTimer = null;
|
pendingTimer = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeReportFailure = (): void => {
|
const refreshCurrentSubtitleState = async (
|
||||||
|
preferredLanguages: Set<string>,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
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<void> => {
|
||||||
const mediaPath = currentMediaPath?.trim() || '';
|
const mediaPath = currentMediaPath?.trim() || '';
|
||||||
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||||
return;
|
return;
|
||||||
@@ -126,13 +157,30 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
|||||||
if (lastReportedMediaPath === mediaPath) {
|
if (lastReportedMediaPath === mediaPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (appOwnedFlowInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
|
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
|
||||||
if (preferredLanguages.size === 0) {
|
if (preferredLanguages.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (primarySubtitleLoadedForCurrentMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
|
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (deps.getCurrentSubtitleState && (await refreshCurrentSubtitleState(preferredLanguages))) {
|
||||||
|
clearPendingTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
currentMediaPath?.trim() !== mediaPath ||
|
||||||
|
appOwnedFlowInFlight ||
|
||||||
|
primarySubtitleLoadedForCurrentMedia
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
lastReportedMediaPath = mediaPath;
|
lastReportedMediaPath = mediaPath;
|
||||||
deps.notifyFailure(
|
deps.notifyFailure(
|
||||||
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
|
'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)) {
|
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingTimer = deps.schedule(() => {
|
if (primarySubtitleLoadedForCurrentMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingTimer = deps.schedule(async () => {
|
||||||
pendingTimer = null;
|
pendingTimer = null;
|
||||||
maybeReportFailure();
|
await maybeReportFailure();
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,6 +211,7 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
|||||||
typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
|
typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
|
||||||
if (currentMediaPath !== normalizedPath) {
|
if (currentMediaPath !== normalizedPath) {
|
||||||
lastReportedMediaPath = null;
|
lastReportedMediaPath = null;
|
||||||
|
primarySubtitleLoadedForCurrentMedia = false;
|
||||||
}
|
}
|
||||||
currentMediaPath = normalizedPath;
|
currentMediaPath = normalizedPath;
|
||||||
currentSid = null;
|
currentSid = null;
|
||||||
@@ -180,6 +232,14 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
|||||||
clearPendingTimer();
|
clearPendingTimer();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
markCurrentMediaPrimarySubtitleLoaded: (): void => {
|
||||||
|
const mediaPath = currentMediaPath?.trim() || '';
|
||||||
|
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
primarySubtitleLoadedForCurrentMedia = true;
|
||||||
|
clearPendingTimer();
|
||||||
|
},
|
||||||
setAppOwnedFlowInFlight: (inFlight: boolean): void => {
|
setAppOwnedFlowInFlight: (inFlight: boolean): void => {
|
||||||
appOwnedFlowInFlight = inFlight;
|
appOwnedFlowInFlight = inFlight;
|
||||||
if (inFlight) {
|
if (inFlight) {
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export interface AppState {
|
|||||||
currentSubtitleData: SubtitleData | null;
|
currentSubtitleData: SubtitleData | null;
|
||||||
activeParsedSubtitleCues: SubtitleCue[];
|
activeParsedSubtitleCues: SubtitleCue[];
|
||||||
activeParsedSubtitleSource: string | null;
|
activeParsedSubtitleSource: string | null;
|
||||||
|
activeParsedSubtitleMediaPath: string | null;
|
||||||
windowTracker: BaseWindowTracker | null;
|
windowTracker: BaseWindowTracker | null;
|
||||||
subtitlePosition: SubtitlePosition | null;
|
subtitlePosition: SubtitlePosition | null;
|
||||||
currentMediaPath: string | null;
|
currentMediaPath: string | null;
|
||||||
@@ -248,6 +249,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
|||||||
currentSubtitleData: null,
|
currentSubtitleData: null,
|
||||||
activeParsedSubtitleCues: [],
|
activeParsedSubtitleCues: [],
|
||||||
activeParsedSubtitleSource: null,
|
activeParsedSubtitleSource: null,
|
||||||
|
activeParsedSubtitleMediaPath: null,
|
||||||
windowTracker: null,
|
windowTracker: null,
|
||||||
subtitlePosition: null,
|
subtitlePosition: null,
|
||||||
currentMediaPath: null,
|
currentMediaPath: null,
|
||||||
|
|||||||
+140
-1
@@ -1,7 +1,60 @@
|
|||||||
import assert from 'node:assert/strict';
|
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 test from 'node:test';
|
||||||
|
|
||||||
import { buildAnimatedImageVideoFilter } from './media-generator';
|
import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator';
|
||||||
|
|
||||||
|
async function withStubbedFfmpeg(
|
||||||
|
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
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', () => {
|
test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in is provided', () => {
|
||||||
assert.equal(
|
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',
|
'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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+11
-7
@@ -158,11 +158,12 @@ export class MediaGenerator {
|
|||||||
videoPath: string,
|
videoPath: string,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
padding: number = 0.5,
|
padding: number = 0,
|
||||||
audioStreamIndex: number | null = null,
|
audioStreamIndex: number | null = null,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const start = Math.max(0, startTime - padding);
|
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||||
const duration = endTime - startTime + 2 * padding;
|
const start = Math.max(0, startTime - safePadding);
|
||||||
|
const duration = endTime - start + safePadding;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`);
|
const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`);
|
||||||
@@ -310,7 +311,7 @@ export class MediaGenerator {
|
|||||||
videoPath: string,
|
videoPath: string,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
padding: number = 0.5,
|
padding: number = 0,
|
||||||
options: {
|
options: {
|
||||||
fps?: number;
|
fps?: number;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
@@ -319,9 +320,12 @@ export class MediaGenerator {
|
|||||||
leadingStillDuration?: number;
|
leadingStillDuration?: number;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
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 { 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));
|
const clampedCrf = Math.max(0, Math.min(63, crf));
|
||||||
|
|
||||||
@@ -359,7 +363,7 @@ export class MediaGenerator {
|
|||||||
fps,
|
fps,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
leadingStillDuration,
|
leadingStillDuration: totalLeadingStillDuration,
|
||||||
}),
|
}),
|
||||||
...encoderArgs,
|
...encoderArgs,
|
||||||
'-y',
|
'-y',
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import type {
|
|||||||
ControllerConfigUpdate,
|
ControllerConfigUpdate,
|
||||||
ControllerPreferenceUpdate,
|
ControllerPreferenceUpdate,
|
||||||
ResolvedControllerConfig,
|
ResolvedControllerConfig,
|
||||||
|
SessionNumericSelectionStartPayload,
|
||||||
YoutubePickerOpenPayload,
|
YoutubePickerOpenPayload,
|
||||||
YoutubePickerResolveRequest,
|
YoutubePickerResolveRequest,
|
||||||
YoutubePickerResolveResult,
|
YoutubePickerResolveResult,
|
||||||
@@ -171,6 +172,11 @@ const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.pl
|
|||||||
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.youtubePickerCancel,
|
IPC_CHANNELS.event.youtubePickerCancel,
|
||||||
);
|
);
|
||||||
|
const onSessionNumericSelectionStartEvent =
|
||||||
|
createQueuedIpcListenerWithPayload<SessionNumericSelectionStartPayload>(
|
||||||
|
IPC_CHANNELS.event.sessionNumericSelectionStart,
|
||||||
|
(payload) => payload as SessionNumericSelectionStartPayload,
|
||||||
|
);
|
||||||
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
|
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.keyboardModeToggleRequested,
|
IPC_CHANNELS.event.keyboardModeToggleRequested,
|
||||||
);
|
);
|
||||||
@@ -385,6 +391,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||||
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
|
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
|
||||||
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
||||||
|
onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent,
|
||||||
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||||
|
|||||||
@@ -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 () => {
|
test('keyboard mode: left and right move token selection while popup remains open', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
function startPendingNumericSelection(
|
function startPendingNumericSelection(
|
||||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||||
|
timeoutMs: number = ctx.state.sessionActionTimeoutMs,
|
||||||
): void {
|
): void {
|
||||||
cancelPendingNumericSelection(false);
|
cancelPendingNumericSelection(false);
|
||||||
const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout';
|
const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout';
|
||||||
@@ -159,15 +160,17 @@ export function createKeyboardHandlers(
|
|||||||
timeout: setTimeout(() => {
|
timeout: setTimeout(() => {
|
||||||
pendingNumericSelection = null;
|
pendingNumericSelection = null;
|
||||||
showSessionSelectionMessage(timeoutMessage);
|
showSessionSelectionMessage(timeoutMessage);
|
||||||
}, ctx.state.sessionActionTimeoutMs),
|
}, timeoutMs),
|
||||||
};
|
};
|
||||||
showSessionSelectionMessage(promptMessage);
|
showSessionSelectionMessage(promptMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginSessionNumericSelection(
|
function beginSessionNumericSelection(
|
||||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
|
||||||
|
timeoutMs?: number,
|
||||||
): void {
|
): void {
|
||||||
startPendingNumericSelection(actionId);
|
startPendingNumericSelection(actionId, timeoutMs);
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePendingNumericSelection(e: KeyboardEvent): boolean {
|
function handlePendingNumericSelection(e: KeyboardEvent): boolean {
|
||||||
|
|||||||
@@ -530,6 +530,12 @@ function registerModalOpenHandlers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerKeyboardCommandHandlers(): void {
|
function registerKeyboardCommandHandlers(): void {
|
||||||
|
window.electronAPI.onSessionNumericSelectionStart((payload) => {
|
||||||
|
runGuarded('session:numeric-selection-start', () => {
|
||||||
|
keyboardHandlers.beginSessionNumericSelection(payload.actionId, payload.timeoutMs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
window.electronAPI.onKeyboardModeToggleRequested(() => {
|
window.electronAPI.onKeyboardModeToggleRequested(() => {
|
||||||
runGuarded('keyboard-mode-toggle:requested', () => {
|
runGuarded('keyboard-mode-toggle:requested', () => {
|
||||||
keyboardHandlers.handleKeyboardModeToggleRequested();
|
keyboardHandlers.handleKeyboardModeToggleRequested();
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export const IPC_CHANNELS = {
|
|||||||
youtubePickerOpen: 'youtube:picker-open',
|
youtubePickerOpen: 'youtube:picker-open',
|
||||||
youtubePickerCancel: 'youtube:picker-cancel',
|
youtubePickerCancel: 'youtube:picker-cancel',
|
||||||
playlistBrowserOpen: 'playlist-browser:open',
|
playlistBrowserOpen: 'playlist-browser:open',
|
||||||
|
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
||||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||||
sessionHelpOpen: 'session-help:open',
|
sessionHelpOpen: 'session-help:open',
|
||||||
|
|||||||
@@ -27,9 +27,17 @@ interface HistoryEntry {
|
|||||||
timingKey: string;
|
timingKey: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
|
secondaryText?: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubtitleTimingBlock {
|
||||||
|
displayText: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
secondaryText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SubtitleTimingTracker {
|
export class SubtitleTimingTracker {
|
||||||
private timings = new Map<string, TimingEntry>();
|
private timings = new Map<string, TimingEntry>();
|
||||||
private history: HistoryEntry[] = [];
|
private history: HistoryEntry[] = [];
|
||||||
@@ -41,11 +49,12 @@ export class SubtitleTimingTracker {
|
|||||||
this.startCleanup();
|
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);
|
const normalizedText = this.normalizeText(text);
|
||||||
if (!normalizedText) return;
|
if (!normalizedText) return;
|
||||||
|
|
||||||
const displayText = this.prepareDisplayText(text);
|
const displayText = this.prepareDisplayText(text);
|
||||||
|
const displaySecondaryText = secondaryText ? this.prepareDisplayText(secondaryText) : undefined;
|
||||||
const timingKey = normalizedText;
|
const timingKey = normalizedText;
|
||||||
|
|
||||||
this.timings.set(timingKey, {
|
this.timings.set(timingKey, {
|
||||||
@@ -60,6 +69,7 @@ export class SubtitleTimingTracker {
|
|||||||
// Update timing to most recent occurrence
|
// Update timing to most recent occurrence
|
||||||
lastEntry.startTime = startTime;
|
lastEntry.startTime = startTime;
|
||||||
lastEntry.endTime = endTime;
|
lastEntry.endTime = endTime;
|
||||||
|
lastEntry.secondaryText = displaySecondaryText;
|
||||||
lastEntry.timestamp = Date.now();
|
lastEntry.timestamp = Date.now();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -69,6 +79,7 @@ export class SubtitleTimingTracker {
|
|||||||
timingKey,
|
timingKey,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
secondaryText: displaySecondaryText,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,6 +117,23 @@ export class SubtitleTimingTracker {
|
|||||||
return this.history.slice(-count).map((entry) => entry.displayText);
|
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.
|
* Get display text for the most recent subtitle.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -378,6 +378,11 @@ export interface CharacterDictionarySelectionResult {
|
|||||||
staleMediaIds: number[];
|
staleMediaIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionNumericSelectionStartPayload {
|
||||||
|
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
getOverlayLayer: () => 'visible' | 'modal' | null;
|
getOverlayLayer: () => 'visible' | 'modal' | null;
|
||||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||||
@@ -451,6 +456,9 @@ export interface ElectronAPI {
|
|||||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||||
onPrimarySubtitleBarToggle: (callback: () => void) => void;
|
onPrimarySubtitleBarToggle: (callback: () => void) => void;
|
||||||
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
||||||
|
onSessionNumericSelectionStart: (
|
||||||
|
callback: (payload: SessionNumericSelectionStartPayload) => void,
|
||||||
|
) => void;
|
||||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||||
|
|||||||
Reference in New Issue
Block a user