feat: wire session bindings through main, ipc, and cli runtime

This commit is contained in:
2026-04-10 02:54:01 -07:00
parent fd6dea9d33
commit 48f74db239
52 changed files with 1931 additions and 426 deletions

View File

@@ -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")

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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

View File

@@ -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