diff --git a/plugin/subminer/bootstrap.lua b/plugin/subminer/bootstrap.lua index 62eaabf0..5a05c6ec 100644 --- a/plugin/subminer/bootstrap.lua +++ b/plugin/subminer/bootstrap.lua @@ -14,7 +14,7 @@ function M.init() local utils = require("mp.utils") 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 state = require("state").new() @@ -61,6 +61,9 @@ function M.init() ctx.process = make_lazy_proxy("process", function() return require("process").create(ctx) end) + ctx.session_bindings = make_lazy_proxy("session_bindings", function() + return require("session_bindings").create(ctx) + end) ctx.ui = make_lazy_proxy("ui", function() return require("ui").create(ctx) end) @@ -72,6 +75,7 @@ function M.init() end) ctx.ui.register_keybindings() + ctx.session_bindings.register_bindings() ctx.messages.register_script_messages() ctx.lifecycle.register_lifecycle_hooks() ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded") diff --git a/plugin/subminer/environment.lua b/plugin/subminer/environment.lua index 3aa6f796..9bfc5847 100644 --- a/plugin/subminer/environment.lua +++ b/plugin/subminer/environment.lua @@ -1,7 +1,9 @@ local M = {} +local unpack_fn = table.unpack or unpack function M.create(ctx) local mp = ctx.mp + local utils = ctx.utils local detected_backend = nil local app_running_cache_value = nil @@ -30,6 +32,57 @@ function M.create(ctx) return "/tmp/subminer-socket" 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() return not is_windows() and not is_macos() end @@ -198,7 +251,10 @@ function M.create(ctx) is_windows = is_windows, is_macos = is_macos, is_linux = is_linux, + join_path = join_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_app_running = is_subminer_app_running, is_subminer_app_running_async = is_subminer_app_running_async, diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index 44c5ade9..a62824da 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -47,6 +47,9 @@ function M.create(ctx) mp.register_script_message("subminer-stats-toggle", function() mp.osd_message("Stats: press ` (backtick) in overlay", 3) end) + mp.register_script_message("subminer-reload-session-bindings", function() + ctx.session_bindings.reload_bindings() + end) end return { diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua new file mode 100644 index 00000000..66b25a7f --- /dev/null +++ b/plugin/subminer/session_bindings.lua @@ -0,0 +1,310 @@ +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() + clear_bindings() + + local artifact, load_error = load_artifact() + if not artifact then + subminer_log("warn", "session-bindings", load_error) + return false + end + + 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) + state.session_binding_names[#state.session_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 + + subminer_log( + "info", + "session-bindings", + "Registered " .. tostring(#state.session_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 diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 8814b0ee..285450e9 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -33,6 +33,9 @@ function M.new() auto_play_ready_timeout = nil, auto_play_ready_osd_timer = nil, suppress_ready_overlay_restore = false, + session_binding_names = {}, + session_numeric_binding_names = {}, + session_numeric_selection = nil, } end diff --git a/scripts/mkv-to-readme-video.sh b/scripts/mkv-to-readme-video.sh index 04fbd6ab..faadd370 100755 --- a/scripts/mkv-to-readme-video.sh +++ b/scripts/mkv-to-readme-video.sh @@ -28,6 +28,27 @@ USAGE force=0 generate_webp=0 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 case "$1" in @@ -63,9 +84,19 @@ if [[ -z "$input" ]]; then exit 1 fi -if ! command -v ffmpeg > /dev/null 2>&1; then - echo "Error: ffmpeg is not installed or not in PATH." >&2 - exit 1 +input="$(normalize_path "$input")" +ffmpeg_bin="$(normalize_path "$ffmpeg_bin")" + +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 if [[ ! -f "$input" ]]; then @@ -102,7 +133,7 @@ fi has_encoder() { 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() { @@ -123,7 +154,7 @@ webm_vf="${crop_vf},fps=30" echo "Generating MP4: $mp4_out" if has_encoder "h264_nvenc"; then echo "Trying GPU encoder for MP4: h264_nvenc" - if ffmpeg "$overwrite_flag" -i "$input" \ + if "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$crop_vf" \ -c:v h264_nvenc -preset p6 -rc:v vbr -cq:v 20 -b:v 0 \ -pix_fmt yuv420p -movflags +faststart \ @@ -132,7 +163,7 @@ if has_encoder "h264_nvenc"; then : else echo "GPU MP4 encode failed; retrying with CPU encoder: libx264" - ffmpeg "$overwrite_flag" -i "$input" \ + "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$crop_vf" \ -c:v libx264 -preset slow -crf 20 \ -profile:v high -level 4.1 -pix_fmt yuv420p \ @@ -142,7 +173,7 @@ if has_encoder "h264_nvenc"; then fi else echo "Using CPU encoder for MP4: libx264" - ffmpeg "$overwrite_flag" -i "$input" \ + "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$crop_vf" \ -c:v libx264 -preset slow -crf 20 \ -profile:v high -level 4.1 -pix_fmt yuv420p \ @@ -154,7 +185,7 @@ fi echo "Generating WebM: $webm_out" if has_encoder "av1_nvenc"; then echo "Trying GPU encoder for WebM: av1_nvenc" - if ffmpeg "$overwrite_flag" -i "$input" \ + if "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$webm_vf" \ -c:v av1_nvenc -preset p6 -cq:v 34 -b:v 0 \ -c:a libopus -b:a 96k \ @@ -162,7 +193,7 @@ if has_encoder "av1_nvenc"; then : else 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" \ -c:v libvpx-vp9 -crf 34 -b:v 0 \ -row-mt 1 -threads 8 \ @@ -171,7 +202,7 @@ if has_encoder "av1_nvenc"; then fi else echo "Using CPU encoder for WebM: libvpx-vp9" - ffmpeg "$overwrite_flag" -i "$input" \ + "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$webm_vf" \ -c:v libvpx-vp9 -crf 34 -b:v 0 \ -row-mt 1 -threads 8 \ @@ -185,7 +216,7 @@ if [[ "$generate_webp" -eq 1 ]]; then exit 1 fi 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" \ -c:v "$webp_encoder" \ -q:v 80 \ @@ -195,7 +226,7 @@ if [[ "$generate_webp" -eq 1 ]]; then fi 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" \ -vframes 1 \ -q:v 2 \ diff --git a/scripts/mkv-to-readme-video.test.ts b/scripts/mkv-to-readme-video.test.ts index 6e56877c..5ff99b09 100644 --- a/scripts/mkv-to-readme-video.test.ts +++ b/scripts/mkv-to-readme-video.test.ts @@ -19,11 +19,33 @@ function writeExecutable(filePath: string, contents: string): void { 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', () => { withTempDir((root) => { const binDir = path.join(root, 'bin'); const inputPath = path.join(root, 'sample.mkv'); const ffmpegLogPath = path.join(root, 'ffmpeg-args.log'); + const ffmpegLogPathBash = toBashPath(ffmpegLogPath); fs.mkdirSync(binDir, { recursive: true }); fs.writeFileSync(inputPath, 'fake-video', 'utf8'); @@ -44,22 +66,33 @@ EOF exit 0 fi -printf '%s\\n' "$*" >> "${ffmpegLogPath}" +if [[ "$#" -eq 0 ]]; then + exit 0 +fi + +printf '%s\\n' "$*" >> "${ffmpegLogPathBash}" output="" for arg in "$@"; do output="$arg" done +if [[ -z "$output" ]]; then + exit 0 +fi mkdir -p "$(dirname "$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(), - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, - }, encoding: 'utf8', }); diff --git a/scripts/update-aur-package.sh b/scripts/update-aur-package.sh index fdd62385..5fa6e45f 100755 --- a/scripts/update-aur-package.sh +++ b/scripts/update-aur-package.sh @@ -13,6 +13,26 @@ appimage= wrapper= 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 case "$1" in --pkg-dir) @@ -53,6 +73,10 @@ if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$ fi 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" srcinfo="${pkg_dir}/.SRCINFO" @@ -82,6 +106,9 @@ awk \ found_pkgver = 0 found_sha_block = 0 } + { + sub(/\r$/, "") + } /^pkgver=/ { print "pkgver=" version found_pkgver = 1 @@ -140,6 +167,9 @@ awk \ found_source_wrapper = 0 found_source_assets = 0 } + { + sub(/\r$/, "") + } /^\tpkgver = / { print "\tpkgver = " version found_pkgver = 1 diff --git a/scripts/update-aur-package.test.ts b/scripts/update-aur-package.test.ts index b9a52cc8..d8320d64 100644 --- a/scripts/update-aur-package.test.ts +++ b/scripts/update-aur-package.test.ts @@ -1,5 +1,6 @@ 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 os from 'node:os'; import path from 'node:path'; @@ -9,6 +10,23 @@ function createWorkspace(name: string): string { 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', () => { const workspace = createWorkspace('subminer-aur-package'); 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', '--pkg-dir', - pkgDir, + toBashPath(pkgDir), '--version', 'v0.6.3', '--appimage', - appImagePath, + toBashPath(appImagePath), '--wrapper', - wrapperPath, + toBashPath(wrapperPath), '--assets', - assetsPath, + toBashPath(assetsPath), ], { 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 srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8'); - const expectedSums = [appImagePath, wrapperPath, assetsPath].map( - (filePath) => execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0], + const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) => + crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'), ); assert.match(pkgbuild, /^pkgver=0\.6\.3$/m); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 5d0094da..84275417 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -73,6 +73,33 @@ test('parseArgs captures youtube startup forwarding flags', () => { 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', () => { const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']); diff --git a/src/cli/args.ts b/src/cli/args.ts index 49651646..f294da27 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -25,6 +25,15 @@ export interface CliArgs { triggerSubsync: boolean; markAudioCard: boolean; openRuntimeOptions: boolean; + openJimaku: boolean; + openYoutubePicker: boolean; + openPlaylistBrowser: boolean; + replayCurrentSubtitle: boolean; + playNextSubtitle: boolean; + shiftSubDelayPrevLine: boolean; + shiftSubDelayNextLine: boolean; + copySubtitleCount?: number; + mineSentenceCount?: number; anilistStatus: boolean; anilistLogout: boolean; anilistSetup: boolean; @@ -103,6 +112,13 @@ export function parseArgs(argv: string[]): CliArgs { triggerSubsync: false, markAudioCard: false, openRuntimeOptions: false, + openJimaku: false, + openYoutubePicker: false, + openPlaylistBrowser: false, + replayCurrentSubtitle: false, + playNextSubtitle: false, + shiftSubDelayPrevLine: false, + shiftSubDelayNextLine: false, anilistStatus: false, anilistLogout: false, anilistSetup: false, @@ -180,6 +196,26 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--trigger-subsync') args.triggerSubsync = true; else if (arg === '--mark-audio-card') args.markAudioCard = 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-logout') args.anilistLogout = true; else if (arg === '--anilist-setup') args.anilistSetup = true; @@ -372,6 +408,15 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.triggerSubsync || args.markAudioCard || 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.anilistLogout || args.anilistSetup || @@ -424,6 +469,15 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.triggerSubsync && !args.markAudioCard && !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.anilistLogout && !args.anilistSetup && @@ -467,6 +521,15 @@ export function shouldStartApp(args: CliArgs): boolean { args.triggerSubsync || args.markAudioCard || 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.stats || args.jellyfin || @@ -505,6 +568,15 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.triggerSubsync && !args.markAudioCard && !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.anilistLogout && !args.anilistSetup && @@ -547,7 +619,16 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.triggerFieldGrouping || args.triggerSubsync || 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 ); } diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index b75466ff..4fcdf131 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -29,6 +29,13 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerSubsync: false, markAudioCard: false, openRuntimeOptions: false, + openJimaku: false, + openYoutubePicker: false, + openPlaylistBrowser: false, + replayCurrentSubtitle: false, + playNextSubtitle: false, + shiftSubDelayPrevLine: false, + shiftSubDelayNextLine: false, anilistStatus: false, anilistLogout: false, anilistSetup: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 4ee8482d..d3100d66 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -31,6 +31,13 @@ function makeArgs(overrides: Partial = {}): CliArgs { markAudioCard: false, refreshKnownWords: false, openRuntimeOptions: false, + openJimaku: false, + openYoutubePicker: false, + openPlaylistBrowser: false, + replayCurrentSubtitle: false, + playNextSubtitle: false, + shiftSubDelayPrevLine: false, + shiftSubDelayNextLine: false, anilistStatus: false, anilistLogout: false, anilistSetup: false, @@ -143,6 +150,9 @@ function createDeps(overrides: Partial = {}) { openRuntimeOptionsPalette: () => { calls.push('openRuntimeOptionsPalette'); }, + dispatchSessionAction: async () => { + calls.push('dispatchSessionAction'); + }, getAnilistStatus: () => ({ tokenStatus: 'resolved', tokenSource: 'stored', diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index d97be405..caa74c10 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -1,4 +1,5 @@ import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args'; +import type { SessionActionDispatchRequest } from '../../types/runtime'; export interface CliCommandServiceDeps { setLogLevel?: (level: NonNullable) => void; @@ -32,6 +33,7 @@ export interface CliCommandServiceDeps { triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; openRuntimeOptionsPalette: () => void; + dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise; getAnilistStatus: () => { tokenStatus: 'not_checked' | 'resolved' | 'error'; tokenSource: 'none' | 'literal' | 'stored'; @@ -168,6 +170,7 @@ export interface CliCommandDepsRuntimeOptions { }; ui: UiCliRuntime; app: AppCliRuntime; + dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise; getMultiCopyTimeoutMs: () => number; schedule: (fn: () => void, delayMs: number) => unknown; log: (message: string) => void; @@ -226,6 +229,7 @@ export function createCliCommandDepsRuntime( triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig, markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard, openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette, + dispatchSessionAction: options.dispatchSessionAction, getAnilistStatus: options.anilist.getStatus, clearAnilistToken: options.anilist.clearToken, openAnilistSetup: options.anilist.openSetup, @@ -268,6 +272,19 @@ export function handleCliCommand( source: CliCommandSource = 'initial', deps: CliCommandServiceDeps, ): void { + const dispatchCliSessionAction = ( + request: SessionActionDispatchRequest, + logLabel: string, + osdLabel: string, + ): void => { + runAsyncWithOsd( + () => deps.dispatchSessionAction?.(request) ?? Promise.resolve(), + deps, + logLabel, + osdLabel, + ); + }; + if (args.logLevel) { deps.setLogLevel?.(args.logLevel); } @@ -381,6 +398,56 @@ export function handleCliCommand( ); } else if (args.openRuntimeOptions) { 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) { const status = deps.getAnilistStatus(); deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`); diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 28ff2d47..70768fc6 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -127,7 +127,9 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ setMecabEnabled: () => {}, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), @@ -226,7 +228,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { getMecabTokenizer: () => null, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), @@ -382,7 +386,9 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = setMecabEnabled: () => {}, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), @@ -707,7 +713,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { setMecabEnabled: () => {}, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), @@ -786,7 +794,9 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon setMecabEnabled: () => {}, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), @@ -872,7 +882,9 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy setMecabEnabled: () => {}, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index ff781457..afbc7acc 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -1,6 +1,7 @@ import electron from 'electron'; -import type { IpcMainEvent } from 'electron'; +import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron'; import type { + CompiledSessionBinding, ControllerConfigUpdate, PlaylistBrowserMutationResult, PlaylistBrowserSnapshot, @@ -12,6 +13,7 @@ import type { SubtitlePosition, SubsyncManualRunRequest, SubsyncResult, + SessionActionDispatchRequest, YoutubePickerResolveRequest, YoutubePickerResolveResult, } from '../../types'; @@ -30,11 +32,14 @@ import { parseYoutubePickerResolveRequest, } from '../../shared/ipc/validators'; -const { BrowserWindow, ipcMain } = electron; +const { ipcMain } = electron; export interface IpcServiceDeps { onOverlayModalClosed: (modal: OverlayHostedModal) => void; - onOverlayModalOpened?: (modal: OverlayHostedModal) => void; + onOverlayModalOpened?: ( + modal: OverlayHostedModal, + senderWindow: ElectronBrowserWindow | null, + ) => void; openYomitanSettings: () => void; quitApp: () => void; toggleDevTools: () => void; @@ -56,7 +61,9 @@ export interface IpcServiceDeps { setMecabEnabled: (enabled: boolean) => void; handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; + getSessionBindings?: () => CompiledSessionBinding[]; getConfiguredShortcuts: () => unknown; + dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise; getStatsToggleKey: () => string; getMarkWatchedKey: () => string; getControllerConfig: () => ResolvedControllerConfig; @@ -154,7 +161,10 @@ export interface IpcDepsRuntimeOptions { getMainWindow: () => WindowLike | null; getVisibleOverlayVisibility: () => boolean; onOverlayModalClosed: (modal: OverlayHostedModal) => void; - onOverlayModalOpened?: (modal: OverlayHostedModal) => void; + onOverlayModalOpened?: ( + modal: OverlayHostedModal, + senderWindow: ElectronBrowserWindow | null, + ) => void; openYomitanSettings: () => void; quitApp: () => void; toggleVisibleOverlay: () => void; @@ -169,7 +179,9 @@ export interface IpcDepsRuntimeOptions { getMecabTokenizer: () => MecabTokenizerLike | null; handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; + getSessionBindings?: () => CompiledSessionBinding[]; getConfiguredShortcuts: () => unknown; + dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise; getStatsToggleKey: () => string; getMarkWatchedKey: () => string; getControllerConfig: () => ResolvedControllerConfig; @@ -238,7 +250,9 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService }, handleMpvCommand: options.handleMpvCommand, getKeybindings: options.getKeybindings, + getSessionBindings: options.getSessionBindings ?? (() => []), getConfiguredShortcuts: options.getConfiguredShortcuts, + dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}), getStatsToggleKey: options.getStatsToggleKey, getMarkWatchedKey: options.getMarkWatchedKey, getControllerConfig: options.getControllerConfig, @@ -299,7 +313,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar (event: unknown, ignore: unknown, options: unknown = {}) => { if (typeof ignore !== 'boolean') return; 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()) { senderWindow.setIgnoreMouseEvents(ignore, parsedOptions); } @@ -311,11 +326,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar if (!parsedModal) return; 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); if (!parsedModal) return; if (!deps.onOverlayModalOpened) return; - deps.onOverlayModalOpened(parsedModal); + const senderWindow = + electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; + deps.onOverlayModalOpened(parsedModal, senderWindow); }); ipc.handle( @@ -431,10 +448,36 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar 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).actionId === 'string' + ? ((request as Record).actionId as SessionActionDispatchRequest['actionId']) + : null; + if (!actionId) { + throw new Error('Invalid session action id'); + } + const payload = + (request as Record).payload && + typeof (request as Record).payload === 'object' + ? ((request as Record).payload as SessionActionDispatchRequest['payload']) + : undefined; + await deps.dispatchSessionAction?.({ actionId, payload }); + }, + ); + ipc.handle(IPC_CHANNELS.request.getKeybindings, () => { return deps.getKeybindings(); }); + ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => { + return deps.getSessionBindings?.() ?? []; + }); + ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => { return deps.getConfiguredShortcuts(); }); diff --git a/src/core/services/overlay-shortcut.ts b/src/core/services/overlay-shortcut.ts index 09ea8f10..af248577 100644 --- a/src/core/services/overlay-shortcut.ts +++ b/src/core/services/overlay-shortcut.ts @@ -1,10 +1,4 @@ -import electron from 'electron'; 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 { copySubtitle: () => void; @@ -42,140 +36,13 @@ export function shouldActivateOverlayShortcuts(args: { } export function registerOverlayShortcuts( - shortcuts: ConfiguredShortcuts, - handlers: OverlayShortcutHandlers, + _shortcuts: ConfiguredShortcuts, + _handlers: OverlayShortcutHandlers, ): boolean { - let registeredAny = false; - 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; + return false; } -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 unregisterOverlayShortcuts(_shortcuts: ConfiguredShortcuts): void {} export function registerOverlayShortcutsRuntime(deps: OverlayShortcutLifecycleDeps): boolean { return registerOverlayShortcuts(deps.getConfiguredShortcuts(), deps.getOverlayHandlers()); diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts new file mode 100644 index 00000000..02699743 --- /dev/null +++ b/src/core/services/session-actions.ts @@ -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; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + mineSentenceCard: () => Promise; + mineSentenceCount: (count: number) => void; + toggleSecondarySub: () => void; + markLastCardAsAudioCard: () => Promise; + openRuntimeOptionsPalette: () => void; + openJimaku: () => void; + openYoutubeTrackPicker: () => void | Promise; + openPlaylistBrowser: () => boolean | void | Promise; + replayCurrentSubtitle: () => void; + playNextSubtitle: () => void; + shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise; + 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 { + 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; + } + } +} diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts new file mode 100644 index 00000000..f4e417da --- /dev/null +++ b/src/core/services/session-bindings.test.ts @@ -0,0 +1,175 @@ +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 { + 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 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.', + }, + ]); +}); diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts new file mode 100644 index 00000000..c28247de --- /dev/null +++ b/src/core/services/session-bindings.ts @@ -0,0 +1,426 @@ +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; + 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 = { + 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): { 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' ? '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 + | Omit + | 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(); + const legacyToggleVisibleOverlayGlobal = ( + input.rawConfig?.shortcuts as Record | 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); + 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); + 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, + }; +} diff --git a/src/core/services/shortcut.ts b/src/core/services/shortcut.ts index bf88459e..d81766ad 100644 --- a/src/core/services/shortcut.ts +++ b/src/core/services/shortcut.ts @@ -20,42 +20,6 @@ export interface RegisterGlobalShortcutsServiceOptions { } 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', () => { options.onOpenYomitanSettings(); }); diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 94b74f1a..4f4ea85b 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -29,6 +29,13 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerSubsync: false, markAudioCard: false, openRuntimeOptions: false, + openJimaku: false, + openYoutubePicker: false, + openPlaylistBrowser: false, + replayCurrentSubtitle: false, + playNextSubtitle: false, + shiftSubDelayPrevLine: false, + shiftSubDelayNextLine: false, anilistStatus: false, anilistLogout: false, anilistSetup: false, diff --git a/src/main.ts b/src/main.ts index facebc05..966a12b4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -138,9 +138,7 @@ import { ensureWindowsOverlayTransparencyNative, getWindowsForegroundProcessNameNative, queryWindowsForegroundProcessName, - queryWindowsTargetWindowHandle, setWindowsOverlayOwnerNative, - syncWindowsOverlayToMpvZOrder, } from './window-trackers/windows-helper'; import { commandNeedsOverlayStartupPrereqs, @@ -354,6 +352,7 @@ import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-reso import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { startStatsServer } from './core/services/stats-server'; import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; +import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/stats-window.js'; import { createFirstRunSetupService, getFirstRunSetupCompletionMessage, @@ -1941,12 +1940,6 @@ function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | nu return null; } - if (targetMpvSocketPath) { - return queryWindowsTargetWindowHandle({ - targetMpvSocketPath, - }); - } - try { const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32'); const poll = win32.findMpvWindows(); @@ -1994,15 +1987,7 @@ async function syncWindowsVisibleOverlayToMpvZOrder(): Promise { (mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1); return true; } - - const synced = await syncWindowsOverlayToMpvZOrder({ - overlayWindowHandle: getWindowsNativeWindowHandle(mainWindow), - targetMpvSocketPath: appState.mpvSocketPath, - }); - if (synced) { - (mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1); - } - return synced; + return false; } function requestWindowsVisibleOverlayZOrderSync(): void { @@ -4166,6 +4151,7 @@ function compileCurrentSessionBindings(): { return compileSessionBindings({ keybindings: appState.keybindings, shortcuts: getConfiguredShortcuts(), + statsToggleKey: getResolvedConfig().stats.toggleKey, platform: resolveSessionBindingPlatform(), rawConfig: getResolvedConfig(), }); @@ -4513,6 +4499,18 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise { 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), @@ -5095,15 +5093,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) { return; } - if (appState.mpvSocketPath) { - void syncWindowsOverlayToMpvZOrder({ - overlayWindowHandle: getWindowsNativeWindowHandle(mainWindow), - targetMpvSocketPath: appState.mpvSocketPath, - }).catch((error) => { - logger.warn('Failed to bind Windows overlay owner to mpv', error); - }); - return; - } const tracker = appState.windowTracker; const mpvResult = tracker ? (() => { diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 608cd47a..4cec9dc0 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -30,6 +30,7 @@ export interface CliCommandRuntimeServiceContext { triggerFieldGrouping: () => Promise; triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; + dispatchSessionAction: CliCommandRuntimeServiceDepsParams['dispatchSessionAction']; getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus']; clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken']; openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup']; @@ -113,6 +114,7 @@ function createCliCommandDepsFromContext( hasMainWindow: context.hasMainWindow, runYoutubePlaybackFlow: context.runYoutubePlaybackFlow, }, + dispatchSessionAction: context.dispatchSessionAction, ui: { openFirstRunSetup: context.openFirstRunSetup, openYomitanSettings: context.openYomitanSettings, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index eb0f348c..0e9646f1 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -73,7 +73,9 @@ export interface MainIpcRuntimeServiceDepsParams { getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer']; handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; + getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; + dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction']; getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey']; getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; @@ -178,6 +180,7 @@ export interface CliCommandRuntimeServiceDepsParams { hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow']; runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow']; }; + dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction']; ui: { openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; @@ -233,7 +236,9 @@ export function createMainIpcRuntimeServiceDeps( getMecabTokenizer: params.getMecabTokenizer, handleMpvCommand: params.handleMpvCommand, getKeybindings: params.getKeybindings, + getSessionBindings: params.getSessionBindings, getConfiguredShortcuts: params.getConfiguredShortcuts, + dispatchSessionAction: params.dispatchSessionAction, getStatsToggleKey: params.getStatsToggleKey, getMarkWatchedKey: params.getMarkWatchedKey, getControllerConfig: params.getControllerConfig, @@ -347,6 +352,7 @@ export function createCliCommandRuntimeServiceDeps( hasMainWindow: params.app.hasMainWindow, runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow, }, + dispatchSessionAction: params.dispatchSessionAction, ui: { openFirstRunSetup: params.ui.openFirstRunSetup, openYomitanSettings: params.ui.openYomitanSettings, diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index 057d3d50..d6bfe9c7 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -42,6 +42,7 @@ test('build cli command context deps maps handlers and values', () => { markLastCardAsAudioCard: async () => { calls.push('mark'); }, + dispatchSessionAction: async () => {}, getAnilistStatus: () => ({}) as never, clearAnilistToken: () => calls.push('clear-token'), openAnilistSetup: () => calls.push('anilist'), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index fcea1e99..0a161295 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -28,6 +28,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { triggerFieldGrouping: () => Promise; triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; + dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction']; getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken']; openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup']; @@ -77,6 +78,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { triggerFieldGrouping: deps.triggerFieldGrouping, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, markLastCardAsAudioCard: deps.markLastCardAsAudioCard, + dispatchSessionAction: deps.dispatchSessionAction, getAnilistStatus: deps.getAnilistStatus, clearAnilistToken: deps.clearAnilistToken, openAnilistSetup: deps.openAnilistSetup, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index bf2565ca..cb826a4c 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -37,6 +37,7 @@ test('cli command context factory composes main deps and context handlers', () = triggerFieldGrouping: async () => {}, triggerSubsyncFromConfig: async () => {}, markLastCardAsAudioCard: async () => {}, + dispatchSessionAction: async () => {}, getAnilistStatus: () => ({ tokenStatus: 'resolved', tokenSource: 'literal', diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 51de7053..6644283f 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -53,6 +53,7 @@ test('cli command context main deps builder maps state and callbacks', async () markLastCardAsAudioCard: async () => { calls.push('mark-audio'); }, + dispatchSessionAction: async () => {}, getAnilistStatus: () => ({ tokenStatus: 'resolved', diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 4c19c324..18fb7106 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -39,6 +39,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { triggerFieldGrouping: () => Promise; triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; + dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction']; getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; clearAnilistToken: () => void; @@ -103,6 +104,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { triggerFieldGrouping: () => deps.triggerFieldGrouping(), triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(), + dispatchSessionAction: (request) => deps.dispatchSessionAction(request), getAnilistStatus: () => deps.getAnilistStatus(), clearAnilistToken: () => deps.clearAnilistToken(), openAnilistSetup: () => deps.openAnilistSetupWindow(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index 90053a0a..209a8b79 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -36,6 +36,7 @@ function createDeps() { triggerFieldGrouping: async () => {}, triggerSubsyncFromConfig: async () => {}, markLastCardAsAudioCard: async () => {}, + dispatchSessionAction: async () => {}, getAnilistStatus: () => ({}) as never, clearAnilistToken: () => {}, openAnilistSetup: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index 3b652c0b..f4fd2f31 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -33,6 +33,7 @@ export type CliCommandContextFactoryDeps = { triggerFieldGrouping: () => Promise; triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; + dispatchSessionAction: CliCommandRuntimeServiceContext['dispatchSessionAction']; getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus']; clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken']; openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup']; @@ -89,6 +90,7 @@ export function createCliCommandContext( triggerFieldGrouping: deps.triggerFieldGrouping, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, markLastCardAsAudioCard: deps.markLastCardAsAudioCard, + dispatchSessionAction: deps.dispatchSessionAction, getAnilistStatus: deps.getAnilistStatus, clearAnilistToken: deps.clearAnilistToken, openAnilistSetup: deps.openAnilistSetup, diff --git a/src/main/runtime/composers/cli-startup-composer.test.ts b/src/main/runtime/composers/cli-startup-composer.test.ts index 04fa7e49..50c1cab4 100644 --- a/src/main/runtime/composers/cli-startup-composer.test.ts +++ b/src/main/runtime/composers/cli-startup-composer.test.ts @@ -30,6 +30,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => { triggerFieldGrouping: async () => {}, triggerSubsyncFromConfig: async () => {}, markLastCardAsAudioCard: async () => {}, + dispatchSessionAction: async () => {}, getAnilistStatus: () => ({}) as never, clearAnilistToken: () => {}, openAnilistSetupWindow: () => {}, diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index d9f768af..8cb26a7c 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -53,7 +53,9 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b saveSubtitlePosition: () => {}, getMecabTokenizer: () => null, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}) as never, + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => ({}) as never, diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index 0a5e228e..d504eb85 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -14,6 +14,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { const applyHotReload = createConfigHotReloadAppliedHandler({ setKeybindings: () => calls.push('set:keybindings'), + setSessionBindings: () => calls.push('set:session-bindings'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`), 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:session-bindings')); assert.ok(calls.includes('refresh:shortcuts')); assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`)); 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({ setKeybindings: () => calls.push('set:keybindings'), + setSessionBindings: () => calls.push('set:session-bindings'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), setSecondarySubMode: () => calls.push('set:secondary'), broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), @@ -64,7 +67,7 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie config, ); - assert.deepEqual(calls, ['set:keybindings']); + assert.deepEqual(calls, ['set:keybindings', 'set:session-bindings']); }); test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => { diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index cf4f9c64..a25906ad 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -1,10 +1,13 @@ import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload'; +import { compileSessionBindings } from '../../core/services/session-bindings'; 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'; type ConfigHotReloadAppliedDeps = { setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; + setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void; refreshGlobalAndOverlayShortcuts: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; @@ -33,8 +36,21 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) { } 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 { - keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS), + keybindings, + sessionBindings, subtitleStyle: resolveSubtitleStyleForRenderer(config), subtitleSidebar: config.subtitleSidebar, secondarySubMode: config.secondarySub.defaultMode, @@ -45,6 +61,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => { const payload = buildConfigHotReloadPayload(config); deps.setKeybindings(payload.keybindings); + deps.setSessionBindings(payload.sessionBindings); if (diff.hotReloadFields.includes('shortcuts')) { deps.refreshGlobalAndOverlayShortcuts(); diff --git a/src/main/runtime/config-hot-reload-main-deps.test.ts b/src/main/runtime/config-hot-reload-main-deps.test.ts index fc01aa05..21957a47 100644 --- a/src/main/runtime/config-hot-reload-main-deps.test.ts +++ b/src/main/runtime/config-hot-reload-main-deps.test.ts @@ -86,21 +86,25 @@ test('config hot reload message main deps builder maps notifications', () => { test('config hot reload applied main deps builder maps callbacks', () => { const calls: string[] = []; - const deps = createBuildConfigHotReloadAppliedMainDepsHandler({ + const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({ setKeybindings: () => calls.push('keybindings'), + setSessionBindings: () => calls.push('session-bindings'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'), setSecondarySubMode: () => calls.push('set-secondary'), broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'), - })(); + }); + const deps = buildDeps(); deps.setKeybindings([]); + deps.setSessionBindings([]); deps.refreshGlobalAndOverlayShortcuts(); deps.setSecondarySubMode('hover'); deps.broadcastToOverlayWindows('config:hot-reload', {}); deps.applyAnkiRuntimeConfigPatch({ ai: true }); assert.deepEqual(calls, [ 'keybindings', + 'session-bindings', 'refresh-shortcuts', 'set-secondary', 'broadcast:config:hot-reload', diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts index e93ca694..009a52b2 100644 --- a/src/main/runtime/config-hot-reload-main-deps.ts +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -62,6 +62,7 @@ export function createBuildConfigHotReloadMessageMainDepsHandler( export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; + setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void; refreshGlobalAndOverlayShortcuts: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; @@ -72,6 +73,8 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { return () => ({ setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => deps.setKeybindings(keybindings), + setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => + deps.setSessionBindings(sessionBindings), refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(), setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), broadcastToOverlayWindows: (channel: string, payload: unknown) => diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 5272aa2f..42897df6 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -43,6 +43,13 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerSubsync: false, markAudioCard: false, openRuntimeOptions: false, + openJimaku: false, + openYoutubePicker: false, + openPlaylistBrowser: false, + replayCurrentSubtitle: false, + playNextSubtitle: false, + shiftSubDelayPrevLine: false, + shiftSubDelayNextLine: false, anilistStatus: false, anilistLogout: false, anilistSetup: false, diff --git a/src/main/runtime/session-bindings-artifact.ts b/src/main/runtime/session-bindings-artifact.ts new file mode 100644 index 00000000..16858147 --- /dev/null +++ b/src/main/runtime/session-bindings-artifact.ts @@ -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; +} diff --git a/src/main/state.ts b/src/main/state.ts index 16372dd2..4a0be88a 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -1,6 +1,7 @@ import type { BrowserWindow, Extension, Session } from 'electron'; import type { + CompiledSessionBinding, Keybinding, MpvSubtitleRenderMetrics, SecondarySubMode, @@ -170,6 +171,7 @@ export interface AppState { anilistClientSecretState: AnilistSecretResolutionState; mecabTokenizer: MecabTokenizer | null; keybindings: Keybinding[]; + sessionBindings: CompiledSessionBinding[]; subtitleTimingTracker: SubtitleTimingTracker | null; immersionTracker: ImmersionTrackerService | null; ankiIntegration: AnkiIntegration | null; @@ -252,6 +254,7 @@ export function createAppState(values: AppStateInitialValues): AppState { anilistClientSecretState: createInitialAnilistSecretResolutionState(), mecabTokenizer: null, keybindings: [], + sessionBindings: [], subtitleTimingTracker: null, immersionTracker: null, ankiIntegration: null, diff --git a/src/preload.ts b/src/preload.ts index bc112f6a..faaca5be 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -223,8 +223,11 @@ const electronAPI: ElectronAPI = { getKeybindings: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), + getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings), getConfiguredShortcuts: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), + dispatchSessionAction: (actionId, payload) => + ipcRenderer.invoke(IPC_CHANNELS.command.dispatchSessionAction, { actionId, payload }), getStatsToggleKey: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey), getMarkWatchedKey: (): Promise => diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index d81b7c6f..815c0acd 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { createKeyboardHandlers } from './keyboard.js'; import { createRendererState } from '../state.js'; +import type { CompiledSessionBinding } from '../../types'; import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js'; type CommandEventDetail = { @@ -50,6 +51,8 @@ function installKeyboardTestGlobals() { const windowListeners = new Map void>>(); const commandEvents: CommandEventDetail[] = []; const mpvCommands: Array> = []; + const sessionActions: Array<{ actionId: string; payload?: unknown }> = []; + let sessionBindings: CompiledSessionBinding[] = []; let playbackPausedResponse: boolean | null = false; let statsToggleKey = 'Backquote'; let markWatchedKey = 'KeyW'; @@ -153,10 +156,14 @@ function installKeyboardTestGlobals() { }, electronAPI: { getKeybindings: async () => [], + getSessionBindings: async () => sessionBindings, getConfiguredShortcuts: async () => configuredShortcuts, sendMpvCommand: (command: Array) => { mpvCommands.push(command); }, + dispatchSessionAction: async (actionId: string, payload?: unknown) => { + sessionActions.push({ actionId, payload }); + }, getPlaybackPaused: async () => playbackPausedResponse, getStatsToggleKey: async () => statsToggleKey, getMarkWatchedKey: async () => markWatchedKey, @@ -273,6 +280,7 @@ function installKeyboardTestGlobals() { return { commandEvents, mpvCommands, + sessionActions, overlay, overlayFocusCalls, focusMainWindowCalls: () => focusMainWindowCalls, @@ -292,6 +300,9 @@ function installKeyboardTestGlobals() { setConfiguredShortcuts: (value: typeof configuredShortcuts) => { configuredShortcuts = value; }, + setSessionBindings: (value: CompiledSessionBinding[]) => { + sessionBindings = value; + }, setMarkActiveVideoWatchedResult: (value: boolean) => { markActiveVideoWatchedResult = value; }, @@ -521,13 +532,19 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => { try { 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'], }, { - key: 'KeyQ', + sourcePath: 'keybindings[1].key', + originalKey: 'KeyQ', + key: { code: 'KeyQ', modifiers: [] }, + actionType: 'mpv-command', command: ['quit'], }, ] as never); @@ -549,9 +566,12 @@ test('paused configured subtitle-jump keybinding re-applies pause after backward try { 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], }, ] as never); @@ -574,9 +594,12 @@ test('configured subtitle-jump keybinding preserves pause when pause state is un try { 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], }, ] as never); @@ -763,13 +786,19 @@ test('youtube picker: unhandled keys still dispatch mpv keybindings', async () = try { 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'], }, { - key: 'KeyQ', + sourcePath: 'keybindings[1].key', + originalKey: 'KeyQ', + key: { code: 'KeyQ', modifiers: [] }, + actionType: 'mpv-command', command: ['quit'], }, ] 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 () => { - const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); +test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); try { - ctx.platform.isLinuxPlatform = true; 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 }); - assert.deepEqual(testGlobals.mpvCommands, [['__subsync-trigger']]); + assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'triggerSubsync', payload: undefined }]); } finally { testGlobals.restore(); } }); -test('linux overlay shortcut: Ctrl+Shift+J dispatches jimaku special command locally', async () => { - const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); +test('session binding: Ctrl+Shift+J dispatches jimaku action locally', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); try { - ctx.platform.isLinuxPlatform = true; 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 }); - assert.deepEqual(testGlobals.mpvCommands, [['__jimaku-open']]); + assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openJimaku', payload: undefined }]); } finally { testGlobals.restore(); } }); -test('linux overlay shortcut: CommandOrControl+Shift+O dispatches runtime options locally', async () => { - const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); +test('session binding: Ctrl+Shift+O dispatches runtime options locally', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); try { - ctx.platform.isLinuxPlatform = true; 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 }); - assert.deepEqual(testGlobals.mpvCommands, [['__runtime-options-open']]); + assert.deepEqual(testGlobals.sessionActions, [ + { actionId: 'openRuntimeOptions', payload: undefined }, + ]); } finally { testGlobals.restore(); } diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 67f6d02e..1e9bc42b 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -1,8 +1,6 @@ -import { SPECIAL_COMMANDS } from '../../config/definitions'; -import type { Keybinding, ShortcutsConfig } from '../../types'; +import type { CompiledSessionBinding, ShortcutsConfig } from '../../types'; import type { RendererContext } from '../context'; import { - YOMITAN_POPUP_HOST_SELECTOR, YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_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). const CHORD_TIMEOUT_MS = 1000; const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected'; - const linuxOverlayShortcutCommands = new Map(); let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null; let pendingLookupRefreshAfterSubtitleSeek = false; let resetSelectionToStartOnNextSubtitleSync = false; let lookupScanFallbackTimer: ReturnType | null = null; + let pendingNumericSelection: + | { + actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple'; + timeout: ReturnType | null; + } + | null = null; const CHORD_MAP = new Map< string, @@ -62,9 +65,6 @@ export function createKeyboardHandlers( if (target.closest('.modal')) return true; if (ctx.dom.subtitleContainer.contains(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"]')) return true; return false; @@ -80,115 +80,117 @@ export function createKeyboardHandlers( 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 = { - 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): void { - linuxOverlayShortcutCommands.clear(); - 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); - } - } + ctx.state.sessionActionTimeoutMs = shortcuts.multiCopyTimeoutMs; } async function refreshConfiguredShortcuts(): Promise { 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( key: string, code: string, @@ -512,7 +514,7 @@ export function createKeyboardHandlers( clientY: number, modifiers: ScanModifierState = {}, ): void { - if (typeof PointerEvent !== 'undefined') { + if (typeof PointerEvent === 'function') { const pointerEventInit = { bubbles: true, cancelable: true, @@ -535,23 +537,25 @@ export function createKeyboardHandlers( target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit)); } - const mouseEventInit = { - bubbles: true, - cancelable: true, - composed: true, - clientX, - clientY, - button: 0, - buttons: 0, - shiftKey: modifiers.shiftKey ?? false, - ctrlKey: modifiers.ctrlKey ?? false, - altKey: modifiers.altKey ?? false, - metaKey: modifiers.metaKey ?? false, - } satisfies MouseEventInit; - target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit)); - target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 })); - target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit)); - target.dispatchEvent(new MouseEvent('click', mouseEventInit)); + if (typeof MouseEvent === 'function') { + const mouseEventInit = { + bubbles: true, + cancelable: true, + composed: true, + clientX, + clientY, + button: 0, + buttons: 0, + shiftKey: modifiers.shiftKey ?? false, + ctrlKey: modifiers.ctrlKey ?? false, + altKey: modifiers.altKey ?? false, + metaKey: modifiers.metaKey ?? false, + } satisfies MouseEventInit; + target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit)); + target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 })); + target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit)); + target.dispatchEvent(new MouseEvent('click', mouseEventInit)); + } } function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void { @@ -824,7 +828,7 @@ export function createKeyboardHandlers( if (modifierOnlyCodes.has(e.code)) return false; const keyString = keyEventToString(e); - if (ctx.state.keybindingsMap.has(keyString)) { + if (ctx.state.sessionBindingMap.has(keyString)) { return false; } @@ -850,7 +854,7 @@ export function createKeyboardHandlers( fallbackUnavailable: boolean; } { const firstChoice = 'KeyH'; - if (!ctx.state.keybindingsMap.has('KeyH')) { + if (!ctx.state.sessionBindingMap.has('KeyH')) { return { bindingKey: firstChoice, fallbackUsed: false, @@ -858,18 +862,18 @@ export function createKeyboardHandlers( }; } - if (ctx.state.keybindingsMap.has('KeyK')) { + if (!ctx.state.sessionBindingMap.has('KeyK')) { return { bindingKey: 'KeyK', fallbackUsed: true, - fallbackUnavailable: true, + fallbackUnavailable: false, }; } return { bindingKey: 'KeyK', fallbackUsed: true, - fallbackUnavailable: false, + fallbackUnavailable: true, }; } @@ -894,13 +898,13 @@ export function createKeyboardHandlers( } async function setupMpvInputForwarding(): Promise { - const [keybindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([ - window.electronAPI.getKeybindings(), + const [sessionBindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([ + window.electronAPI.getSessionBindings(), window.electronAPI.getConfiguredShortcuts(), window.electronAPI.getStatsToggleKey(), window.electronAPI.getMarkWatchedKey(), ]); - updateKeybindings(keybindings); + updateSessionBindings(sessionBindings); updateConfiguredShortcuts(shortcuts); ctx.state.statsToggleKey = statsToggleKey; ctx.state.markWatchedKey = markWatchedKey; @@ -1010,6 +1014,14 @@ export function createKeyboardHandlers( return; } + if (isTextEntryTarget(e.target)) { + return; + } + + if (handlePendingNumericSelection(e)) { + return; + } + if (isStatsOverlayToggle(e)) { e.preventDefault(); window.electronAPI.toggleStatsOverlay(); @@ -1099,19 +1111,10 @@ export function createKeyboardHandlers( } const keyString = keyEventToString(e); - const linuxOverlayCommand = ctx.platform.isLinuxPlatform - ? linuxOverlayShortcutCommands.get(keyString) - : undefined; - if (linuxOverlayCommand) { + const binding = ctx.state.sessionBindingMap.get(keyString); + if (binding) { e.preventDefault(); - dispatchConfiguredMpvCommand(linuxOverlayCommand); - return; - } - const command = ctx.state.keybindingsMap.get(keyString); - - if (command) { - e.preventDefault(); - dispatchConfiguredMpvCommand(command); + dispatchSessionBinding(binding); } }); @@ -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 { + beginSessionNumericSelection, setupMpvInputForwarding, refreshConfiguredShortcuts, - updateKeybindings, + updateSessionBindings, syncKeyboardTokenSelection, handleSubtitleContentUpdated, handleKeyboardModeToggleRequested, diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index df3d363c..1f4fdd46 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -628,7 +628,7 @@ async function init(): Promise { }); window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => { runGuarded('config:hot-reload', () => { - keyboardHandlers.updateKeybindings(payload.keybindings); + keyboardHandlers.updateSessionBindings(payload.sessionBindings); void keyboardHandlers.refreshConfiguredShortcuts(); subtitleRenderer.applySubtitleStyle(payload.subtitleStyle); subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 40ddff59..c4902c81 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -1,4 +1,5 @@ import type { + CompiledSessionBinding, PlaylistBrowserSnapshot, ControllerButtonSnapshot, ControllerDeviceInfo, @@ -116,7 +117,9 @@ export type RendererState = { frequencyDictionaryBand4Color: string; frequencyDictionaryBand5Color: string; - keybindingsMap: Map; + sessionBindings: CompiledSessionBinding[]; + sessionBindingMap: Map; + sessionActionTimeoutMs: number; statsToggleKey: string; markWatchedKey: string; chordPending: boolean; @@ -219,7 +222,9 @@ export function createRendererState(): RendererState { frequencyDictionaryBand4Color: '#8bd5ca', frequencyDictionaryBand5Color: '#8aadf4', - keybindingsMap: new Map(), + sessionBindings: [], + sessionBindingMap: new Map(), + sessionActionTimeoutMs: 3000, statsToggleKey: 'Backquote', markWatchedKey: 'KeyW', chordPending: false, diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index 065a5953..f00837b6 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -32,9 +32,9 @@ export function isYomitanPopupIframe(element: Element | null): boolean { return hasModernPopupClass || hasLegacyPopupId; } -export function hasYomitanPopupIframe(root: ParentNode = document): boolean { +export function hasYomitanPopupIframe(root: ParentNode | null | undefined = document): boolean { return ( - typeof root.querySelector === 'function' && + typeof root?.querySelector === 'function' && (root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null || root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null) ); @@ -58,14 +58,17 @@ function isMarkedVisiblePopupHost(element: Element): boolean { return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true'; } -function queryPopupElements(root: ParentNode, selector: string): T[] { - if (typeof root.querySelectorAll !== 'function') { +function queryPopupElements( + root: ParentNode | null | undefined, + selector: string, +): T[] { + if (typeof root?.querySelectorAll !== 'function') { return []; } return Array.from(root.querySelectorAll(selector)); } -export function isYomitanPopupVisible(root: ParentNode = document): boolean { +export function isYomitanPopupVisible(root: ParentNode | null | undefined = document): boolean { const visiblePopupHosts = queryPopupElements(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR); if (visiblePopupHosts.length > 0) { return true; diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 30615b4a..3d703a33 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -37,6 +37,7 @@ export const IPC_CHANNELS = { overlayModalOpened: 'overlay:modal-opened', toggleStatsOverlay: 'stats:toggle-overlay', markActiveVideoWatched: 'immersion:mark-active-video-watched', + dispatchSessionAction: 'session-action:dispatch', }, request: { getVisibleOverlayVisibility: 'get-visible-overlay-visibility', @@ -49,6 +50,7 @@ export const IPC_CHANNELS = { getSubtitleStyle: 'get-subtitle-style', getMecabStatus: 'get-mecab-status', getKeybindings: 'get-keybindings', + getSessionBindings: 'get-session-bindings', getConfigShortcuts: 'get-config-shortcuts', getStatsToggleKey: 'get-stats-toggle-key', getMarkWatchedKey: 'get-mark-watched-key', diff --git a/src/types.ts b/src/types.ts index 33e5adb1..4d066f56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,4 +3,5 @@ export * from './types/config'; export * from './types/integrations'; export * from './types/runtime'; export * from './types/runtime-options'; +export * from './types/session-bindings'; export * from './types/subtitle'; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index b9949f7a..0029fde1 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -5,6 +5,11 @@ import type { KikuMergePreviewResponse, } from './anki'; import type { ResolvedConfig, ShortcutsConfig } from './config'; +import type { + CompiledSessionBinding, + SessionActionId, + SessionActionPayload, +} from './session-bindings'; import type { JimakuApiResponse, JimakuDownloadQuery, @@ -321,11 +326,17 @@ export interface ClipboardAppendResult { export interface ConfigHotReloadPayload { keybindings: Keybinding[]; + sessionBindings: CompiledSessionBinding[]; subtitleStyle: SubtitleStyleConfig | null; subtitleSidebar: Required; secondarySubMode: SecondarySubMode; } +export interface SessionActionDispatchRequest { + actionId: SessionActionId; + payload?: SessionActionPayload; +} + export type ResolvedControllerConfig = ResolvedConfig['controller']; export interface ElectronAPI { @@ -349,7 +360,9 @@ export interface ElectronAPI { setMecabEnabled: (enabled: boolean) => void; sendMpvCommand: (command: (string | number)[]) => void; getKeybindings: () => Promise; + getSessionBindings: () => Promise; getConfiguredShortcuts: () => Promise>; + dispatchSessionAction: (actionId: SessionActionId, payload?: SessionActionPayload) => Promise; getStatsToggleKey: () => Promise; getMarkWatchedKey: () => Promise; markActiveVideoWatched: () => Promise; diff --git a/src/types/session-bindings.ts b/src/types/session-bindings.ts new file mode 100644 index 00000000..8da90521 --- /dev/null +++ b/src/types/session-bindings.ts @@ -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[]; +} diff --git a/src/window-trackers/windows-tracker.ts b/src/window-trackers/windows-tracker.ts index ad341a39..bb0ad535 100644 --- a/src/window-trackers/windows-tracker.ts +++ b/src/window-trackers/windows-tracker.ts @@ -32,8 +32,18 @@ type WindowsTrackerDeps = { now?: () => number; }; +function shouldUsePowershellTrackerFallback(): boolean { + const helperMode = process.env.SUBMINER_WINDOWS_TRACKER_HELPER?.trim().toLowerCase(); + if (helperMode === 'powershell') { + return true; + } + + const helperPath = process.env.SUBMINER_WINDOWS_TRACKER_HELPER_PATH?.trim().toLowerCase(); + return helperPath?.endsWith('.ps1') ?? false; +} + function defaultPollMpvWindows(targetMpvSocketPath?: string | null): MpvPollResult { - if (targetMpvSocketPath) { + if (targetMpvSocketPath && shouldUsePowershellTrackerFallback()) { const helperResult = queryWindowsTrackerMpvWindows({ targetMpvSocketPath, });