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