feat(config): add configuration window (#70)

This commit is contained in:
2026-05-21 04:16:21 -07:00
committed by GitHub
parent a54f03f0cd
commit dc52bc2fba
287 changed files with 14507 additions and 8134 deletions
+3 -75
View File
@@ -1,75 +1,3 @@
# SubMiner configuration
# Place this file in ~/.config/mpv/script-opts/
# Path to SubMiner binary (leave empty for auto-detection)
# Auto-detection searches common locations, including:
# - macOS: /Applications/SubMiner.app/Contents/MacOS/SubMiner, ~/Applications/SubMiner.app/Contents/MacOS/SubMiner
# - Windows: %LOCALAPPDATA%\Programs\SubMiner\SubMiner.exe, %ProgramFiles%\SubMiner\SubMiner.exe
# - Linux: ~/.local/bin/SubMiner.AppImage, /opt/SubMiner/SubMiner.AppImage, /usr/local/bin/SubMiner, /usr/local/bin/subminer, /usr/bin/SubMiner, /usr/bin/subminer
binary_path=
# Path to mpv IPC socket (must match input-ipc-server in mpv.conf)
# Windows installs rewrite this to \\.\pipe\subminer-socket during installation.
socket_path=/tmp/subminer-socket
# Enable texthooker WebSocket server
texthooker_enabled=yes
# Texthooker WebSocket port
texthooker_port=5174
# Window manager backend: auto, hyprland, sway, x11, macos, windows
# "auto" detects based on environment variables
backend=auto
# Automatically start overlay when a file is loaded
# Runs only when mpv input-ipc-server matches socket_path.
auto_start=yes
# Automatically show visible overlay when overlay starts
# Runs only when mpv input-ipc-server matches socket_path.
auto_start_visible_overlay=yes
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
# Requires auto_start=yes and auto_start_visible_overlay=yes.
auto_start_pause_until_ready=yes
# Show OSD messages for overlay status
osd_messages=yes
# Log level for plugin and SubMiner binary: debug, info, warn, error
log_level=info
# Enable AniSkip intro detection + markers.
aniskip_enabled=yes
# Force title (optional). Launcher fills this from guessit when available.
aniskip_title=
# Force season (optional). Launcher fills this from guessit when available.
aniskip_season=
# Force MAL id (optional). Leave blank for title lookup.
aniskip_mal_id=
# Force episode number (optional). Leave blank for filename/title detection.
aniskip_episode=
# Optional pre-fetched AniSkip payload for this media (JSON or base64 JSON). When set, the plugin uses this directly and skips network lookup.
aniskip_payload=
# Show intro skip OSD button while inside OP range.
aniskip_show_button=yes
# OSD text shown for intro skip action.
# `%s` is replaced by keybinding.
aniskip_button_text=You can skip by pressing %s
# Keybinding to execute intro skip when button is visible.
aniskip_button_key=TAB
# OSD hint duration in seconds (shown during first 3s of intro).
aniskip_button_duration=3
# MPV keybindings provided by plugin/subminer/main.lua:
# y-s start, y-S stop, y-t toggle visible overlay
# SubMiner managed playback config lives in SubMiner config.jsonc.
# This file is intentionally empty so installed/default mpv script-opts do not
# override the app config modal or generated config file.
+8 -2
View File
@@ -18,8 +18,14 @@ function M.create(ctx)
local function is_macos()
local platform = mp.get_property("platform") or ""
if platform == "macos" or platform == "darwin" then
return true
if platform ~= "" then
local normalized = platform:lower()
if normalized == "macos" or normalized == "darwin" or normalized == "osx" then
return true
end
if normalized == "windows" or normalized == "win32" or normalized == "linux" then
return false
end
end
local ostype = os.getenv("OSTYPE") or ""
return ostype:find("darwin") ~= nil
+81 -18
View File
@@ -1,5 +1,8 @@
local M = {}
local AUTO_START_SOCKET_RETRY_DELAY_SECONDS = 0.2
local AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS = 25
function M.create(ctx)
local mp = ctx.mp
local opts = ctx.opts
@@ -52,24 +55,95 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_auto_start, false)
end
local function rearm_managed_subtitle_defaults()
local function next_auto_start_retry_generation()
state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1
return state.auto_start_retry_generation
end
local function has_matching_subminer_socket()
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
return false
end
return true
end
local function rearm_managed_subtitle_load_defaults()
if not has_matching_subminer_socket() then
return false
end
mp.set_property_native("sub-auto", "fuzzy")
mp.set_property_native("sid", "auto")
mp.set_property_native("secondary-sid", "auto")
return true
end
local function refresh_managed_subtitle_autoloading()
if not has_matching_subminer_socket() then
return false
end
mp.set_property_native("sub-auto", "fuzzy")
return true
end
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
if generation ~= state.auto_start_retry_generation then
return
end
if media_identity ~= nil and state.current_media_identity ~= media_identity then
return
end
if not resolve_auto_start_enabled() then
schedule_aniskip_fetch("file-loaded", 0)
return
end
local has_matching_socket = refresh_managed_subtitle_autoloading()
if not has_matching_socket then
if attempt < AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS then
mp.add_timeout(AUTO_START_SOCKET_RETRY_DELAY_SECONDS, function()
start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt + 1)
end)
return
end
subminer_log(
"info",
"lifecycle",
"Skipping auto-start: input-ipc-server does not match configured socket_path"
)
schedule_aniskip_fetch("file-loaded", 0)
return
end
process.start_overlay({
auto_start_trigger = true,
socket_path = opts.socket_path,
rearm_pause_until_ready = not same_media_loaded,
})
-- Give the overlay process a moment to initialize before querying AniSkip.
schedule_aniskip_fetch("overlay-start", 0.8)
end
local function on_start_file()
if state.pending_reload_media_identity ~= nil then
return
end
rearm_managed_subtitle_load_defaults()
end
local function on_file_loaded()
local media_identity = resolve_media_identity()
local retry_generation = next_auto_start_retry_generation()
local previous_media_identity = state.current_media_identity
local same_media_reload = (
media_identity ~= nil
and state.pending_reload_media_identity ~= nil
and media_identity == state.pending_reload_media_identity
)
local same_media_loaded = (
media_identity ~= nil
and previous_media_identity ~= nil
and media_identity == previous_media_identity
)
state.pending_reload_media_identity = nil
state.current_media_identity = media_identity
@@ -92,32 +166,18 @@ function M.create(ctx)
if not preserve_active_auto_start_gate then
process.disarm_auto_play_ready_gate()
end
has_matching_socket = rearm_managed_subtitle_defaults()
if should_auto_start then
if not has_matching_socket then
subminer_log(
"info",
"lifecycle",
"Skipping auto-start: input-ipc-server does not match configured socket_path"
)
schedule_aniskip_fetch("file-loaded", 0)
return
end
process.start_overlay({
auto_start_trigger = true,
socket_path = opts.socket_path,
})
-- Give the overlay process a moment to initialize before querying AniSkip.
schedule_aniskip_fetch("overlay-start", 0.8)
start_overlay_when_socket_ready(retry_generation, media_identity, same_media_loaded, 1)
return
end
refresh_managed_subtitle_autoloading()
schedule_aniskip_fetch("file-loaded", 0)
end
local function on_shutdown()
next_auto_start_retry_generation()
aniskip.clear_aniskip_state()
hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate()
@@ -126,6 +186,7 @@ function M.create(ctx)
end
local function register_lifecycle_hooks()
mp.register_event("start-file", on_start_file)
mp.register_event("file-loaded", on_file_loaded)
mp.register_event("shutdown", on_shutdown)
mp.register_event("file-loaded", function()
@@ -139,6 +200,8 @@ function M.create(ctx)
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
return
end
next_auto_start_retry_generation()
state.current_media_identity = nil
state.pending_reload_media_identity = nil
if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay()
+4 -4
View File
@@ -27,16 +27,16 @@ function M.load(options_lib, default_socket_path)
local opts = {
binary_path = "",
socket_path = default_socket_path,
texthooker_enabled = true,
texthooker_enabled = false,
texthooker_port = 5174,
backend = "auto",
auto_start = true,
auto_start_visible_overlay = true,
auto_start = false,
auto_start_visible_overlay = false,
auto_start_pause_until_ready = true,
auto_start_pause_until_ready_timeout_seconds = 15,
osd_messages = true,
log_level = "info",
aniskip_enabled = true,
aniskip_enabled = false,
aniskip_title = "",
aniskip_season = "",
aniskip_mal_id = "",
+192 -42
View File
@@ -2,12 +2,15 @@ local M = {}
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_START_MAX_ATTEMPTS = 6
local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
function M.create(ctx)
local mp = ctx.mp
local utils = ctx.utils
local opts = ctx.opts
local state = ctx.state
local binary = ctx.binary
@@ -17,6 +20,8 @@ function M.create(ctx)
local show_osd = ctx.log.show_osd
local normalize_log_level = ctx.log.normalize_log_level
local run_control_command_async
local APP_ARGC_ENV = "SUBMINER_APP_ARGC"
local APP_ARG_PREFIX = "SUBMINER_APP_ARG_"
local function resolve_visible_overlay_startup()
local raw_visible_overlay = opts.auto_start_visible_overlay
@@ -112,10 +117,12 @@ function M.create(ctx)
local function disarm_auto_play_ready_gate(options)
local should_resume = options == nil or options.resume_playback ~= false
local was_armed = state.auto_play_ready_gate_armed
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
clear_auto_play_ready_timeout()
clear_auto_play_ready_osd_timer()
state.auto_play_ready_gate_armed = false
if was_armed and should_resume then
state.auto_play_ready_should_resume_playback = false
if was_armed and should_resume and should_resume_playback then
mp.set_property_native("pause", false)
end
end
@@ -124,17 +131,26 @@ function M.create(ctx)
if not state.auto_play_ready_gate_armed then
return
end
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
disarm_auto_play_ready_gate({ resume_playback = false })
mp.set_property_native("pause", false)
show_osd(AUTO_PLAY_READY_READY_OSD)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
if should_resume_playback then
mp.set_property_native("pause", false)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
else
subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready"))
end
end
local function arm_auto_play_ready_gate()
if state.auto_play_ready_gate_armed then
local was_armed = state.auto_play_ready_gate_armed
if was_armed then
clear_auto_play_ready_timeout()
clear_auto_play_ready_osd_timer()
end
if not was_armed then
state.auto_play_ready_should_resume_playback = mp.get_property_native("pause") ~= true
end
state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true)
show_osd(AUTO_PLAY_READY_LOADING_OSD)
@@ -164,10 +180,15 @@ function M.create(ctx)
local function notify_auto_play_ready()
release_auto_play_ready_gate("tokenization-ready")
if state.suppress_ready_overlay_restore then
local force_ready_overlay_restore = state.force_ready_overlay_restore == true
state.force_ready_overlay_restore = false
if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then
return
end
if state.overlay_running and resolve_visible_overlay_startup() then
if force_ready_overlay_restore then
state.suppress_ready_overlay_restore = false
end
if state.overlay_running and (force_ready_overlay_restore or resolve_visible_overlay_startup()) then
run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path,
})
@@ -186,7 +207,9 @@ function M.create(ctx)
end
if action == "start" then
table.insert(args, "--background")
if overrides.background ~= false then
table.insert(args, "--background")
end
table.insert(args, "--managed-playback")
local backend = resolve_backend(overrides.backend)
@@ -199,7 +222,10 @@ function M.create(ctx)
table.insert(args, "--socket")
table.insert(args, socket_path)
local should_show_visible = resolve_visible_overlay_startup()
local should_show_visible = overrides.show_visible_overlay
if should_show_visible == nil then
should_show_visible = resolve_visible_overlay_startup()
end
if should_show_visible then
table.insert(args, "--show-visible-overlay")
else
@@ -215,12 +241,75 @@ function M.create(ctx)
return args
end
local function is_appimage_binary(path)
return environment.is_linux() and type(path) == "string" and path:lower():match("%.appimage$") ~= nil
end
local function append_transport_env(env, args)
local count = math.max(#args - 1, 0)
env[#env + 1] = APP_ARGC_ENV .. "=" .. tostring(count)
for index = 2, #args do
env[#env + 1] = APP_ARG_PREFIX .. tostring(index - 2) .. "=" .. tostring(args[index])
end
end
local function env_has_name(env, name)
local prefix = name .. "="
for _, value in ipairs(env) do
if type(value) == "string" and value:sub(1, #prefix) == prefix then
return true
end
end
return false
end
local function append_default_app_log_env(env)
local log_dir = environment.join_path(environment.resolve_subminer_config_dir(), "logs")
local date = os.date("%Y-%m-%d")
if not env_has_name(env, "SUBMINER_APP_LOG") then
env[#env + 1] = "SUBMINER_APP_LOG=" .. environment.join_path(log_dir, "app-" .. date .. ".log")
end
if not env_has_name(env, "SUBMINER_MPV_LOG") then
env[#env + 1] = "SUBMINER_MPV_LOG=" .. environment.join_path(log_dir, "mpv-" .. date .. ".log")
end
end
local function build_appimage_subprocess_env(args)
local env = {}
if utils and type(utils.get_env_list) == "function" then
for _, value in ipairs(utils.get_env_list()) do
if
type(value) == "string"
and not value:match("^" .. APP_ARGC_ENV .. "=")
and not value:match("^" .. APP_ARG_PREFIX .. "%d+=")
then
env[#env + 1] = value
end
end
end
append_default_app_log_env(env)
append_transport_env(env, args)
return env
end
local function build_subprocess_command(args)
if is_appimage_binary(args[1]) then
return {
args = { args[1] },
env = build_appimage_subprocess_env(args),
}
end
return { args = args }
end
run_control_command_async = function(action, overrides, callback)
local args = build_command_args(action, overrides)
local command = build_subprocess_command(args)
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
mp.command_native_async({
name = "subprocess",
args = args,
args = command.args,
env = command.env,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
@@ -232,11 +321,36 @@ function M.create(ctx)
end)
end
local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt)
attempt = attempt or 1
run_control_command_async("app-ping", nil, function(_ok, result)
local status = result and result.status
local is_running = status == 0
local is_not_running = status == 1
if (expected_running and is_running) or ((not expected_running) and is_not_running) then
on_ready()
return
end
if attempt >= OVERLAY_RESTART_PING_MAX_ATTEMPTS then
subminer_log("warn", "process", "Timed out waiting for SubMiner app to " .. label)
if on_timeout then
on_timeout()
end
return
end
mp.add_timeout(OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS, function()
wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt + 1)
end)
end)
end
local function run_binary_command_async(args, callback)
local command = build_subprocess_command(args)
subminer_log("debug", "process", "Binary command: " .. table.concat(args, " "))
mp.command_native_async({
name = "subprocess",
args = args,
args = command.args,
env = command.env,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
@@ -299,7 +413,15 @@ function M.create(ctx)
if overrides.auto_start_trigger == true then
subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
local socket_path = overrides.socket_path or opts.socket_path
if not state.auto_play_ready_gate_armed then
local should_pause_until_ready = (
overrides.rearm_pause_until_ready == true
and resolve_visible_overlay_startup()
and resolve_pause_until_ready()
and has_matching_mpv_ipc_socket(socket_path)
)
if should_pause_until_ready then
arm_auto_play_ready_gate()
elseif not state.auto_play_ready_gate_armed then
disarm_auto_play_ready_gate()
end
local visibility_action = resolve_visible_overlay_startup()
@@ -347,9 +469,11 @@ function M.create(ctx)
end
state.overlay_running = true
local command = build_subprocess_command(args)
mp.command_native_async({
name = "subprocess",
args = args,
args = command.args,
env = command.env,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
@@ -383,10 +507,13 @@ function M.create(ctx)
end)
end
launch_overlay_with_retry(1)
if texthooker_enabled then
ensure_texthooker_running(function() end)
end
environment.is_subminer_app_running_async(function(app_running)
overrides.background = not app_running
launch_overlay_with_retry(1)
if texthooker_enabled then
ensure_texthooker_running(function() end)
end
end, { force_refresh = true })
end
local function start_overlay_from_script_message(...)
@@ -501,38 +628,61 @@ function M.create(ctx)
subminer_log("info", "process", "Restarting overlay...")
show_osd("Restarting...")
run_control_command_async("stop", nil, function()
run_control_command_async("stop", nil, function(ok, result)
if not ok then
local reason = result and result.stderr or "unknown error"
subminer_log("warn", "process", "Restart stop command failed: " .. reason)
show_osd("Restart failed")
return
end
state.overlay_running = false
state.texthooker_running = false
disarm_auto_play_ready_gate()
state.suppress_ready_overlay_restore = false
state.force_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
local start_args = build_command_args("start")
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
wait_for_app_ping_state(false, "release the single-instance lock", function()
local start_args = build_command_args("start", {
show_visible_overlay = true,
})
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
state.overlay_running = true
mp.command_native_async({
name = "subprocess",
args = start_args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
if not success or (result and result.status ~= 0) then
state.overlay_running = false
subminer_log(
"error",
"process",
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
)
show_osd("Restart failed")
else
show_osd("Restarted successfully")
state.overlay_running = true
local command = build_subprocess_command(start_args)
mp.command_native_async({
name = "subprocess",
args = command.args,
env = command.env,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
if not success or (result and result.status ~= 0) then
state.overlay_running = false
subminer_log(
"error",
"process",
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
)
show_osd("Restart failed")
else
wait_for_app_ping_state(true, "own the single-instance lock", function()
run_control_command_async("show-visible-overlay")
show_osd("Restarted successfully")
end, function()
run_control_command_async("show-visible-overlay")
show_osd("Restarted successfully")
end)
end
end)
if resolve_texthooker_enabled(nil) then
ensure_texthooker_running(function() end)
end
end, function()
show_osd("Restart failed")
end)
if resolve_texthooker_enabled(nil) then
ensure_texthooker_running(function() end)
end
end)
end
+2
View File
@@ -151,6 +151,8 @@ function M.create(ctx)
return { "--toggle-subtitle-sidebar" }
elseif action_id == "markAudioCard" then
return { "--mark-audio-card" }
elseif action_id == "markWatched" then
return { "--mark-watched" }
elseif action_id == "openRuntimeOptions" then
return { "--open-runtime-options" }
elseif action_id == "openJimaku" then
+3
View File
@@ -30,11 +30,14 @@ function M.new()
prompt_shown = false,
},
auto_play_ready_gate_armed = false,
auto_play_ready_should_resume_playback = false,
auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false,
force_ready_overlay_restore = false,
current_media_identity = nil,
pending_reload_media_identity = nil,
auto_start_retry_generation = 0,
session_binding_generation = 0,
session_binding_names = {},
session_numeric_binding_names = {},