Compare commits

..

5 Commits

66 changed files with 2755 additions and 533 deletions

View File

@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Addressed the latest CodeRabbit follow-ups on the Windows overlay flow, including exact mpv target resolution, lower-overlay helper arguments, Win32 failure detection, and overlay cleanup on tracker loss.

View File

@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.12.0-beta.2", "version": "0.12.0-beta.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",

View File

@@ -14,7 +14,7 @@ function M.init()
local utils = require("mp.utils") local utils = require("mp.utils")
local options_helper = require("options") local options_helper = require("options")
local environment = require("environment").create({ mp = mp }) local environment = require("environment").create({ mp = mp, utils = utils })
local opts = options_helper.load(options_lib, environment.default_socket_path()) local opts = options_helper.load(options_lib, environment.default_socket_path())
local state = require("state").new() local state = require("state").new()
@@ -61,6 +61,9 @@ function M.init()
ctx.process = make_lazy_proxy("process", function() ctx.process = make_lazy_proxy("process", function()
return require("process").create(ctx) return require("process").create(ctx)
end) end)
ctx.session_bindings = make_lazy_proxy("session_bindings", function()
return require("session_bindings").create(ctx)
end)
ctx.ui = make_lazy_proxy("ui", function() ctx.ui = make_lazy_proxy("ui", function()
return require("ui").create(ctx) return require("ui").create(ctx)
end) end)
@@ -72,6 +75,7 @@ function M.init()
end) end)
ctx.ui.register_keybindings() ctx.ui.register_keybindings()
ctx.session_bindings.register_bindings()
ctx.messages.register_script_messages() ctx.messages.register_script_messages()
ctx.lifecycle.register_lifecycle_hooks() ctx.lifecycle.register_lifecycle_hooks()
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded") ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")

View File

@@ -1,7 +1,9 @@
local M = {} local M = {}
local unpack_fn = table.unpack or unpack
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
local utils = ctx.utils
local detected_backend = nil local detected_backend = nil
local app_running_cache_value = nil local app_running_cache_value = nil
@@ -30,6 +32,57 @@ function M.create(ctx)
return "/tmp/subminer-socket" return "/tmp/subminer-socket"
end end
local function path_separator()
return is_windows() and "\\" or "/"
end
local function join_path(...)
local parts = { ... }
if utils and type(utils.join_path) == "function" then
return utils.join_path(unpack_fn(parts))
end
return table.concat(parts, path_separator())
end
local function file_exists(path)
if not utils or type(utils.file_info) ~= "function" then
return false
end
return utils.file_info(path) ~= nil
end
local function resolve_subminer_config_dir()
local home = os.getenv("HOME") or os.getenv("USERPROFILE") or ""
local candidates = {}
if is_windows() then
local app_data = os.getenv("APPDATA") or join_path(home, "AppData", "Roaming")
candidates = {
join_path(app_data, "SubMiner"),
}
else
local xdg_config_home = os.getenv("XDG_CONFIG_HOME")
local primary_base = (type(xdg_config_home) == "string" and xdg_config_home ~= "")
and xdg_config_home
or join_path(home, ".config")
candidates = {
join_path(primary_base, "SubMiner"),
join_path(home, ".config", "SubMiner"),
}
end
for _, dir in ipairs(candidates) do
if file_exists(join_path(dir, "config.jsonc")) or file_exists(join_path(dir, "config.json")) or file_exists(dir) then
return dir
end
end
return candidates[1]
end
local function resolve_session_bindings_artifact_path()
return join_path(resolve_subminer_config_dir(), "session-bindings.json")
end
local function is_linux() local function is_linux()
return not is_windows() and not is_macos() return not is_windows() and not is_macos()
end end
@@ -198,7 +251,10 @@ function M.create(ctx)
is_windows = is_windows, is_windows = is_windows,
is_macos = is_macos, is_macos = is_macos,
is_linux = is_linux, is_linux = is_linux,
join_path = join_path,
default_socket_path = default_socket_path, default_socket_path = default_socket_path,
resolve_subminer_config_dir = resolve_subminer_config_dir,
resolve_session_bindings_artifact_path = resolve_session_bindings_artifact_path,
is_subminer_process_running = is_subminer_process_running, is_subminer_process_running = is_subminer_process_running,
is_subminer_app_running = is_subminer_app_running, is_subminer_app_running = is_subminer_app_running,
is_subminer_app_running_async = is_subminer_app_running_async, is_subminer_app_running_async = is_subminer_app_running_async,

View File

@@ -47,6 +47,9 @@ function M.create(ctx)
mp.register_script_message("subminer-stats-toggle", function() mp.register_script_message("subminer-stats-toggle", function()
mp.osd_message("Stats: press ` (backtick) in overlay", 3) mp.osd_message("Stats: press ` (backtick) in overlay", 3)
end) end)
mp.register_script_message("subminer-reload-session-bindings", function()
ctx.session_bindings.reload_bindings()
end)
end end
return { return {

View File

@@ -0,0 +1,316 @@
local M = {}
local unpack_fn = table.unpack or unpack
local KEY_NAME_MAP = {
Space = "SPACE",
Tab = "TAB",
Enter = "ENTER",
Escape = "ESC",
Backspace = "BS",
Delete = "DEL",
ArrowUp = "UP",
ArrowDown = "DOWN",
ArrowLeft = "LEFT",
ArrowRight = "RIGHT",
Slash = "/",
Backslash = "\\",
Minus = "-",
Equal = "=",
Comma = ",",
Period = ".",
Quote = "'",
Semicolon = ";",
BracketLeft = "[",
BracketRight = "]",
Backquote = "`",
}
local MODIFIER_MAP = {
ctrl = "Ctrl",
alt = "Alt",
shift = "Shift",
meta = "Meta",
}
function M.create(ctx)
local mp = ctx.mp
local utils = ctx.utils
local state = ctx.state
local process = ctx.process
local environment = ctx.environment
local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd
local function read_file(path)
local handle = io.open(path, "r")
if not handle then
return nil
end
local content = handle:read("*a")
handle:close()
return content
end
local function remove_binding_names(names)
for _, name in ipairs(names) do
mp.remove_key_binding(name)
end
for index = #names, 1, -1 do
names[index] = nil
end
end
local function key_code_to_mpv_name(code)
if KEY_NAME_MAP[code] then
return KEY_NAME_MAP[code]
end
local letter = code:match("^Key([A-Z])$")
if letter then
return string.lower(letter)
end
local digit = code:match("^Digit([0-9])$")
if digit then
return digit
end
local function_key = code:match("^(F%d+)$")
if function_key then
return function_key
end
return nil
end
local function key_spec_to_mpv_binding(key)
if type(key) ~= "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
local mapped = MODIFIER_MAP[modifier]
if mapped then
parts[#parts + 1] = mapped
end
end
parts[#parts + 1] = key_name
return table.concat(parts, "+")
end
local function build_cli_args(action_id, payload)
if action_id == "toggleVisibleOverlay" then
return { "--toggle-visible-overlay" }
elseif action_id == "copySubtitle" then
return { "--copy-subtitle" }
elseif action_id == "copySubtitleMultiple" then
return { "--copy-subtitle-count", tostring(payload and payload.count or 1) }
elseif action_id == "updateLastCardFromClipboard" then
return { "--update-last-card-from-clipboard" }
elseif action_id == "triggerFieldGrouping" then
return { "--trigger-field-grouping" }
elseif action_id == "triggerSubsync" then
return { "--trigger-subsync" }
elseif action_id == "mineSentence" then
return { "--mine-sentence" }
elseif action_id == "mineSentenceMultiple" then
return { "--mine-sentence-count", tostring(payload and payload.count or 1) }
elseif action_id == "toggleSecondarySub" then
return { "--toggle-secondary-sub" }
elseif action_id == "markAudioCard" then
return { "--mark-audio-card" }
elseif action_id == "openRuntimeOptions" then
return { "--open-runtime-options" }
elseif action_id == "openJimaku" then
return { "--open-jimaku" }
elseif action_id == "openYoutubePicker" then
return { "--open-youtube-picker" }
elseif action_id == "openPlaylistBrowser" then
return { "--open-playlist-browser" }
elseif action_id == "replayCurrentSubtitle" then
return { "--replay-current-subtitle" }
elseif action_id == "playNextSubtitle" then
return { "--play-next-subtitle" }
elseif action_id == "shiftSubDelayPrevLine" then
return { "--shift-sub-delay-prev-line" }
elseif action_id == "shiftSubDelayNextLine" then
return { "--shift-sub-delay-next-line" }
end
return nil
end
local function invoke_cli_action(action_id, payload)
if not process.check_binary_available() then
show_osd("Error: binary not found")
return
end
local cli_args = build_cli_args(action_id, payload)
if not cli_args then
subminer_log("warn", "session-bindings", "No CLI mapping for action: " .. tostring(action_id))
return
end
local args = { state.binary_path }
for _, arg in ipairs(cli_args) do
args[#args + 1] = arg
end
process.run_binary_command_async(args, function(ok, result, error)
if ok then
return
end
local reason = error or (result and result.stderr) or "unknown error"
subminer_log("warn", "session-bindings", "Session action failed: " .. tostring(reason))
show_osd("Session action failed")
end)
end
local function clear_numeric_selection(show_cancelled)
if state.session_numeric_selection and state.session_numeric_selection.timeout then
state.session_numeric_selection.timeout:kill()
end
state.session_numeric_selection = nil
remove_binding_names(state.session_numeric_binding_names)
if show_cancelled then
show_osd("Cancelled")
end
end
local function start_numeric_selection(action_id, timeout_ms)
clear_numeric_selection(false)
for digit = 1, 9 do
local digit_string = tostring(digit)
local name = "subminer-session-digit-" .. digit_string
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name
mp.add_forced_key_binding(digit_string, name, function()
clear_numeric_selection(false)
invoke_cli_action(action_id, { count = digit })
end)
end
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] =
"subminer-session-digit-cancel"
mp.add_forced_key_binding("ESC", "subminer-session-digit-cancel", function()
clear_numeric_selection(true)
end)
state.session_numeric_selection = {
action_id = action_id,
timeout = mp.add_timeout((timeout_ms or 3000) / 1000, function()
clear_numeric_selection(false)
show_osd(action_id == "copySubtitleMultiple" and "Copy timeout" or "Mine timeout")
end),
}
show_osd(
action_id == "copySubtitleMultiple"
and "Copy how many lines? Press 1-9 (Esc to cancel)"
or "Mine how many lines? Press 1-9 (Esc to cancel)"
)
end
local function execute_mpv_command(command)
if type(command) ~= "table" or command[1] == nil then
return
end
mp.commandv(unpack_fn(command))
end
local function handle_binding(binding, numeric_selection_timeout_ms)
if binding.actionType == "mpv-command" then
execute_mpv_command(binding.command)
return
end
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms)
return
end
invoke_cli_action(binding.actionId, binding.payload)
end
local function load_artifact()
local artifact_path = environment.resolve_session_bindings_artifact_path()
local raw = read_file(artifact_path)
if not raw or raw == "" then
return nil, "Missing session binding artifact: " .. tostring(artifact_path)
end
local parsed, parse_error = utils.parse_json(raw)
if not parsed then
return nil, "Failed to parse session binding artifact: " .. tostring(parse_error)
end
if type(parsed) ~= "table" or type(parsed.bindings) ~= "table" then
return nil, "Invalid session binding artifact"
end
return parsed, nil
end
local function clear_bindings()
clear_numeric_selection(false)
remove_binding_names(state.session_binding_names)
end
local function register_bindings()
local artifact, load_error = load_artifact()
if not artifact then
subminer_log("warn", "session-bindings", load_error)
return false
end
clear_numeric_selection(false)
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
local key_name = key_spec_to_mpv_binding(binding.key)
if key_name then
local name = "subminer-session-binding-" .. tostring(index)
next_binding_names[#next_binding_names + 1] = name
mp.add_forced_key_binding(key_name, name, function()
handle_binding(binding, timeout_ms)
end)
else
subminer_log(
"warn",
"session-bindings",
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
)
end
end
remove_binding_names(previous_binding_names)
subminer_log(
"info",
"session-bindings",
"Registered " .. tostring(#next_binding_names) .. " shared session bindings"
)
return true
end
local function reload_bindings()
return register_bindings()
end
return {
register_bindings = register_bindings,
reload_bindings = reload_bindings,
clear_bindings = clear_bindings,
}
end
return M

View File

@@ -33,6 +33,9 @@ function M.new()
auto_play_ready_timeout = nil, auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil, auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false, suppress_ready_overlay_restore = false,
session_binding_names = {},
session_numeric_binding_names = {},
session_numeric_selection = nil,
} }
end end

View File

@@ -1,5 +1,5 @@
param( param(
[ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner')] [ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner', 'target-hwnd')]
[string]$Mode = 'geometry', [string]$Mode = 'geometry',
[string]$SocketPath, [string]$SocketPath,
[string]$OverlayWindowHandle [string]$OverlayWindowHandle
@@ -64,6 +64,9 @@ public static class SubMinerWindowsHelper {
[DllImport("user32.dll", SetLastError = true)] [DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
[DllImport("kernel32.dll")]
public static extern void SetLastError(uint dwErrCode);
[DllImport("dwmapi.dll")] [DllImport("dwmapi.dll")]
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute); public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
} }
@@ -83,6 +86,38 @@ public static class SubMinerWindowsHelper {
$HWND_TOPMOST = [IntPtr](-1) $HWND_TOPMOST = [IntPtr](-1)
$HWND_NOTOPMOST = [IntPtr](-2) $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') { if ($Mode -eq 'foreground-process') {
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow() $foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
if ($foregroundWindow -eq [IntPtr]::Zero) { if ($foregroundWindow -eq [IntPtr]::Zero) {
@@ -115,7 +150,9 @@ public static class SubMinerWindowsHelper {
} }
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, [IntPtr]::Zero) [SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, [IntPtr]::Zero)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'clear-owner'
Write-Output 'ok' Write-Output 'ok'
exit 0 exit 0
} }
@@ -281,6 +318,11 @@ public static class SubMinerWindowsHelper {
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1 $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 ($Mode -eq 'set-owner') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) { if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required') [Console]::Error.WriteLine('overlay-window-handle-required')
@@ -289,7 +331,9 @@ public static class SubMinerWindowsHelper {
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd $targetWindow = [IntPtr]$bestMatch.HWnd
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow) [SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'set-owner'
Write-Output 'ok' Write-Output 'ok'
exit 0 exit 0
} }
@@ -302,20 +346,26 @@ public static class SubMinerWindowsHelper {
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd $targetWindow = [IntPtr]$bestMatch.HWnd
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow) [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) $targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE)
$targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0 $targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0
$overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE) $overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE)
$overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0 $overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0
if ($targetWindowIsTopmost -and -not $overlayIsTopmost) { if ($targetWindowIsTopmost -and -not $overlayIsTopmost) {
[void][SubMinerWindowsHelper]::SetWindowPos( [SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS $overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS
) )
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay topmost adjustment'
} elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) { } elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) {
[void][SubMinerWindowsHelper]::SetWindowPos( [SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS $overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS
) )
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay notopmost adjustment'
} }
$GW_HWNDPREV = 3 $GW_HWNDPREV = 3
@@ -335,9 +385,11 @@ public static class SubMinerWindowsHelper {
} }
} }
[void][SubMinerWindowsHelper]::SetWindowPos( [SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $insertAfter, 0, 0, 0, 0, $SWP_FLAGS $overlayWindow, $insertAfter, 0, 0, 0, 0, $SWP_FLAGS
) )
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay z-order adjustment'
Write-Output 'ok' Write-Output 'ok'
exit 0 exit 0
} }

View File

@@ -28,6 +28,27 @@ USAGE
force=0 force=0
generate_webp=0 generate_webp=0
input="" input=""
ffmpeg_bin="${FFMPEG_BIN:-ffmpeg}"
normalize_path() {
local value="$1"
if command -v cygpath > /dev/null 2>&1; then
case "$value" in
[A-Za-z]:\\* | [A-Za-z]:/*)
cygpath -u "$value"
return 0
;;
esac
fi
if [[ "$value" =~ ^([A-Za-z]):[\\/](.*)$ ]]; then
local drive="${BASH_REMATCH[1],,}"
local rest="${BASH_REMATCH[2]}"
rest="${rest//\\//}"
printf '/mnt/%s/%s\n' "$drive" "$rest"
return 0
fi
printf '%s\n' "$value"
}
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@@ -63,9 +84,19 @@ if [[ -z "$input" ]]; then
exit 1 exit 1
fi fi
if ! command -v ffmpeg > /dev/null 2>&1; then input="$(normalize_path "$input")"
echo "Error: ffmpeg is not installed or not in PATH." >&2 ffmpeg_bin="$(normalize_path "$ffmpeg_bin")"
exit 1
if [[ "$ffmpeg_bin" == */* ]]; then
if [[ ! -x "$ffmpeg_bin" ]]; then
echo "Error: ffmpeg binary is not executable: $ffmpeg_bin" >&2
exit 1
fi
else
if ! command -v "$ffmpeg_bin" > /dev/null 2>&1; then
echo "Error: ffmpeg is not installed or not in PATH." >&2
exit 1
fi
fi fi
if [[ ! -f "$input" ]]; then if [[ ! -f "$input" ]]; then
@@ -102,7 +133,7 @@ fi
has_encoder() { has_encoder() {
local encoder="$1" local encoder="$1"
ffmpeg -hide_banner -encoders 2> /dev/null | awk -v encoder="$encoder" '$2 == encoder { found = 1 } END { exit(found ? 0 : 1) }' "$ffmpeg_bin" -hide_banner -encoders 2> /dev/null | awk -v encoder="$encoder" '$2 == encoder { found = 1 } END { exit(found ? 0 : 1) }'
} }
pick_webp_encoder() { pick_webp_encoder() {
@@ -123,7 +154,7 @@ webm_vf="${crop_vf},fps=30"
echo "Generating MP4: $mp4_out" echo "Generating MP4: $mp4_out"
if has_encoder "h264_nvenc"; then if has_encoder "h264_nvenc"; then
echo "Trying GPU encoder for MP4: h264_nvenc" echo "Trying GPU encoder for MP4: h264_nvenc"
if ffmpeg "$overwrite_flag" -i "$input" \ if "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$crop_vf" \ -vf "$crop_vf" \
-c:v h264_nvenc -preset p6 -rc:v vbr -cq:v 20 -b:v 0 \ -c:v h264_nvenc -preset p6 -rc:v vbr -cq:v 20 -b:v 0 \
-pix_fmt yuv420p -movflags +faststart \ -pix_fmt yuv420p -movflags +faststart \
@@ -132,7 +163,7 @@ if has_encoder "h264_nvenc"; then
: :
else else
echo "GPU MP4 encode failed; retrying with CPU encoder: libx264" echo "GPU MP4 encode failed; retrying with CPU encoder: libx264"
ffmpeg "$overwrite_flag" -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$crop_vf" \ -vf "$crop_vf" \
-c:v libx264 -preset slow -crf 20 \ -c:v libx264 -preset slow -crf 20 \
-profile:v high -level 4.1 -pix_fmt yuv420p \ -profile:v high -level 4.1 -pix_fmt yuv420p \
@@ -142,7 +173,7 @@ if has_encoder "h264_nvenc"; then
fi fi
else else
echo "Using CPU encoder for MP4: libx264" echo "Using CPU encoder for MP4: libx264"
ffmpeg "$overwrite_flag" -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$crop_vf" \ -vf "$crop_vf" \
-c:v libx264 -preset slow -crf 20 \ -c:v libx264 -preset slow -crf 20 \
-profile:v high -level 4.1 -pix_fmt yuv420p \ -profile:v high -level 4.1 -pix_fmt yuv420p \
@@ -154,7 +185,7 @@ fi
echo "Generating WebM: $webm_out" echo "Generating WebM: $webm_out"
if has_encoder "av1_nvenc"; then if has_encoder "av1_nvenc"; then
echo "Trying GPU encoder for WebM: av1_nvenc" echo "Trying GPU encoder for WebM: av1_nvenc"
if ffmpeg "$overwrite_flag" -i "$input" \ if "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$webm_vf" \ -vf "$webm_vf" \
-c:v av1_nvenc -preset p6 -cq:v 34 -b:v 0 \ -c:v av1_nvenc -preset p6 -cq:v 34 -b:v 0 \
-c:a libopus -b:a 96k \ -c:a libopus -b:a 96k \
@@ -162,7 +193,7 @@ if has_encoder "av1_nvenc"; then
: :
else else
echo "GPU WebM encode failed; retrying with CPU encoder: libvpx-vp9" echo "GPU WebM encode failed; retrying with CPU encoder: libvpx-vp9"
ffmpeg "$overwrite_flag" -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$webm_vf" \ -vf "$webm_vf" \
-c:v libvpx-vp9 -crf 34 -b:v 0 \ -c:v libvpx-vp9 -crf 34 -b:v 0 \
-row-mt 1 -threads 8 \ -row-mt 1 -threads 8 \
@@ -171,7 +202,7 @@ if has_encoder "av1_nvenc"; then
fi fi
else else
echo "Using CPU encoder for WebM: libvpx-vp9" echo "Using CPU encoder for WebM: libvpx-vp9"
ffmpeg "$overwrite_flag" -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "$webm_vf" \ -vf "$webm_vf" \
-c:v libvpx-vp9 -crf 34 -b:v 0 \ -c:v libvpx-vp9 -crf 34 -b:v 0 \
-row-mt 1 -threads 8 \ -row-mt 1 -threads 8 \
@@ -185,7 +216,7 @@ if [[ "$generate_webp" -eq 1 ]]; then
exit 1 exit 1
fi fi
echo "Generating animated WebP with $webp_encoder: $webp_out" echo "Generating animated WebP with $webp_encoder: $webp_out"
ffmpeg "$overwrite_flag" -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -i "$input" \
-vf "${crop_vf},fps=24,scale=960:-1:flags=lanczos" \ -vf "${crop_vf},fps=24,scale=960:-1:flags=lanczos" \
-c:v "$webp_encoder" \ -c:v "$webp_encoder" \
-q:v 80 \ -q:v 80 \
@@ -195,7 +226,7 @@ if [[ "$generate_webp" -eq 1 ]]; then
fi fi
echo "Generating poster: $poster_out" echo "Generating poster: $poster_out"
ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \ "$ffmpeg_bin" "$overwrite_flag" -ss 00:00:05 -i "$input" \
-vf "$crop_vf" \ -vf "$crop_vf" \
-vframes 1 \ -vframes 1 \
-q:v 2 \ -q:v 2 \

View File

@@ -19,11 +19,33 @@ function writeExecutable(filePath: string, contents: string): void {
fs.chmodSync(filePath, 0o755); fs.chmodSync(filePath, 0o755);
} }
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
function toBashPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
const normalized = filePath.replace(/\\/g, '/');
const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
if (!match) return normalized;
const drive = match[1]!;
const rest = match[2]!;
const probe = spawnSync('bash', ['-c', 'uname -s'], { encoding: 'utf8' });
if (probe.status === 0 && /linux/i.test(probe.stdout)) {
return `/mnt/${drive.toLowerCase()}/${rest}`;
}
return `${drive.toUpperCase()}:/${rest}`;
}
test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => { test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => {
withTempDir((root) => { withTempDir((root) => {
const binDir = path.join(root, 'bin'); const binDir = path.join(root, 'bin');
const inputPath = path.join(root, 'sample.mkv'); const inputPath = path.join(root, 'sample.mkv');
const ffmpegLogPath = path.join(root, 'ffmpeg-args.log'); const ffmpegLogPath = path.join(root, 'ffmpeg-args.log');
const ffmpegLogPathBash = toBashPath(ffmpegLogPath);
fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(inputPath, 'fake-video', 'utf8'); fs.writeFileSync(inputPath, 'fake-video', 'utf8');
@@ -44,22 +66,33 @@ EOF
exit 0 exit 0
fi fi
printf '%s\\n' "$*" >> "${ffmpegLogPath}" if [[ "$#" -eq 0 ]]; then
exit 0
fi
printf '%s\\n' "$*" >> "${ffmpegLogPathBash}"
output="" output=""
for arg in "$@"; do for arg in "$@"; do
output="$arg" output="$arg"
done done
if [[ -z "$output" ]]; then
exit 0
fi
mkdir -p "$(dirname "$output")" mkdir -p "$(dirname "$output")"
touch "$output" touch "$output"
`, `,
); );
const result = spawnSync('bash', ['scripts/mkv-to-readme-video.sh', '--webp', inputPath], { const ffmpegShimPath = toBashPath(path.join(binDir, 'ffmpeg'));
const ffmpegShimDir = toBashPath(binDir);
const inputBashPath = toBashPath(inputPath);
const command = [
`chmod +x ${shellQuote(ffmpegShimPath)}`,
`PATH=${shellQuote(`${ffmpegShimDir}:`)}"$PATH"`,
`scripts/mkv-to-readme-video.sh --webp ${shellQuote(inputBashPath)}`,
].join('; ');
const result = spawnSync('bash', ['-lc', command], {
cwd: process.cwd(), cwd: process.cwd(),
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ''}`,
},
encoding: 'utf8', encoding: 'utf8',
}); });

View File

@@ -13,6 +13,26 @@ appimage=
wrapper= wrapper=
assets= assets=
normalize_path() {
local value="$1"
if command -v cygpath >/dev/null 2>&1; then
case "$value" in
[A-Za-z]:\\* | [A-Za-z]:/*)
cygpath -u "$value"
return 0
;;
esac
fi
if [[ "$value" =~ ^([A-Za-z]):[\\/](.*)$ ]]; then
local drive="${BASH_REMATCH[1],,}"
local rest="${BASH_REMATCH[2]}"
rest="${rest//\\//}"
printf '/mnt/%s/%s\n' "$drive" "$rest"
return 0
fi
printf '%s\n' "$value"
}
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--pkg-dir) --pkg-dir)
@@ -53,6 +73,10 @@ if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$
fi fi
version="${version#v}" version="${version#v}"
pkg_dir="$(normalize_path "$pkg_dir")"
appimage="$(normalize_path "$appimage")"
wrapper="$(normalize_path "$wrapper")"
assets="$(normalize_path "$assets")"
pkgbuild="${pkg_dir}/PKGBUILD" pkgbuild="${pkg_dir}/PKGBUILD"
srcinfo="${pkg_dir}/.SRCINFO" srcinfo="${pkg_dir}/.SRCINFO"
@@ -82,6 +106,9 @@ awk \
found_pkgver = 0 found_pkgver = 0
found_sha_block = 0 found_sha_block = 0
} }
{
sub(/\r$/, "")
}
/^pkgver=/ { /^pkgver=/ {
print "pkgver=" version print "pkgver=" version
found_pkgver = 1 found_pkgver = 1
@@ -140,6 +167,9 @@ awk \
found_source_wrapper = 0 found_source_wrapper = 0
found_source_assets = 0 found_source_assets = 0
} }
{
sub(/\r$/, "")
}
/^\tpkgver = / { /^\tpkgver = / {
print "\tpkgver = " version print "\tpkgver = " version
found_pkgver = 1 found_pkgver = 1

View File

@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process'; import { execFileSync, spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -9,6 +10,23 @@ function createWorkspace(name: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
} }
function toBashPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
const normalized = filePath.replace(/\\/g, '/');
const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
if (!match) return normalized;
const drive = match[1]!;
const rest = match[2]!;
const probe = spawnSync('bash', ['-c', 'uname -s'], { encoding: 'utf8' });
if (probe.status === 0 && /linux/i.test(probe.stdout)) {
return `/mnt/${drive.toLowerCase()}/${rest}`;
}
return `${drive.toUpperCase()}:/${rest}`;
}
test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => { test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
const workspace = createWorkspace('subminer-aur-package'); const workspace = createWorkspace('subminer-aur-package');
const pkgDir = path.join(workspace, 'aur-subminer-bin'); const pkgDir = path.join(workspace, 'aur-subminer-bin');
@@ -29,15 +47,15 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
[ [
'scripts/update-aur-package.sh', 'scripts/update-aur-package.sh',
'--pkg-dir', '--pkg-dir',
pkgDir, toBashPath(pkgDir),
'--version', '--version',
'v0.6.3', 'v0.6.3',
'--appimage', '--appimage',
appImagePath, toBashPath(appImagePath),
'--wrapper', '--wrapper',
wrapperPath, toBashPath(wrapperPath),
'--assets', '--assets',
assetsPath, toBashPath(assetsPath),
], ],
{ {
cwd: process.cwd(), cwd: process.cwd(),
@@ -47,8 +65,8 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8'); const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8'); const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
const expectedSums = [appImagePath, wrapperPath, assetsPath].map( const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) =>
(filePath) => execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0], crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
); );
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m); assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);

View File

@@ -73,6 +73,33 @@ test('parseArgs captures youtube startup forwarding flags', () => {
assert.equal(shouldStartApp(args), true); assert.equal(shouldStartApp(args), true);
}); });
test('parseArgs captures session action forwarding flags', () => {
const args = parseArgs([
'--open-jimaku',
'--open-youtube-picker',
'--open-playlist-browser',
'--replay-current-subtitle',
'--play-next-subtitle',
'--shift-sub-delay-prev-line',
'--shift-sub-delay-next-line',
'--copy-subtitle-count',
'3',
'--mine-sentence-count=2',
]);
assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
assert.equal(args.replayCurrentSubtitle, true);
assert.equal(args.playNextSubtitle, true);
assert.equal(args.shiftSubDelayPrevLine, true);
assert.equal(args.shiftSubDelayNextLine, true);
assert.equal(args.copySubtitleCount, 3);
assert.equal(args.mineSentenceCount, 2);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
});
test('youtube playback does not use generic overlay-runtime bootstrap classification', () => { test('youtube playback does not use generic overlay-runtime bootstrap classification', () => {
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']); const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);

View File

@@ -25,6 +25,15 @@ export interface CliArgs {
triggerSubsync: boolean; triggerSubsync: boolean;
markAudioCard: boolean; markAudioCard: boolean;
openRuntimeOptions: boolean; openRuntimeOptions: boolean;
openJimaku: boolean;
openYoutubePicker: boolean;
openPlaylistBrowser: boolean;
replayCurrentSubtitle: boolean;
playNextSubtitle: boolean;
shiftSubDelayPrevLine: boolean;
shiftSubDelayNextLine: boolean;
copySubtitleCount?: number;
mineSentenceCount?: number;
anilistStatus: boolean; anilistStatus: boolean;
anilistLogout: boolean; anilistLogout: boolean;
anilistSetup: boolean; anilistSetup: boolean;
@@ -103,6 +112,13 @@ export function parseArgs(argv: string[]): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,
@@ -180,6 +196,26 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--trigger-subsync') args.triggerSubsync = true; else if (arg === '--trigger-subsync') args.triggerSubsync = true;
else if (arg === '--mark-audio-card') args.markAudioCard = true; else if (arg === '--mark-audio-card') args.markAudioCard = true;
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true; else if (arg === '--open-runtime-options') args.openRuntimeOptions = 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;
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
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=')) {
const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value)) args.copySubtitleCount = value;
} else if (arg === '--copy-subtitle-count') {
const value = Number(readValue(argv[i + 1]));
if (Number.isInteger(value)) args.copySubtitleCount = value;
} else if (arg.startsWith('--mine-sentence-count=')) {
const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value)) 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; else if (arg === '--anilist-status') args.anilistStatus = true;
else if (arg === '--anilist-logout') args.anilistLogout = true; else if (arg === '--anilist-logout') args.anilistLogout = true;
else if (arg === '--anilist-setup') args.anilistSetup = true; else if (arg === '--anilist-setup') args.anilistSetup = true;
@@ -372,6 +408,15 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.anilistStatus || args.anilistStatus ||
args.anilistLogout || args.anilistLogout ||
args.anilistSetup || args.anilistSetup ||
@@ -424,6 +469,15 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.triggerSubsync && !args.triggerSubsync &&
!args.markAudioCard && !args.markAudioCard &&
!args.openRuntimeOptions && !args.openRuntimeOptions &&
!args.openJimaku &&
!args.openYoutubePicker &&
!args.openPlaylistBrowser &&
!args.replayCurrentSubtitle &&
!args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
!args.anilistLogout && !args.anilistLogout &&
!args.anilistSetup && !args.anilistSetup &&
@@ -467,6 +521,15 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.dictionary || args.dictionary ||
args.stats || args.stats ||
args.jellyfin || args.jellyfin ||
@@ -505,6 +568,15 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.triggerSubsync && !args.triggerSubsync &&
!args.markAudioCard && !args.markAudioCard &&
!args.openRuntimeOptions && !args.openRuntimeOptions &&
!args.openJimaku &&
!args.openYoutubePicker &&
!args.openPlaylistBrowser &&
!args.replayCurrentSubtitle &&
!args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
!args.anilistLogout && !args.anilistLogout &&
!args.anilistSetup && !args.anilistSetup &&
@@ -547,7 +619,16 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.triggerFieldGrouping || args.triggerFieldGrouping ||
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions args.openRuntimeOptions ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined
); );
} }

View File

@@ -29,6 +29,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,

View File

@@ -31,6 +31,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
markAudioCard: false, markAudioCard: false,
refreshKnownWords: false, refreshKnownWords: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,
@@ -143,6 +150,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette: () => {
calls.push('openRuntimeOptionsPalette'); calls.push('openRuntimeOptionsPalette');
}, },
dispatchSessionAction: async () => {
calls.push('dispatchSessionAction');
},
getAnilistStatus: () => ({ getAnilistStatus: () => ({
tokenStatus: 'resolved', tokenStatus: 'resolved',
tokenSource: 'stored', tokenSource: 'stored',

View File

@@ -1,4 +1,5 @@
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args'; import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
import type { SessionActionDispatchRequest } from '../../types/runtime';
export interface CliCommandServiceDeps { export interface CliCommandServiceDeps {
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void; setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
@@ -32,6 +33,7 @@ export interface CliCommandServiceDeps {
triggerSubsyncFromConfig: () => Promise<void>; triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
getAnilistStatus: () => { getAnilistStatus: () => {
tokenStatus: 'not_checked' | 'resolved' | 'error'; tokenStatus: 'not_checked' | 'resolved' | 'error';
tokenSource: 'none' | 'literal' | 'stored'; tokenSource: 'none' | 'literal' | 'stored';
@@ -168,6 +170,7 @@ export interface CliCommandDepsRuntimeOptions {
}; };
ui: UiCliRuntime; ui: UiCliRuntime;
app: AppCliRuntime; app: AppCliRuntime;
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => unknown; schedule: (fn: () => void, delayMs: number) => unknown;
log: (message: string) => void; log: (message: string) => void;
@@ -226,6 +229,7 @@ export function createCliCommandDepsRuntime(
triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig, triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig,
markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard, markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard,
openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette, openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette,
dispatchSessionAction: options.dispatchSessionAction,
getAnilistStatus: options.anilist.getStatus, getAnilistStatus: options.anilist.getStatus,
clearAnilistToken: options.anilist.clearToken, clearAnilistToken: options.anilist.clearToken,
openAnilistSetup: options.anilist.openSetup, openAnilistSetup: options.anilist.openSetup,
@@ -268,6 +272,19 @@ export function handleCliCommand(
source: CliCommandSource = 'initial', source: CliCommandSource = 'initial',
deps: CliCommandServiceDeps, deps: CliCommandServiceDeps,
): void { ): void {
const dispatchCliSessionAction = (
request: SessionActionDispatchRequest,
logLabel: string,
osdLabel: string,
): void => {
runAsyncWithOsd(
() => deps.dispatchSessionAction?.(request) ?? Promise.resolve(),
deps,
logLabel,
osdLabel,
);
};
if (args.logLevel) { if (args.logLevel) {
deps.setLogLevel?.(args.logLevel); deps.setLogLevel?.(args.logLevel);
} }
@@ -381,6 +398,56 @@ export function handleCliCommand(
); );
} else if (args.openRuntimeOptions) { } else if (args.openRuntimeOptions) {
deps.openRuntimeOptionsPalette(); deps.openRuntimeOptionsPalette();
} else if (args.openJimaku) {
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
} else if (args.openYoutubePicker) {
dispatchCliSessionAction(
{ actionId: 'openYoutubePicker' },
'openYoutubePicker',
'Open YouTube picker failed',
);
} else if (args.openPlaylistBrowser) {
dispatchCliSessionAction(
{ actionId: 'openPlaylistBrowser' },
'openPlaylistBrowser',
'Open playlist browser failed',
);
} else if (args.replayCurrentSubtitle) {
dispatchCliSessionAction(
{ actionId: 'replayCurrentSubtitle' },
'replayCurrentSubtitle',
'Replay subtitle failed',
);
} else if (args.playNextSubtitle) {
dispatchCliSessionAction(
{ actionId: 'playNextSubtitle' },
'playNextSubtitle',
'Play next subtitle failed',
);
} else if (args.shiftSubDelayPrevLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayPrevLine' },
'shiftSubDelayPrevLine',
'Shift subtitle delay failed',
);
} else if (args.shiftSubDelayNextLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayNextLine' },
'shiftSubDelayNextLine',
'Shift subtitle delay failed',
);
} else if (args.copySubtitleCount !== undefined) {
dispatchCliSessionAction(
{ actionId: 'copySubtitleMultiple', payload: { count: args.copySubtitleCount } },
'copySubtitleMultiple',
'Copy failed',
);
} else if (args.mineSentenceCount !== undefined) {
dispatchCliSessionAction(
{ actionId: 'mineSentenceMultiple', payload: { count: args.mineSentenceCount } },
'mineSentenceMultiple',
'Mine sentence failed',
);
} else if (args.anilistStatus) { } else if (args.anilistStatus) {
const status = deps.getAnilistStatus(); const status = deps.getAnilistStatus();
deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`); deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`);

View File

@@ -127,7 +127,9 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
setMecabEnabled: () => {}, setMecabEnabled: () => {},
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
@@ -226,7 +228,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getMecabTokenizer: () => null, getMecabTokenizer: () => null,
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
@@ -382,7 +386,9 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
setMecabEnabled: () => {}, setMecabEnabled: () => {},
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
@@ -707,7 +713,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
setMecabEnabled: () => {}, setMecabEnabled: () => {},
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
@@ -786,7 +794,9 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
setMecabEnabled: () => {}, setMecabEnabled: () => {},
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
@@ -872,7 +882,9 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
setMecabEnabled: () => {}, setMecabEnabled: () => {},
handleMpvCommand: () => {}, handleMpvCommand: () => {},
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}), getConfiguredShortcuts: () => ({}),
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),

View File

@@ -1,6 +1,7 @@
import electron from 'electron'; import electron from 'electron';
import type { IpcMainEvent } from 'electron'; import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
import type { import type {
CompiledSessionBinding,
ControllerConfigUpdate, ControllerConfigUpdate,
PlaylistBrowserMutationResult, PlaylistBrowserMutationResult,
PlaylistBrowserSnapshot, PlaylistBrowserSnapshot,
@@ -12,6 +13,7 @@ import type {
SubtitlePosition, SubtitlePosition,
SubsyncManualRunRequest, SubsyncManualRunRequest,
SubsyncResult, SubsyncResult,
SessionActionDispatchRequest,
YoutubePickerResolveRequest, YoutubePickerResolveRequest,
YoutubePickerResolveResult, YoutubePickerResolveResult,
} from '../../types'; } from '../../types';
@@ -30,11 +32,14 @@ import {
parseYoutubePickerResolveRequest, parseYoutubePickerResolveRequest,
} from '../../shared/ipc/validators'; } from '../../shared/ipc/validators';
const { BrowserWindow, ipcMain } = electron; const { ipcMain } = electron;
export interface IpcServiceDeps { export interface IpcServiceDeps {
onOverlayModalClosed: (modal: OverlayHostedModal) => void; onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalOpened?: (modal: OverlayHostedModal) => void; onOverlayModalOpened?: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
toggleDevTools: () => void; toggleDevTools: () => void;
@@ -56,7 +61,9 @@ export interface IpcServiceDeps {
setMecabEnabled: (enabled: boolean) => void; setMecabEnabled: (enabled: boolean) => void;
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string; getStatsToggleKey: () => string;
getMarkWatchedKey: () => string; getMarkWatchedKey: () => string;
getControllerConfig: () => ResolvedControllerConfig; getControllerConfig: () => ResolvedControllerConfig;
@@ -154,7 +161,10 @@ export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null; getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean; getVisibleOverlayVisibility: () => boolean;
onOverlayModalClosed: (modal: OverlayHostedModal) => void; onOverlayModalClosed: (modal: OverlayHostedModal) => void;
onOverlayModalOpened?: (modal: OverlayHostedModal) => void; onOverlayModalOpened?: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
@@ -169,7 +179,9 @@ export interface IpcDepsRuntimeOptions {
getMecabTokenizer: () => MecabTokenizerLike | null; getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string; getStatsToggleKey: () => string;
getMarkWatchedKey: () => string; getMarkWatchedKey: () => string;
getControllerConfig: () => ResolvedControllerConfig; getControllerConfig: () => ResolvedControllerConfig;
@@ -238,7 +250,9 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
}, },
handleMpvCommand: options.handleMpvCommand, handleMpvCommand: options.handleMpvCommand,
getKeybindings: options.getKeybindings, getKeybindings: options.getKeybindings,
getSessionBindings: options.getSessionBindings ?? (() => []),
getConfiguredShortcuts: options.getConfiguredShortcuts, getConfiguredShortcuts: options.getConfiguredShortcuts,
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
getStatsToggleKey: options.getStatsToggleKey, getStatsToggleKey: options.getStatsToggleKey,
getMarkWatchedKey: options.getMarkWatchedKey, getMarkWatchedKey: options.getMarkWatchedKey,
getControllerConfig: options.getControllerConfig, getControllerConfig: options.getControllerConfig,
@@ -299,7 +313,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
(event: unknown, ignore: unknown, options: unknown = {}) => { (event: unknown, ignore: unknown, options: unknown = {}) => {
if (typeof ignore !== 'boolean') return; if (typeof ignore !== 'boolean') return;
const parsedOptions = parseOptionalForwardingOptions(options); const parsedOptions = parseOptionalForwardingOptions(options);
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender); const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
if (senderWindow && !senderWindow.isDestroyed()) { if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions); senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
} }
@@ -311,11 +326,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
if (!parsedModal) return; if (!parsedModal) return;
deps.onOverlayModalClosed(parsedModal); deps.onOverlayModalClosed(parsedModal);
}); });
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (_event: unknown, modal: unknown) => { ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal); const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return; if (!parsedModal) return;
if (!deps.onOverlayModalOpened) return; if (!deps.onOverlayModalOpened) return;
deps.onOverlayModalOpened(parsedModal); const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalOpened(parsedModal, senderWindow);
}); });
ipc.handle( ipc.handle(
@@ -431,10 +448,36 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.handleMpvCommand(parsedCommand); deps.handleMpvCommand(parsedCommand);
}); });
ipc.handle(
IPC_CHANNELS.command.dispatchSessionAction,
async (_event: unknown, request: unknown) => {
if (!request || typeof request !== 'object') {
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 });
},
);
ipc.handle(IPC_CHANNELS.request.getKeybindings, () => { ipc.handle(IPC_CHANNELS.request.getKeybindings, () => {
return deps.getKeybindings(); return deps.getKeybindings();
}); });
ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => {
return deps.getSessionBindings?.() ?? [];
});
ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => { ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => {
return deps.getConfiguredShortcuts(); return deps.getConfiguredShortcuts();
}); });

View File

@@ -2,23 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init'; import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
try {
run();
} finally {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
});
}
}
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => { test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
let createdIntegrations = 0; let createdIntegrations = 0;
let startedIntegrations = 0; let startedIntegrations = 0;
@@ -564,62 +547,60 @@ test('initializeOverlayRuntime hides overlay windows when tracker loses the targ
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']); assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
}); });
test('initializeOverlayRuntime preserves visible overlay on Windows tracker loss when target is not minimized', () => { test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => {
withPlatform('win32', () => { const calls: string[] = [];
const calls: string[] = []; const tracker = {
const tracker = { onGeometryChange: null as ((...args: unknown[]) => void) | null,
onGeometryChange: null as ((...args: unknown[]) => void) | null, onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null, onWindowLost: null as (() => void) | null,
onWindowLost: null as (() => void) | null, onWindowFocusChange: null as ((focused: boolean) => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null, isTargetWindowMinimized: () => false,
isTargetWindowMinimized: () => false, start: () => {},
start: () => {}, };
}; const overlayWindows = [
const overlayWindows = [ {
{ hide: () => calls.push('hide-visible'),
hide: () => calls.push('hide-visible'), },
}, ];
];
initializeOverlayRuntime({ initializeOverlayRuntime({
backendOverride: null, backendOverride: null,
createMainWindow: () => {}, createMainWindow: () => {},
registerGlobalShortcuts: () => {}, registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {}, updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => true, isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => { updateVisibleOverlayVisibility: () => {
calls.push('update-visible'); calls.push('update-visible');
}, },
refreshCurrentSubtitle: () => {}, refreshCurrentSubtitle: () => {},
getOverlayWindows: () => overlayWindows as never, getOverlayWindows: () => overlayWindows as never,
syncOverlayShortcuts: () => { syncOverlayShortcuts: () => {
calls.push('sync-shortcuts'); calls.push('sync-shortcuts');
}, },
setWindowTracker: () => {}, setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock', getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => tracker as never, createWindowTracker: () => tracker as never,
getResolvedConfig: () => ({ getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never, ankiConnect: { enabled: false } as never,
}), }),
getSubtitleTimingTracker: () => null, getSubtitleTimingTracker: () => null,
getMpvClient: () => null, getMpvClient: () => null,
getRuntimeOptionsManager: () => null, getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => {}, setAnkiIntegration: () => {},
showDesktopNotification: () => {}, showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({ createFieldGroupingCallback: () => async () => ({
keepNoteId: 1, keepNoteId: 1,
deleteNoteId: 2, deleteNoteId: 2,
deleteDuplicate: false, deleteDuplicate: false,
cancelled: false, cancelled: false,
}), }),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
calls.length = 0;
tracker.onWindowLost?.();
assert.deepEqual(calls, ['sync-shortcuts']);
}); });
calls.length = 0;
tracker.onWindowLost?.();
assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']);
}); });
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => { test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {

View File

@@ -105,15 +105,6 @@ export function initializeOverlayRuntime(options: {
}; };
windowTracker.onWindowLost = () => { windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.(); options.releaseOverlayOwner?.();
if (
process.platform === 'win32' &&
typeof windowTracker.isTargetWindowMinimized === 'function' &&
!windowTracker.isTargetWindowMinimized()
) {
options.syncOverlayShortcuts();
return;
}
for (const window of options.getOverlayWindows()) { for (const window of options.getOverlayWindows()) {
window.hide(); window.hide();
} }

View File

@@ -6,7 +6,11 @@ import {
OverlayShortcutRuntimeDeps, OverlayShortcutRuntimeDeps,
runOverlayShortcutLocalFallback, runOverlayShortcutLocalFallback,
} from './overlay-shortcut-handler'; } from './overlay-shortcut-handler';
import { shouldActivateOverlayShortcuts } from './overlay-shortcut'; import {
registerOverlayShortcutsRuntime,
shouldActivateOverlayShortcuts,
unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut';
function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts { function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return { return {
@@ -313,3 +317,85 @@ test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => {
true, true,
); );
}); });
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,
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
cancelPendingMultiCopy: () => {},
cancelPendingMineSentenceMultiple: () => {},
});
assert.equal(result, true);
});
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,
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy');
},
cancelPendingMineSentenceMultiple: () => {
calls.push('cancel-mine-sentence-multiple');
},
});
assert.equal(result, false);
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
});

View File

@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import {
registerOverlayShortcuts,
syncOverlayShortcutsRuntime,
unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
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,
...overrides,
};
}
test('registerOverlayShortcuts reports active overlay shortcuts when configured', () => {
assert.equal(
registerOverlayShortcuts(createShortcuts({ openJimaku: 'Ctrl+J' }), {
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
true,
);
});
test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent', () => {
assert.equal(
registerOverlayShortcuts(createShortcuts(), {
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
false,
);
});
test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active', () => {
const calls: string[] = [];
const result = syncOverlayShortcutsRuntime(false, true, {
getConfiguredShortcuts: () => createShortcuts(),
getOverlayHandlers: () => ({
copySubtitle: () => {},
copySubtitleMultiple: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy');
},
cancelPendingMineSentenceMultiple: () => {
calls.push('cancel-mine-sentence-multiple');
},
});
assert.equal(result, false);
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
});

View File

@@ -1,10 +1,4 @@
import electron from 'electron';
import { ConfiguredShortcuts } from '../utils/shortcut-config'; import { ConfiguredShortcuts } from '../utils/shortcut-config';
import { isGlobalShortcutRegisteredSafe } from './shortcut-fallback';
import { createLogger } from '../../logger';
const { globalShortcut } = electron;
const logger = createLogger('main:overlay-shortcut-service');
export interface OverlayShortcutHandlers { export interface OverlayShortcutHandlers {
copySubtitle: () => void; copySubtitle: () => void;
@@ -27,6 +21,27 @@ export interface OverlayShortcutLifecycleDeps {
cancelPendingMineSentenceMultiple: () => void; cancelPendingMineSentenceMultiple: () => void;
} }
const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>> = [
'copySubtitle',
'copySubtitleMultiple',
'updateLastCardFromClipboard',
'triggerFieldGrouping',
'triggerSubsync',
'mineSentence',
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openRuntimeOptions',
'openJimaku',
];
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
return OVERLAY_SHORTCUT_KEYS.some((key) => {
const shortcut = shortcuts[key];
return typeof shortcut === 'string' && shortcut.trim().length > 0;
});
}
export function shouldActivateOverlayShortcuts(args: { export function shouldActivateOverlayShortcuts(args: {
overlayRuntimeInitialized: boolean; overlayRuntimeInitialized: boolean;
isMacOSPlatform: boolean; isMacOSPlatform: boolean;
@@ -43,139 +58,12 @@ export function shouldActivateOverlayShortcuts(args: {
export function registerOverlayShortcuts( export function registerOverlayShortcuts(
shortcuts: ConfiguredShortcuts, shortcuts: ConfiguredShortcuts,
handlers: OverlayShortcutHandlers, _handlers: OverlayShortcutHandlers,
): boolean { ): boolean {
let registeredAny = false; return hasConfiguredOverlayShortcuts(shortcuts);
const registerOverlayShortcut = (
accelerator: string,
handler: () => void,
label: string,
): void => {
if (isGlobalShortcutRegisteredSafe(accelerator)) {
registeredAny = true;
return;
}
const ok = globalShortcut.register(accelerator, handler);
if (!ok) {
logger.warn(`Failed to register overlay shortcut ${label}: ${accelerator}`);
return;
}
registeredAny = true;
};
if (shortcuts.copySubtitleMultiple) {
registerOverlayShortcut(
shortcuts.copySubtitleMultiple,
() => handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs),
'copySubtitleMultiple',
);
}
if (shortcuts.copySubtitle) {
registerOverlayShortcut(shortcuts.copySubtitle, () => handlers.copySubtitle(), 'copySubtitle');
}
if (shortcuts.triggerFieldGrouping) {
registerOverlayShortcut(
shortcuts.triggerFieldGrouping,
() => handlers.triggerFieldGrouping(),
'triggerFieldGrouping',
);
}
if (shortcuts.triggerSubsync) {
registerOverlayShortcut(
shortcuts.triggerSubsync,
() => handlers.triggerSubsync(),
'triggerSubsync',
);
}
if (shortcuts.mineSentence) {
registerOverlayShortcut(shortcuts.mineSentence, () => handlers.mineSentence(), 'mineSentence');
}
if (shortcuts.mineSentenceMultiple) {
registerOverlayShortcut(
shortcuts.mineSentenceMultiple,
() => handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs),
'mineSentenceMultiple',
);
}
if (shortcuts.toggleSecondarySub) {
registerOverlayShortcut(
shortcuts.toggleSecondarySub,
() => handlers.toggleSecondarySub(),
'toggleSecondarySub',
);
}
if (shortcuts.updateLastCardFromClipboard) {
registerOverlayShortcut(
shortcuts.updateLastCardFromClipboard,
() => handlers.updateLastCardFromClipboard(),
'updateLastCardFromClipboard',
);
}
if (shortcuts.markAudioCard) {
registerOverlayShortcut(
shortcuts.markAudioCard,
() => handlers.markAudioCard(),
'markAudioCard',
);
}
if (shortcuts.openRuntimeOptions) {
registerOverlayShortcut(
shortcuts.openRuntimeOptions,
() => handlers.openRuntimeOptions(),
'openRuntimeOptions',
);
}
if (shortcuts.openJimaku) {
registerOverlayShortcut(shortcuts.openJimaku, () => handlers.openJimaku(), 'openJimaku');
}
return registeredAny;
} }
export function unregisterOverlayShortcuts(shortcuts: ConfiguredShortcuts): void { export function unregisterOverlayShortcuts(_shortcuts: ConfiguredShortcuts): void {}
if (shortcuts.copySubtitle) {
globalShortcut.unregister(shortcuts.copySubtitle);
}
if (shortcuts.copySubtitleMultiple) {
globalShortcut.unregister(shortcuts.copySubtitleMultiple);
}
if (shortcuts.updateLastCardFromClipboard) {
globalShortcut.unregister(shortcuts.updateLastCardFromClipboard);
}
if (shortcuts.triggerFieldGrouping) {
globalShortcut.unregister(shortcuts.triggerFieldGrouping);
}
if (shortcuts.triggerSubsync) {
globalShortcut.unregister(shortcuts.triggerSubsync);
}
if (shortcuts.mineSentence) {
globalShortcut.unregister(shortcuts.mineSentence);
}
if (shortcuts.mineSentenceMultiple) {
globalShortcut.unregister(shortcuts.mineSentenceMultiple);
}
if (shortcuts.toggleSecondarySub) {
globalShortcut.unregister(shortcuts.toggleSecondarySub);
}
if (shortcuts.markAudioCard) {
globalShortcut.unregister(shortcuts.markAudioCard);
}
if (shortcuts.openRuntimeOptions) {
globalShortcut.unregister(shortcuts.openRuntimeOptions);
}
if (shortcuts.openJimaku) {
globalShortcut.unregister(shortcuts.openJimaku);
}
}
export function registerOverlayShortcutsRuntime(deps: OverlayShortcutLifecycleDeps): boolean { export function registerOverlayShortcutsRuntime(deps: OverlayShortcutLifecycleDeps): boolean {
return registerOverlayShortcuts(deps.getConfiguredShortcuts(), deps.getOverlayHandlers()); return registerOverlayShortcuts(deps.getConfiguredShortcuts(), deps.getOverlayHandlers());

View File

@@ -14,7 +14,7 @@ test('overlay window config explicitly disables renderer sandbox for preload com
}); });
test('Windows visible overlay window config does not start as always-on-top', () => { test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatform = process.platform; const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { Object.defineProperty(process, 'platform', {
configurable: true, configurable: true,
@@ -29,10 +29,9 @@ test('Windows visible overlay window config does not start as always-on-top', ()
assert.equal(options.alwaysOnTop, false); assert.equal(options.alwaysOnTop, false);
} finally { } finally {
Object.defineProperty(process, 'platform', { if (originalPlatformDescriptor) {
configurable: true, Object.defineProperty(process, 'platform', originalPlatformDescriptor);
value: originalPlatform, }
});
} }
}); });

View File

@@ -17,6 +17,9 @@ const overlayWindowContentReady = new WeakSet<BrowserWindow>();
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady'; const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
export function isOverlayWindowContentReady(window: BrowserWindow): boolean { export function isOverlayWindowContentReady(window: BrowserWindow): boolean {
if (window.isDestroyed()) {
return false;
}
return ( return (
overlayWindowContentReady.has(window) || overlayWindowContentReady.has(window) ||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[

View File

@@ -0,0 +1,109 @@
import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../../types';
import type { SessionActionId } from '../../types/session-bindings';
import type { SessionActionDispatchRequest } from '../../types/runtime';
export interface SessionActionExecutorDeps {
toggleStatsOverlay: () => void;
toggleVisibleOverlay: () => void;
copyCurrentSubtitle: () => void;
copySubtitleCount: (count: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
mineSentenceCard: () => Promise<void>;
mineSentenceCount: (count: number) => void;
toggleSecondarySub: () => void;
markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
replayCurrentSubtitle: () => void;
playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
}
function resolveCount(count: number | undefined): number {
const normalized = typeof count === 'number' && Number.isInteger(count) ? count : 1;
return Math.min(9, Math.max(1, normalized));
}
export async function dispatchSessionAction(
request: SessionActionDispatchRequest,
deps: SessionActionExecutorDeps,
): Promise<void> {
switch (request.actionId) {
case 'toggleStatsOverlay':
deps.toggleStatsOverlay();
return;
case 'toggleVisibleOverlay':
deps.toggleVisibleOverlay();
return;
case 'copySubtitle':
deps.copyCurrentSubtitle();
return;
case 'copySubtitleMultiple':
deps.copySubtitleCount(resolveCount(request.payload?.count));
return;
case 'updateLastCardFromClipboard':
await deps.updateLastCardFromClipboard();
return;
case 'triggerFieldGrouping':
await deps.triggerFieldGrouping();
return;
case 'triggerSubsync':
await deps.triggerSubsyncFromConfig();
return;
case 'mineSentence':
await deps.mineSentenceCard();
return;
case 'mineSentenceMultiple':
deps.mineSentenceCount(resolveCount(request.payload?.count));
return;
case 'toggleSecondarySub':
deps.toggleSecondarySub();
return;
case 'markAudioCard':
await deps.markLastCardAsAudioCard();
return;
case 'openRuntimeOptions':
deps.openRuntimeOptionsPalette();
return;
case 'openJimaku':
deps.openJimaku();
return;
case 'openYoutubePicker':
await deps.openYoutubeTrackPicker();
return;
case 'openPlaylistBrowser':
await deps.openPlaylistBrowser();
return;
case 'replayCurrentSubtitle':
deps.replayCurrentSubtitle();
return;
case 'playNextSubtitle':
deps.playNextSubtitle();
return;
case 'shiftSubDelayPrevLine':
await deps.shiftSubDelayToAdjacentSubtitle('previous');
return;
case 'shiftSubDelayNextLine':
await deps.shiftSubDelayToAdjacentSubtitle('next');
return;
case 'cycleRuntimeOption': {
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
if (!runtimeOptionId) {
deps.showMpvOsd('Runtime option id is required.');
return;
}
const direction = request.payload?.direction === -1 ? -1 : 1;
const result = deps.cycleRuntimeOption(runtimeOptionId, direction);
if (!result.ok && result.error) {
deps.showMpvOsd(result.error);
}
return;
}
}
}

View File

@@ -0,0 +1,247 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Keybinding } from '../../types';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import { compileSessionBindings } from './session-bindings';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
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,
...overrides,
};
}
function createKeybinding(key: string, command: Keybinding['command']): Keybinding {
return { key, command };
}
test('compileSessionBindings merges shortcuts and keybindings into one canonical list', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
openJimaku: 'Ctrl+Shift+J',
}),
keybindings: [
createKeybinding('KeyF', ['cycle', 'fullscreen']),
createKeybinding('Ctrl+Shift+Y', [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]),
],
platform: 'linux',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(
result.bindings.map((binding) => ({
actionType: binding.actionType,
sourcePath: binding.sourcePath,
code: binding.key.code,
modifiers: binding.key.modifiers,
target:
binding.actionType === 'session-action'
? binding.actionId
: binding.command.join(' '),
})),
[
{
actionType: 'mpv-command',
sourcePath: 'keybindings[0].key',
code: 'KeyF',
modifiers: [],
target: 'cycle fullscreen',
},
{
actionType: 'session-action',
sourcePath: 'keybindings[1].key',
code: 'KeyY',
modifiers: ['ctrl', 'shift'],
target: 'openYoutubePicker',
},
{
actionType: 'session-action',
sourcePath: 'shortcuts.openJimaku',
code: 'KeyJ',
modifiers: ['ctrl', 'shift'],
target: 'openJimaku',
},
{
actionType: 'session-action',
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
code: 'KeyO',
modifiers: ['alt', 'shift'],
target: 'toggleVisibleOverlay',
},
],
);
});
test('compileSessionBindings resolves CommandOrControl per platform', () => {
const input = {
shortcuts: createShortcuts({
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
}),
keybindings: [],
};
const windows = compileSessionBindings({ ...input, platform: 'win32' });
const mac = compileSessionBindings({ ...input, platform: 'darwin' });
assert.deepEqual(windows.bindings[0]?.key.modifiers, ['ctrl', 'shift']);
assert.deepEqual(mac.bindings[0]?.key.modifiers, ['shift', 'meta']);
});
test('compileSessionBindings resolves CommandOrControl in DOM key strings per platform', () => {
const input = {
shortcuts: createShortcuts(),
keybindings: [createKeybinding('CommandOrControl+Shift+J', ['cycle', 'fullscreen'])],
statsToggleKey: 'CommandOrControl+Backquote',
};
const windows = compileSessionBindings({ ...input, platform: 'win32' });
const mac = compileSessionBindings({ ...input, platform: 'darwin' });
assert.deepEqual(
windows.bindings
.map((binding) => ({
sourcePath: binding.sourcePath,
modifiers: binding.key.modifiers,
}))
.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)),
[
{
sourcePath: 'keybindings[0].key',
modifiers: ['ctrl', 'shift'],
},
{
sourcePath: 'stats.toggleKey',
modifiers: ['ctrl'],
},
],
);
assert.deepEqual(
mac.bindings
.map((binding) => ({
sourcePath: binding.sourcePath,
modifiers: binding.key.modifiers,
}))
.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)),
[
{
sourcePath: 'keybindings[0].key',
modifiers: ['shift', 'meta'],
},
{
sourcePath: 'stats.toggleKey',
modifiers: ['meta'],
},
],
);
});
test('compileSessionBindings drops conflicting bindings that canonicalize to the same key', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openJimaku: 'Ctrl+Shift+J',
}),
keybindings: [createKeybinding('Ctrl+Shift+J', [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN])],
platform: 'linux',
});
assert.deepEqual(result.bindings, []);
assert.equal(result.warnings.length, 1);
assert.equal(result.warnings[0]?.kind, 'conflict');
assert.deepEqual(result.warnings[0]?.conflictingPaths, [
'shortcuts.openJimaku',
'keybindings[0].key',
]);
});
test('compileSessionBindings omits disabled bindings', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openJimaku: null,
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
}),
keybindings: [createKeybinding('Ctrl+Shift+J', null)],
platform: 'linux',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), [
'shortcuts.toggleVisibleOverlayGlobal',
]);
});
test('compileSessionBindings warns on unsupported shortcut and keybinding syntax', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openJimaku: 'Hyper+J',
}),
keybindings: [createKeybinding('Ctrl+ß', ['cycle', 'fullscreen'])],
platform: 'linux',
});
assert.deepEqual(result.bindings, []);
assert.deepEqual(
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
['unsupported:shortcuts.openJimaku', 'unsupported:keybindings[0].key'],
);
});
test('compileSessionBindings warns on deprecated toggleVisibleOverlayGlobal config', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [],
platform: 'linux',
rawConfig: {
shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
},
} as never,
});
assert.equal(result.bindings.length, 0);
assert.deepEqual(result.warnings, [
{
kind: 'deprecated-config',
path: 'shortcuts.toggleVisibleOverlayGlobal',
value: 'Alt+Shift+O',
message: 'Rename shortcuts.toggleVisibleOverlayGlobal to shortcuts.toggleVisibleOverlay.',
},
]);
});
test('compileSessionBindings includes stats toggle in the shared session binding artifact', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [],
statsToggleKey: 'Backquote',
platform: 'win32',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(result.bindings, [
{
sourcePath: 'stats.toggleKey',
originalKey: 'Backquote',
key: {
code: 'Backquote',
modifiers: [],
},
actionType: 'session-action',
actionId: 'toggleStatsOverlay',
},
]);
});

View File

@@ -0,0 +1,431 @@
import type { Keybinding, ResolvedConfig } from '../../types';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import type {
CompiledMpvCommandBinding,
CompiledSessionActionBinding,
CompiledSessionBinding,
PluginSessionBindingsArtifact,
SessionActionId,
SessionBindingWarning,
SessionKeyModifier,
SessionKeySpec,
} from '../../types/session-bindings';
import { SPECIAL_COMMANDS } from '../../config';
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
type CompileSessionBindingsInput = {
keybindings: Keybinding[];
shortcuts: ConfiguredShortcuts;
statsToggleKey?: string | null;
platform: PlatformKeyModel;
rawConfig?: ResolvedConfig | null;
};
type DraftBinding = {
binding: CompiledSessionBinding;
actionFingerprint: string;
};
const MODIFIER_ORDER: SessionKeyModifier[] = ['ctrl', 'alt', 'shift', 'meta'];
const SESSION_SHORTCUT_ACTIONS: Array<{
key: keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>;
actionId: SessionActionId;
}> = [
{ key: 'toggleVisibleOverlayGlobal', actionId: 'toggleVisibleOverlay' },
{ key: 'copySubtitle', actionId: 'copySubtitle' },
{ key: 'copySubtitleMultiple', actionId: 'copySubtitleMultiple' },
{ key: 'updateLastCardFromClipboard', actionId: 'updateLastCardFromClipboard' },
{ key: 'triggerFieldGrouping', actionId: 'triggerFieldGrouping' },
{ key: 'triggerSubsync', actionId: 'triggerSubsync' },
{ key: 'mineSentence', actionId: 'mineSentence' },
{ key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' },
{ key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' },
{ key: 'markAudioCard', actionId: 'markAudioCard' },
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
];
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
return [...new Set(modifiers)].sort(
(left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right),
);
}
function normalizeCodeToken(token: string): string | null {
const normalized = token.trim();
if (!normalized) return null;
if (/^[a-z]$/i.test(normalized)) {
return `Key${normalized.toUpperCase()}`;
}
if (/^[0-9]$/.test(normalized)) {
return `Digit${normalized}`;
}
const exactMap: Record<string, string> = {
space: 'Space',
tab: 'Tab',
enter: 'Enter',
return: 'Enter',
esc: 'Escape',
escape: 'Escape',
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
backspace: 'Backspace',
delete: 'Delete',
slash: 'Slash',
backslash: 'Backslash',
minus: 'Minus',
plus: 'Equal',
equal: 'Equal',
comma: 'Comma',
period: 'Period',
quote: 'Quote',
semicolon: 'Semicolon',
bracketleft: 'BracketLeft',
bracketright: 'BracketRight',
backquote: 'Backquote',
};
const lower = normalized.toLowerCase();
if (exactMap[lower]) return exactMap[lower];
if (
/^key[a-z]$/i.test(normalized) ||
/^digit[0-9]$/i.test(normalized) ||
/^arrow(?:up|down|left|right)$/i.test(normalized) ||
/^f\d{1,2}$/i.test(normalized)
) {
return normalized[0]!.toUpperCase() + normalized.slice(1);
}
return null;
}
function parseAccelerator(
accelerator: string,
platform: PlatformKeyModel,
): { key: SessionKeySpec | null; message?: string } {
const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl');
if (!normalized) {
return { key: null, message: 'Empty accelerator is not supported.' };
}
const parts = normalized.split('+').filter(Boolean);
const keyToken = parts.pop();
if (!keyToken) {
return { key: null, message: 'Missing accelerator key token.' };
}
const modifiers: SessionKeyModifier[] = [];
for (const modifier of parts) {
const lower = modifier.toLowerCase();
if (lower === 'ctrl' || lower === 'control') {
modifiers.push('ctrl');
continue;
}
if (lower === 'alt' || lower === 'option') {
modifiers.push('alt');
continue;
}
if (lower === 'shift') {
modifiers.push('shift');
continue;
}
if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') {
modifiers.push('meta');
continue;
}
if (lower === 'commandorcontrol') {
modifiers.push(platform === 'darwin' ? 'meta' : 'ctrl');
continue;
}
return {
key: null,
message: `Unsupported accelerator modifier: ${modifier}`,
};
}
const code = normalizeCodeToken(keyToken);
if (!code) {
return {
key: null,
message: `Unsupported accelerator key token: ${keyToken}`,
};
}
return {
key: {
code,
modifiers: normalizeModifiers(modifiers),
},
};
}
function parseDomKeyString(
key: string,
platform: PlatformKeyModel,
): { key: SessionKeySpec | null; message?: string } {
const parts = key
.split('+')
.map((part) => part.trim())
.filter(Boolean);
const keyToken = parts.pop();
if (!keyToken) {
return { key: null, message: 'Missing keybinding key token.' };
}
const modifiers: SessionKeyModifier[] = [];
for (const modifier of parts) {
const lower = modifier.toLowerCase();
if (lower === 'ctrl' || lower === 'control') {
modifiers.push('ctrl');
continue;
}
if (lower === 'alt' || lower === 'option') {
modifiers.push('alt');
continue;
}
if (lower === 'shift') {
modifiers.push('shift');
continue;
}
if (
lower === 'meta' ||
lower === 'super' ||
lower === 'command' ||
lower === 'cmd' ||
lower === 'commandorcontrol'
) {
modifiers.push(
lower === 'commandorcontrol' ? (platform === 'darwin' ? 'meta' : 'ctrl') : 'meta',
);
continue;
}
return {
key: null,
message: `Unsupported keybinding modifier: ${modifier}`,
};
}
const code = normalizeCodeToken(keyToken);
if (!code) {
return {
key: null,
message: `Unsupported keybinding token: ${keyToken}`,
};
}
return {
key: {
code,
modifiers: normalizeModifiers(modifiers),
},
};
}
export function getSessionKeySpecSignature(key: SessionKeySpec): string {
return [...key.modifiers, key.code].join('+');
}
function resolveCommandBinding(
binding: Keybinding,
):
| Omit<CompiledMpvCommandBinding, 'key' | 'sourcePath' | 'originalKey'>
| Omit<CompiledSessionActionBinding, 'key' | 'sourcePath' | 'originalKey'>
| null {
const command = binding.command ?? [];
const first = typeof command[0] === 'string' ? command[0] : '';
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
return { actionType: 'session-action', actionId: 'triggerSubsync' };
}
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) {
return { actionType: 'session-action', actionId: 'openRuntimeOptions' };
}
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) {
return { actionType: 'session-action', actionId: 'openJimaku' };
}
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
return { actionType: 'session-action', actionId: 'openYoutubePicker' };
}
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) {
return { actionType: 'session-action', actionId: 'openPlaylistBrowser' };
}
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) {
return { actionType: 'session-action', actionId: 'replayCurrentSubtitle' };
}
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) {
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' };
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, runtimeOptionId, rawDirection] = first.split(':');
return {
actionType: 'session-action',
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId,
direction: rawDirection === 'prev' ? -1 : 1,
},
};
}
return {
actionType: 'mpv-command',
command,
};
}
function getBindingFingerprint(binding: CompiledSessionBinding): string {
if (binding.actionType === 'mpv-command') {
return `mpv:${JSON.stringify(binding.command)}`;
}
return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`;
}
export function compileSessionBindings(
input: CompileSessionBindingsInput,
): {
bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[];
} {
const warnings: SessionBindingWarning[] = [];
const candidates = new Map<string, DraftBinding[]>();
const legacyToggleVisibleOverlayGlobal = (
input.rawConfig?.shortcuts as Record<string, unknown> | undefined
)?.toggleVisibleOverlayGlobal;
const statsToggleKey = input.statsToggleKey ?? input.rawConfig?.stats?.toggleKey ?? null;
if (legacyToggleVisibleOverlayGlobal !== undefined) {
warnings.push({
kind: 'deprecated-config',
path: 'shortcuts.toggleVisibleOverlayGlobal',
value: legacyToggleVisibleOverlayGlobal,
message: 'Rename shortcuts.toggleVisibleOverlayGlobal to shortcuts.toggleVisibleOverlay.',
});
}
for (const shortcut of SESSION_SHORTCUT_ACTIONS) {
const accelerator = input.shortcuts[shortcut.key];
if (!accelerator) continue;
const parsed = parseAccelerator(accelerator, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: `shortcuts.${shortcut.key}`,
value: accelerator,
message: parsed.message ?? 'Unsupported accelerator syntax.',
});
continue;
}
const binding: CompiledSessionActionBinding = {
sourcePath: `shortcuts.${shortcut.key}`,
originalKey: accelerator,
key: parsed.key,
actionType: 'session-action',
actionId: shortcut.actionId,
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding,
actionFingerprint: getBindingFingerprint(binding),
});
candidates.set(signature, draft);
}
if (statsToggleKey) {
const parsed = parseDomKeyString(statsToggleKey, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: 'stats.toggleKey',
value: statsToggleKey,
message: parsed.message ?? 'Unsupported stats toggle key syntax.',
});
} else {
const binding: CompiledSessionActionBinding = {
sourcePath: 'stats.toggleKey',
originalKey: statsToggleKey,
key: parsed.key,
actionType: 'session-action',
actionId: 'toggleStatsOverlay',
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding,
actionFingerprint: getBindingFingerprint(binding),
});
candidates.set(signature, draft);
}
}
input.keybindings.forEach((binding, index) => {
if (!binding.command) return;
const parsed = parseDomKeyString(binding.key, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: `keybindings[${index}].key`,
value: binding.key,
message: parsed.message ?? 'Unsupported keybinding syntax.',
});
return;
}
const resolved = resolveCommandBinding(binding);
if (!resolved) return;
const compiled: CompiledSessionBinding = {
sourcePath: `keybindings[${index}].key`,
originalKey: binding.key,
key: parsed.key,
...resolved,
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding: compiled,
actionFingerprint: getBindingFingerprint(compiled),
});
candidates.set(signature, draft);
});
const bindings: CompiledSessionBinding[] = [];
for (const [signature, draftBindings] of candidates.entries()) {
const uniqueFingerprints = new Set(draftBindings.map((entry) => entry.actionFingerprint));
if (uniqueFingerprints.size > 1) {
warnings.push({
kind: 'conflict',
path: draftBindings[0]!.binding.sourcePath,
value: signature,
conflictingPaths: draftBindings.map((entry) => entry.binding.sourcePath),
message: `Conflicting session bindings compile to ${signature}; SubMiner will bind neither action.`,
});
continue;
}
bindings.push(draftBindings[0]!.binding);
}
bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
return { bindings, warnings };
}
export function buildPluginSessionBindingsArtifact(input: {
bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[];
numericSelectionTimeoutMs: number;
now?: Date;
}): PluginSessionBindingsArtifact {
return {
version: 1,
generatedAt: (input.now ?? new Date()).toISOString(),
numericSelectionTimeoutMs: input.numericSelectionTimeoutMs,
bindings: input.bindings,
warnings: input.warnings,
};
}

View File

@@ -20,42 +20,6 @@ export interface RegisterGlobalShortcutsServiceOptions {
} }
export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void { export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void {
const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal;
const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase();
const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase();
const normalizedSettings = 'alt+shift+y';
if (visibleShortcut) {
const toggleVisibleRegistered = globalShortcut.register(visibleShortcut, () => {
options.onToggleVisibleOverlay();
});
if (!toggleVisibleRegistered) {
logger.warn(
`Failed to register global shortcut toggleVisibleOverlayGlobal: ${visibleShortcut}`,
);
}
}
if (options.shortcuts.openJimaku && options.onOpenJimaku) {
if (
normalizedJimaku &&
(normalizedJimaku === normalizedVisible || normalizedJimaku === normalizedSettings)
) {
logger.warn(
'Skipped registering openJimaku because it collides with another global shortcut',
);
} else {
const openJimakuRegistered = globalShortcut.register(options.shortcuts.openJimaku, () => {
options.onOpenJimaku?.();
});
if (!openJimakuRegistered) {
logger.warn(
`Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`,
);
}
}
}
const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => { const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => {
options.onOpenYomitanSettings(); options.onOpenYomitanSettings();
}); });

View File

@@ -29,6 +29,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,

View File

@@ -109,11 +109,13 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer'; import { MecabTokenizer } from './mecab-tokenizer';
import type { import type {
CompiledSessionBinding,
JimakuApiResponse, JimakuApiResponse,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
ResolvedConfig, ResolvedConfig,
RuntimeOptionState, RuntimeOptionState,
SessionActionDispatchRequest,
SecondarySubMode, SecondarySubMode,
SubtitleData, SubtitleData,
SubtitlePosition, SubtitlePosition,
@@ -137,7 +139,6 @@ import {
getWindowsForegroundProcessNameNative, getWindowsForegroundProcessNameNative,
queryWindowsForegroundProcessName, queryWindowsForegroundProcessName,
setWindowsOverlayOwnerNative, setWindowsOverlayOwnerNative,
syncWindowsOverlayToMpvZOrder,
} from './window-trackers/windows-helper'; } from './window-trackers/windows-helper';
import { import {
commandNeedsOverlayStartupPrereqs, commandNeedsOverlayStartupPrereqs,
@@ -351,6 +352,7 @@ import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-reso
import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { startStatsServer } from './core/services/stats-server'; import { startStatsServer } from './core/services/stats-server';
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/stats-window.js';
import { import {
createFirstRunSetupService, createFirstRunSetupService,
getFirstRunSetupCompletionMessage, getFirstRunSetupCompletionMessage,
@@ -413,6 +415,8 @@ import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store'; import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './core/services/session-bindings';
import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts'; import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
import { createMainRuntimeRegistry } from './main/runtime/registry'; import { createMainRuntimeRegistry } from './main/runtime/registry';
import { import {
@@ -449,6 +453,7 @@ import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import { import {
createFrequencyDictionaryRuntimeService, createFrequencyDictionaryRuntimeService,
@@ -1535,6 +1540,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
setKeybindings: (keybindings) => { setKeybindings: (keybindings) => {
appState.keybindings = keybindings; appState.keybindings = keybindings;
}, },
setSessionBindings: (sessionBindings) => {
persistSessionBindings(sessionBindings);
},
refreshGlobalAndOverlayShortcuts: () => { refreshGlobalAndOverlayShortcuts: () => {
refreshGlobalAndOverlayShortcuts(); refreshGlobalAndOverlayShortcuts();
}, },
@@ -1927,6 +1935,21 @@ function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
: handle.readUInt32LE(0); : handle.readUInt32LE(0);
} }
function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | null): number | null {
if (process.platform !== 'win32') {
return null;
}
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;
} catch {
return null;
}
}
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> { async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
return false; return false;
@@ -1959,19 +1982,12 @@ async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
} }
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) { const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1); (mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
return true; return true;
} }
return false;
const synced = await syncWindowsOverlayToMpvZOrder({
overlayWindowHandle: getWindowsNativeWindowHandle(mainWindow),
targetMpvSocketPath: appState.mpvSocketPath,
});
if (synced) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
}
return synced;
} }
function requestWindowsVisibleOverlayZOrderSync(): void { function requestWindowsVisibleOverlayZOrderSync(): void {
@@ -3378,6 +3394,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
loadSubtitlePosition: () => loadSubtitlePosition(), loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => { resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
refreshCurrentSessionBindings();
}, },
createMpvClient: () => { createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService(); appState.mpvClient = createMpvClientRuntimeService();
@@ -3520,6 +3537,9 @@ function ensureOverlayStartupPrereqs(): void {
} }
if (appState.keybindings.length === 0) { if (appState.keybindings.length === 0) {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
refreshCurrentSessionBindings();
} else if (appState.sessionBindings.length === 0) {
refreshCurrentSessionBindings();
} }
if (!appState.mpvClient) { if (!appState.mpvClient) {
appState.mpvClient = createMpvClientRuntimeService(); appState.mpvClient = createMpvClientRuntimeService();
@@ -4118,6 +4138,54 @@ const {
}, },
}); });
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32';
return 'linux';
}
function compileCurrentSessionBindings(): {
bindings: CompiledSessionBinding[];
warnings: ReturnType<typeof compileSessionBindings>['warnings'];
} {
return compileSessionBindings({
keybindings: appState.keybindings,
shortcuts: getConfiguredShortcuts(),
statsToggleKey: getResolvedConfig().stats.toggleKey,
platform: resolveSessionBindingPlatform(),
rawConfig: getResolvedConfig(),
});
}
function persistSessionBindings(
bindings: CompiledSessionBinding[],
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
): void {
appState.sessionBindings = bindings;
writeSessionBindingsArtifact(
CONFIG_DIR,
buildPluginSessionBindingsArtifact({
bindings,
warnings,
numericSelectionTimeoutMs: getConfiguredShortcuts().multiCopyTimeoutMs,
}),
);
if (appState.mpvClient?.connected) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
'subminer-reload-session-bindings',
]);
}
}
function refreshCurrentSessionBindings(): void {
const compiled = compileCurrentSessionBindings();
for (const warning of compiled.warnings) {
logger.warn(`[session-bindings] ${warning.message}`);
}
persistSessionBindings(compiled.bindings, compiled.warnings);
}
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: { appendToMpvLogMainDeps: {
logPath: DEFAULT_MPV_LOG_PATH, logPath: DEFAULT_MPV_LOG_PATH,
@@ -4429,6 +4497,51 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text) => showMpvOsd(text),
}); });
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
await dispatchSessionActionCore(request, {
toggleStatsOverlay: () =>
toggleStatsOverlayWindow({
staticDir: statsDistPath,
preloadPath: statsPreloadPath,
getApiBaseUrl: () => ensureStatsServerStarted(),
getToggleKey: () => getResolvedConfig().stats.toggleKey,
resolveBounds: () => getCurrentOverlayGeometry(),
onVisibilityChanged: (visible) => {
appState.statsOverlayVisible = visible;
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
},
}),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
copyCurrentSubtitle: () => copyCurrentSubtitle(),
copySubtitleCount: (count) => handleMultiCopyDigit(count),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
mineSentenceCard: () => mineSentenceCard(),
mineSentenceCount: (count) => handleMineSentenceDigit(count),
toggleSecondarySub: () => handleCycleSecondarySubMode(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => overlayModalRuntime.openJimaku(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
shiftSubDelayToAdjacentSubtitle: (direction) =>
shiftSubtitleDelayToAdjacentCueHandler(direction),
cycleRuntimeOption: (id, direction) => {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' };
}
return applyRuntimeOptionResultRuntime(
appState.runtimeOptionsManager.cycleOption(id, direction),
(text) => showMpvOsd(text),
);
},
showMpvOsd: (text) => showMpvOsd(text),
});
}
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, { const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, {
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
@@ -4586,7 +4699,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position), saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings, getKeybindings: () => appState.keybindings,
getSessionBindings: () => appState.sessionBindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request),
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey, getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
getControllerConfig: () => getResolvedConfig().controller, getControllerConfig: () => getResolvedConfig().controller,
@@ -4707,6 +4822,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
stopApp: () => requestAppQuit(), stopApp: () => requestAppQuit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()), hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
dispatchSessionAction: (request: SessionActionDispatchRequest) => dispatchSessionAction(request),
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
logInfo: (message: string) => logger.info(message), logInfo: (message: string) => logger.info(message),
@@ -4973,7 +5089,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
const mainWindow = overlayManager.getMainWindow(); const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) { const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) {
return; return;
} }
const tracker = appState.windowTracker; const tracker = appState.windowTracker;

View File

@@ -30,6 +30,7 @@ export interface CliCommandRuntimeServiceContext {
triggerFieldGrouping: () => Promise<void>; triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>; triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandRuntimeServiceDepsParams['dispatchSessionAction'];
getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus']; getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus'];
clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken']; clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken'];
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup']; openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
@@ -113,6 +114,7 @@ function createCliCommandDepsFromContext(
hasMainWindow: context.hasMainWindow, hasMainWindow: context.hasMainWindow,
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow, runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
}, },
dispatchSessionAction: context.dispatchSessionAction,
ui: { ui: {
openFirstRunSetup: context.openFirstRunSetup, openFirstRunSetup: context.openFirstRunSetup,
openYomitanSettings: context.openYomitanSettings, openYomitanSettings: context.openYomitanSettings,

View File

@@ -73,7 +73,9 @@ export interface MainIpcRuntimeServiceDepsParams {
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer']; getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey']; getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey']; getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
@@ -178,6 +180,7 @@ export interface CliCommandRuntimeServiceDepsParams {
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow']; hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow']; runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
}; };
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
ui: { ui: {
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
@@ -233,7 +236,9 @@ export function createMainIpcRuntimeServiceDeps(
getMecabTokenizer: params.getMecabTokenizer, getMecabTokenizer: params.getMecabTokenizer,
handleMpvCommand: params.handleMpvCommand, handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings, getKeybindings: params.getKeybindings,
getSessionBindings: params.getSessionBindings,
getConfiguredShortcuts: params.getConfiguredShortcuts, getConfiguredShortcuts: params.getConfiguredShortcuts,
dispatchSessionAction: params.dispatchSessionAction,
getStatsToggleKey: params.getStatsToggleKey, getStatsToggleKey: params.getStatsToggleKey,
getMarkWatchedKey: params.getMarkWatchedKey, getMarkWatchedKey: params.getMarkWatchedKey,
getControllerConfig: params.getControllerConfig, getControllerConfig: params.getControllerConfig,
@@ -347,6 +352,7 @@ export function createCliCommandRuntimeServiceDeps(
hasMainWindow: params.app.hasMainWindow, hasMainWindow: params.app.hasMainWindow,
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow, runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
}, },
dispatchSessionAction: params.dispatchSessionAction,
ui: { ui: {
openFirstRunSetup: params.ui.openFirstRunSetup, openFirstRunSetup: params.ui.openFirstRunSetup,
openYomitanSettings: params.ui.openYomitanSettings, openYomitanSettings: params.ui.openYomitanSettings,

View File

@@ -42,6 +42,7 @@ test('build cli command context deps maps handlers and values', () => {
markLastCardAsAudioCard: async () => { markLastCardAsAudioCard: async () => {
calls.push('mark'); calls.push('mark');
}, },
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({}) as never, getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => calls.push('clear-token'), clearAnilistToken: () => calls.push('clear-token'),
openAnilistSetup: () => calls.push('anilist'), openAnilistSetup: () => calls.push('anilist'),

View File

@@ -28,6 +28,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
triggerFieldGrouping: () => Promise<void>; triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>; triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken']; clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup']; openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
@@ -77,6 +78,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
triggerFieldGrouping: deps.triggerFieldGrouping, triggerFieldGrouping: deps.triggerFieldGrouping,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
markLastCardAsAudioCard: deps.markLastCardAsAudioCard, markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
dispatchSessionAction: deps.dispatchSessionAction,
getAnilistStatus: deps.getAnilistStatus, getAnilistStatus: deps.getAnilistStatus,
clearAnilistToken: deps.clearAnilistToken, clearAnilistToken: deps.clearAnilistToken,
openAnilistSetup: deps.openAnilistSetup, openAnilistSetup: deps.openAnilistSetup,

View File

@@ -37,6 +37,7 @@ test('cli command context factory composes main deps and context handlers', () =
triggerFieldGrouping: async () => {}, triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {}, markLastCardAsAudioCard: async () => {},
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({ getAnilistStatus: () => ({
tokenStatus: 'resolved', tokenStatus: 'resolved',
tokenSource: 'literal', tokenSource: 'literal',

View File

@@ -53,6 +53,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
markLastCardAsAudioCard: async () => { markLastCardAsAudioCard: async () => {
calls.push('mark-audio'); calls.push('mark-audio');
}, },
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({ getAnilistStatus: () => ({
tokenStatus: 'resolved', tokenStatus: 'resolved',

View File

@@ -39,6 +39,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
triggerFieldGrouping: () => Promise<void>; triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>; triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: () => void; clearAnilistToken: () => void;
@@ -103,6 +104,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
triggerFieldGrouping: () => deps.triggerFieldGrouping(), triggerFieldGrouping: () => deps.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(), markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
dispatchSessionAction: (request) => deps.dispatchSessionAction(request),
getAnilistStatus: () => deps.getAnilistStatus(), getAnilistStatus: () => deps.getAnilistStatus(),
clearAnilistToken: () => deps.clearAnilistToken(), clearAnilistToken: () => deps.clearAnilistToken(),
openAnilistSetup: () => deps.openAnilistSetupWindow(), openAnilistSetup: () => deps.openAnilistSetupWindow(),

View File

@@ -36,6 +36,7 @@ function createDeps() {
triggerFieldGrouping: async () => {}, triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {}, markLastCardAsAudioCard: async () => {},
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({}) as never, getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {}, clearAnilistToken: () => {},
openAnilistSetup: () => {}, openAnilistSetup: () => {},

View File

@@ -33,6 +33,7 @@ export type CliCommandContextFactoryDeps = {
triggerFieldGrouping: () => Promise<void>; triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>; triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
dispatchSessionAction: CliCommandRuntimeServiceContext['dispatchSessionAction'];
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus']; getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken']; clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup']; openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
@@ -89,6 +90,7 @@ export function createCliCommandContext(
triggerFieldGrouping: deps.triggerFieldGrouping, triggerFieldGrouping: deps.triggerFieldGrouping,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
markLastCardAsAudioCard: deps.markLastCardAsAudioCard, markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
dispatchSessionAction: deps.dispatchSessionAction,
getAnilistStatus: deps.getAnilistStatus, getAnilistStatus: deps.getAnilistStatus,
clearAnilistToken: deps.clearAnilistToken, clearAnilistToken: deps.clearAnilistToken,
openAnilistSetup: deps.openAnilistSetup, openAnilistSetup: deps.openAnilistSetup,

View File

@@ -30,6 +30,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
triggerFieldGrouping: async () => {}, triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {}, markLastCardAsAudioCard: async () => {},
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({}) as never, getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {}, clearAnilistToken: () => {},
openAnilistSetupWindow: () => {}, openAnilistSetupWindow: () => {},

View File

@@ -53,7 +53,9 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
saveSubtitlePosition: () => {}, saveSubtitlePosition: () => {},
getMecabTokenizer: () => null, getMecabTokenizer: () => null,
getKeybindings: () => [], getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}) as never, getConfiguredShortcuts: () => ({}) as never,
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => ({}) as never, getControllerConfig: () => ({}) as never,

View File

@@ -14,6 +14,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
const applyHotReload = createConfigHotReloadAppliedHandler({ const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'), setKeybindings: () => calls.push('set:keybindings'),
setSessionBindings: () => calls.push('set:session-bindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`), setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
broadcastToOverlayWindows: (channel, payload) => broadcastToOverlayWindows: (channel, payload) =>
@@ -37,6 +38,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
); );
assert.ok(calls.includes('set:keybindings')); assert.ok(calls.includes('set:keybindings'));
assert.ok(calls.includes('set:session-bindings'));
assert.ok(calls.includes('refresh:shortcuts')); assert.ok(calls.includes('refresh:shortcuts'));
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`)); assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:'))); assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
@@ -50,6 +52,7 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
const applyHotReload = createConfigHotReloadAppliedHandler({ const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'), setKeybindings: () => calls.push('set:keybindings'),
setSessionBindings: () => calls.push('set:session-bindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: () => calls.push('set:secondary'), setSecondarySubMode: () => calls.push('set:secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
@@ -64,7 +67,7 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
config, config,
); );
assert.deepEqual(calls, ['set:keybindings']); assert.deepEqual(calls, ['set:keybindings', 'set:session-bindings']);
}); });
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => { test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {

View File

@@ -1,10 +1,13 @@
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload'; import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { compileSessionBindings } from '../../core/services/session-bindings';
import { resolveKeybindings } from '../../core/utils/keybindings'; import { resolveKeybindings } from '../../core/utils/keybindings';
import { DEFAULT_KEYBINDINGS } from '../../config'; import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types'; import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
type ConfigHotReloadAppliedDeps = { type ConfigHotReloadAppliedDeps = {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void;
refreshGlobalAndOverlayShortcuts: () => void; refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
@@ -33,8 +36,21 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
} }
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload { export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS);
const { bindings: sessionBindings } = compileSessionBindings({
keybindings,
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
platform:
process.platform === 'darwin'
? 'darwin'
: process.platform === 'win32'
? 'win32'
: 'linux',
rawConfig: config,
});
return { return {
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS), keybindings,
sessionBindings,
subtitleStyle: resolveSubtitleStyleForRenderer(config), subtitleStyle: resolveSubtitleStyleForRenderer(config),
subtitleSidebar: config.subtitleSidebar, subtitleSidebar: config.subtitleSidebar,
secondarySubMode: config.secondarySub.defaultMode, secondarySubMode: config.secondarySub.defaultMode,
@@ -45,6 +61,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => { return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
const payload = buildConfigHotReloadPayload(config); const payload = buildConfigHotReloadPayload(config);
deps.setKeybindings(payload.keybindings); deps.setKeybindings(payload.keybindings);
deps.setSessionBindings(payload.sessionBindings);
if (diff.hotReloadFields.includes('shortcuts')) { if (diff.hotReloadFields.includes('shortcuts')) {
deps.refreshGlobalAndOverlayShortcuts(); deps.refreshGlobalAndOverlayShortcuts();

View File

@@ -86,21 +86,25 @@ test('config hot reload message main deps builder maps notifications', () => {
test('config hot reload applied main deps builder maps callbacks', () => { test('config hot reload applied main deps builder maps callbacks', () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createBuildConfigHotReloadAppliedMainDepsHandler({ const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({
setKeybindings: () => calls.push('keybindings'), setKeybindings: () => calls.push('keybindings'),
setSessionBindings: () => calls.push('session-bindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
setSecondarySubMode: () => calls.push('set-secondary'), setSecondarySubMode: () => calls.push('set-secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'), applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'),
})(); });
const deps = buildDeps();
deps.setKeybindings([]); deps.setKeybindings([]);
deps.setSessionBindings([]);
deps.refreshGlobalAndOverlayShortcuts(); deps.refreshGlobalAndOverlayShortcuts();
deps.setSecondarySubMode('hover'); deps.setSecondarySubMode('hover');
deps.broadcastToOverlayWindows('config:hot-reload', {}); deps.broadcastToOverlayWindows('config:hot-reload', {});
deps.applyAnkiRuntimeConfigPatch({ ai: true }); deps.applyAnkiRuntimeConfigPatch({ ai: true });
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'keybindings', 'keybindings',
'session-bindings',
'refresh-shortcuts', 'refresh-shortcuts',
'set-secondary', 'set-secondary',
'broadcast:config:hot-reload', 'broadcast:config:hot-reload',

View File

@@ -62,6 +62,7 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void;
refreshGlobalAndOverlayShortcuts: () => void; refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
@@ -72,6 +73,8 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
return () => ({ return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
deps.setKeybindings(keybindings), deps.setKeybindings(keybindings),
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) =>
deps.setSessionBindings(sessionBindings),
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(), refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
broadcastToOverlayWindows: (channel: string, payload: unknown) => broadcastToOverlayWindows: (channel: string, payload: unknown) =>

View File

@@ -43,6 +43,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,

View File

@@ -0,0 +1,17 @@
import fs from 'node:fs';
import path from 'node:path';
import type { PluginSessionBindingsArtifact } from '../../types';
export function getSessionBindingsArtifactPath(configDir: string): string {
return path.join(configDir, 'session-bindings.json');
}
export function writeSessionBindingsArtifact(
configDir: string,
artifact: PluginSessionBindingsArtifact,
): string {
const artifactPath = getSessionBindingsArtifactPath(configDir);
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
return artifactPath;
}

View File

@@ -1,6 +1,7 @@
import type { BrowserWindow, Extension, Session } from 'electron'; import type { BrowserWindow, Extension, Session } from 'electron';
import type { import type {
CompiledSessionBinding,
Keybinding, Keybinding,
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
SecondarySubMode, SecondarySubMode,
@@ -170,6 +171,7 @@ export interface AppState {
anilistClientSecretState: AnilistSecretResolutionState; anilistClientSecretState: AnilistSecretResolutionState;
mecabTokenizer: MecabTokenizer | null; mecabTokenizer: MecabTokenizer | null;
keybindings: Keybinding[]; keybindings: Keybinding[];
sessionBindings: CompiledSessionBinding[];
subtitleTimingTracker: SubtitleTimingTracker | null; subtitleTimingTracker: SubtitleTimingTracker | null;
immersionTracker: ImmersionTrackerService | null; immersionTracker: ImmersionTrackerService | null;
ankiIntegration: AnkiIntegration | null; ankiIntegration: AnkiIntegration | null;
@@ -252,6 +254,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
anilistClientSecretState: createInitialAnilistSecretResolutionState(), anilistClientSecretState: createInitialAnilistSecretResolutionState(),
mecabTokenizer: null, mecabTokenizer: null,
keybindings: [], keybindings: [],
sessionBindings: [],
subtitleTimingTracker: null, subtitleTimingTracker: null,
immersionTracker: null, immersionTracker: null,
ankiIntegration: null, ankiIntegration: null,

View File

@@ -223,8 +223,11 @@ const electronAPI: ElectronAPI = {
getKeybindings: (): Promise<Keybinding[]> => getKeybindings: (): Promise<Keybinding[]> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings),
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> => getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
dispatchSessionAction: (actionId, payload) =>
ipcRenderer.invoke(IPC_CHANNELS.command.dispatchSessionAction, { actionId, payload }),
getStatsToggleKey: (): Promise<string> => getStatsToggleKey: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey), ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey),
getMarkWatchedKey: (): Promise<string> => getMarkWatchedKey: (): Promise<string> =>

View File

@@ -3,6 +3,7 @@ import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js'; import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js'; import { createRendererState } from '../state.js';
import type { CompiledSessionBinding } from '../../types';
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js'; import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
type CommandEventDetail = { type CommandEventDetail = {
@@ -50,6 +51,8 @@ function installKeyboardTestGlobals() {
const windowListeners = new Map<string, Array<(event: unknown) => void>>(); const windowListeners = new Map<string, Array<(event: unknown) => void>>();
const commandEvents: CommandEventDetail[] = []; const commandEvents: CommandEventDetail[] = [];
const mpvCommands: Array<Array<string | number>> = []; const mpvCommands: Array<Array<string | number>> = [];
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
let sessionBindings: CompiledSessionBinding[] = [];
let playbackPausedResponse: boolean | null = false; let playbackPausedResponse: boolean | null = false;
let statsToggleKey = 'Backquote'; let statsToggleKey = 'Backquote';
let markWatchedKey = 'KeyW'; let markWatchedKey = 'KeyW';
@@ -153,10 +156,14 @@ function installKeyboardTestGlobals() {
}, },
electronAPI: { electronAPI: {
getKeybindings: async () => [], getKeybindings: async () => [],
getSessionBindings: async () => sessionBindings,
getConfiguredShortcuts: async () => configuredShortcuts, getConfiguredShortcuts: async () => configuredShortcuts,
sendMpvCommand: (command: Array<string | number>) => { sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command); mpvCommands.push(command);
}, },
dispatchSessionAction: async (actionId: string, payload?: unknown) => {
sessionActions.push({ actionId, payload });
},
getPlaybackPaused: async () => playbackPausedResponse, getPlaybackPaused: async () => playbackPausedResponse,
getStatsToggleKey: async () => statsToggleKey, getStatsToggleKey: async () => statsToggleKey,
getMarkWatchedKey: async () => markWatchedKey, getMarkWatchedKey: async () => markWatchedKey,
@@ -273,6 +280,7 @@ function installKeyboardTestGlobals() {
return { return {
commandEvents, commandEvents,
mpvCommands, mpvCommands,
sessionActions,
overlay, overlay,
overlayFocusCalls, overlayFocusCalls,
focusMainWindowCalls: () => focusMainWindowCalls, focusMainWindowCalls: () => focusMainWindowCalls,
@@ -292,6 +300,9 @@ function installKeyboardTestGlobals() {
setConfiguredShortcuts: (value: typeof configuredShortcuts) => { setConfiguredShortcuts: (value: typeof configuredShortcuts) => {
configuredShortcuts = value; configuredShortcuts = value;
}, },
setSessionBindings: (value: CompiledSessionBinding[]) => {
sessionBindings = value;
},
setMarkActiveVideoWatchedResult: (value: boolean) => { setMarkActiveVideoWatchedResult: (value: boolean) => {
markActiveVideoWatchedResult = value; markActiveVideoWatchedResult = value;
}, },
@@ -521,13 +532,19 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
try { try {
await handlers.setupMpvInputForwarding(); await handlers.setupMpvInputForwarding();
handlers.updateKeybindings([ handlers.updateSessionBindings([
{ {
key: 'Space', sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'], command: ['cycle', 'pause'],
}, },
{ {
key: 'KeyQ', sourcePath: 'keybindings[1].key',
originalKey: 'KeyQ',
key: { code: 'KeyQ', modifiers: [] },
actionType: 'mpv-command',
command: ['quit'], command: ['quit'],
}, },
] as never); ] as never);
@@ -549,9 +566,12 @@ test('paused configured subtitle-jump keybinding re-applies pause after backward
try { try {
await handlers.setupMpvInputForwarding(); await handlers.setupMpvInputForwarding();
handlers.updateKeybindings([ handlers.updateSessionBindings([
{ {
key: 'Shift+KeyH', sourcePath: 'keybindings[0].key',
originalKey: 'Shift+KeyH',
key: { code: 'KeyH', modifiers: ['shift'] },
actionType: 'mpv-command',
command: ['sub-seek', -1], command: ['sub-seek', -1],
}, },
] as never); ] as never);
@@ -574,9 +594,12 @@ test('configured subtitle-jump keybinding preserves pause when pause state is un
try { try {
await handlers.setupMpvInputForwarding(); await handlers.setupMpvInputForwarding();
handlers.updateKeybindings([ handlers.updateSessionBindings([
{ {
key: 'Shift+KeyH', sourcePath: 'keybindings[0].key',
originalKey: 'Shift+KeyH',
key: { code: 'KeyH', modifiers: ['shift'] },
actionType: 'mpv-command',
command: ['sub-seek', -1], command: ['sub-seek', -1],
}, },
] as never); ] as never);
@@ -763,13 +786,19 @@ test('youtube picker: unhandled keys still dispatch mpv keybindings', async () =
try { try {
await handlers.setupMpvInputForwarding(); await handlers.setupMpvInputForwarding();
handlers.updateKeybindings([ handlers.updateSessionBindings([
{ {
key: 'Space', sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'], command: ['cycle', 'pause'],
}, },
{ {
key: 'KeyQ', sourcePath: 'keybindings[1].key',
originalKey: 'KeyQ',
key: { code: 'KeyQ', modifiers: [] },
actionType: 'mpv-command',
command: ['quit'], command: ['quit'],
}, },
] as never); ] as never);
@@ -785,46 +814,72 @@ test('youtube picker: unhandled keys still dispatch mpv keybindings', async () =
} }
}); });
test('linux overlay shortcut: Ctrl+Alt+S dispatches subsync special command locally', async () => { test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); const { handlers, testGlobals } = createKeyboardHandlerHarness();
try { try {
ctx.platform.isLinuxPlatform = true;
await handlers.setupMpvInputForwarding(); await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.triggerSubsync',
originalKey: 'Ctrl+Alt+S',
key: { code: 'KeyS', modifiers: ['ctrl', 'alt'] },
actionType: 'session-action',
actionId: 'triggerSubsync',
},
] as never);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS', ctrlKey: true, altKey: true }); testGlobals.dispatchKeydown({ key: 's', code: 'KeyS', ctrlKey: true, altKey: true });
assert.deepEqual(testGlobals.mpvCommands, [['__subsync-trigger']]); assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'triggerSubsync', payload: undefined }]);
} finally { } finally {
testGlobals.restore(); testGlobals.restore();
} }
}); });
test('linux overlay shortcut: Ctrl+Shift+J dispatches jimaku special command locally', async () => { test('session binding: Ctrl+Shift+J dispatches jimaku action locally', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); const { handlers, testGlobals } = createKeyboardHandlerHarness();
try { try {
ctx.platform.isLinuxPlatform = true;
await handlers.setupMpvInputForwarding(); await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openJimaku',
originalKey: 'Ctrl+Shift+J',
key: { code: 'KeyJ', modifiers: ['ctrl', 'shift'] },
actionType: 'session-action',
actionId: 'openJimaku',
},
] as never);
testGlobals.dispatchKeydown({ key: 'J', code: 'KeyJ', ctrlKey: true, shiftKey: true }); testGlobals.dispatchKeydown({ key: 'J', code: 'KeyJ', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.mpvCommands, [['__jimaku-open']]); assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openJimaku', payload: undefined }]);
} finally { } finally {
testGlobals.restore(); testGlobals.restore();
} }
}); });
test('linux overlay shortcut: CommandOrControl+Shift+O dispatches runtime options locally', async () => { test('session binding: Ctrl+Shift+O dispatches runtime options locally', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); const { handlers, testGlobals } = createKeyboardHandlerHarness();
try { try {
ctx.platform.isLinuxPlatform = true;
await handlers.setupMpvInputForwarding(); await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openRuntimeOptions',
originalKey: 'CommandOrControl+Shift+O',
key: { code: 'KeyO', modifiers: ['ctrl', 'shift'] },
actionType: 'session-action',
actionId: 'openRuntimeOptions',
},
] as never);
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', ctrlKey: true, shiftKey: true }); testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.mpvCommands, [['__runtime-options-open']]); assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'openRuntimeOptions', payload: undefined },
]);
} finally { } finally {
testGlobals.restore(); testGlobals.restore();
} }

View File

@@ -1,8 +1,6 @@
import { SPECIAL_COMMANDS } from '../../config/definitions'; import type { CompiledSessionBinding, ShortcutsConfig } from '../../types';
import type { Keybinding, ShortcutsConfig } from '../../types';
import type { RendererContext } from '../context'; import type { RendererContext } from '../context';
import { import {
YOMITAN_POPUP_HOST_SELECTOR,
YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT, YOMITAN_POPUP_SHOWN_EVENT,
YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_COMMAND_EVENT,
@@ -37,11 +35,16 @@ export function createKeyboardHandlers(
// Timeout for the modal chord capture window (e.g. Y followed by H/K). // Timeout for the modal chord capture window (e.g. Y followed by H/K).
const CHORD_TIMEOUT_MS = 1000; const CHORD_TIMEOUT_MS = 1000;
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected'; const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
const linuxOverlayShortcutCommands = new Map<string, (string | number)[]>();
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null; let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
let pendingLookupRefreshAfterSubtitleSeek = false; let pendingLookupRefreshAfterSubtitleSeek = false;
let resetSelectionToStartOnNextSubtitleSync = false; let resetSelectionToStartOnNextSubtitleSync = false;
let lookupScanFallbackTimer: ReturnType<typeof setTimeout> | null = null; let lookupScanFallbackTimer: ReturnType<typeof setTimeout> | null = null;
let pendingNumericSelection:
| {
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
timeout: ReturnType<typeof setTimeout> | null;
}
| null = null;
const CHORD_MAP = new Map< const CHORD_MAP = new Map<
string, string,
@@ -62,9 +65,6 @@ export function createKeyboardHandlers(
if (target.closest('.modal')) return true; if (target.closest('.modal')) return true;
if (ctx.dom.subtitleContainer.contains(target)) return true; if (ctx.dom.subtitleContainer.contains(target)) return true;
if (isYomitanPopupIframe(target)) return true; if (isYomitanPopupIframe(target)) return true;
if (target.closest && target.closest(YOMITAN_POPUP_HOST_SELECTOR)) {
return true;
}
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]')) if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
return true; return true;
return false; return false;
@@ -80,115 +80,117 @@ export function createKeyboardHandlers(
return parts.join('+'); return parts.join('+');
} }
function acceleratorToKeyToken(token: string): string | null {
const normalized = token.trim();
if (!normalized) return null;
if (/^[a-z]$/i.test(normalized)) {
return `Key${normalized.toUpperCase()}`;
}
if (/^[0-9]$/.test(normalized)) {
return `Digit${normalized}`;
}
const exactMap: Record<string, string> = {
space: 'Space',
tab: 'Tab',
enter: 'Enter',
return: 'Enter',
esc: 'Escape',
escape: 'Escape',
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
backspace: 'Backspace',
delete: 'Delete',
slash: 'Slash',
backslash: 'Backslash',
minus: 'Minus',
plus: 'Equal',
equal: 'Equal',
comma: 'Comma',
period: 'Period',
quote: 'Quote',
semicolon: 'Semicolon',
bracketleft: 'BracketLeft',
bracketright: 'BracketRight',
backquote: 'Backquote',
};
const lower = normalized.toLowerCase();
if (exactMap[lower]) return exactMap[lower];
if (/^key[a-z]$/i.test(normalized) || /^digit[0-9]$/i.test(normalized)) {
return normalized[0]!.toUpperCase() + normalized.slice(1);
}
if (/^arrow(?:up|down|left|right)$/i.test(normalized)) {
return normalized[0]!.toUpperCase() + normalized.slice(1);
}
if (/^f\d{1,2}$/i.test(normalized)) {
return normalized.toUpperCase();
}
return null;
}
function acceleratorToKeyString(accelerator: string): string | null {
const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl');
if (!normalized) return null;
const parts = normalized.split('+').filter(Boolean);
const keyToken = parts.pop();
if (!keyToken) return null;
const eventParts: string[] = [];
for (const modifier of parts) {
const lower = modifier.toLowerCase();
if (lower === 'ctrl' || lower === 'control') {
eventParts.push('Ctrl');
continue;
}
if (lower === 'alt' || lower === 'option') {
eventParts.push('Alt');
continue;
}
if (lower === 'shift') {
eventParts.push('Shift');
continue;
}
if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') {
eventParts.push('Meta');
continue;
}
if (lower === 'commandorcontrol') {
eventParts.push(ctx.platform.isMacOSPlatform ? 'Meta' : 'Ctrl');
continue;
}
return null;
}
const normalizedKey = acceleratorToKeyToken(keyToken);
if (!normalizedKey) return null;
eventParts.push(normalizedKey);
return eventParts.join('+');
}
function updateConfiguredShortcuts(shortcuts: Required<ShortcutsConfig>): void { function updateConfiguredShortcuts(shortcuts: Required<ShortcutsConfig>): void {
linuxOverlayShortcutCommands.clear(); ctx.state.sessionActionTimeoutMs = shortcuts.multiCopyTimeoutMs;
const bindings: Array<[string | null, (string | number)[]]> = [
[shortcuts.triggerSubsync, [SPECIAL_COMMANDS.SUBSYNC_TRIGGER]],
[shortcuts.openRuntimeOptions, [SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN]],
[shortcuts.openJimaku, [SPECIAL_COMMANDS.JIMAKU_OPEN]],
];
for (const [accelerator, command] of bindings) {
if (!accelerator) continue;
const keyString = acceleratorToKeyString(accelerator);
if (keyString) {
linuxOverlayShortcutCommands.set(keyString, command);
}
}
} }
async function refreshConfiguredShortcuts(): Promise<void> { async function refreshConfiguredShortcuts(): Promise<void> {
updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts()); updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts());
} }
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
ctx.state.sessionBindings = bindings;
ctx.state.sessionBindingMap = new Map(
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
);
}
function keyEventToStringFromBinding(binding: CompiledSessionBinding): string {
const parts: string[] = [];
for (const modifier of binding.key.modifiers) {
if (modifier === 'ctrl') parts.push('Ctrl');
else if (modifier === 'alt') parts.push('Alt');
else if (modifier === 'shift') parts.push('Shift');
else if (modifier === 'meta') parts.push('Meta');
}
parts.push(binding.key.code);
return parts.join('+');
}
function isTextEntryTarget(target: EventTarget | null): boolean {
if (!target || typeof target !== 'object' || !('closest' in target)) return false;
const element = target as { closest: (selector: string) => unknown };
if (element.closest('[contenteditable="true"]')) return true;
return Boolean(element.closest('input, textarea, select'));
}
function showSessionSelectionMessage(message: string): void {
window.electronAPI.sendMpvCommand(['show-text', message, '3000']);
}
function cancelPendingNumericSelection(showCancelled: boolean): void {
if (!pendingNumericSelection) return;
if (pendingNumericSelection.timeout !== null) {
clearTimeout(pendingNumericSelection.timeout);
}
pendingNumericSelection = null;
if (showCancelled) {
showSessionSelectionMessage('Cancelled');
}
}
function startPendingNumericSelection(
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
): void {
cancelPendingNumericSelection(false);
const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout';
const promptMessage =
actionId === 'copySubtitleMultiple'
? 'Copy how many lines? Press 1-9 (Esc to cancel)'
: 'Mine how many lines? Press 1-9 (Esc to cancel)';
pendingNumericSelection = {
actionId,
timeout: setTimeout(() => {
pendingNumericSelection = null;
showSessionSelectionMessage(timeoutMessage);
}, ctx.state.sessionActionTimeoutMs),
};
showSessionSelectionMessage(promptMessage);
}
function beginSessionNumericSelection(
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
): void {
startPendingNumericSelection(actionId);
}
function handlePendingNumericSelection(e: KeyboardEvent): boolean {
if (!pendingNumericSelection) return false;
if (e.key === 'Escape') {
e.preventDefault();
cancelPendingNumericSelection(true);
return true;
}
if (!/^[1-9]$/.test(e.key) || e.ctrlKey || e.metaKey || e.altKey) {
return false;
}
e.preventDefault();
const count = Number(e.key);
const actionId = pendingNumericSelection.actionId;
cancelPendingNumericSelection(false);
void window.electronAPI.dispatchSessionAction(actionId, { count });
return true;
}
function dispatchSessionBinding(binding: CompiledSessionBinding): void {
if (
binding.actionType === 'session-action' &&
(binding.actionId === 'copySubtitleMultiple' || binding.actionId === 'mineSentenceMultiple')
) {
startPendingNumericSelection(binding.actionId);
return;
}
if (binding.actionType === 'mpv-command') {
dispatchConfiguredMpvCommand(binding.command);
return;
}
void window.electronAPI.dispatchSessionAction(binding.actionId, binding.payload);
}
function dispatchYomitanPopupKeydown( function dispatchYomitanPopupKeydown(
key: string, key: string,
code: string, code: string,
@@ -512,7 +514,7 @@ export function createKeyboardHandlers(
clientY: number, clientY: number,
modifiers: ScanModifierState = {}, modifiers: ScanModifierState = {},
): void { ): void {
if (typeof PointerEvent !== 'undefined') { if (typeof PointerEvent === 'function') {
const pointerEventInit = { const pointerEventInit = {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
@@ -535,23 +537,25 @@ export function createKeyboardHandlers(
target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit)); target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit));
} }
const mouseEventInit = { if (typeof MouseEvent === 'function') {
bubbles: true, const mouseEventInit = {
cancelable: true, bubbles: true,
composed: true, cancelable: true,
clientX, composed: true,
clientY, clientX,
button: 0, clientY,
buttons: 0, button: 0,
shiftKey: modifiers.shiftKey ?? false, buttons: 0,
ctrlKey: modifiers.ctrlKey ?? false, shiftKey: modifiers.shiftKey ?? false,
altKey: modifiers.altKey ?? false, ctrlKey: modifiers.ctrlKey ?? false,
metaKey: modifiers.metaKey ?? false, altKey: modifiers.altKey ?? false,
} satisfies MouseEventInit; metaKey: modifiers.metaKey ?? false,
target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit)); } satisfies MouseEventInit;
target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 })); target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit));
target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit)); target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 }));
target.dispatchEvent(new MouseEvent('click', mouseEventInit)); target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
target.dispatchEvent(new MouseEvent('click', mouseEventInit));
}
} }
function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void { function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void {
@@ -824,7 +828,7 @@ export function createKeyboardHandlers(
if (modifierOnlyCodes.has(e.code)) return false; if (modifierOnlyCodes.has(e.code)) return false;
const keyString = keyEventToString(e); const keyString = keyEventToString(e);
if (ctx.state.keybindingsMap.has(keyString)) { if (ctx.state.sessionBindingMap.has(keyString)) {
return false; return false;
} }
@@ -850,7 +854,7 @@ export function createKeyboardHandlers(
fallbackUnavailable: boolean; fallbackUnavailable: boolean;
} { } {
const firstChoice = 'KeyH'; const firstChoice = 'KeyH';
if (!ctx.state.keybindingsMap.has('KeyH')) { if (!ctx.state.sessionBindingMap.has('KeyH')) {
return { return {
bindingKey: firstChoice, bindingKey: firstChoice,
fallbackUsed: false, fallbackUsed: false,
@@ -858,18 +862,18 @@ export function createKeyboardHandlers(
}; };
} }
if (ctx.state.keybindingsMap.has('KeyK')) { if (!ctx.state.sessionBindingMap.has('KeyK')) {
return { return {
bindingKey: 'KeyK', bindingKey: 'KeyK',
fallbackUsed: true, fallbackUsed: true,
fallbackUnavailable: true, fallbackUnavailable: false,
}; };
} }
return { return {
bindingKey: 'KeyK', bindingKey: 'KeyK',
fallbackUsed: true, fallbackUsed: true,
fallbackUnavailable: false, fallbackUnavailable: true,
}; };
} }
@@ -894,13 +898,13 @@ export function createKeyboardHandlers(
} }
async function setupMpvInputForwarding(): Promise<void> { async function setupMpvInputForwarding(): Promise<void> {
const [keybindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([ const [sessionBindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
window.electronAPI.getKeybindings(), window.electronAPI.getSessionBindings(),
window.electronAPI.getConfiguredShortcuts(), window.electronAPI.getConfiguredShortcuts(),
window.electronAPI.getStatsToggleKey(), window.electronAPI.getStatsToggleKey(),
window.electronAPI.getMarkWatchedKey(), window.electronAPI.getMarkWatchedKey(),
]); ]);
updateKeybindings(keybindings); updateSessionBindings(sessionBindings);
updateConfiguredShortcuts(shortcuts); updateConfiguredShortcuts(shortcuts);
ctx.state.statsToggleKey = statsToggleKey; ctx.state.statsToggleKey = statsToggleKey;
ctx.state.markWatchedKey = markWatchedKey; ctx.state.markWatchedKey = markWatchedKey;
@@ -1010,6 +1014,14 @@ export function createKeyboardHandlers(
return; return;
} }
if (isTextEntryTarget(e.target)) {
return;
}
if (handlePendingNumericSelection(e)) {
return;
}
if (isStatsOverlayToggle(e)) { if (isStatsOverlayToggle(e)) {
e.preventDefault(); e.preventDefault();
window.electronAPI.toggleStatsOverlay(); window.electronAPI.toggleStatsOverlay();
@@ -1099,19 +1111,10 @@ export function createKeyboardHandlers(
} }
const keyString = keyEventToString(e); const keyString = keyEventToString(e);
const linuxOverlayCommand = ctx.platform.isLinuxPlatform const binding = ctx.state.sessionBindingMap.get(keyString);
? linuxOverlayShortcutCommands.get(keyString) if (binding) {
: undefined;
if (linuxOverlayCommand) {
e.preventDefault(); e.preventDefault();
dispatchConfiguredMpvCommand(linuxOverlayCommand); dispatchSessionBinding(binding);
return;
}
const command = ctx.state.keybindingsMap.get(keyString);
if (command) {
e.preventDefault();
dispatchConfiguredMpvCommand(command);
} }
}); });
@@ -1129,19 +1132,11 @@ export function createKeyboardHandlers(
}); });
} }
function updateKeybindings(keybindings: Keybinding[]): void {
ctx.state.keybindingsMap = new Map();
for (const binding of keybindings) {
if (binding.command) {
ctx.state.keybindingsMap.set(binding.key, binding.command);
}
}
}
return { return {
beginSessionNumericSelection,
setupMpvInputForwarding, setupMpvInputForwarding,
refreshConfiguredShortcuts, refreshConfiguredShortcuts,
updateKeybindings, updateSessionBindings,
syncKeyboardTokenSelection, syncKeyboardTokenSelection,
handleSubtitleContentUpdated, handleSubtitleContentUpdated,
handleKeyboardModeToggleRequested, handleKeyboardModeToggleRequested,

View File

@@ -628,7 +628,7 @@ async function init(): Promise<void> {
}); });
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => { window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
runGuarded('config:hot-reload', () => { runGuarded('config:hot-reload', () => {
keyboardHandlers.updateKeybindings(payload.keybindings); keyboardHandlers.updateSessionBindings(payload.sessionBindings);
void keyboardHandlers.refreshConfiguredShortcuts(); void keyboardHandlers.refreshConfiguredShortcuts();
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle); subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode); subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);

View File

@@ -1,4 +1,5 @@
import type { import type {
CompiledSessionBinding,
PlaylistBrowserSnapshot, PlaylistBrowserSnapshot,
ControllerButtonSnapshot, ControllerButtonSnapshot,
ControllerDeviceInfo, ControllerDeviceInfo,
@@ -116,7 +117,9 @@ export type RendererState = {
frequencyDictionaryBand4Color: string; frequencyDictionaryBand4Color: string;
frequencyDictionaryBand5Color: string; frequencyDictionaryBand5Color: string;
keybindingsMap: Map<string, (string | number)[]>; sessionBindings: CompiledSessionBinding[];
sessionBindingMap: Map<string, CompiledSessionBinding>;
sessionActionTimeoutMs: number;
statsToggleKey: string; statsToggleKey: string;
markWatchedKey: string; markWatchedKey: string;
chordPending: boolean; chordPending: boolean;
@@ -219,7 +222,9 @@ export function createRendererState(): RendererState {
frequencyDictionaryBand4Color: '#8bd5ca', frequencyDictionaryBand4Color: '#8bd5ca',
frequencyDictionaryBand5Color: '#8aadf4', frequencyDictionaryBand5Color: '#8aadf4',
keybindingsMap: new Map(), sessionBindings: [],
sessionBindingMap: new Map(),
sessionActionTimeoutMs: 3000,
statsToggleKey: 'Backquote', statsToggleKey: 'Backquote',
markWatchedKey: 'KeyW', markWatchedKey: 'KeyW',
chordPending: false, chordPending: false,

View File

@@ -1,6 +1,11 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { YOMITAN_LOOKUP_EVENT, registerYomitanLookupListener } from './yomitan-popup.js'; import {
YOMITAN_LOOKUP_EVENT,
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
isYomitanPopupVisible,
registerYomitanLookupListener,
} from './yomitan-popup.js';
test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', () => { test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', () => {
const target = new EventTarget(); const target = new EventTarget();
@@ -16,3 +21,12 @@ test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event',
assert.deepEqual(calls, ['lookup']); assert.deepEqual(calls, ['lookup']);
}); });
test('isYomitanPopupVisible falls back to querySelector when querySelectorAll is unavailable', () => {
const root = {
querySelector: (selector: string) =>
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ? ({} as Element) : null,
} as ParentNode;
assert.equal(isYomitanPopupVisible(root), true);
});

View File

@@ -32,9 +32,9 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
return hasModernPopupClass || hasLegacyPopupId; return hasModernPopupClass || hasLegacyPopupId;
} }
export function hasYomitanPopupIframe(root: ParentNode = document): boolean { export function hasYomitanPopupIframe(root: ParentNode | null | undefined = document): boolean {
return ( return (
typeof root.querySelector === 'function' && typeof root?.querySelector === 'function' &&
(root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null || (root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null ||
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null) root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null)
); );
@@ -58,14 +58,21 @@ function isMarkedVisiblePopupHost(element: Element): boolean {
return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true'; return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true';
} }
function queryPopupElements<T extends Element>(root: ParentNode, selector: string): T[] { function queryPopupElements<T extends Element>(
if (typeof root.querySelectorAll !== 'function') { root: ParentNode | null | undefined,
return []; selector: string,
): T[] {
if (typeof root?.querySelectorAll === 'function') {
return Array.from(root.querySelectorAll<T>(selector));
} }
return Array.from(root.querySelectorAll<T>(selector)); if (typeof root?.querySelector === 'function') {
const first = root.querySelector(selector) as T | null;
return first ? [first] : [];
}
return [];
} }
export function isYomitanPopupVisible(root: ParentNode = document): boolean { export function isYomitanPopupVisible(root: ParentNode | null | undefined = document): boolean {
const visiblePopupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR); const visiblePopupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
if (visiblePopupHosts.length > 0) { if (visiblePopupHosts.length > 0) {
return true; return true;

View File

@@ -37,6 +37,7 @@ export const IPC_CHANNELS = {
overlayModalOpened: 'overlay:modal-opened', overlayModalOpened: 'overlay:modal-opened',
toggleStatsOverlay: 'stats:toggle-overlay', toggleStatsOverlay: 'stats:toggle-overlay',
markActiveVideoWatched: 'immersion:mark-active-video-watched', markActiveVideoWatched: 'immersion:mark-active-video-watched',
dispatchSessionAction: 'session-action:dispatch',
}, },
request: { request: {
getVisibleOverlayVisibility: 'get-visible-overlay-visibility', getVisibleOverlayVisibility: 'get-visible-overlay-visibility',
@@ -49,6 +50,7 @@ export const IPC_CHANNELS = {
getSubtitleStyle: 'get-subtitle-style', getSubtitleStyle: 'get-subtitle-style',
getMecabStatus: 'get-mecab-status', getMecabStatus: 'get-mecab-status',
getKeybindings: 'get-keybindings', getKeybindings: 'get-keybindings',
getSessionBindings: 'get-session-bindings',
getConfigShortcuts: 'get-config-shortcuts', getConfigShortcuts: 'get-config-shortcuts',
getStatsToggleKey: 'get-stats-toggle-key', getStatsToggleKey: 'get-stats-toggle-key',
getMarkWatchedKey: 'get-mark-watched-key', getMarkWatchedKey: 'get-mark-watched-key',

View File

@@ -3,4 +3,5 @@ export * from './types/config';
export * from './types/integrations'; export * from './types/integrations';
export * from './types/runtime'; export * from './types/runtime';
export * from './types/runtime-options'; export * from './types/runtime-options';
export * from './types/session-bindings';
export * from './types/subtitle'; export * from './types/subtitle';

View File

@@ -5,6 +5,11 @@ import type {
KikuMergePreviewResponse, KikuMergePreviewResponse,
} from './anki'; } from './anki';
import type { ResolvedConfig, ShortcutsConfig } from './config'; import type { ResolvedConfig, ShortcutsConfig } from './config';
import type {
CompiledSessionBinding,
SessionActionId,
SessionActionPayload,
} from './session-bindings';
import type { import type {
JimakuApiResponse, JimakuApiResponse,
JimakuDownloadQuery, JimakuDownloadQuery,
@@ -321,11 +326,17 @@ export interface ClipboardAppendResult {
export interface ConfigHotReloadPayload { export interface ConfigHotReloadPayload {
keybindings: Keybinding[]; keybindings: Keybinding[];
sessionBindings: CompiledSessionBinding[];
subtitleStyle: SubtitleStyleConfig | null; subtitleStyle: SubtitleStyleConfig | null;
subtitleSidebar: Required<SubtitleSidebarConfig>; subtitleSidebar: Required<SubtitleSidebarConfig>;
secondarySubMode: SecondarySubMode; secondarySubMode: SecondarySubMode;
} }
export interface SessionActionDispatchRequest {
actionId: SessionActionId;
payload?: SessionActionPayload;
}
export type ResolvedControllerConfig = ResolvedConfig['controller']; export type ResolvedControllerConfig = ResolvedConfig['controller'];
export interface ElectronAPI { export interface ElectronAPI {
@@ -349,7 +360,9 @@ export interface ElectronAPI {
setMecabEnabled: (enabled: boolean) => void; setMecabEnabled: (enabled: boolean) => void;
sendMpvCommand: (command: (string | number)[]) => void; sendMpvCommand: (command: (string | number)[]) => void;
getKeybindings: () => Promise<Keybinding[]>; getKeybindings: () => Promise<Keybinding[]>;
getSessionBindings: () => Promise<CompiledSessionBinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>; getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
dispatchSessionAction: (actionId: SessionActionId, payload?: SessionActionPayload) => Promise<void>;
getStatsToggleKey: () => Promise<string>; getStatsToggleKey: () => Promise<string>;
getMarkWatchedKey: () => Promise<string>; getMarkWatchedKey: () => Promise<string>;
markActiveVideoWatched: () => Promise<boolean>; markActiveVideoWatched: () => Promise<boolean>;

View File

@@ -0,0 +1,71 @@
export type SessionKeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta';
export type SessionActionId =
| 'toggleStatsOverlay'
| 'toggleVisibleOverlay'
| 'copySubtitle'
| 'copySubtitleMultiple'
| 'updateLastCardFromClipboard'
| 'triggerFieldGrouping'
| 'triggerSubsync'
| 'mineSentence'
| 'mineSentenceMultiple'
| 'toggleSecondarySub'
| 'markAudioCard'
| 'openRuntimeOptions'
| 'openJimaku'
| 'openYoutubePicker'
| 'openPlaylistBrowser'
| 'replayCurrentSubtitle'
| 'playNextSubtitle'
| 'shiftSubDelayPrevLine'
| 'shiftSubDelayNextLine'
| 'cycleRuntimeOption';
export interface SessionKeySpec {
code: string;
modifiers: SessionKeyModifier[];
}
export interface SessionBindingWarning {
kind: 'unsupported' | 'conflict' | 'deprecated-config';
path: string;
message: string;
value: unknown;
conflictingPaths?: string[];
}
export interface SessionActionPayload {
count?: number;
runtimeOptionId?: string;
direction?: 1 | -1;
}
type CompiledSessionBindingBase = {
sourcePath: string;
originalKey: string;
key: SessionKeySpec;
};
export interface CompiledMpvCommandBinding extends CompiledSessionBindingBase {
actionType: 'mpv-command';
command: (string | number)[];
}
export interface CompiledSessionActionBinding extends CompiledSessionBindingBase {
actionType: 'session-action';
actionId: SessionActionId;
payload?: SessionActionPayload;
}
export type CompiledSessionBinding =
| CompiledMpvCommandBinding
| CompiledSessionActionBinding;
export interface PluginSessionBindingsArtifact {
version: 1;
generatedAt: string;
numericSelectionTimeoutMs: number;
bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[];
}

View File

@@ -48,6 +48,8 @@ const OpenProcess = kernel32.func(
'intptr __stdcall OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId)', 'intptr __stdcall OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId)',
); );
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(intptr hObject)'); const CloseHandle = kernel32.func('bool __stdcall CloseHandle(intptr hObject)');
const GetLastError = kernel32.func('uint __stdcall GetLastError()');
const SetLastError = kernel32.func('void __stdcall SetLastError(uint dwErrCode)');
const QueryFullProcessImageNameW = kernel32.func( const QueryFullProcessImageNameW = kernel32.func(
'bool __stdcall QueryFullProcessImageNameW(intptr hProcess, uint dwFlags, _Out_ uint16 *lpExeName, _Inout_ uint *lpdwSize)', 'bool __stdcall QueryFullProcessImageNameW(intptr hProcess, uint dwFlags, _Out_ uint16 *lpExeName, _Inout_ uint *lpdwSize)',
); );
@@ -69,12 +71,47 @@ const HWND_TOPMOST = -1;
const HWND_NOTOPMOST = -2; const HWND_NOTOPMOST = -2;
function extendOverlayFrameIntoClientArea(overlayHwnd: number): void { function extendOverlayFrameIntoClientArea(overlayHwnd: number): void {
DwmExtendFrameIntoClientArea(overlayHwnd, { const hr = DwmExtendFrameIntoClientArea(overlayHwnd, {
cxLeftWidth: -1, cxLeftWidth: -1,
cxRightWidth: -1, cxRightWidth: -1,
cyTopHeight: -1, cyTopHeight: -1,
cyBottomHeight: -1, cyBottomHeight: -1,
}); });
if (hr !== 0) {
throw new Error(`DwmExtendFrameIntoClientArea failed (${hr})`);
}
}
function resetLastError(): void {
SetLastError(0);
}
function assertSetWindowLongPtrSucceeded(operation: string, result: number): void {
if (result !== 0) {
return;
}
if (GetLastError() === 0) {
return;
}
throw new Error(`${operation} failed (${GetLastError()})`);
}
function assertSetWindowPosSucceeded(operation: string, result: boolean): void {
if (result) {
return;
}
throw new Error(`${operation} failed (${GetLastError()})`);
}
function assertGetWindowLongSucceeded(operation: string, result: number): number {
if (result !== 0 || GetLastError() === 0) {
return result;
}
throw new Error(`${operation} failed (${GetLastError()})`);
} }
export interface WindowBounds { export interface WindowBounds {
@@ -203,7 +240,9 @@ export function getForegroundProcessName(): string | null {
} }
export function setOverlayOwner(overlayHwnd: number, mpvHwnd: number): void { export function setOverlayOwner(overlayHwnd: number, mpvHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd); resetLastError();
const result = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
assertSetWindowLongPtrSucceeded('setOverlayOwner', result);
extendOverlayFrameIntoClientArea(overlayHwnd); extendOverlayFrameIntoClientArea(overlayHwnd);
} }
@@ -212,21 +251,38 @@ export function ensureOverlayTransparency(overlayHwnd: number): void {
} }
export function clearOverlayOwner(overlayHwnd: number): void { export function clearOverlayOwner(overlayHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, 0); resetLastError();
const result = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, 0);
assertSetWindowLongPtrSucceeded('clearOverlayOwner', result);
} }
export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void { export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd); resetLastError();
const mpvExStyle = GetWindowLongW(mpvHwnd, GWL_EXSTYLE); const ownerResult = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
assertSetWindowLongPtrSucceeded('bindOverlayAboveMpv owner assignment', ownerResult);
resetLastError();
const mpvExStyle = assertGetWindowLongSucceeded(
'bindOverlayAboveMpv target window style',
GetWindowLongW(mpvHwnd, GWL_EXSTYLE),
);
const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0; const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0;
const overlayExStyle = GetWindowLongW(overlayHwnd, GWL_EXSTYLE); resetLastError();
const overlayExStyle = assertGetWindowLongSucceeded(
'bindOverlayAboveMpv overlay window style',
GetWindowLongW(overlayHwnd, GWL_EXSTYLE),
);
const overlayIsTopmost = (overlayExStyle & WS_EX_TOPMOST) !== 0; const overlayIsTopmost = (overlayExStyle & WS_EX_TOPMOST) !== 0;
if (mpvIsTopmost && !overlayIsTopmost) { if (mpvIsTopmost && !overlayIsTopmost) {
SetWindowPos(overlayHwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_FLAGS); resetLastError();
const topmostResult = SetWindowPos(overlayHwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_FLAGS);
assertSetWindowPosSucceeded('bindOverlayAboveMpv topmost adjustment', topmostResult);
} else if (!mpvIsTopmost && overlayIsTopmost) { } else if (!mpvIsTopmost && overlayIsTopmost) {
SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS); resetLastError();
const notTopmostResult = SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
assertSetWindowPosSucceeded('bindOverlayAboveMpv notopmost adjustment', notTopmostResult);
} }
const windowAboveMpv = GetWindowFn(mpvHwnd, GW_HWNDPREV); const windowAboveMpv = GetWindowFn(mpvHwnd, GW_HWNDPREV);
@@ -241,10 +297,17 @@ export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void
} }
} }
SetWindowPos(overlayHwnd, insertAfter, 0, 0, 0, 0, SWP_FLAGS); resetLastError();
const positionResult = SetWindowPos(overlayHwnd, insertAfter, 0, 0, 0, 0, SWP_FLAGS);
assertSetWindowPosSucceeded('bindOverlayAboveMpv z-order adjustment', positionResult);
} }
export function lowerOverlay(overlayHwnd: number): void { export function lowerOverlay(overlayHwnd: number): void {
SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS); resetLastError();
SetWindowPos(overlayHwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_FLAGS); const notTopmostResult = SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
assertSetWindowPosSucceeded('lowerOverlay notopmost adjustment', notTopmostResult);
resetLastError();
const bottomResult = SetWindowPos(overlayHwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_FLAGS);
assertSetWindowPosSucceeded('lowerOverlay bottom adjustment', bottomResult);
} }

View File

@@ -7,6 +7,8 @@ import {
parseWindowTrackerHelperOutput, parseWindowTrackerHelperOutput,
parseWindowTrackerHelperState, parseWindowTrackerHelperState,
queryWindowsForegroundProcessName, queryWindowsForegroundProcessName,
queryWindowsTargetWindowHandle,
queryWindowsTrackerMpvWindows,
resolveWindowsTrackerHelper, resolveWindowsTrackerHelper,
syncWindowsOverlayToMpvZOrder, syncWindowsOverlayToMpvZOrder,
} from './windows-helper'; } from './windows-helper';
@@ -129,7 +131,77 @@ test('lowerWindowsOverlayInZOrder forwards overlay handle to powershell helper',
assert.equal(lowered, true); assert.equal(lowered, true);
assert.equal(capturedMode, 'lower-overlay'); assert.equal(capturedMode, 'lower-overlay');
assert.deepEqual(capturedArgs, ['67890']); 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, {
matches: [
{
hwnd: 0,
bounds: {
x: 120,
y: 240,
width: 1280,
height: 720,
},
area: 1280 * 720,
isForeground: true,
},
],
focusState: true,
windowState: 'visible',
});
assert.equal(capturedMode, 'geometry');
assert.deepEqual(capturedArgs, ['\\\\.\\pipe\\subminer-socket']);
});
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', () => { test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {

View File

@@ -19,8 +19,9 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as os from 'node:os'; import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import { execFile, type ExecFileException } from 'child_process'; import { execFile, spawnSync, type ExecFileException } from 'child_process';
import type { WindowGeometry } from '../types'; import type { WindowGeometry } from '../types';
import type { MpvPollResult } from './win32';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows-helper'); const log = createLogger('tracker').child('windows-helper');
@@ -33,7 +34,8 @@ export type WindowsTrackerHelperRunMode =
| 'bind-overlay' | 'bind-overlay'
| 'lower-overlay' | 'lower-overlay'
| 'set-owner' | 'set-owner'
| 'clear-owner'; | 'clear-owner'
| 'target-hwnd';
export type WindowsTrackerHelperLaunchSpec = { export type WindowsTrackerHelperLaunchSpec = {
kind: WindowsTrackerHelperKind; kind: WindowsTrackerHelperKind;
@@ -267,6 +269,29 @@ type WindowsTrackerHelperRunnerResult = {
stderr: 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( function runWindowsTrackerHelperWithExecFile(
spec: WindowsTrackerHelperLaunchSpec, spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode, mode: WindowsTrackerHelperRunMode,
@@ -316,6 +341,92 @@ export async function queryWindowsForegroundProcessName(deps: {
return parseWindowTrackerHelperForegroundProcess(stdout); 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: { export async function syncWindowsOverlayToMpvZOrder(deps: {
overlayWindowHandle: string; overlayWindowHandle: string;
targetMpvSocketPath?: string | null; targetMpvSocketPath?: string | null;
@@ -360,7 +471,7 @@ export async function lowerWindowsOverlayInZOrder(deps: {
} }
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile; const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const { stdout } = await runHelper(spec, 'lower-overlay', [deps.overlayWindowHandle]); const { stdout } = await runHelper(spec, 'lower-overlay', ['', deps.overlayWindowHandle]);
return stdout.trim() === 'ok'; return stdout.trim() === 'ok';
} }
@@ -384,14 +495,10 @@ export function ensureWindowsOverlayTransparencyNative(overlayHwnd: number): boo
} }
} }
export function bindWindowsOverlayAboveMpvNative(overlayHwnd: number): boolean { export function bindWindowsOverlayAboveMpvNative(overlayHwnd: number, mpvHwnd: number): boolean {
try { try {
const win32 = require('./win32') as typeof import('./win32'); const win32 = require('./win32') as typeof import('./win32');
const poll = win32.findMpvWindows(); win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd);
const focused = poll.matches.find((m) => m.isForeground);
const best = focused ?? poll.matches.sort((a, b) => b.area - a.area)[0];
if (!best) return false;
win32.bindOverlayAboveMpv(overlayHwnd, best.hwnd);
win32.ensureOverlayTransparency(overlayHwnd); win32.ensureOverlayTransparency(overlayHwnd);
return true; return true;
} catch { } catch {

View File

@@ -39,16 +39,19 @@ const mpvMinimized: MpvPollResult = {
test('WindowsWindowTracker skips overlapping polls while poll is in flight', () => { test('WindowsWindowTracker skips overlapping polls while poll is in flight', () => {
let pollCalls = 0; let pollCalls = 0;
const tracker = new WindowsWindowTracker(undefined, { let tracker: WindowsWindowTracker;
tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => { pollMpvWindows: () => {
pollCalls += 1; pollCalls += 1;
if (pollCalls === 1) {
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
}
return mpvVisible(); return mpvVisible();
}, },
}); });
(tracker as unknown as { pollGeometry: () => void }).pollGeometry(); (tracker as unknown as { pollGeometry: () => void }).pollGeometry();
(tracker as unknown as { pollGeometry: () => void }).pollGeometry(); assert.equal(pollCalls, 1);
assert.equal(pollCalls, 2);
}); });
test('WindowsWindowTracker updates geometry from poll output', () => { test('WindowsWindowTracker updates geometry from poll output', () => {

View File

@@ -19,6 +19,7 @@
import { BaseWindowTracker } from './base-tracker'; import { BaseWindowTracker } from './base-tracker';
import type { WindowGeometry } from '../types'; import type { WindowGeometry } from '../types';
import type { MpvPollResult } from './win32'; import type { MpvPollResult } from './win32';
import { queryWindowsTrackerMpvWindows } from './windows-helper';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows'); const log = createLogger('tracker').child('windows');
@@ -31,7 +32,26 @@ type WindowsTrackerDeps = {
now?: () => number; now?: () => number;
}; };
function defaultPollMpvWindows(): MpvPollResult { 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;
}
}
const win32 = require('./win32') as typeof import('./win32'); const win32 = require('./win32') as typeof import('./win32');
return win32.findMpvWindows(); return win32.findMpvWindows();
} }
@@ -49,10 +69,12 @@ export class WindowsWindowTracker extends BaseWindowTracker {
private consecutiveMisses = 0; private consecutiveMisses = 0;
private trackingLossStartedAtMs: number | null = null; private trackingLossStartedAtMs: number | null = null;
private targetWindowMinimized = false; private targetWindowMinimized = false;
private readonly targetMpvSocketPath: string | null;
constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) { constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
super(); super();
this.pollMpvWindows = deps.pollMpvWindows ?? defaultPollMpvWindows; this.targetMpvSocketPath = _targetMpvSocketPath?.trim() || null;
this.pollMpvWindows = deps.pollMpvWindows ?? (() => defaultPollMpvWindows(this.targetMpvSocketPath));
this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2)); this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2));
this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500)); this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500));
this.minimizedTrackingLossGraceMs = Math.max( this.minimizedTrackingLossGraceMs = Math.max(