Compare commits

...

7 Commits

78 changed files with 2265 additions and 1518 deletions
@@ -0,0 +1,6 @@
type: changed
area: overlay
- Added configurable overlay shortcuts for session help, controller select, and controller debug actions.
- Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path.
- Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser.
+5 -1
View File
@@ -173,7 +173,11 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
"openControllerSelect": "Alt+C", // Open controller select setting.
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
"toggleSubtitleSidebar": "\\" // Toggle subtitle sidebar setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
+12 -3
View File
@@ -536,7 +536,11 @@ See `config.example.jsonc` for detailed configuration options.
"mineSentenceMultiple": "CommandOrControl+Shift+S",
"markAudioCard": "CommandOrControl+Shift+A",
"openRuntimeOptions": "CommandOrControl+Shift+O",
"openSessionHelp": "CommandOrControl+Shift+H",
"openControllerSelect": "Alt+C",
"openControllerDebug": "Alt+Shift+C",
"openJimaku": "Ctrl+Shift+J",
"toggleSubtitleSidebar": "\\",
"multiCopyTimeoutMs": 3000
}
}
@@ -556,7 +560,11 @@ See `config.example.jsonc` for detailed configuration options.
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Shift+H"`) |
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"\\"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
@@ -573,9 +581,10 @@ Important behavior:
- Controller input is only active while keyboard-only mode is enabled.
- Keyboard-only mode continues to work normally without a controller.
- By default SubMiner uses the first connected controller.
- `Alt+C` opens the controller config modal, where you can save the selected controller and remap actions inline.
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
- Turning keyboard-only mode off clears the keyboard-only token highlight state.
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
@@ -694,7 +703,7 @@ These shortcuts are only active when the overlay window is visible and automatic
### Session Help Modal
The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend.
The session help modal opens from the overlay with `Ctrl/Cmd+Shift+H` by default. The mpv plugin also exposes it through the `Y-H` chord (falling back to `Y-K` if needed). It shows the current session keybindings and color legend.
You can filter the modal quickly with `/`:
+5 -1
View File
@@ -173,7 +173,11 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
"openControllerSelect": "Alt+C", // Open controller select setting.
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
"toggleSubtitleSidebar": "\\" // Toggle subtitle sidebar setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
+7 -5
View File
@@ -67,6 +67,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| ------------------ | -------------------------------------------------------- | ------------------------------ |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
@@ -79,12 +80,12 @@ The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a
## Controller Shortcuts
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration.
These overlay-local shortcuts open controller utilities for the Chrome Gamepad API integration.
| Shortcut | Action | Configurable |
| ------------- | ------------------------------ | ------------ |
| `Alt+C` | Open controller config + remap modal | Fixed |
| `Alt+Shift+C` | Open controller debug modal | Fixed |
| Shortcut | Action | Configurable |
| ------------- | ------------------------------------ | -------------------------------- |
| `Alt+C` | Open controller config + remap modal | `shortcuts.openControllerSelect` |
| `Alt+Shift+C` | Open controller debug modal | `shortcuts.openControllerDebug` |
Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller.
@@ -101,6 +102,7 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press
| `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay |
| `y-c` | Check overlay status |
| `y-h` | Open session help |
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
+4 -2
View File
@@ -272,12 +272,12 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
1. Connect a controller before or after launching SubMiner.
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
3. Press `Alt+C` in the overlay to pick the controller you want to save and remap any action inline.
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline. `Alt+Shift+C` still opens the live debug modal with raw axes/button values for non-standard pads.
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
### Default Button Mapping
@@ -321,6 +321,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
`Ctrl/Cmd+Shift+H` opens the session help modal with the current overlay and mpv keybindings. If you use the mpv plugin, the same help view is also available through the `y-h` chord.
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
### Drag-and-Drop
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.12.0-beta.1",
"version": "0.12.0-beta.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
+17
View File
@@ -229,6 +229,22 @@ function M.create(ctx)
end)
end
local function run_binary_command_async(args, callback)
subminer_log("debug", "process", "Binary command: " .. table.concat(args, " "))
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
local ok = success and (result == nil or result.status == 0)
if callback then
callback(ok, result, error)
end
end)
end
local function parse_start_script_message_overrides(...)
local overrides = {}
for i = 1, select("#", ...) do
@@ -528,6 +544,7 @@ function M.create(ctx)
build_command_args = build_command_args,
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
run_control_command_async = run_control_command_async,
run_binary_command_async = run_binary_command_async,
parse_start_script_message_overrides = parse_start_script_message_overrides,
ensure_texthooker_running = ensure_texthooker_running,
start_overlay = start_overlay,
+44 -3
View File
@@ -89,13 +89,20 @@ function M.create(ctx)
return nil
end
if type(key.code) ~= "string" then
return nil
end
if type(key.modifiers) ~= "table" then
return nil
end
local key_name = key_code_to_mpv_name(key.code)
if not key_name then
return nil
end
local parts = {}
for _, modifier in ipairs(key.modifiers or {}) do
for _, modifier in ipairs(key.modifiers) do
local mapped = MODIFIER_MAP[modifier]
if mapped then
parts[#parts + 1] = mapped
@@ -108,6 +115,8 @@ function M.create(ctx)
local function build_cli_args(action_id, payload)
if action_id == "toggleVisibleOverlay" then
return { "--toggle-visible-overlay" }
elseif action_id == "toggleStatsOverlay" then
return { "--toggle-stats-overlay" }
elseif action_id == "copySubtitle" then
return { "--copy-subtitle" }
elseif action_id == "copySubtitleMultiple" then
@@ -124,6 +133,8 @@ function M.create(ctx)
return { "--mine-sentence-count", tostring(payload and payload.count or 1) }
elseif action_id == "toggleSecondarySub" then
return { "--toggle-secondary-sub" }
elseif action_id == "toggleSubtitleSidebar" then
return { "--toggle-subtitle-sidebar" }
elseif action_id == "markAudioCard" then
return { "--mark-audio-card" }
elseif action_id == "openRuntimeOptions" then
@@ -132,6 +143,12 @@ function M.create(ctx)
return { "--open-jimaku" }
elseif action_id == "openYoutubePicker" then
return { "--open-youtube-picker" }
elseif action_id == "openSessionHelp" then
return { "--open-session-help" }
elseif action_id == "openControllerSelect" then
return { "--open-controller-select" }
elseif action_id == "openControllerDebug" then
return { "--open-controller-debug" }
elseif action_id == "openPlaylistBrowser" then
return { "--open-playlist-browser" }
elseif action_id == "replayCurrentSubtitle" then
@@ -142,6 +159,13 @@ function M.create(ctx)
return { "--shift-sub-delay-prev-line" }
elseif action_id == "shiftSubDelayNextLine" then
return { "--shift-sub-delay-next-line" }
elseif action_id == "cycleRuntimeOption" then
local runtime_option_id = payload and payload.runtimeOptionId or nil
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
return nil
end
local direction = payload and payload.direction == -1 and "prev" or "next"
return { "--cycle-runtime-option", runtime_option_id .. ":" .. direction }
end
return nil
@@ -163,7 +187,24 @@ function M.create(ctx)
for _, arg in ipairs(cli_args) do
args[#args + 1] = arg
end
process.run_binary_command_async(args, function(ok, result, error)
local runner = process.run_binary_command_async
if type(runner) ~= "function" then
runner = function(binary_args, callback)
mp.command_native_async({
name = "subprocess",
args = binary_args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
local ok = success and (result == nil or result.status == 0)
if callback then
callback(ok, result, error)
end
end)
end
end
runner(args, function(ok, result, error)
if ok then
return
end
@@ -272,7 +313,6 @@ function M.create(ctx)
local previous_binding_names = state.session_binding_names
local next_binding_names = {}
state.session_binding_names = next_binding_names
local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000
for index, binding in ipairs(artifact.bindings) do
@@ -293,6 +333,7 @@ function M.create(ctx)
end
remove_binding_names(previous_binding_names)
state.session_binding_names = next_binding_names
subminer_log(
"info",
+6
View File
@@ -90,6 +90,12 @@ function M.create(ctx)
mp.add_key_binding("y-c", "subminer-status", function()
process.check_status()
end)
mp.add_key_binding("y-h", "subminer-session-help", function()
if not ensure_binary_for_menu() then
return
end
process.run_control_command_async("open-session-help")
end)
if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function()
aniskip.skip_intro_now()
-401
View File
@@ -1,401 +0,0 @@
param(
[ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner', 'target-hwnd')]
[string]$Mode = 'geometry',
[string]$SocketPath,
[string]$OverlayWindowHandle
)
$ErrorActionPreference = 'Stop'
try {
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class SubMinerWindowsHelper {
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
public struct RECT {
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[DllImport("user32.dll")]
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool SetWindowPos(
IntPtr hWnd,
IntPtr hWndInsertAfter,
int X,
int Y,
int cx,
int cy,
uint uFlags
);
[DllImport("user32.dll", SetLastError = true)]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[DllImport("user32.dll", SetLastError = true)]
public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
[DllImport("kernel32.dll")]
public static extern void SetLastError(uint dwErrCode);
[DllImport("dwmapi.dll")]
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
}
"@
$DWMWA_EXTENDED_FRAME_BOUNDS = 9
$SWP_NOSIZE = 0x0001
$SWP_NOMOVE = 0x0002
$SWP_NOACTIVATE = 0x0010
$SWP_NOOWNERZORDER = 0x0200
$SWP_FLAGS = $SWP_NOSIZE -bor $SWP_NOMOVE -bor $SWP_NOACTIVATE -bor $SWP_NOOWNERZORDER
$GWL_EXSTYLE = -20
$WS_EX_TOPMOST = 0x00000008
$GWLP_HWNDPARENT = -8
$HWND_TOP = [IntPtr]::Zero
$HWND_BOTTOM = [IntPtr]::One
$HWND_TOPMOST = [IntPtr](-1)
$HWND_NOTOPMOST = [IntPtr](-2)
function Assert-SetWindowLongPtrSucceeded {
param(
[IntPtr]$Result,
[string]$Operation
)
if ($Result -ne [IntPtr]::Zero) {
return
}
if ([Runtime.InteropServices.Marshal]::GetLastWin32Error() -eq 0) {
return
}
$lastError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
throw "$Operation failed ($lastError)"
}
function Assert-SetWindowPosSucceeded {
param(
[bool]$Result,
[string]$Operation
)
if ($Result) {
return
}
$lastError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
throw "$Operation failed ($lastError)"
}
if ($Mode -eq 'foreground-process') {
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
if ($foregroundWindow -eq [IntPtr]::Zero) {
Write-Output 'not-found'
exit 0
}
[uint32]$foregroundProcessId = 0
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($foregroundWindow, [ref]$foregroundProcessId)
if ($foregroundProcessId -eq 0) {
Write-Output 'not-found'
exit 0
}
try {
$foregroundProcess = Get-Process -Id $foregroundProcessId -ErrorAction Stop
} catch {
Write-Output 'not-found'
exit 0
}
Write-Output "process=$($foregroundProcess.ProcessName)"
exit 0
}
if ($Mode -eq 'clear-owner') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, [IntPtr]::Zero)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'clear-owner'
Write-Output 'ok'
exit 0
}
function Get-WindowBounds {
param([IntPtr]$hWnd)
$rect = New-Object SubMinerWindowsHelper+RECT
$size = [System.Runtime.InteropServices.Marshal]::SizeOf($rect)
$dwmResult = [SubMinerWindowsHelper]::DwmGetWindowAttribute(
$hWnd,
$DWMWA_EXTENDED_FRAME_BOUNDS,
[ref]$rect,
$size
)
if ($dwmResult -ne 0) {
if (-not [SubMinerWindowsHelper]::GetWindowRect($hWnd, [ref]$rect)) {
return $null
}
}
$width = $rect.Right - $rect.Left
$height = $rect.Bottom - $rect.Top
if ($width -le 0 -or $height -le 0) {
return $null
}
return [PSCustomObject]@{
X = $rect.Left
Y = $rect.Top
Width = $width
Height = $height
Area = $width * $height
}
}
$commandLineByPid = @{}
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
foreach ($process in Get-CimInstance Win32_Process) {
$commandLineByPid[[uint32]$process.ProcessId] = $process.CommandLine
}
}
$mpvMatches = New-Object System.Collections.Generic.List[object]
$targetWindowState = 'not-found'
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
$callback = [SubMinerWindowsHelper+EnumWindowsProc]{
param([IntPtr]$hWnd, [IntPtr]$lParam)
if (-not [SubMinerWindowsHelper]::IsWindowVisible($hWnd)) {
return $true
}
[uint32]$windowProcessId = 0
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId)
if ($windowProcessId -eq 0) {
return $true
}
try {
$process = Get-Process -Id $windowProcessId -ErrorAction Stop
} catch {
return $true
}
if ($process.ProcessName -ine 'mpv') {
return $true
}
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
$commandLine = $commandLineByPid[[uint32]$windowProcessId]
if ([string]::IsNullOrWhiteSpace($commandLine)) {
return $true
}
if (
($commandLine -notlike "*--input-ipc-server=$SocketPath*") -and
($commandLine -notlike "*--input-ipc-server $SocketPath*")
) {
return $true
}
}
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
if (-not [string]::IsNullOrWhiteSpace($SocketPath) -and $targetWindowState -ne 'visible') {
$targetWindowState = 'minimized'
}
return $true
}
$bounds = Get-WindowBounds -hWnd $hWnd
if ($null -eq $bounds) {
return $true
}
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
$targetWindowState = 'visible'
}
$mpvMatches.Add([PSCustomObject]@{
HWnd = $hWnd
X = $bounds.X
Y = $bounds.Y
Width = $bounds.Width
Height = $bounds.Height
Area = $bounds.Area
IsForeground = ($foregroundWindow -ne [IntPtr]::Zero -and $hWnd -eq $foregroundWindow)
})
return $true
}
[void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero)
if ($Mode -eq 'lower-overlay') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
[void][SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow,
$HWND_NOTOPMOST,
0,
0,
0,
0,
$SWP_FLAGS
)
[void][SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow,
$HWND_BOTTOM,
0,
0,
0,
0,
$SWP_FLAGS
)
Write-Output 'ok'
exit 0
}
$focusedMatch = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1
if ($null -ne $focusedMatch) {
[Console]::Error.WriteLine('focus=focused')
} else {
[Console]::Error.WriteLine('focus=not-focused')
}
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
[Console]::Error.WriteLine("state=$targetWindowState")
}
if ($mpvMatches.Count -eq 0) {
Write-Output 'not-found'
exit 0
}
$bestMatch = if ($null -ne $focusedMatch) {
$focusedMatch
} else {
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1
}
if ($Mode -eq 'target-hwnd') {
Write-Output "$($bestMatch.HWnd)"
exit 0
}
if ($Mode -eq 'set-owner') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'set-owner'
Write-Output 'ok'
exit 0
}
if ($Mode -eq 'bind-overlay') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
exit 1
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'bind-overlay owner assignment'
$targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE)
$targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0
$overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE)
$overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0
if ($targetWindowIsTopmost -and -not $overlayIsTopmost) {
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS
)
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay topmost adjustment'
} elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) {
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS
)
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay notopmost adjustment'
}
$GW_HWNDPREV = 3
$windowAboveMpv = [SubMinerWindowsHelper]::GetWindow($targetWindow, $GW_HWNDPREV)
if ($windowAboveMpv -ne [IntPtr]::Zero -and $windowAboveMpv -eq $overlayWindow) {
Write-Output 'ok'
exit 0
}
$insertAfter = $HWND_TOP
if ($windowAboveMpv -ne [IntPtr]::Zero) {
$aboveExStyle = [SubMinerWindowsHelper]::GetWindowLong($windowAboveMpv, $GWL_EXSTYLE)
$aboveIsTopmost = ($aboveExStyle -band $WS_EX_TOPMOST) -ne 0
if ($aboveIsTopmost -eq $targetWindowIsTopmost) {
$insertAfter = $windowAboveMpv
}
}
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $insertAfter, 0, 0, 0, 0, $SWP_FLAGS
)
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay z-order adjustment'
Write-Output 'ok'
exit 0
}
Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)"
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
exit 1
}
-8
View File
@@ -8,8 +8,6 @@ const repoRoot = path.resolve(scriptDir, '..');
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
const windowsHelperSourcePath = path.join(scriptDir, 'get-mpv-window-windows.ps1');
const windowsHelperOutputPath = path.join(scriptsOutputDir, 'get-mpv-window-windows.ps1');
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
const macosHelperSourceCopyPath = path.join(scriptsOutputDir, 'get-mpv-window-macos.swift');
@@ -33,11 +31,6 @@ function copyRendererAssets() {
process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`);
}
function stageWindowsHelper() {
copyFile(windowsHelperSourcePath, windowsHelperOutputPath);
process.stdout.write(`Staged Windows helper: ${windowsHelperOutputPath}\n`);
}
function fallbackToMacosSource() {
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
@@ -77,7 +70,6 @@ function buildMacosHelper() {
function main() {
copyRendererAssets();
stageWindowsHelper();
buildMacosHelper();
}
+32
View File
@@ -75,6 +75,7 @@ test('parseArgs captures youtube startup forwarding flags', () => {
test('parseArgs captures session action forwarding flags', () => {
const args = parseArgs([
'--toggle-stats-overlay',
'--open-jimaku',
'--open-youtube-picker',
'--open-playlist-browser',
@@ -82,11 +83,14 @@ test('parseArgs captures session action forwarding flags', () => {
'--play-next-subtitle',
'--shift-sub-delay-prev-line',
'--shift-sub-delay-next-line',
'--cycle-runtime-option',
'anki.autoUpdateNewCards:prev',
'--copy-subtitle-count',
'3',
'--mine-sentence-count=2',
]);
assert.equal(args.toggleStatsOverlay, true);
assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
@@ -94,12 +98,25 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.playNextSubtitle, true);
assert.equal(args.shiftSubDelayPrevLine, true);
assert.equal(args.shiftSubDelayNextLine, true);
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
assert.equal(args.cycleRuntimeOptionDirection, -1);
assert.equal(args.copySubtitleCount, 3);
assert.equal(args.mineSentenceCount, 2);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
});
test('parseArgs ignores non-positive numeric session action counts', () => {
const args = parseArgs([
'--copy-subtitle-count=0',
'--mine-sentence-count',
'-1',
]);
assert.equal(args.copySubtitleCount, undefined);
assert.equal(args.mineSentenceCount, undefined);
});
test('youtube playback does not use generic overlay-runtime bootstrap classification', () => {
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
@@ -199,6 +216,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
assert.equal(shouldStartApp(anilistRetryQueue), false);
const toggleStatsOverlay = parseArgs(['--toggle-stats-overlay']);
assert.equal(toggleStatsOverlay.toggleStatsOverlay, true);
assert.equal(hasExplicitCommand(toggleStatsOverlay), true);
assert.equal(shouldStartApp(toggleStatsOverlay), true);
const cycleRuntimeOption = parseArgs([
'--cycle-runtime-option',
'anki.autoUpdateNewCards:next',
]);
assert.equal(cycleRuntimeOption.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
assert.equal(cycleRuntimeOption.cycleRuntimeOptionDirection, 1);
assert.equal(hasExplicitCommand(cycleRuntimeOption), true);
assert.equal(shouldStartApp(cycleRuntimeOption), true);
assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true);
const dictionary = parseArgs(['--dictionary']);
assert.equal(dictionary.dictionary, true);
assert.equal(hasExplicitCommand(dictionary), true);
+82 -7
View File
@@ -24,7 +24,12 @@ export interface CliArgs {
triggerFieldGrouping: boolean;
triggerSubsync: boolean;
markAudioCard: boolean;
toggleStatsOverlay: boolean;
toggleSubtitleSidebar: boolean;
openRuntimeOptions: boolean;
openSessionHelp: boolean;
openControllerSelect: boolean;
openControllerDebug: boolean;
openJimaku: boolean;
openYoutubePicker: boolean;
openPlaylistBrowser: boolean;
@@ -32,6 +37,8 @@ export interface CliArgs {
playNextSubtitle: boolean;
shiftSubDelayPrevLine: boolean;
shiftSubDelayNextLine: boolean;
cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1;
copySubtitleCount?: number;
mineSentenceCount?: number;
anilistStatus: boolean;
@@ -111,7 +118,12 @@ export function parseArgs(argv: string[]): CliArgs {
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
@@ -154,6 +166,24 @@ export function parseArgs(argv: string[]): CliArgs {
return value;
};
const parseCycleRuntimeOption = (
value: string | undefined,
): { id: string; direction: 1 | -1 } | null => {
if (!value) return null;
const separatorIndex = value.lastIndexOf(':');
if (separatorIndex <= 0 || separatorIndex === value.length - 1) return null;
const id = value.slice(0, separatorIndex).trim();
const rawDirection = value.slice(separatorIndex + 1).trim().toLowerCase();
if (!id) return null;
if (rawDirection === 'next' || rawDirection === '1') {
return { id, direction: 1 };
}
if (rawDirection === 'prev' || rawDirection === '-1') {
return { id, direction: -1 };
}
return null;
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg || !arg.startsWith('--')) continue;
@@ -195,7 +225,12 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--trigger-field-grouping') args.triggerFieldGrouping = true;
else if (arg === '--trigger-subsync') args.triggerSubsync = true;
else if (arg === '--mark-audio-card') args.markAudioCard = true;
else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true;
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
else if (arg === '--open-session-help') args.openSessionHelp = true;
else if (arg === '--open-controller-select') args.openControllerSelect = true;
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
else if (arg === '--open-jimaku') args.openJimaku = true;
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
@@ -203,20 +238,31 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
else if (arg.startsWith('--copy-subtitle-count=')) {
else if (arg.startsWith('--cycle-runtime-option=')) {
const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]);
if (parsed) {
args.cycleRuntimeOptionId = parsed.id;
args.cycleRuntimeOptionDirection = parsed.direction;
}
} else if (arg === '--cycle-runtime-option') {
const parsed = parseCycleRuntimeOption(readValue(argv[i + 1]));
if (parsed) {
args.cycleRuntimeOptionId = parsed.id;
args.cycleRuntimeOptionDirection = parsed.direction;
}
} else if (arg.startsWith('--copy-subtitle-count=')) {
const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value)) args.copySubtitleCount = value;
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
} else if (arg === '--copy-subtitle-count') {
const value = Number(readValue(argv[i + 1]));
if (Number.isInteger(value)) args.copySubtitleCount = value;
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
} else if (arg.startsWith('--mine-sentence-count=')) {
const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value)) args.mineSentenceCount = value;
if (Number.isInteger(value) && value > 0) args.mineSentenceCount = value;
} else if (arg === '--mine-sentence-count') {
const value = Number(readValue(argv[i + 1]));
if (Number.isInteger(value)) args.mineSentenceCount = value;
}
else if (arg === '--anilist-status') args.anilistStatus = true;
if (Number.isInteger(value) && value > 0) args.mineSentenceCount = value;
} else if (arg === '--anilist-status') args.anilistStatus = true;
else if (arg === '--anilist-logout') args.anilistLogout = true;
else if (arg === '--anilist-setup') args.anilistSetup = true;
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
@@ -407,7 +453,12 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.triggerFieldGrouping ||
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
@@ -415,6 +466,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.anilistStatus ||
@@ -468,7 +520,12 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.triggerFieldGrouping &&
!args.triggerSubsync &&
!args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
!args.openYoutubePicker &&
!args.openPlaylistBrowser &&
@@ -476,6 +533,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.cycleRuntimeOptionId === undefined &&
args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined &&
!args.anilistStatus &&
@@ -520,7 +578,12 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerFieldGrouping ||
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
@@ -528,6 +591,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.dictionary ||
@@ -567,7 +631,12 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.triggerFieldGrouping &&
!args.triggerSubsync &&
!args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
!args.openYoutubePicker &&
!args.openPlaylistBrowser &&
@@ -575,6 +644,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.cycleRuntimeOptionId === undefined &&
args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined &&
!args.anilistStatus &&
@@ -616,10 +686,14 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.mineSentenceMultiple ||
args.updateLastCardFromClipboard ||
args.toggleSecondarySub ||
args.toggleSubtitleSidebar ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
args.markAudioCard ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
@@ -627,6 +701,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined
);
+4
View File
@@ -35,7 +35,11 @@ ${B}Mining${R}
--trigger-field-grouping Run Kiku field grouping
--trigger-subsync Run subtitle sync
--toggle-secondary-sub Cycle secondary subtitle mode
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette
--open-session-help Open session help modal
--open-controller-select Open controller select modal
--open-controller-debug Open controller debug modal
${B}AniList${R}
--anilist-setup Open AniList authentication flow
+4
View File
@@ -88,6 +88,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
markAudioCard: 'CommandOrControl+Shift+A',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openSessionHelp: 'CommandOrControl+Shift+H',
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '\\',
},
secondarySub: {
secondarySubLanguages: [],
+7
View File
@@ -28,7 +28,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
@@ -36,6 +41,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
+12 -3
View File
@@ -76,9 +76,7 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
);
assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok(
calls.includes(
'log:Runtime ready: immersion tracker startup deferred until first media activity.',
),
calls.includes('log:Runtime ready: immersion tracker startup requested.'),
);
});
@@ -103,6 +101,17 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a
);
});
test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => {
const { deps, calls } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('createImmersionTracker'));
assert.ok(calls.indexOf('createImmersionTracker') < calls.indexOf('handleInitialArgs'));
});
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
+35
View File
@@ -29,8 +29,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
refreshKnownWords: false,
openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
@@ -38,6 +43,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
@@ -509,6 +516,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
expected: 'startPendingMineSentenceMultiple:2500',
},
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
{
args: { openRuntimeOptions: true },
expected: 'openRuntimeOptionsPalette',
@@ -528,6 +536,33 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
}
});
test('handleCliCommand dispatches cycle-runtime-option session action', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(
makeArgs({
cycleRuntimeOptionId: 'anki.autoUpdateNewCards',
cycleRuntimeOptionDirection: -1,
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: 'anki.autoUpdateNewCards',
direction: -1,
},
});
});
test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
+42
View File
@@ -396,8 +396,38 @@ export function handleCliCommand(
'markLastCardAsAudioCard',
'Audio card failed',
);
} else if (args.toggleStatsOverlay) {
dispatchCliSessionAction(
{ actionId: 'toggleStatsOverlay' },
'toggleStatsOverlay',
'Stats toggle failed',
);
} else if (args.toggleSubtitleSidebar) {
dispatchCliSessionAction(
{ actionId: 'toggleSubtitleSidebar' },
'toggleSubtitleSidebar',
'Subtitle sidebar toggle failed',
);
} else if (args.openRuntimeOptions) {
deps.openRuntimeOptionsPalette();
} else if (args.openSessionHelp) {
dispatchCliSessionAction(
{ actionId: 'openSessionHelp' },
'openSessionHelp',
'Open session help failed',
);
} else if (args.openControllerSelect) {
dispatchCliSessionAction(
{ actionId: 'openControllerSelect' },
'openControllerSelect',
'Open controller select failed',
);
} else if (args.openControllerDebug) {
dispatchCliSessionAction(
{ actionId: 'openControllerDebug' },
'openControllerDebug',
'Open controller debug failed',
);
} else if (args.openJimaku) {
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
} else if (args.openYoutubePicker) {
@@ -436,6 +466,18 @@ export function handleCliCommand(
'shiftSubDelayNextLine',
'Shift subtitle delay failed',
);
} else if (args.cycleRuntimeOptionId !== undefined) {
dispatchCliSessionAction(
{
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: args.cycleRuntimeOptionId,
direction: args.cycleRuntimeOptionDirection ?? 1,
},
},
'cycleRuntimeOption',
'Runtime option change failed',
);
} else if (args.copySubtitleCount !== undefined) {
dispatchCliSessionAction(
{ actionId: 'copySubtitleMultiple', payload: { count: args.copySubtitleCount } },
+54 -1
View File
@@ -3,7 +3,11 @@ import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types';
import type {
PlaylistBrowserSnapshot,
SessionActionDispatchRequest,
SubtitleSidebarSnapshot,
} from '../../types';
interface FakeIpcRegistrar {
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
@@ -860,6 +864,55 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
]);
});
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const dispatched: SessionActionDispatchRequest[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
dispatchSessionAction: async (request) => {
dispatched.push(request);
},
}),
registrar,
);
const dispatchHandler = handlers.handle.get(IPC_CHANNELS.command.dispatchSessionAction);
assert.ok(dispatchHandler);
await assert.rejects(async () => {
await dispatchHandler!({}, { actionId: 'cycleRuntimeOption', payload: { direction: 1 } });
}, /Invalid session action payload/);
await assert.rejects(async () => {
await dispatchHandler!({}, { actionId: 'unknown-action' });
}, /Invalid session action payload/);
await dispatchHandler!({}, {
actionId: 'copySubtitleMultiple',
payload: { count: 3 },
});
await dispatchHandler!({}, {
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: 'anki.autoUpdateNewCards',
direction: -1,
},
});
assert.deepEqual(dispatched, [
{
actionId: 'copySubtitleMultiple',
payload: { count: 3 },
},
{
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: 'anki.autoUpdateNewCards',
direction: -1,
},
},
]);
});
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(
+16 -18
View File
@@ -27,6 +27,7 @@ import {
parseRuntimeOptionDirection,
parseRuntimeOptionId,
parseRuntimeOptionValue,
parseSessionActionDispatchRequest,
parseSubtitlePosition,
parseSubsyncManualRunRequest,
parseYoutubePickerResolveRequest,
@@ -35,7 +36,10 @@ import {
const { ipcMain } = electron;
export interface IpcServiceDeps {
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalClosed: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayModalOpened?: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
@@ -160,7 +164,10 @@ interface IpcMainRegistrar {
export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean;
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalClosed: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayModalOpened?: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
@@ -321,10 +328,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
},
);
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (_event: unknown, modal: unknown) => {
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
deps.onOverlayModalClosed(parsedModal);
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalClosed(parsedModal, senderWindow);
});
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
@@ -451,22 +460,11 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
ipc.handle(
IPC_CHANNELS.command.dispatchSessionAction,
async (_event: unknown, request: unknown) => {
if (!request || typeof request !== 'object') {
const parsedRequest = parseSessionActionDispatchRequest(request);
if (!parsedRequest) {
throw new Error('Invalid session action payload');
}
const actionId =
typeof (request as Record<string, unknown>).actionId === 'string'
? ((request as Record<string, unknown>).actionId as SessionActionDispatchRequest['actionId'])
: null;
if (!actionId) {
throw new Error('Invalid session action id');
}
const payload =
(request as Record<string, unknown>).payload &&
typeof (request as Record<string, unknown>).payload === 'object'
? ((request as Record<string, unknown>).payload as SessionActionDispatchRequest['payload'])
: undefined;
await deps.dispatchSessionAction?.({ actionId, payload });
await deps.dispatchSessionAction?.(parsedRequest);
},
);
@@ -27,6 +27,10 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
...overrides,
};
}
@@ -319,23 +323,8 @@ test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => {
});
test('registerOverlayShortcutsRuntime reports active shortcuts when configured', () => {
const result = registerOverlayShortcutsRuntime({
getConfiguredShortcuts: () =>
({
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: 'Ctrl+J',
}) as never,
const deps = {
getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }),
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
@@ -351,30 +340,17 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
}),
cancelPendingMultiCopy: () => {},
cancelPendingMineSentenceMultiple: () => {},
});
};
const result = registerOverlayShortcutsRuntime(deps);
assert.equal(result, true);
assert.equal(unregisterOverlayShortcutsRuntime(result, deps), false);
});
test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active', () => {
const calls: string[] = [];
const result = unregisterOverlayShortcutsRuntime(true, {
getConfiguredShortcuts: () =>
({
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
}) as never,
const deps = {
getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }),
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
@@ -394,8 +370,10 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
cancelPendingMineSentenceMultiple: () => {
calls.push('cancel-mine-sentence-multiple');
},
});
};
assert.equal(registerOverlayShortcutsRuntime(deps), true);
const result = unregisterOverlayShortcutsRuntime(true, deps);
assert.equal(result, false);
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
});
@@ -22,6 +22,10 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
...overrides,
};
}
@@ -664,6 +664,80 @@ test('tracked Windows overlay stays interactive while the overlay window itself
assert.ok(!calls.includes('enforce-order'));
});
test('tracked Windows overlay reshows click-through even if focus state is stale after a modal closes', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
window.hide();
calls.length = 0;
setFocused(true);
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
});
test('tracked Windows overlay binds above mpv even when tracker focus lags', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
+5 -5
View File
@@ -1,14 +1,13 @@
import type { BrowserWindow } from 'electron';
import { BaseWindowTracker } from '../../window-trackers';
import { WindowGeometry } from '../../types';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
BrowserWindow,
ReturnType<typeof setTimeout>
>();
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
const opacityCapableWindow = window as BrowserWindow & {
setOpacity?: (opacity: number) => void;
@@ -92,6 +91,7 @@ export function updateVisibleOverlayVisibility(args: {
const showPassiveVisibleOverlay = (): void => {
const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible();
const shouldDefaultToPassthrough =
args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough;
const isVisibleOverlayFocused =
@@ -116,8 +116,10 @@ export function updateVisibleOverlayVisibility(args: {
windowsForegroundProcessName === windowsOverlayProcessName)) &&
!isTrackedWindowsTargetMinimized &&
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
const shouldIgnoreMouseEvents =
forceMousePassthrough || (shouldDefaultToPassthrough && !isVisibleOverlayFocused);
forceMousePassthrough ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost =
!args.isWindowsPlatform ||
@@ -126,8 +128,6 @@ export function updateVisibleOverlayVisibility(args: {
isTrackedWindowsTargetFocused ||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
const wasVisible = mainWindow.isVisible();
if (shouldIgnoreMouseEvents) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
} else {
@@ -0,0 +1 @@
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
+5 -1
View File
@@ -10,11 +10,12 @@ import {
} from './overlay-window-input';
import { buildOverlayWindowOptions } from './overlay-window-options';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
const overlayWindowContentReady = new WeakSet<BrowserWindow>();
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
export function isOverlayWindowContentReady(window: BrowserWindow): boolean {
if (window.isDestroyed()) {
@@ -97,6 +98,9 @@ export function createOverlayWindow(
},
): BrowserWindow {
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = false;
if (!(process.platform === 'win32' && kind === 'visible')) {
options.ensureOverlayWindowLevel(window);
+16
View File
@@ -13,8 +13,12 @@ export interface SessionActionExecutorDeps {
mineSentenceCard: () => Promise<void>;
mineSentenceCount: (count: number) => void;
toggleSecondarySub: () => void;
toggleSubtitleSidebar: () => void;
markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void;
openSessionHelp: () => void;
openControllerSelect: () => void;
openControllerDebug: () => void;
openJimaku: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
@@ -65,12 +69,24 @@ export async function dispatchSessionAction(
case 'toggleSecondarySub':
deps.toggleSecondarySub();
return;
case 'toggleSubtitleSidebar':
deps.toggleSubtitleSidebar();
return;
case 'markAudioCard':
await deps.markLastCardAsAudioCard();
return;
case 'openRuntimeOptions':
deps.openRuntimeOptionsPalette();
return;
case 'openSessionHelp':
deps.openSessionHelp();
return;
case 'openControllerSelect':
deps.openControllerSelect();
return;
case 'openControllerDebug':
deps.openControllerDebug();
return;
case 'openJimaku':
deps.openJimaku();
return;
@@ -20,6 +20,10 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
...overrides,
};
}
@@ -33,6 +37,7 @@ test('compileSessionBindings merges shortcuts and keybindings into one canonical
shortcuts: createShortcuts({
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openControllerSelect: 'Alt+C',
}),
keybindings: [
createKeybinding('KeyF', ['cycle', 'fullscreen']),
@@ -68,6 +73,13 @@ test('compileSessionBindings merges shortcuts and keybindings into one canonical
modifiers: ['ctrl', 'shift'],
target: 'openYoutubePicker',
},
{
actionType: 'session-action',
sourcePath: 'shortcuts.openControllerSelect',
code: 'KeyC',
modifiers: ['alt'],
target: 'openControllerSelect',
},
{
actionType: 'session-action',
sourcePath: 'shortcuts.openJimaku',
@@ -200,6 +212,24 @@ test('compileSessionBindings warns on unsupported shortcut and keybinding syntax
);
});
test('compileSessionBindings rejects malformed command arrays', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [
createKeybinding('Ctrl+J', ['show-text', 3000]),
createKeybinding('Ctrl+K', ['show-text', { bad: true } as never] as never),
],
platform: 'linux',
});
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
assert.equal(result.bindings[0]?.actionType, 'mpv-command');
assert.deepEqual(result.bindings[0]?.command, ['show-text', 3000]);
assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
'unsupported:keybindings[1].key',
]);
});
test('compileSessionBindings warns on deprecated toggleVisibleOverlayGlobal config', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
+58 -5
View File
@@ -45,6 +45,10 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'markAudioCard', actionId: 'markAudioCard' },
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
{ key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' },
];
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
@@ -53,6 +57,10 @@ function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier
);
}
function isValidCommandEntry(value: unknown): value is string | number {
return typeof value === 'string' || typeof value === 'number';
}
function normalizeCodeToken(token: string): string | null {
const normalized = token.trim();
if (!normalized) return null;
@@ -97,7 +105,26 @@ function normalizeCodeToken(token: string): string | null {
/^arrow(?:up|down|left|right)$/i.test(normalized) ||
/^f\d{1,2}$/i.test(normalized)
) {
return normalized[0]!.toUpperCase() + normalized.slice(1);
const keyMatch = normalized.match(/^key([a-z])$/i);
if (keyMatch) {
return `Key${keyMatch[1]!.toUpperCase()}`;
}
const digitMatch = normalized.match(/^digit([0-9])$/i);
if (digitMatch) {
return `Digit${digitMatch[1]}`;
}
const arrowMatch = normalized.match(/^arrow(up|down|left|right)$/i);
if (arrowMatch) {
const direction = arrowMatch[1]!;
return `Arrow${direction[0]!.toUpperCase()}${direction.slice(1).toLowerCase()}`;
}
const functionKeyMatch = normalized.match(/^f(\d{1,2})$/i);
if (functionKeyMatch) {
return `F${functionKeyMatch[1]}`;
}
}
return null;
}
@@ -234,8 +261,19 @@ function resolveCommandBinding(
| Omit<CompiledMpvCommandBinding, 'key' | 'sourcePath' | 'originalKey'>
| Omit<CompiledSessionActionBinding, 'key' | 'sourcePath' | 'originalKey'>
| null {
const command = binding.command ?? [];
const first = typeof command[0] === 'string' ? command[0] : '';
const command = binding.command;
if (!Array.isArray(command) || command.length === 0 || !command.every(isValidCommandEntry)) {
return null;
}
const first = command[0];
if (typeof first !== 'string') {
return {
actionType: 'mpv-command',
command,
};
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
return { actionType: 'session-action', actionId: 'triggerSubsync' };
}
@@ -264,7 +302,14 @@ function resolveCommandBinding(
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, runtimeOptionId, rawDirection] = first.split(':');
const parts = first.split(':');
if (parts.length !== 3) {
return null;
}
const [, runtimeOptionId, rawDirection] = parts;
if (!runtimeOptionId || (rawDirection !== 'prev' && rawDirection !== 'next')) {
return null;
}
return {
actionType: 'session-action',
actionId: 'cycleRuntimeOption',
@@ -379,7 +424,15 @@ export function compileSessionBindings(
return;
}
const resolved = resolveCommandBinding(binding);
if (!resolved) return;
if (!resolved) {
warnings.push({
kind: 'unsupported',
path: `keybindings[${index}].key`,
value: binding.command,
message: 'Unsupported keybinding command syntax.',
});
return;
}
const compiled: CompiledSessionBinding = {
sourcePath: `keybindings[${index}].key`,
originalKey: binding.key,
@@ -28,7 +28,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
@@ -36,6 +41,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
+2 -1
View File
@@ -311,7 +311,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.createSubtitleTimingTracker();
if (deps.createImmersionTracker) {
deps.log('Runtime ready: immersion tracker startup deferred until first media activity.');
deps.createImmersionTracker();
deps.log('Runtime ready: immersion tracker startup requested.');
} else {
deps.log('Runtime ready: immersion tracker dependency is missing.');
}
+16
View File
@@ -14,6 +14,10 @@ export interface ConfiguredShortcuts {
markAudioCard: string | null | undefined;
openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined;
openSessionHelp: string | null | undefined;
openControllerSelect: string | null | undefined;
openControllerDebug: string | null | undefined;
toggleSubtitleSidebar: string | null | undefined;
}
export function resolveConfiguredShortcuts(
@@ -78,5 +82,17 @@ export function resolveConfiguredShortcuts(
openJimaku: normalizeShortcut(
config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku,
),
openSessionHelp: normalizeShortcut(
config.shortcuts?.openSessionHelp ?? defaultConfig.shortcuts?.openSessionHelp,
),
openControllerSelect: normalizeShortcut(
config.shortcuts?.openControllerSelect ?? defaultConfig.shortcuts?.openControllerSelect,
),
openControllerDebug: normalizeShortcut(
config.shortcuts?.openControllerDebug ?? defaultConfig.shortcuts?.openControllerDebug,
),
toggleSubtitleSidebar: normalizeShortcut(
config.shortcuts?.toggleSubtitleSidebar ?? defaultConfig.shortcuts?.toggleSubtitleSidebar,
),
};
}
+136 -35
View File
@@ -133,12 +133,12 @@ import {
} from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import {
bindWindowsOverlayAboveMpvNative,
clearWindowsOverlayOwnerNative,
ensureWindowsOverlayTransparencyNative,
getWindowsForegroundProcessNameNative,
queryWindowsForegroundProcessName,
setWindowsOverlayOwnerNative,
bindWindowsOverlayAboveMpv,
clearWindowsOverlayOwner,
ensureWindowsOverlayTransparency,
findWindowsMpvTargetWindowHandle,
getWindowsForegroundProcessName,
setWindowsOverlayOwner,
} from './window-trackers/windows-helper';
import {
commandNeedsOverlayStartupPrereqs,
@@ -452,8 +452,14 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
import { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
createFrequencyDictionaryRuntimeService,
@@ -1484,9 +1490,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
openRuntimeOptionsPalette();
},
openJimaku: () => {
sendToActiveOverlayWindow('jimaku:open', undefined, {
restoreOnModalClose: 'jimaku',
});
openJimakuOverlay();
},
markAudioCard: () => markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => {
@@ -1903,7 +1907,6 @@ let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout
let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let windowsVisibleOverlayForegroundPollInFlight = false;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
@@ -1941,10 +1944,8 @@ function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | nu
}
try {
const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused?.hwnd ?? poll.matches.sort((a, b) => b.area - a.area)[0]?.hwnd ?? null;
void targetMpvSocketPath;
return findWindowsMpvTargetWindowHandle();
} catch {
return null;
}
@@ -1983,7 +1984,7 @@ async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) {
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
return true;
}
@@ -2075,7 +2076,7 @@ function maybePollWindowsVisibleOverlayForegroundProcess(): void {
return;
}
const processName = getWindowsForegroundProcessNameNative();
const processName = getWindowsForegroundProcessName();
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
@@ -2098,6 +2099,15 @@ function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
}
function clearWindowsVisibleOverlayForegroundPollLoop(): void {
if (windowsVisibleOverlayForegroundPollInterval === null) {
return;
}
clearInterval(windowsVisibleOverlayForegroundPollInterval);
windowsVisibleOverlayForegroundPollInterval = null;
}
function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32') {
return;
@@ -2205,8 +2215,84 @@ function setOverlayDebugVisualizationEnabled(enabled: boolean): void {
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled);
}
function createOverlayHostedModalOpenDeps(): {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
} {
return {
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
ensureOverlayWindowsReadyForVisibilityActions: () =>
ensureOverlayWindowsReadyForVisibilityActions(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
logWarn: (message) => logger.warn(message),
};
}
function openOverlayHostedModalWithOsd(
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
unavailableMessage: string,
failureLogMessage: string,
): void {
void openModal(createOverlayHostedModalOpenDeps()).then((opened) => {
if (!opened) {
showMpvOsd(unavailableMessage);
}
}).catch((error) => {
logger.error(failureLogMessage, error);
showMpvOsd(unavailableMessage);
});
}
function openRuntimeOptionsPalette(): void {
overlayVisibilityComposer.openRuntimeOptionsPalette();
openOverlayHostedModalWithOsd(
openRuntimeOptionsModalRuntime,
'Runtime options overlay unavailable.',
'Failed to open runtime options overlay.',
);
}
function openJimakuOverlay(): void {
openOverlayHostedModalWithOsd(
openJimakuModalRuntime,
'Jimaku overlay unavailable.',
'Failed to open Jimaku overlay.',
);
}
function openSessionHelpOverlay(): void {
openOverlayHostedModalWithOsd(
openSessionHelpModalRuntime,
'Session help overlay unavailable.',
'Failed to open session help overlay.',
);
}
function openControllerSelectOverlay(): void {
openOverlayHostedModalWithOsd(
openControllerSelectModalRuntime,
'Controller select overlay unavailable.',
'Failed to open controller select overlay.',
);
}
function openControllerDebugOverlay(): void {
openOverlayHostedModalWithOsd(
openControllerDebugModalRuntime,
'Controller debug overlay unavailable.',
'Failed to open controller debug overlay.',
);
}
function openPlaylistBrowser(): void {
@@ -2214,16 +2300,11 @@ function openPlaylistBrowser(): void {
showMpvOsd('Playlist browser requires active playback.');
return;
}
const opened = openPlaylistBrowserRuntime({
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
ensureOverlayWindowsReadyForVisibilityActions: () =>
ensureOverlayWindowsReadyForVisibilityActions(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
});
if (!opened) {
showMpvOsd('Playlist browser overlay unavailable.');
}
openOverlayHostedModalWithOsd(
openPlaylistBrowserRuntime,
'Playlist browser overlay unavailable.',
'Failed to open playlist browser overlay.',
);
}
function getResolvedConfig() {
@@ -2994,6 +3075,8 @@ const {
annotationSubtitleWsService.stop();
},
stopTexthookerService: () => texthookerService.stop(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
clearWindowsVisibleOverlayForegroundPollLoop(),
getMainOverlayWindow: () => overlayManager.getMainWindow(),
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
getModalOverlayWindow: () => overlayManager.getModalWindow(),
@@ -4057,7 +4140,7 @@ function createMainWindow(): BrowserWindow {
const window = createMainWindowHandler();
if (process.platform === 'win32') {
const overlayHwnd = getWindowsNativeWindowHandleNumber(window);
if (!ensureWindowsOverlayTransparencyNative(overlayHwnd)) {
if (!ensureWindowsOverlayTransparency(overlayHwnd)) {
logger.warn('Failed to eagerly extend Windows overlay transparency via koffi');
}
}
@@ -4236,6 +4319,10 @@ function handleCycleSecondarySubMode(): void {
cycleSecondarySubMode();
}
function toggleSubtitleSidebar(): void {
broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle);
}
async function triggerSubsyncFromConfig(): Promise<void> {
await subsyncRuntime.triggerFromConfig();
}
@@ -4520,9 +4607,13 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
mineSentenceCard: () => mineSentenceCard(),
mineSentenceCount: (count) => handleMineSentenceDigit(count),
toggleSecondarySub: () => handleCycleSecondarySubMode(),
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => overlayModalRuntime.openJimaku(),
openJimaku: () => openJimakuOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
openControllerSelect: () => openControllerSelectOverlay(),
openControllerDebug: () => openControllerDebugOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
@@ -4551,7 +4642,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => overlayModalRuntime.openJimaku(),
openJimaku: () => openJimakuOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => {
@@ -4591,7 +4682,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mainWindow.focus();
}
},
onOverlayModalClosed: (modal) => {
onOverlayModalClosed: (modal, senderWindow) => {
const modalWindow = overlayManager.getModalWindow();
if (
senderWindow &&
modalWindow &&
senderWindow === modalWindow &&
!senderWindow.isDestroyed()
) {
senderWindow.setIgnoreMouseEvents(true, { forward: true });
senderWindow.hide();
}
handleOverlayModalClosed(modal);
},
onOverlayModalOpened: (modal) => {
@@ -5090,7 +5191,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) {
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
return;
}
const tracker = appState.windowTracker;
@@ -5100,14 +5201,14 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused ?? poll.matches.sort((a, b) => b.area - a.area)[0] ?? null;
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
} catch {
return null;
}
})()
: null;
if (!mpvResult) return;
if (!setWindowsOverlayOwnerNative(overlayHwnd, mpvResult.hwnd)) {
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
logger.warn('Failed to set overlay owner via koffi');
}
},
@@ -5115,7 +5216,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (!clearWindowsOverlayOwnerNative(overlayHwnd)) {
if (!clearWindowsOverlayOwner(overlayHwnd)) {
logger.warn('Failed to clear overlay owner via koffi');
}
},
+2 -1
View File
@@ -23,7 +23,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ kind: string },
{ scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean },
{ getModalWindow: () => null },
{ getMainWindow: () => null; getModalWindow: () => null },
{
inputState: boolean;
getModalInputExclusive: () => boolean;
@@ -82,6 +82,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
}) as const,
createMainRuntimeRegistry: () => ({ registry: true }),
createOverlayManager: () => ({
getMainWindow: () => null,
getModalWindow: () => null,
}),
createOverlayModalInputState: () => ({
+22 -1
View File
@@ -74,6 +74,7 @@ export interface MainBootServicesParams<
getModalWindow: () => BrowserWindow | null;
syncOverlayShortcutsForModal: (isActive: boolean) => void;
syncOverlayVisibilityForModal: () => void;
restoreMainWindowFocus?: () => void;
}) => TOverlayModalInputState;
createOverlayContentMeasurementStore: (params: {
logger: TLogger;
@@ -131,7 +132,7 @@ export function createMainBootServices<
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager extends { getModalWindow: () => BrowserWindow | null },
TOverlayManager extends { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null },
TOverlayModalInputState extends OverlayModalInputStateShape,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
@@ -212,6 +213,26 @@ export function createMainBootServices<
syncOverlayVisibilityForModal: () => {
params.getSyncOverlayVisibilityForModal()();
},
restoreMainWindowFocus: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return;
try {
const electron = require('electron') as {
app?: { focus?: (options?: { steal?: boolean }) => void };
};
electron.app?.focus?.({ steal: true });
} catch {
// Ignore in non-Electron environments.
}
const maybeFocusable = mainWindow as typeof mainWindow & {
setFocusable?: (focusable: boolean) => void;
};
maybeFocusable.setFocusable?.(true);
mainWindow.focus();
if (!mainWindow.webContents.isFocused()) {
mainWindow.webContents.focus();
}
},
});
const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({
logger,
+213 -18
View File
@@ -7,13 +7,16 @@ type MockWindow = {
visible: boolean;
focused: boolean;
ignoreMouseEvents: boolean;
forwardedIgnoreMouseEvents: boolean;
webContentsFocused: boolean;
showCount: number;
hideCount: number;
sent: unknown[][];
loading: boolean;
url: string;
contentReady: boolean;
loadCallbacks: Array<() => void>;
readyToShowCallbacks: Array<() => void>;
};
function createMockWindow(): MockWindow & {
@@ -28,7 +31,9 @@ function createMockWindow(): MockWindow & {
getHideCount: () => number;
show: () => void;
hide: () => void;
destroy: () => void;
focus: () => void;
once: (event: 'ready-to-show', cb: () => void) => void;
webContents: {
focused: boolean;
isLoading: () => boolean;
@@ -44,13 +49,16 @@ function createMockWindow(): MockWindow & {
visible: false,
focused: false,
ignoreMouseEvents: false,
forwardedIgnoreMouseEvents: false,
webContentsFocused: false,
showCount: 0,
hideCount: 0,
sent: [],
loading: false,
url: 'file:///overlay/index.html?layer=modal',
contentReady: true,
loadCallbacks: [],
readyToShowCallbacks: [],
};
const window = {
...state,
@@ -58,8 +66,9 @@ function createMockWindow(): MockWindow & {
isVisible: () => state.visible,
isFocused: () => state.focused,
getURL: () => state.url,
setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
state.ignoreMouseEvents = ignore;
state.forwardedIgnoreMouseEvents = options?.forward === true;
},
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
moveTop: () => {},
@@ -73,9 +82,16 @@ function createMockWindow(): MockWindow & {
state.visible = false;
state.hideCount += 1;
},
destroy: () => {
state.destroyed = true;
state.visible = false;
},
focus: () => {
state.focused = true;
},
once: (_event: 'ready-to-show', cb: () => void) => {
state.readyToShowCallbacks.push(cb);
},
webContents: {
isLoading: () => state.loading,
getURL: () => state.url,
@@ -139,6 +155,25 @@ function createMockWindow(): MockWindow & {
},
});
Object.defineProperty(window, 'forwardedIgnoreMouseEvents', {
get: () => state.forwardedIgnoreMouseEvents,
set: (value: boolean) => {
state.forwardedIgnoreMouseEvents = value;
},
});
Object.defineProperty(window, 'contentReady', {
get: () => state.contentReady,
set: (value: boolean) => {
state.contentReady = value;
(window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
value;
},
});
(window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
state.contentReady;
return window;
}
@@ -195,10 +230,29 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
assert.deepEqual(window.sent, [['jimaku:open']]);
});
test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => null,
createModalWindow: () => null,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
assert.equal(
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
}),
false,
);
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), false);
});
test('sendToActiveOverlayWindow waits for blank modal URL before sending open command', () => {
const window = createMockWindow();
window.url = '';
window.loading = true;
window.contentReady = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
@@ -217,9 +271,14 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
assert.deepEqual(window.sent, []);
assert.equal(window.loadCallbacks.length, 1);
assert.equal(window.readyToShowCallbacks.length, 1);
window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal';
window.loadCallbacks[0]!();
assert.deepEqual(window.sent, []);
window.contentReady = true;
window.readyToShowCallbacks[0]!();
runtime.notifyOverlayModalOpened('runtime-options');
assert.deepEqual(window.sent, [['runtime-options:open']]);
@@ -248,10 +307,10 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
);
runtime.handleOverlayModalClosed('runtime-options');
assert.equal(window.getHideCount(), 0);
assert.equal(window.isDestroyed(), false);
runtime.handleOverlayModalClosed('subsync');
assert.equal(window.getHideCount(), 1);
assert.equal(window.isDestroyed(), true);
});
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => {
@@ -325,11 +384,12 @@ test('modal window path makes visible main overlay click-through until modal clo
assert.equal(sent, true);
assert.equal(mainWindow.ignoreMouseEvents, true);
assert.equal(mainWindow.forwardedIgnoreMouseEvents, true);
assert.equal(modalWindow.ignoreMouseEvents, false);
runtime.handleOverlayModalClosed('youtube-track-picker');
assert.equal(mainWindow.ignoreMouseEvents, false);
assert.equal(mainWindow.ignoreMouseEvents, true);
});
test('modal window path hides visible main overlay until modal closes', () => {
@@ -359,8 +419,8 @@ test('modal window path hides visible main overlay until modal closes', () => {
runtime.handleOverlayModalClosed('youtube-track-picker');
assert.equal(mainWindow.getShowCount(), 1);
assert.equal(mainWindow.isVisible(), true);
assert.equal(mainWindow.getShowCount(), 0);
assert.equal(mainWindow.isVisible(), false);
});
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
@@ -437,7 +497,7 @@ test('notifyOverlayModalOpened enables input on visible main overlay window when
assert.equal(mainWindow.webContentsFocused, true);
});
test('handleOverlayModalClosed resets modal state even when modal window does not exist', () => {
test('handleOverlayModalClosed is a no-op when no modal window can be targeted', () => {
const state: boolean[] = [];
const runtime = createOverlayModalRuntimeService(
{
@@ -454,16 +514,17 @@ test('handleOverlayModalClosed resets modal state even when modal window does no
},
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
assert.equal(sent, false);
runtime.notifyOverlayModalOpened('runtime-options');
runtime.handleOverlayModalClosed('runtime-options');
assert.deepEqual(state, [true, false]);
assert.deepEqual(state, []);
});
test('handleOverlayModalClosed hides modal window for single kiku modal', () => {
test('handleOverlayModalClosed destroys modal window for single kiku modal', () => {
const window = createMockWindow();
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
@@ -482,11 +543,11 @@ test('handleOverlayModalClosed hides modal window for single kiku modal', () =>
);
runtime.handleOverlayModalClosed('kiku');
assert.equal(window.getHideCount(), 1);
assert.equal(window.isDestroyed(), true);
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
});
test('modal fallback reveal keeps mouse events ignored until modal confirms open', async () => {
test('modal fallback reveal skips showing window when content is not ready', async () => {
const window = createMockWindow();
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
@@ -500,30 +561,164 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
window.loading = true;
window.url = '';
window.contentReady = false;
const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, {
restoreOnModalClose: 'jimaku',
});
assert.equal(sent, true);
assert.equal(window.ignoreMouseEvents, false);
await new Promise<void>((resolve) => {
setTimeout(resolve, 260);
});
assert.equal(window.getShowCount(), 1);
assert.equal(window.ignoreMouseEvents, false);
assert.equal(window.getShowCount(), 0);
runtime.notifyOverlayModalOpened('jimaku');
assert.equal(window.getShowCount(), 1);
assert.equal(window.ignoreMouseEvents, false);
});
test('waitForModalOpen resolves true after modal acknowledgement', async () => {
test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => {
const window = createMockWindow();
window.contentReady = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => null,
createModalWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => {
throw new Error('modal window should not be created when already present');
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
assert.equal(sent, true);
assert.deepEqual(window.sent, []);
assert.equal(window.loadCallbacks.length, 1);
assert.equal(window.readyToShowCallbacks.length, 1);
window.loadCallbacks[0]!();
assert.deepEqual(window.sent, []);
window.contentReady = true;
window.readyToShowCallbacks[0]!();
assert.deepEqual(window.sent, [['runtime-options:open']]);
});
test('modal reopen creates a fresh window after close destroys the previous one', () => {
const firstWindow = createMockWindow();
const secondWindow = createMockWindow();
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () =>
currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null,
createModalWindow: () => {
currentModal = secondWindow;
return secondWindow as never;
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
runtime.notifyOverlayModalOpened('runtime-options');
runtime.handleOverlayModalClosed('runtime-options');
assert.equal(firstWindow.isDestroyed(), true);
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
assert.equal(sent, true);
assert.equal(currentModal, secondWindow);
assert.equal(secondWindow.getShowCount(), 0);
});
test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => {
const firstWindow = createMockWindow();
const secondWindow = createMockWindow();
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
const state: boolean[] = [];
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () =>
currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null,
createModalWindow: () => {
currentModal = secondWindow;
return secondWindow as never;
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{
onModalStateChange: (active: boolean): void => {
state.push(active);
},
},
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
runtime.notifyOverlayModalOpened('runtime-options');
runtime.handleOverlayModalClosed('runtime-options');
assert.deepEqual(state, [true, false]);
assert.equal(firstWindow.isDestroyed(), true);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
runtime.notifyOverlayModalOpened('runtime-options');
assert.deepEqual(state, [true, false, true]);
assert.equal(currentModal, secondWindow);
});
test('visible stale modal window is made interactive again before reopening', () => {
const window = createMockWindow();
window.visible = true;
window.focused = true;
window.webContentsFocused = false;
window.ignoreMouseEvents = true;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
assert.equal(sent, true);
assert.equal(window.ignoreMouseEvents, false);
assert.equal(window.isFocused(), true);
assert.equal(window.webContentsFocused, true);
assert.deepEqual(window.sent, [['runtime-options:open']]);
});
test('waitForModalOpen resolves true after modal acknowledgement', async () => {
const modalWindow = createMockWindow();
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
+72 -26
View File
@@ -1,9 +1,30 @@
import type { BrowserWindow } from 'electron';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
function requestOverlayApplicationFocus(): void {
try {
const electron = require('electron') as {
app?: {
focus?: (options?: { steal?: boolean }) => void;
};
};
electron.app?.focus?.({ steal: true });
} catch {
// Ignore focus-steal failures in non-Electron test environments.
}
}
function setWindowFocusable(window: BrowserWindow): void {
const maybeFocusableWindow = window as BrowserWindow & {
setFocusable?: (focusable: boolean) => void;
};
maybeFocusableWindow.setFocusable?.(true);
}
export interface OverlayWindowResolver {
getMainWindow: () => BrowserWindow | null;
getModalWindow: () => BrowserWindow | null;
@@ -42,6 +63,7 @@ export function createOverlayModalRuntimeService(
let modalActive = false;
let mainWindowMousePassthroughForcedByModal = false;
let mainWindowHiddenByModal = false;
let modalWindowPrimedForImmediateShow = false;
let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -87,9 +109,21 @@ export function createOverlayModalRuntimeService(
};
const isWindowReadyForIpc = (window: BrowserWindow): boolean => {
if (window.isDestroyed()) {
return false;
}
if (window.webContents.isLoading()) {
return false;
}
const overlayWindow = window as BrowserWindow & {
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
};
if (
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
) {
return false;
}
const currentURL = window.webContents.getURL();
return currentURL !== '' && currentURL !== 'about:blank';
};
@@ -109,11 +143,17 @@ export function createOverlayModalRuntimeService(
return;
}
window.webContents.once('did-finish-load', () => {
if (!window.isDestroyed() && !window.webContents.isLoading()) {
sendNow(window);
let delivered = false;
const deliverWhenReady = (): void => {
if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) {
return;
}
});
delivered = true;
sendNow(window);
};
window.webContents.once('did-finish-load', deliverWhenReady);
window.once('ready-to-show', deliverWhenReady);
};
const showModalWindow = (
@@ -122,6 +162,8 @@ export function createOverlayModalRuntimeService(
passThroughMouseEvents: boolean;
} = { passThroughMouseEvents: false },
): void => {
setWindowFocusable(window);
requestOverlayApplicationFocus();
if (!window.isVisible()) {
window.show();
}
@@ -138,15 +180,14 @@ export function createOverlayModalRuntimeService(
};
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
setWindowFocusable(window);
requestOverlayApplicationFocus();
window.setIgnoreMouseEvents(false);
elevateModalWindow(window);
if (window.isVisible()) {
window.setIgnoreMouseEvents(false);
if (!window.isFocused()) {
window.focus();
}
if (!window.webContents.isFocused()) {
window.webContents.focus();
}
elevateModalWindow(window);
window.focus();
window.webContents.focus();
return;
}
@@ -231,6 +272,9 @@ export function createOverlayModalRuntimeService(
if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) {
return;
}
if (!isWindowReadyForIpc(targetWindow)) {
return;
}
showModalWindow(targetWindow, { passThroughMouseEvents: false });
}, MODAL_REVEAL_FALLBACK_DELAY_MS);
};
@@ -256,9 +300,9 @@ export function createOverlayModalRuntimeService(
};
if (restoreOnModalClose) {
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
const mainWindow = getTargetOverlayWindow();
if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
sendOrQueueForWindow(mainWindow, (window) => {
if (payload === undefined) {
window.webContents.send(channel);
@@ -272,15 +316,23 @@ export function createOverlayModalRuntimeService(
const modalWindow = resolveModalWindow();
if (!modalWindow) return false;
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
deps.setModalWindowBounds(deps.getModalGeometry());
const wasVisible = modalWindow.isVisible();
if (!wasVisible) {
scheduleModalWindowReveal(modalWindow);
if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
showModalWindow(modalWindow);
} else {
scheduleModalWindowReveal(modalWindow);
}
} else if (!modalWindow.isFocused()) {
showModalWindow(modalWindow);
}
sendOrQueueForWindow(modalWindow, (window) => {
if (window.isVisible()) {
ensureModalWindowInteractive(window);
}
if (payload === undefined) {
window.webContents.send(channel);
} else {
@@ -320,12 +372,13 @@ export function createOverlayModalRuntimeService(
const modalWindow = deps.getModalWindow();
if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal();
notifyModalStateChange(false);
setMainWindowMousePassthroughForModal(false);
setMainWindowVisibilityForModal(false);
if (modalWindow && !modalWindow.isDestroyed()) {
modalWindow.hide();
modalWindow.destroy();
}
modalWindowPrimedForImmediateShow = false;
mainWindowMousePassthroughForcedByModal = false;
mainWindowHiddenByModal = false;
notifyModalStateChange(false);
}
};
@@ -350,14 +403,7 @@ export function createOverlayModalRuntimeService(
}
if (targetWindow.isVisible()) {
targetWindow.setIgnoreMouseEvents(false);
elevateModalWindow(targetWindow);
if (!targetWindow.isFocused()) {
targetWindow.focus();
}
if (!targetWindow.webContents.isFocused()) {
targetWindow.webContents.focus();
}
ensureModalWindowInteractive(targetWindow);
return;
}
@@ -16,6 +16,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
clearWindowsVisibleOverlayForegroundPollLoop: () => calls.push('clear-windows-visible-overlay-poll'),
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
@@ -40,9 +41,10 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 28);
assert.equal(calls.length, 29);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
@@ -6,6 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
destroyMainOverlayWindow: () => void;
destroyModalOverlayWindow: () => void;
destroyYomitanParserWindow: () => void;
@@ -36,6 +37,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.destroyMainOverlayWindow();
deps.destroyModalOverlayWindow();
deps.destroyYomitanParserWindow();
@@ -18,6 +18,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
getMainOverlayWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-main-overlay-window'),
@@ -85,6 +87,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('stop-discord-presence'));
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
assert.equal(reconnectTimer, null);
assert.equal(immersionTracker, null);
});
@@ -99,6 +102,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
getMainOverlayWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-main-overlay-window'),
@@ -25,6 +25,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
clearMainOverlayWindow: () => void;
getModalOverlayWindow: () => DestroyableWindow | null;
@@ -64,6 +65,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
destroyMainOverlayWindow: () => {
const window = deps.getMainOverlayWindow();
if (!window) return;
@@ -21,6 +21,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
getMainOverlayWindow: () => null,
clearMainOverlayWindow: () => {},
getModalOverlayWindow: () => null,
+48
View File
@@ -0,0 +1,48 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const CONTROLLER_DEBUG_MODAL: OverlayHostedModal = 'controller-debug';
const CONTROLLER_DEBUG_OPEN_TIMEOUT_MS = 1500;
export async function openControllerDebugModal(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: CONTROLLER_DEBUG_MODAL,
timeoutMs: CONTROLLER_DEBUG_OPEN_TIMEOUT_MS,
retryWarning:
'Controller debug modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.controllerDebugOpen,
modal: CONTROLLER_DEBUG_MODAL,
preferModalWindow: true,
},
),
},
);
}
@@ -0,0 +1,48 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const CONTROLLER_SELECT_MODAL: OverlayHostedModal = 'controller-select';
const CONTROLLER_SELECT_OPEN_TIMEOUT_MS = 1500;
export async function openControllerSelectModal(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: CONTROLLER_SELECT_MODAL,
timeoutMs: CONTROLLER_SELECT_OPEN_TIMEOUT_MS,
retryWarning:
'Controller select modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.controllerSelectOpen,
modal: CONTROLLER_SELECT_MODAL,
preferModalWindow: true,
},
),
},
);
}
@@ -42,7 +42,12 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
@@ -50,6 +55,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
@@ -86,6 +93,17 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
});
test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => {
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ start: true, copySubtitleCount: 2 })),
false,
);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, mineSentenceCount: 1 })),
false,
);
});
test('setup service auto-completes legacy installs with config and dictionaries', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
@@ -68,15 +68,26 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.hideVisibleOverlay ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.copySubtitleCount !== undefined ||
args.mineSentence ||
args.mineSentenceMultiple ||
args.mineSentenceCount !== undefined ||
args.updateLastCardFromClipboard ||
args.refreshKnownWords ||
args.toggleSecondarySub ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.openRuntimeOptions ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.anilistStatus ||
args.anilistLogout ||
args.anilistSetup ||
@@ -18,6 +18,10 @@ function createShortcuts(): ConfiguredShortcuts {
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
};
}
@@ -22,6 +22,10 @@ function createShortcuts(): ConfiguredShortcuts {
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
};
}
@@ -161,6 +161,44 @@ test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv
assert.ok(calls.includes('info:Auto-connecting MPV client for immersion tracking'));
});
test('createImmersionTrackerStartupHandler keeps tracker startup alive when mpv auto-connect throws', () => {
const calls: string[] = [];
const trackerInstance = { kind: 'tracker' };
let assignedTracker: unknown = null;
const handler = createImmersionTrackerStartupHandler({
getResolvedConfig: () => makeConfig(),
getConfiguredDbPath: () => '/tmp/subminer.db',
createTrackerService: () => trackerInstance,
setTracker: (nextTracker) => {
assignedTracker = nextTracker;
},
getMpvClient: () => ({
connected: false,
connect: () => {
throw new Error('socket not ready');
},
}),
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message, details) => calls.push(`warn:${message}:${(details as Error).message}`),
});
handler();
assert.equal(assignedTracker, trackerInstance);
assert.ok(calls.includes('seedTracker'));
assert.ok(
calls.includes(
'warn:MPV auto-connect failed during immersion tracker startup; continuing.:socket not ready',
),
);
assert.equal(
calls.some((entry) => entry.startsWith('warn:Immersion tracker startup failed; disabling tracking.')),
false,
);
});
test('createImmersionTrackerStartupHandler disables tracker on failure', () => {
const calls: string[] = [];
let assignedTracker: unknown = 'initial';
+5 -1
View File
@@ -102,7 +102,11 @@ export function createImmersionTrackerStartupHandler(
const mpvClient = deps.getMpvClient();
if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) {
deps.logInfo('Auto-connecting MPV client for immersion tracking');
mpvClient.connect();
try {
mpvClient.connect();
} catch (error) {
deps.logWarn('MPV auto-connect failed during immersion tracker startup; continuing.', error);
}
}
deps.seedTrackerFromCurrentMedia();
} catch (error) {
+48
View File
@@ -0,0 +1,48 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const JIMAKU_MODAL: OverlayHostedModal = 'jimaku';
const JIMAKU_OPEN_TIMEOUT_MS = 1500;
export async function openJimakuModal(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: JIMAKU_MODAL,
timeoutMs: JIMAKU_OPEN_TIMEOUT_MS,
retryWarning:
'Jimaku modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.jimakuOpen,
modal: JIMAKU_MODAL,
preferModalWindow: true,
},
),
},
);
}
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openOverlayHostedModal } from './overlay-hosted-modal-open';
test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => {
const calls: string[] = [];
const opened = openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: () => {
calls.push('ensureOverlayStartupPrereqs');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
},
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
calls.push(`send:${channel}`);
assert.equal(payload, undefined);
assert.deepEqual(runtimeOptions, {
restoreOnModalClose: 'runtime-options',
preferModalWindow: undefined,
});
return true;
},
},
{
channel: 'runtime-options:open',
modal: 'runtime-options',
},
);
assert.equal(opened, true);
assert.deepEqual(calls, [
'ensureOverlayStartupPrereqs',
'ensureOverlayWindowsReadyForVisibilityActions',
'send:runtime-options:open',
]);
});
test('openOverlayHostedModal forwards payload and modal-window preference', () => {
const payload = { sessionId: 'yt-1' };
const opened = openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: (channel, forwardedPayload, runtimeOptions) => {
assert.equal(channel, 'youtube:picker-open');
assert.deepEqual(forwardedPayload, payload);
assert.deepEqual(runtimeOptions, {
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow: true,
});
return false;
},
},
{
channel: 'youtube:picker-open',
modal: 'youtube-track-picker',
payload,
preferModalWindow: true,
},
);
assert.equal(opened, false);
});
@@ -0,0 +1,57 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
export function openOverlayHostedModal(
deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
},
input: {
channel: string;
modal: OverlayHostedModal;
payload?: unknown;
preferModalWindow?: boolean;
},
): boolean {
deps.ensureOverlayStartupPrereqs();
deps.ensureOverlayWindowsReadyForVisibilityActions();
return deps.sendToActiveOverlayWindow(input.channel, input.payload, {
restoreOnModalClose: input.modal,
preferModalWindow: input.preferModalWindow,
});
}
export async function retryOverlayModalOpen(
deps: {
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
},
input: {
modal: OverlayHostedModal;
timeoutMs: number;
retryWarning: string;
sendOpen: () => boolean;
},
): Promise<boolean> {
if (!input.sendOpen()) {
return false;
}
if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) {
return true;
}
deps.logWarn(input.retryWarning);
if (!input.sendOpen()) {
return false;
}
return await deps.waitForModalOpen(input.modal, input.timeoutMs);
}
@@ -23,6 +23,9 @@ function createModalWindow() {
setIgnoreMouseEvents: (ignore: boolean) => {
calls.push(`ignore:${ignore}`);
},
setFocusable: (focusable: boolean) => {
calls.push(`focusable:${focusable}`);
},
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
},
@@ -58,6 +61,7 @@ test('overlay modal input state activates modal window interactivity and syncs d
assert.equal(state.getModalInputExclusive(), true);
assert.deepEqual(modalWindow.calls, [
'focusable:true',
'ignore:false',
'top:true:screen-saver:1',
'focus',
@@ -66,6 +70,25 @@ test('overlay modal input state activates modal window interactivity and syncs d
assert.deepEqual(calls, ['shortcuts:true', 'visibility']);
});
test('overlay modal input state restores main window focus on deactivation', () => {
const modalWindow = createModalWindow();
const calls: string[] = [];
const state = createOverlayModalInputState({
getModalWindow: () => modalWindow as never,
syncOverlayShortcutsForModal: () => {},
syncOverlayVisibilityForModal: () => {},
restoreMainWindowFocus: () => {
calls.push('restore-focus');
},
});
state.handleModalInputStateChange(true);
assert.deepEqual(calls, []);
state.handleModalInputStateChange(false);
assert.deepEqual(calls, ['restore-focus']);
});
test('overlay modal input state is idempotent for unchanged state', () => {
const calls: string[] = [];
const state = createOverlayModalInputState({
@@ -1,9 +1,30 @@
import type { BrowserWindow } from 'electron';
function requestOverlayApplicationFocus(): void {
try {
const electron = require('electron') as {
app?: {
focus?: (options?: { steal?: boolean }) => void;
};
};
electron.app?.focus?.({ steal: true });
} catch {
// Ignore focus-steal failures in non-Electron test environments.
}
}
function setWindowFocusable(window: BrowserWindow): void {
const maybeFocusableWindow = window as BrowserWindow & {
setFocusable?: (focusable: boolean) => void;
};
maybeFocusableWindow.setFocusable?.(true);
}
export type OverlayModalInputStateDeps = {
getModalWindow: () => BrowserWindow | null;
syncOverlayShortcutsForModal: (isActive: boolean) => void;
syncOverlayVisibilityForModal: () => void;
restoreMainWindowFocus?: () => void;
};
export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
@@ -18,6 +39,8 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
if (isActive) {
const modalWindow = deps.getModalWindow();
if (modalWindow && !modalWindow.isDestroyed()) {
setWindowFocusable(modalWindow);
requestOverlayApplicationFocus();
modalWindow.setIgnoreMouseEvents(false);
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
modalWindow.focus();
@@ -29,6 +52,9 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
deps.syncOverlayShortcutsForModal(isActive);
deps.syncOverlayVisibilityForModal();
if (!isActive) {
deps.restoreMainWindowFocus?.();
}
};
return {
+22 -2
View File
@@ -3,10 +3,10 @@ import test from 'node:test';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openPlaylistBrowser } from './playlist-browser-open';
test('playlist browser open bootstraps overlay runtime before dispatching the modal event', () => {
test('playlist browser open bootstraps overlay runtime and sends modal event with preferModalWindow', async () => {
const calls: string[] = [];
const opened = openPlaylistBrowser({
const opened = await openPlaylistBrowser({
ensureOverlayStartupPrereqs: () => {
calls.push('prereqs');
},
@@ -18,11 +18,31 @@ test('playlist browser open bootstraps overlay runtime before dispatching the mo
assert.equal(payload, undefined);
assert.deepEqual(runtimeOptions, {
restoreOnModalClose: 'playlist-browser',
preferModalWindow: true,
});
return true;
},
waitForModalOpen: async () => true,
logWarn: () => {},
});
assert.equal(opened, true);
assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.playlistBrowserOpen}`]);
});
test('playlist browser open retries after first attempt timeout', async () => {
let attempt = 0;
const opened = await openPlaylistBrowser({
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: () => true,
waitForModalOpen: async () => {
attempt += 1;
return attempt >= 2;
},
logWarn: () => {},
});
assert.equal(opened, true);
assert.equal(attempt, 2);
});
+32 -7
View File
@@ -1,9 +1,11 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const PLAYLIST_BROWSER_MODAL: OverlayHostedModal = 'playlist-browser';
const PLAYLIST_BROWSER_OPEN_TIMEOUT_MS = 1500;
export function openPlaylistBrowser(deps: {
export async function openPlaylistBrowser(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
@@ -14,10 +16,33 @@ export function openPlaylistBrowser(deps: {
preferModalWindow?: boolean;
},
) => boolean;
}): boolean {
deps.ensureOverlayStartupPrereqs();
deps.ensureOverlayWindowsReadyForVisibilityActions();
return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, {
restoreOnModalClose: PLAYLIST_BROWSER_MODAL,
});
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: PLAYLIST_BROWSER_MODAL,
timeoutMs: PLAYLIST_BROWSER_OPEN_TIMEOUT_MS,
retryWarning:
'Playlist browser modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.playlistBrowserOpen,
modal: PLAYLIST_BROWSER_MODAL,
preferModalWindow: true,
},
),
},
);
}
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openRuntimeOptionsModal } from './runtime-options-open';
test('runtime options open prefers dedicated modal window on first attempt', async () => {
const calls: string[] = [];
const opened = await openRuntimeOptionsModal({
ensureOverlayStartupPrereqs: () => {
calls.push('ensure-startup');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('ensure-windows');
},
sendToActiveOverlayWindow: (channel, payload, options) => {
calls.push(`send:${channel}`);
assert.equal(payload, undefined);
assert.deepEqual(options, {
restoreOnModalClose: 'runtime-options',
preferModalWindow: true,
});
return true;
},
waitForModalOpen: async (modal, timeoutMs) => {
assert.equal(modal, 'runtime-options');
assert.equal(timeoutMs, 1500);
return true;
},
logWarn: () => {
throw new Error('should not warn on first-attempt success');
},
});
assert.equal(opened, true);
assert.deepEqual(calls, ['ensure-startup', 'ensure-windows', 'send:runtime-options:open']);
});
test('runtime options open retries after an open timeout', async () => {
const calls: string[] = [];
const warnings: string[] = [];
let waitCalls = 0;
const opened = await openRuntimeOptionsModal({
ensureOverlayStartupPrereqs: () => {
calls.push('ensure-startup');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('ensure-windows');
},
sendToActiveOverlayWindow: (channel, payload, options) => {
calls.push(`send:${channel}`);
assert.equal(payload, undefined);
assert.deepEqual(options, {
restoreOnModalClose: 'runtime-options',
preferModalWindow: true,
});
return true;
},
waitForModalOpen: async (modal, timeoutMs) => {
assert.equal(modal, 'runtime-options');
assert.equal(timeoutMs, 1500);
waitCalls += 1;
return waitCalls === 2;
},
logWarn: (message) => {
warnings.push(message);
},
});
assert.equal(opened, true);
assert.deepEqual(calls, [
'ensure-startup',
'ensure-windows',
'send:runtime-options:open',
'ensure-startup',
'ensure-windows',
'send:runtime-options:open',
]);
assert.deepEqual(warnings, [
'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
]);
});
test('runtime options open fails when no overlay window can be targeted', async () => {
let waitCalls = 0;
const opened = await openRuntimeOptionsModal({
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: () => false,
waitForModalOpen: async () => {
waitCalls += 1;
return true;
},
logWarn: () => {},
});
assert.equal(opened, false);
assert.equal(waitCalls, 0);
});
+47
View File
@@ -0,0 +1,47 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const RUNTIME_OPTIONS_MODAL: OverlayHostedModal = 'runtime-options';
const RUNTIME_OPTIONS_OPEN_TIMEOUT_MS = 1500;
export async function openRuntimeOptionsModal(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: RUNTIME_OPTIONS_MODAL,
timeoutMs: RUNTIME_OPTIONS_OPEN_TIMEOUT_MS,
retryWarning:
'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: 'runtime-options:open',
modal: RUNTIME_OPTIONS_MODAL,
preferModalWindow: true,
},
),
},
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const SESSION_HELP_MODAL: OverlayHostedModal = 'session-help';
const SESSION_HELP_OPEN_TIMEOUT_MS = 1500;
export async function openSessionHelpModal(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: SESSION_HELP_MODAL,
timeoutMs: SESSION_HELP_OPEN_TIMEOUT_MS,
retryWarning:
'Session help modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.sessionHelpOpen,
modal: SESSION_HELP_MODAL,
preferModalWindow: true,
},
),
},
);
}
+17 -19
View File
@@ -1,5 +1,6 @@
import type { YoutubePickerOpenPayload } from '../../types';
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { retryOverlayModalOpen } from './overlay-hosted-modal-open';
const YOUTUBE_PICKER_MODAL: OverlayHostedModal = 'youtube-track-picker';
const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500;
@@ -19,24 +20,21 @@ export async function openYoutubeTrackPicker(
},
payload: YoutubePickerOpenPayload,
): Promise<boolean> {
const sendPickerOpen = (): boolean =>
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
preferModalWindow: true,
});
if (!sendPickerOpen()) {
return false;
}
if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) {
return true;
}
deps.logWarn(
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: YOUTUBE_PICKER_MODAL,
timeoutMs: YOUTUBE_PICKER_OPEN_TIMEOUT_MS,
retryWarning:
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
preferModalWindow: true,
}),
},
);
if (!sendPickerOpen()) {
return false;
}
return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS);
}
+10
View File
@@ -123,6 +123,9 @@ function createQueuedIpcListenerWithPayload<T>(
}
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
const onOpenControllerSelectEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerSelectOpen);
const onOpenControllerDebugEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerDebugOpen);
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<YoutubePickerOpenPayload>(
IPC_CHANNELS.event.youtubePickerOpen,
@@ -142,6 +145,9 @@ const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManua
IPC_CHANNELS.event.subsyncOpenManual,
(payload) => payload as SubsyncManualPayload,
);
const onSubtitleSidebarToggleEvent = createQueuedIpcListener(
IPC_CHANNELS.event.subtitleSidebarToggle,
);
const onKikuFieldGroupingRequestEvent =
createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>(
IPC_CHANNELS.event.kikuFieldGroupingRequest,
@@ -326,9 +332,13 @@ const electronAPI: ElectronAPI = {
);
},
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
onOpenSessionHelp: onOpenSessionHelpEvent,
onOpenControllerSelect: onOpenControllerSelectEvent,
onOpenControllerDebug: onOpenControllerDebugEvent,
onOpenJimaku: onOpenJimakuEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
+59 -18
View File
@@ -69,6 +69,10 @@ function installKeyboardTestGlobals() {
markAudioCard: '',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openSessionHelp: 'CommandOrControl+Shift+H',
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '',
toggleVisibleOverlayGlobal: '',
};
let markActiveVideoWatchedResult = true;
@@ -321,8 +325,6 @@ function installKeyboardTestGlobals() {
function createKeyboardHandlerHarness() {
const testGlobals = installKeyboardTestGlobals();
const subtitleRootClassList = createClassList();
let controllerSelectOpenCount = 0;
let controllerDebugOpenCount = 0;
let controllerSelectKeydownCount = 0;
let playlistBrowserKeydownCount = 0;
@@ -373,20 +375,12 @@ function createKeyboardHandlerHarness() {
openSessionHelpModal: () => {},
appendClipboardVideoToQueue: () => {},
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
openControllerSelectModal: () => {
controllerSelectOpenCount += 1;
},
openControllerDebugModal: () => {
controllerDebugOpenCount += 1;
},
});
return {
ctx,
handlers,
testGlobals,
controllerSelectOpenCount: () => controllerSelectOpenCount,
controllerDebugOpenCount: () => controllerDebugOpenCount,
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
setWordCount: (count: number) => {
@@ -659,31 +653,78 @@ test('keyboard mode: controller helpers dispatch popup audio play/cycle and scro
}
});
test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
test('keyboard mode: configured controller debug binding dispatches session action', async () => {
const { testGlobals, handlers } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openControllerDebug',
originalKey: 'Alt+Shift+D',
key: { code: 'KeyD', modifiers: ['alt', 'shift'] },
actionType: 'session-action',
actionId: 'openControllerDebug',
},
] as never);
testGlobals.dispatchKeydown({
key: 'C',
code: 'KeyC',
key: 'D',
code: 'KeyD',
altKey: true,
shiftKey: true,
});
assert.equal(controllerDebugOpenCount(), 1);
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openControllerDebug', payload: undefined }]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
test('keyboard mode: configured controller debug binding is not swallowed while popup is visible', async () => {
const { ctx, testGlobals, handlers } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openControllerDebug',
originalKey: 'Alt+Shift+D',
key: { code: 'KeyD', modifiers: ['alt', 'shift'] },
actionType: 'session-action',
actionId: 'openControllerDebug',
},
] as never);
testGlobals.dispatchKeydown({
key: 'D',
code: 'KeyD',
altKey: true,
shiftKey: true,
});
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openControllerDebug', payload: undefined }]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: former fixed Alt+Shift+C does nothing when controller debug is remapped', async () => {
const { testGlobals, handlers } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openControllerDebug',
originalKey: 'Alt+Shift+D',
key: { code: 'KeyD', modifiers: ['alt', 'shift'] },
actionType: 'session-action',
actionId: 'openControllerDebug',
},
] as never);
testGlobals.dispatchKeydown({
key: 'C',
@@ -692,7 +733,7 @@ test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup i
shiftKey: true,
});
assert.equal(controllerDebugOpenCount(), 1);
assert.deepEqual(testGlobals.sessionActions, []);
} finally {
testGlobals.restore();
}
+1 -20
View File
@@ -27,8 +27,6 @@ export function createKeyboardHandlers(
}) => void;
appendClipboardVideoToQueue: () => void;
getPlaybackPaused: () => Promise<boolean | null>;
openControllerSelectModal: () => void;
openControllerDebugModal: () => void;
toggleSubtitleSidebarModal?: () => void;
},
) {
@@ -298,10 +296,6 @@ export function createKeyboardHandlers(
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
}
function isControllerModalShortcut(e: KeyboardEvent): boolean {
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
}
function isSubtitleSidebarToggle(e: KeyboardEvent): boolean {
const toggleKey = ctx.state.subtitleSidebarToggleKey;
if (!toggleKey) return false;
@@ -1040,10 +1034,7 @@ export function createKeyboardHandlers(
return;
}
if (
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
!isControllerModalShortcut(e)
) {
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
return;
@@ -1100,16 +1091,6 @@ export function createKeyboardHandlers(
return;
}
if (isControllerModalShortcut(e)) {
e.preventDefault();
if (e.shiftKey) {
options.openControllerDebugModal();
} else {
options.openControllerSelectModal();
}
return;
}
const keyString = keyEventToString(e);
const binding = ctx.state.sessionBindingMap.get(keyString);
if (binding) {
+215
View File
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI, RuntimeOptionState } from '../../types';
import { createRendererState } from '../state.js';
import { createRuntimeOptionsModal } from './runtime-options.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) {
tokens.delete(entry);
return false;
}
tokens.add(entry);
return true;
}
if (force) tokens.add(entry);
else tokens.delete(entry);
return force;
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
return {
className: '',
textContent: '',
title: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
};
}
function createRuntimeOptionsListStub() {
return {
innerHTML: '',
appendChild: () => {},
querySelector: () => null,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function withRuntimeOptionsModal(
getRuntimeOptions: () => Promise<RuntimeOptionState[]>,
run: (input: {
modal: ReturnType<typeof createRuntimeOptionsModal>;
state: ReturnType<typeof createRendererState>;
overlayClassList: ReturnType<typeof createClassList>;
modalClassList: ReturnType<typeof createClassList>;
statusNode: {
textContent: string;
classList: ReturnType<typeof createClassList>;
};
syncCalls: string[];
}) => Promise<void> | void,
): Promise<void> {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const statusNode = {
textContent: '',
classList: createClassList(),
};
const overlayClassList = createClassList();
const modalClassList = createClassList(['hidden']);
const syncCalls: string[] = [];
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
electronAPI: {
getRuntimeOptions,
setRuntimeOptionValue: async () => ({ ok: true }),
notifyOverlayModalClosed: () => {},
} satisfies Pick<
ElectronAPI,
'getRuntimeOptions' | 'setRuntimeOptionValue' | 'notifyOverlayModalClosed'
>,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: () => createElementStub(),
},
});
const modal = createRuntimeOptionsModal(
{
dom: {
overlay: { classList: overlayClassList },
runtimeOptionsModal: {
classList: modalClassList,
setAttribute: () => {},
},
runtimeOptionsClose: {
addEventListener: () => {},
},
runtimeOptionsList: createRuntimeOptionsListStub(),
runtimeOptionsStatus: statusNode,
},
state,
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {
syncCalls.push('sync');
},
},
);
return Promise.resolve()
.then(() =>
run({
modal,
state,
overlayClassList,
modalClassList,
statusNode,
syncCalls,
}),
)
.finally(() => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: previousWindow,
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: previousDocument,
});
});
}
test('openRuntimeOptionsModal shows loading shell before runtime options resolve', async () => {
const deferred = createDeferred<RuntimeOptionState[]>();
await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
input.modal.openRuntimeOptionsModal();
assert.equal(input.state.runtimeOptionsModalOpen, true);
assert.equal(input.overlayClassList.contains('interactive'), true);
assert.equal(input.modalClassList.contains('hidden'), false);
assert.equal(input.statusNode.textContent, 'Loading runtime options...');
assert.deepEqual(input.syncCalls, ['sync']);
deferred.resolve([
{
id: 'anki.autoUpdateNewCards',
label: 'Auto-update new cards',
scope: 'ankiConnect',
valueType: 'boolean',
value: true,
allowedValues: [true, false],
requiresRestart: false,
},
]);
await flushAsyncWork();
assert.equal(
input.statusNode.textContent,
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
);
assert.equal(input.statusNode.classList.contains('error'), false);
});
});
test('openRuntimeOptionsModal keeps modal visible when loading fails', async () => {
const deferred = createDeferred<RuntimeOptionState[]>();
await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
input.modal.openRuntimeOptionsModal();
deferred.reject(new Error('boom'));
await flushAsyncWork();
assert.equal(input.state.runtimeOptionsModalOpen, true);
assert.equal(input.overlayClassList.contains('interactive'), true);
assert.equal(input.modalClassList.contains('hidden'), false);
assert.equal(input.statusNode.textContent, 'Failed to load runtime options');
assert.equal(input.statusNode.classList.contains('error'), true);
});
});
+20 -4
View File
@@ -22,6 +22,9 @@ export function createRuntimeOptionsModal(
syncSettingsModalSubtitleSuppression: () => void;
},
) {
const DEFAULT_STATUS_MESSAGE =
'Use arrow keys. Click value to cycle. Enter or double-click to apply.';
function formatRuntimeOptionValue(value: RuntimeOptionValue): string {
if (typeof value === 'boolean') {
return value ? 'On' : 'Off';
@@ -177,10 +180,13 @@ export function createRuntimeOptionsModal(
}
}
async function openRuntimeOptionsModal(): Promise<void> {
async function refreshRuntimeOptions(): Promise<void> {
const optionsList = await window.electronAPI.getRuntimeOptions();
updateRuntimeOptions(optionsList);
setRuntimeOptionsStatus(DEFAULT_STATUS_MESSAGE);
}
function showRuntimeOptionsModalShell(): void {
ctx.state.runtimeOptionsModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
@@ -188,9 +194,19 @@ export function createRuntimeOptionsModal(
ctx.dom.runtimeOptionsModal.classList.remove('hidden');
ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false');
setRuntimeOptionsStatus(
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
);
setRuntimeOptionsStatus('Loading runtime options...');
}
function openRuntimeOptionsModal(): void {
if (!ctx.state.runtimeOptionsModalOpen) {
showRuntimeOptionsModalShell();
} else {
setRuntimeOptionsStatus('Refreshing runtime options...');
}
void refreshRuntimeOptions().catch(() => {
setRuntimeOptionsStatus('Failed to load runtime options', true);
});
}
function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean {
+26 -22
View File
@@ -586,31 +586,10 @@ export function createSessionHelpModal(
}
}
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
function openSessionHelpModal(opening: SessionHelpBindingInfo): void {
openBinding = opening;
priorFocus = document.activeElement;
const dataLoaded = await render();
ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`;
if (openBinding.fallbackUnavailable) {
ctx.dom.sessionHelpWarning.textContent =
'Both Y-H and Y-K are bound; Y-K remains the fallback for this session.';
} else if (openBinding.fallbackUsed) {
ctx.dom.sessionHelpWarning.textContent = 'Y-H is already bound; using Y-K as fallback.';
} else {
ctx.dom.sessionHelpWarning.textContent = '';
}
if (dataLoaded) {
ctx.dom.sessionHelpStatus.textContent =
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
} else {
ctx.dom.sessionHelpStatus.textContent =
'Session help data is unavailable right now. Press Esc to close.';
ctx.dom.sessionHelpWarning.textContent =
'Unable to load latest shortcut settings from the runtime.';
}
ctx.state.sessionHelpModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
@@ -623,6 +602,17 @@ export function createSessionHelpModal(
window.electronAPI.setIgnoreMouseEvents(false);
}
ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`;
if (openBinding.fallbackUnavailable) {
ctx.dom.sessionHelpWarning.textContent =
'Both Y-H and Y-K are bound; Y-K remains the fallback for this session.';
} else if (openBinding.fallbackUsed) {
ctx.dom.sessionHelpWarning.textContent = 'Y-H is already bound; using Y-K as fallback.';
} else {
ctx.dom.sessionHelpWarning.textContent = '';
}
ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...';
if (focusGuard === null) {
focusGuard = (event: FocusEvent) => {
if (!ctx.state.sessionHelpModalOpen) return;
@@ -639,6 +629,19 @@ export function createSessionHelpModal(
requestOverlayFocus();
window.focus();
enforceModalFocus();
void render().then((dataLoaded) => {
if (!ctx.state.sessionHelpModalOpen) return;
if (dataLoaded) {
ctx.dom.sessionHelpStatus.textContent =
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
} else {
ctx.dom.sessionHelpStatus.textContent =
'Session help data is unavailable right now. Press Esc to close.';
ctx.dom.sessionHelpWarning.textContent =
'Unable to load latest shortcut settings from the runtime.';
}
});
}
function closeSessionHelpModal(): void {
@@ -648,6 +651,7 @@ export function createSessionHelpModal(
options.syncSettingsModalSubtitleSuppression();
ctx.dom.sessionHelpModal.classList.add('hidden');
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('session-help');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
+31 -17
View File
@@ -178,14 +178,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
void window.electronAPI.appendClipboardVideoToQueue();
},
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
openControllerSelectModal: () => {
controllerSelectModal.openControllerSelectModal();
window.electronAPI.notifyOverlayModalOpened('controller-select');
},
openControllerDebugModal: () => {
controllerDebugModal.openControllerDebugModal();
window.electronAPI.notifyOverlayModalOpened('controller-debug');
},
toggleSubtitleSidebarModal: () => {
void subtitleSidebarModal.toggleSubtitleSidebarModal();
},
@@ -432,15 +424,31 @@ registerRendererGlobalErrorHandlers(window, recovery);
function registerModalOpenHandlers(): void {
window.electronAPI.onOpenRuntimeOptions(() => {
runGuardedAsync('runtime-options:open', async () => {
try {
await runtimeOptionsModal.openRuntimeOptionsModal();
window.electronAPI.notifyOverlayModalOpened('runtime-options');
} catch {
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
window.electronAPI.notifyOverlayModalClosed('runtime-options');
syncSettingsModalSubtitleSuppression();
}
runGuarded('runtime-options:open', () => {
runtimeOptionsModal.openRuntimeOptionsModal();
window.electronAPI.notifyOverlayModalOpened('runtime-options');
});
});
window.electronAPI.onOpenSessionHelp(() => {
runGuarded('session-help:open', () => {
sessionHelpModal.openSessionHelpModal({
bindingKey: 'KeyH',
fallbackUsed: false,
fallbackUnavailable: false,
});
window.electronAPI.notifyOverlayModalOpened('session-help');
});
});
window.electronAPI.onOpenControllerSelect(() => {
runGuarded('controller-select:open', () => {
controllerSelectModal.openControllerSelectModal();
window.electronAPI.notifyOverlayModalOpened('controller-select');
});
});
window.electronAPI.onOpenControllerDebug(() => {
runGuarded('controller-debug:open', () => {
controllerDebugModal.openControllerDebugModal();
window.electronAPI.notifyOverlayModalOpened('controller-debug');
});
});
window.electronAPI.onOpenJimaku(() => {
@@ -498,6 +506,12 @@ function registerKeyboardCommandHandlers(): void {
keyboardHandlers.handleLookupWindowToggleRequested();
});
});
window.electronAPI.onSubtitleSidebarToggle(() => {
runGuarded('subtitle-sidebar:toggle', () => {
void subtitleSidebarModal.toggleSubtitleSidebarModal();
});
});
}
function runGuarded(action: string, fn: () => void): void {
+5
View File
@@ -11,6 +11,7 @@ export const OVERLAY_HOSTED_MODALS = [
'controller-select',
'controller-debug',
'subtitle-sidebar',
'session-help',
] as const;
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
@@ -111,6 +112,10 @@ export const IPC_CHANNELS = {
playlistBrowserOpen: 'playlist-browser:open',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
controllerSelectOpen: 'controller-select:open',
controllerDebugOpen: 'controller-debug:open',
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
configHotReload: 'config:hot-reload',
},
} as const;
+73
View File
@@ -8,12 +8,37 @@ import type {
import type {
ControllerConfigUpdate,
ControllerPreferenceUpdate,
SessionActionDispatchRequest,
SubsyncManualRunRequest,
} from '../../types/runtime';
import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-options';
import type { SessionActionId, SessionActionPayload } from '../../types/session-bindings';
import type { SubtitlePosition } from '../../types/subtitle';
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
const SESSION_ACTION_IDS: SessionActionId[] = [
'toggleStatsOverlay',
'toggleVisibleOverlay',
'copySubtitle',
'copySubtitleMultiple',
'updateLastCardFromClipboard',
'triggerFieldGrouping',
'triggerSubsync',
'mineSentence',
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openRuntimeOptions',
'openJimaku',
'openYoutubePicker',
'openPlaylistBrowser',
'replayCurrentSubtitle',
'playNextSubtitle',
'shiftSubDelayPrevLine',
'shiftSubDelayNextLine',
'cycleRuntimeOption',
];
const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
'anki.autoUpdateNewCards',
'subtitle.annotation.nPlusOne',
@@ -35,6 +60,43 @@ function isInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value);
}
function isSessionActionId(value: unknown): value is SessionActionId {
return typeof value === 'string' && SESSION_ACTION_IDS.includes(value as SessionActionId);
}
function parseSessionActionPayload(
actionId: SessionActionId,
value: unknown,
): SessionActionPayload | undefined | null {
if (actionId === 'copySubtitleMultiple' || actionId === 'mineSentenceMultiple') {
if (value === undefined) return undefined;
if (!isObject(value)) return null;
const keys = Object.keys(value);
if (keys.some((key) => key !== 'count')) return null;
if (value.count === undefined) return null;
if (!isInteger(value.count) || value.count < 1) return null;
return { count: value.count };
}
if (actionId === 'cycleRuntimeOption') {
if (!isObject(value)) return null;
const keys = Object.keys(value);
if (keys.some((key) => key !== 'runtimeOptionId' && key !== 'direction')) return null;
if (typeof value.runtimeOptionId !== 'string' || value.runtimeOptionId.trim().length === 0) {
return null;
}
if (value.direction !== 1 && value.direction !== -1) {
return null;
}
return {
runtimeOptionId: value.runtimeOptionId,
direction: value.direction,
};
}
return value === undefined ? undefined : null;
}
export function parseOverlayHostedModal(value: unknown): OverlayHostedModal | null {
if (typeof value !== 'string') return null;
return OVERLAY_HOSTED_MODALS.includes(value as OverlayHostedModal)
@@ -182,6 +244,17 @@ export function parseRuntimeOptionValue(value: unknown): RuntimeOptionValue | nu
: null;
}
export function parseSessionActionDispatchRequest(
value: unknown,
): SessionActionDispatchRequest | null {
if (!isObject(value)) return null;
if (!isSessionActionId(value.actionId)) return null;
const payload = parseSessionActionPayload(value.actionId, value.payload);
if (payload === null) return null;
return payload === undefined ? { actionId: value.actionId } : { actionId: value.actionId, payload };
}
export function parseMpvCommand(value: unknown): Array<string | number> | null {
if (!Array.isArray(value)) return null;
return value.every((entry) => typeof entry === 'string' || typeof entry === 'number')
+4
View File
@@ -89,6 +89,10 @@ export interface ShortcutsConfig {
markAudioCard?: string | null;
openRuntimeOptions?: string | null;
openJimaku?: string | null;
openSessionHelp?: string | null;
openControllerSelect?: string | null;
openControllerDebug?: string | null;
toggleSubtitleSidebar?: string | null;
}
export interface Config {
+8 -2
View File
@@ -399,9 +399,13 @@ export interface ElectronAPI {
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise<RuntimeOptionApplyResult>;
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
onOpenRuntimeOptions: (callback: () => void) => void;
onOpenSessionHelp: (callback: () => void) => void;
onOpenControllerSelect: (callback: () => void) => void;
onOpenControllerDebug: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void;
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenPlaylistBrowser: (callback: () => void) => void;
onSubtitleSidebarToggle: (callback: () => void) => void;
onCancelYoutubeTrackPicker: (callback: () => void) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void;
@@ -427,7 +431,8 @@ export interface ElectronAPI {
| 'kiku'
| 'controller-select'
| 'controller-debug'
| 'subtitle-sidebar',
| 'subtitle-sidebar'
| 'session-help',
) => void;
notifyOverlayModalOpened: (
modal:
@@ -439,7 +444,8 @@ export interface ElectronAPI {
| 'kiku'
| 'controller-select'
| 'controller-debug'
| 'subtitle-sidebar',
| 'subtitle-sidebar'
| 'session-help',
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
+4
View File
@@ -11,8 +11,12 @@ export type SessionActionId =
| 'mineSentence'
| 'mineSentenceMultiple'
| 'toggleSecondarySub'
| 'toggleSubtitleSidebar'
| 'markAudioCard'
| 'openRuntimeOptions'
| 'openSessionHelp'
| 'openControllerSelect'
| 'openControllerDebug'
| 'openJimaku'
| 'openYoutubePicker'
| 'openPlaylistBrowser'
+42 -269
View File
@@ -1,287 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
lowerWindowsOverlayInZOrder,
parseWindowTrackerHelperForegroundProcess,
parseWindowTrackerHelperFocusState,
parseWindowTrackerHelperOutput,
parseWindowTrackerHelperState,
queryWindowsForegroundProcessName,
queryWindowsTargetWindowHandle,
queryWindowsTrackerMpvWindows,
resolveWindowsTrackerHelper,
syncWindowsOverlayToMpvZOrder,
} from './windows-helper';
import { findWindowsMpvTargetWindowHandle } from './windows-helper';
import type { MpvPollResult } from './win32';
test('parseWindowTrackerHelperOutput parses helper geometry output', () => {
assert.deepEqual(parseWindowTrackerHelperOutput('120,240,1280,720'), {
x: 120,
y: 240,
width: 1280,
height: 720,
});
});
test('parseWindowTrackerHelperOutput returns null for misses and invalid payloads', () => {
assert.equal(parseWindowTrackerHelperOutput('not-found'), null);
assert.equal(parseWindowTrackerHelperOutput('1,2,3'), null);
assert.equal(parseWindowTrackerHelperOutput('1,2,0,4'), null);
});
test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => {
assert.equal(parseWindowTrackerHelperFocusState('focus=focused'), true);
assert.equal(parseWindowTrackerHelperFocusState('focus=not-focused'), false);
assert.equal(parseWindowTrackerHelperFocusState('warning\nfocus=focused\nnote'), true);
assert.equal(parseWindowTrackerHelperFocusState(''), null);
});
test('parseWindowTrackerHelperState parses helper stderr metadata', () => {
assert.equal(parseWindowTrackerHelperState('state=visible'), 'visible');
assert.equal(parseWindowTrackerHelperState('focus=not-focused\nstate=minimized'), 'minimized');
assert.equal(parseWindowTrackerHelperState('state=unknown'), null);
assert.equal(parseWindowTrackerHelperState(''), null);
});
test('parseWindowTrackerHelperForegroundProcess parses helper stdout metadata', () => {
assert.equal(parseWindowTrackerHelperForegroundProcess('process=mpv'), 'mpv');
assert.equal(parseWindowTrackerHelperForegroundProcess('process=chrome'), 'chrome');
assert.equal(parseWindowTrackerHelperForegroundProcess('not-found'), null);
assert.equal(parseWindowTrackerHelperForegroundProcess(''), null);
});
test('queryWindowsForegroundProcessName reads foreground process from powershell helper', async () => {
const processName = await queryWindowsForegroundProcessName({
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => ({
stdout: 'process=mpv',
stderr: '',
}),
});
assert.equal(processName, 'mpv');
});
test('queryWindowsForegroundProcessName returns null when no powershell helper is available', async () => {
const processName = await queryWindowsForegroundProcessName({
resolveHelper: () => ({
kind: 'native',
command: 'helper.exe',
args: [],
helperPath: 'helper.exe',
}),
});
assert.equal(processName, null);
});
test('syncWindowsOverlayToMpvZOrder forwards socket path and overlay handle to powershell helper', async () => {
let capturedMode: string | null = null;
let capturedArgs: string[] | null = null;
const synced = await syncWindowsOverlayToMpvZOrder({
overlayWindowHandle: '12345',
targetMpvSocketPath: '\\\\.\\pipe\\subminer-socket',
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async (_spec, mode, extraArgs = []) => {
capturedMode = mode;
capturedArgs = extraArgs;
return {
stdout: 'ok',
stderr: '',
};
},
});
assert.equal(synced, true);
assert.equal(capturedMode, 'bind-overlay');
assert.deepEqual(capturedArgs, ['\\\\.\\pipe\\subminer-socket', '12345']);
});
test('lowerWindowsOverlayInZOrder forwards overlay handle to powershell helper', async () => {
let capturedMode: string | null = null;
let capturedArgs: string[] | null = null;
const lowered = await lowerWindowsOverlayInZOrder({
overlayWindowHandle: '67890',
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async (_spec, mode, extraArgs = []) => {
capturedMode = mode;
capturedArgs = extraArgs;
return {
stdout: 'ok',
stderr: '',
};
},
});
assert.equal(lowered, true);
assert.equal(capturedMode, 'lower-overlay');
assert.deepEqual(capturedArgs, ['', '67890']);
});
test('queryWindowsTrackerMpvWindows resolves geometry from the powershell helper', () => {
let capturedMode: string | null = null;
let capturedArgs: string[] | null = null;
const result = queryWindowsTrackerMpvWindows({
targetMpvSocketPath: '\\\\.\\pipe\\subminer-socket',
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelperSync: (_spec, mode, extraArgs = []) => {
capturedMode = mode;
capturedArgs = extraArgs;
return {
stdout: '120,240,1280,720',
stderr: 'focus=focused\nstate=visible',
};
},
});
assert.deepEqual(result, {
test('findWindowsMpvTargetWindowHandle prefers the focused mpv window', () => {
const result: MpvPollResult = {
matches: [
{
hwnd: 0,
bounds: {
x: 120,
y: 240,
width: 1280,
height: 720,
},
hwnd: 111,
bounds: { x: 0, y: 0, width: 1280, height: 720 },
area: 1280 * 720,
isForeground: false,
},
{
hwnd: 222,
bounds: { x: 10, y: 10, width: 800, height: 600 },
area: 800 * 600,
isForeground: true,
},
],
focusState: true,
windowState: 'visible',
});
assert.equal(capturedMode, 'geometry');
assert.deepEqual(capturedArgs, ['\\\\.\\pipe\\subminer-socket']);
};
assert.equal(findWindowsMpvTargetWindowHandle(result), 222);
});
test('queryWindowsTargetWindowHandle resolves the selected hwnd from the powershell helper', () => {
let capturedMode: string | null = null;
let capturedArgs: string[] | null = null;
const hwnd = queryWindowsTargetWindowHandle({
targetMpvSocketPath: '\\\\.\\pipe\\subminer-socket',
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelperSync: (_spec, mode, extraArgs = []) => {
capturedMode = mode;
capturedArgs = extraArgs;
return {
stdout: '12345',
stderr: '',
};
},
});
assert.equal(hwnd, 12345);
assert.equal(capturedMode, 'target-hwnd');
assert.deepEqual(capturedArgs, ['\\\\.\\pipe\\subminer-socket']);
});
test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
resourcesPath: 'C:\\repo\\resources',
existsSync: (candidate) =>
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
helperModeEnv: 'auto',
});
assert.deepEqual(helper, {
kind: 'native',
command: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
args: [],
helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
});
});
test('resolveWindowsTrackerHelper auto mode falls back to powershell helper', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
resourcesPath: 'C:\\repo\\resources',
existsSync: (candidate) =>
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
helperModeEnv: 'auto',
});
assert.deepEqual(helper, {
kind: 'powershell',
command: 'powershell.exe',
args: [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
test('findWindowsMpvTargetWindowHandle falls back to the largest visible mpv window', () => {
const result: MpvPollResult = {
matches: [
{
hwnd: 111,
bounds: { x: 0, y: 0, width: 640, height: 360 },
area: 640 * 360,
isForeground: false,
},
{
hwnd: 222,
bounds: { x: 10, y: 10, width: 1920, height: 1080 },
area: 1920 * 1080,
isForeground: false,
},
],
helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
});
focusState: false,
windowState: 'visible',
};
assert.equal(findWindowsMpvTargetWindowHandle(result), 222);
});
test('resolveWindowsTrackerHelper explicit powershell mode ignores native helper', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
resourcesPath: 'C:\\repo\\resources',
existsSync: (candidate) =>
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe' ||
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
helperModeEnv: 'powershell',
});
test('findWindowsMpvTargetWindowHandle returns null when no mpv windows are visible', () => {
const result: MpvPollResult = {
matches: [],
focusState: false,
windowState: 'not-found',
};
assert.equal(helper?.kind, 'powershell');
assert.equal(helper?.helperPath, 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1');
});
test('resolveWindowsTrackerHelper explicit native mode fails cleanly when helper is missing', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
resourcesPath: 'C:\\repo\\resources',
existsSync: () => false,
helperModeEnv: 'native',
});
assert.equal(helper, null);
});
test('resolveWindowsTrackerHelper explicit helper path overrides default search', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',
resourcesPath: 'C:\\repo\\resources',
existsSync: (candidate) => candidate === 'D:\\custom\\tracker.ps1',
helperModeEnv: 'auto',
helperPathEnv: 'D:\\custom\\tracker.ps1',
});
assert.deepEqual(helper, {
kind: 'powershell',
command: 'powershell.exe',
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\custom\\tracker.ps1'],
helperPath: 'D:\\custom\\tracker.ps1',
});
assert.equal(findWindowsMpvTargetWindowHandle(result), null);
});
+18 -531
View File
@@ -16,488 +16,41 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { execFile, spawnSync, type ExecFileException } from 'child_process';
import type { WindowGeometry } from '../types';
import type { MpvPollResult } from './win32';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows-helper');
export type WindowsTrackerHelperKind = 'powershell' | 'native';
export type WindowsTrackerHelperMode = 'auto' | 'powershell' | 'native';
export type WindowsTrackerHelperRunMode =
| 'geometry'
| 'foreground-process'
| 'bind-overlay'
| 'lower-overlay'
| 'set-owner'
| 'clear-owner'
| 'target-hwnd';
export type WindowsTrackerHelperLaunchSpec = {
kind: WindowsTrackerHelperKind;
command: string;
args: string[];
helperPath: string;
};
type ResolveWindowsTrackerHelperOptions = {
dirname?: string;
resourcesPath?: string;
helperModeEnv?: string | undefined;
helperPathEnv?: string | undefined;
existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
copyFileSync?: (source: string, destination: string) => void;
};
const windowsPath = path.win32;
function normalizeHelperMode(value: string | undefined): WindowsTrackerHelperMode {
const normalized = value?.trim().toLowerCase();
if (normalized === 'powershell' || normalized === 'native') {
return normalized;
}
return 'auto';
function loadWin32(): typeof import('./win32') {
return require('./win32') as typeof import('./win32');
}
function inferHelperKindFromPath(helperPath: string): WindowsTrackerHelperKind | null {
const normalized = helperPath.trim().toLowerCase();
if (normalized.endsWith('.exe')) return 'native';
if (normalized.endsWith('.ps1')) return 'powershell';
return null;
export function findWindowsMpvTargetWindowHandle(result?: MpvPollResult): number | null {
const poll = result ?? loadWin32().findMpvWindows();
const focused = poll.matches.find((match) => match.isForeground);
const best =
focused ?? [...poll.matches].sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0];
return best?.hwnd ?? null;
}
function materializeAsarHelper(
sourcePath: string,
kind: WindowsTrackerHelperKind,
deps: Required<Pick<ResolveWindowsTrackerHelperOptions, 'mkdirSync' | 'copyFileSync'>>,
): string | null {
if (!sourcePath.includes('.asar')) {
return sourcePath;
}
const fileName = kind === 'native' ? 'get-mpv-window-windows.exe' : 'get-mpv-window-windows.ps1';
const targetDir = windowsPath.join(os.tmpdir(), 'subminer', 'helpers');
const targetPath = windowsPath.join(targetDir, fileName);
export function setWindowsOverlayOwner(overlayHwnd: number, mpvHwnd: number): boolean {
try {
deps.mkdirSync(targetDir, { recursive: true });
deps.copyFileSync(sourcePath, targetPath);
log.info(`Materialized Windows helper from asar: ${targetPath}`);
return targetPath;
} catch (error) {
log.warn(`Failed to materialize Windows helper from asar: ${sourcePath}`, error);
return null;
}
}
function createLaunchSpec(
helperPath: string,
kind: WindowsTrackerHelperKind,
): WindowsTrackerHelperLaunchSpec {
if (kind === 'native') {
return {
kind,
command: helperPath,
args: [],
helperPath,
};
}
return {
kind,
command: 'powershell.exe',
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', helperPath],
helperPath,
};
}
function normalizeHelperPathOverride(
helperPathEnv: string | undefined,
mode: WindowsTrackerHelperMode,
): { path: string; kind: WindowsTrackerHelperKind } | null {
const helperPath = helperPathEnv?.trim();
if (!helperPath) {
return null;
}
const inferredKind = inferHelperKindFromPath(helperPath);
const kind = mode === 'auto' ? inferredKind : mode;
if (!kind) {
log.warn(
`Ignoring SUBMINER_WINDOWS_TRACKER_HELPER_PATH with unsupported extension: ${helperPath}`,
);
return null;
}
return { path: helperPath, kind };
}
function getHelperCandidates(
dirname: string,
resourcesPath: string | undefined,
): Array<{
path: string;
kind: WindowsTrackerHelperKind;
}> {
const scriptFileBase = 'get-mpv-window-windows';
const candidates: Array<{ path: string; kind: WindowsTrackerHelperKind }> = [];
if (resourcesPath) {
candidates.push({
path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.exe`),
kind: 'native',
});
candidates.push({
path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.ps1`),
kind: 'powershell',
});
}
candidates.push({
path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.exe`),
kind: 'native',
});
candidates.push({
path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.ps1`),
kind: 'powershell',
});
candidates.push({
path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.exe`),
kind: 'native',
});
candidates.push({
path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.ps1`),
kind: 'powershell',
});
return candidates;
}
export function parseWindowTrackerHelperOutput(output: string): WindowGeometry | null {
const result = output.trim();
if (!result || result === 'not-found') {
return null;
}
const parts = result.split(',');
if (parts.length !== 4) {
return null;
}
const [xText, yText, widthText, heightText] = parts;
const x = Number.parseInt(xText!, 10);
const y = Number.parseInt(yText!, 10);
const width = Number.parseInt(widthText!, 10);
const height = Number.parseInt(heightText!, 10);
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return null;
}
return { x, y, width, height };
}
export function parseWindowTrackerHelperFocusState(output: string): boolean | null {
const focusLine = output
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith('focus='));
if (!focusLine) {
return null;
}
const value = focusLine.slice('focus='.length).trim().toLowerCase();
if (value === 'focused') {
return true;
}
if (value === 'not-focused') {
return false;
}
return null;
}
export function parseWindowTrackerHelperState(output: string): 'visible' | 'minimized' | null {
const stateLine = output
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith('state='));
if (!stateLine) {
return null;
}
const value = stateLine.slice('state='.length).trim().toLowerCase();
if (value === 'visible') {
return 'visible';
}
if (value === 'minimized') {
return 'minimized';
}
return null;
}
export function parseWindowTrackerHelperForegroundProcess(output: string): string | null {
const processLine = output
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith('process='));
if (!processLine) {
return null;
}
const value = processLine.slice('process='.length).trim();
return value.length > 0 ? value : null;
}
type WindowsTrackerHelperRunnerResult = {
stdout: string;
stderr: string;
};
function runWindowsTrackerHelperWithSpawnSync(
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs: string[] = [],
): WindowsTrackerHelperRunnerResult | null {
const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
const result = spawnSync(spec.command, [...spec.args, ...modeArgs, ...extraArgs], {
encoding: 'utf-8',
timeout: 1000,
maxBuffer: 1024 * 1024,
windowsHide: true,
});
if (result.error || result.status !== 0) {
return null;
}
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
function runWindowsTrackerHelperWithExecFile(
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs: string[] = [],
): Promise<WindowsTrackerHelperRunnerResult> {
return new Promise((resolve, reject) => {
const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
execFile(
spec.command,
[...spec.args, ...modeArgs, ...extraArgs],
{
encoding: 'utf-8',
timeout: 1000,
maxBuffer: 1024 * 1024,
windowsHide: true,
},
(error: ExecFileException | null, stdout: string, stderr: string) => {
if (error) {
reject(Object.assign(error, { stderr }));
return;
}
resolve({ stdout, stderr });
},
);
});
}
export async function queryWindowsForegroundProcessName(deps: {
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => Promise<WindowsTrackerHelperRunnerResult>;
} = {}): Promise<string | null> {
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return null;
}
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const { stdout } = await runHelper(spec, 'foreground-process');
return parseWindowTrackerHelperForegroundProcess(stdout);
}
export function queryWindowsTrackerMpvWindows(deps: {
targetMpvSocketPath?: string | null;
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelperSync?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => WindowsTrackerHelperRunnerResult | null;
} = {}): MpvPollResult | null {
const targetMpvSocketPath = deps.targetMpvSocketPath?.trim();
if (!targetMpvSocketPath) {
return null;
}
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return null;
}
const runHelper = deps.runHelperSync ?? runWindowsTrackerHelperWithSpawnSync;
const result = runHelper(spec, 'geometry', [targetMpvSocketPath]);
if (!result) {
return null;
}
const geometry = parseWindowTrackerHelperOutput(result.stdout);
if (!geometry) {
return {
matches: [],
focusState: parseWindowTrackerHelperFocusState(result.stderr) ?? false,
windowState: parseWindowTrackerHelperState(result.stderr) ?? 'not-found',
};
}
const focusState = parseWindowTrackerHelperFocusState(result.stderr) ?? false;
return {
matches: [
{
hwnd: 0,
bounds: geometry,
area: geometry.width * geometry.height,
isForeground: focusState,
},
],
focusState,
windowState: parseWindowTrackerHelperState(result.stderr) ?? 'visible',
};
}
export function queryWindowsTargetWindowHandle(deps: {
targetMpvSocketPath?: string | null;
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelperSync?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => WindowsTrackerHelperRunnerResult | null;
} = {}): number | null {
const targetMpvSocketPath = deps.targetMpvSocketPath?.trim();
if (!targetMpvSocketPath) {
return null;
}
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return null;
}
const runHelper = deps.runHelperSync ?? runWindowsTrackerHelperWithSpawnSync;
const result = runHelper(spec, 'target-hwnd', [targetMpvSocketPath]);
if (!result) {
return null;
}
const handle = Number.parseInt(result.stdout.trim(), 10);
return Number.isFinite(handle) && handle > 0 ? handle : null;
}
export async function syncWindowsOverlayToMpvZOrder(deps: {
overlayWindowHandle: string;
targetMpvSocketPath?: string | null;
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => Promise<WindowsTrackerHelperRunnerResult>;
}): Promise<boolean> {
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return false;
}
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const extraArgs = [deps.targetMpvSocketPath ?? '', deps.overlayWindowHandle];
const { stdout } = await runHelper(spec, 'bind-overlay', extraArgs);
return stdout.trim() === 'ok';
}
export async function lowerWindowsOverlayInZOrder(deps: {
overlayWindowHandle: string;
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => Promise<WindowsTrackerHelperRunnerResult>;
}): Promise<boolean> {
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return false;
}
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const { stdout } = await runHelper(spec, 'lower-overlay', ['', deps.overlayWindowHandle]);
return stdout.trim() === 'ok';
}
export function setWindowsOverlayOwnerNative(overlayHwnd: number, mpvHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
win32.setOverlayOwner(overlayHwnd, mpvHwnd);
loadWin32().setOverlayOwner(overlayHwnd, mpvHwnd);
return true;
} catch {
return false;
}
}
export function ensureWindowsOverlayTransparencyNative(overlayHwnd: number): boolean {
export function ensureWindowsOverlayTransparency(overlayHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
win32.ensureOverlayTransparency(overlayHwnd);
loadWin32().ensureOverlayTransparency(overlayHwnd);
return true;
} catch {
return false;
}
}
export function bindWindowsOverlayAboveMpvNative(overlayHwnd: number, mpvHwnd: number): boolean {
export function bindWindowsOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
const win32 = loadWin32();
win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd);
win32.ensureOverlayTransparency(overlayHwnd);
return true;
@@ -506,85 +59,19 @@ export function bindWindowsOverlayAboveMpvNative(overlayHwnd: number, mpvHwnd: n
}
}
export function clearWindowsOverlayOwnerNative(overlayHwnd: number): boolean {
export function clearWindowsOverlayOwner(overlayHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
win32.clearOverlayOwner(overlayHwnd);
loadWin32().clearOverlayOwner(overlayHwnd);
return true;
} catch {
return false;
}
}
export function getWindowsForegroundProcessNameNative(): string | null {
export function getWindowsForegroundProcessName(): string | null {
try {
const win32 = require('./win32') as typeof import('./win32');
return win32.getForegroundProcessName();
return loadWin32().getForegroundProcessName();
} catch {
return null;
}
}
export function resolveWindowsTrackerHelper(
options: ResolveWindowsTrackerHelperOptions = {},
): WindowsTrackerHelperLaunchSpec | null {
const existsSync = options.existsSync ?? fs.existsSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const copyFileSync = options.copyFileSync ?? fs.copyFileSync;
const dirname = options.dirname ?? __dirname;
const resourcesPath = options.resourcesPath ?? process.resourcesPath;
const mode = normalizeHelperMode(
options.helperModeEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER,
);
const override = normalizeHelperPathOverride(
options.helperPathEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER_PATH,
mode,
);
if (override) {
if (!existsSync(override.path)) {
log.warn(`Configured Windows tracker helper path does not exist: ${override.path}`);
return null;
}
const helperPath = materializeAsarHelper(override.path, override.kind, {
mkdirSync,
copyFileSync,
});
return helperPath ? createLaunchSpec(helperPath, override.kind) : null;
}
const candidates = getHelperCandidates(dirname, resourcesPath);
const orderedCandidates =
mode === 'powershell'
? candidates.filter((candidate) => candidate.kind === 'powershell')
: mode === 'native'
? candidates.filter((candidate) => candidate.kind === 'native')
: candidates;
for (const candidate of orderedCandidates) {
if (!existsSync(candidate.path)) {
continue;
}
const helperPath = materializeAsarHelper(candidate.path, candidate.kind, {
mkdirSync,
copyFileSync,
});
if (!helperPath) {
continue;
}
log.info(`Using Windows helper (${candidate.kind}): ${helperPath}`);
return createLaunchSpec(helperPath, candidate.kind);
}
if (mode === 'native') {
log.warn('Windows native tracker helper requested but no helper was found.');
} else if (mode === 'powershell') {
log.warn('Windows PowerShell tracker helper requested but no helper was found.');
} else {
log.warn('Windows tracker helper not found.');
}
return null;
}
+3 -22
View File
@@ -19,7 +19,6 @@
import { BaseWindowTracker } from './base-tracker';
import type { WindowGeometry } from '../types';
import type { MpvPollResult } from './win32';
import { queryWindowsTrackerMpvWindows } from './windows-helper';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows');
@@ -32,26 +31,8 @@ type WindowsTrackerDeps = {
now?: () => number;
};
function shouldUsePowershellTrackerFallback(): boolean {
const helperMode = process.env.SUBMINER_WINDOWS_TRACKER_HELPER?.trim().toLowerCase();
if (helperMode === 'powershell') {
return true;
}
const helperPath = process.env.SUBMINER_WINDOWS_TRACKER_HELPER_PATH?.trim().toLowerCase();
return helperPath?.endsWith('.ps1') ?? false;
}
function defaultPollMpvWindows(targetMpvSocketPath?: string | null): MpvPollResult {
if (targetMpvSocketPath && shouldUsePowershellTrackerFallback()) {
const helperResult = queryWindowsTrackerMpvWindows({
targetMpvSocketPath,
});
if (helperResult) {
return helperResult;
}
}
function defaultPollMpvWindows(_targetMpvSocketPath?: string | null): MpvPollResult {
void _targetMpvSocketPath;
const win32 = require('./win32') as typeof import('./win32');
return win32.findMpvWindows();
}
@@ -147,7 +128,7 @@ export class WindowsWindowTracker extends BaseWindowTracker {
const focusedMatch = result.matches.find((m) => m.isForeground);
const best =
focusedMatch ??
result.matches.sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]!;
[...result.matches].sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]!;
return {
geometry: best.bounds,