mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
b1bdeabca8
* fix(jellyfin): show overlay, inject plugin, and fix stats title on playb - Show visible overlay automatically during Jellyfin playback so subtitleStyle applies - Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus - Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles - Mark ffsubsync unavailable in subsync modal for remote media paths - Drain queued second-instance commands even when onReady throws * fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause - Keep overlay visible during macOS foreground probe after overlay blur - Hold sidebar hover-pause while a Yomitan lookup popup remains open * fix(jellyfin): fix discovery loop, device identity, tray state, and Disc - Derive device identity from OS hostname; remove legacy configurable client/device fields - Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores - Restart stale tray discovery sessions without re-login when server drops SubMiner cast target - Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes - Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads - Fix picker library discovery when log level is above info - Fix config.example.jsonc trailing commas and array formatting * docs(release): trim and consolidate prerelease notes for 0.15.0 - Remove breaking changes section and several redundant bullet points - Consolidate per-platform updater notes into a single entry - Normalize em-dash separators to hyphens in section headers * fix(config): remove trailing commas from config.example.jsonc - Strip trailing commas throughout both config.example.jsonc copies - Reformat inline arrays to multi-line for JSON strictness - Update Jellyfin subtitle preload and playback launch tests and impl * fix(tokenizer): preserve known-word highlight when POS filters suppress - Known-word cache matches now set isKnown=true even for tokens excluded by POS filters - POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate - Jellyfin subtitle preload continues after cleanup failures instead of aborting - Update config docs and option description to document the known-word bypass behavior * fix(jellyfin): send explicit hide/show overlay instead of toggle - Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known - Prevent paused Jellyfin playback from resuming on overlay hide - Fix subtitle cache cleanup to only remove dirs after successful cleanup * fix(jellyfin): fix remote progress sync, seek reporting, and startup sto - arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events - force immediate progress report on seek-like position jumps at the mpv time-pos level - send positionTicks and failed=false in reportStopped payload - remove EventName from HTTP timeline payloads (websocket-only field) - add startup grace window to drop stop events before media finishes loading * fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi - Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift - Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress - Preserve manual hide across Jellyfin path-changing redirects even when media-title drops - Rearm managed subtitle defaults on path-changing redirects - Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC - Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus - Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary - Add stats window layer management so delete/update dialogs appear above stats window - Fix Jellyfin remote progress sync during Linux websocket reconnect windows * Fix CodeRabbit review feedback * fix(jellyfin): subtitle timing, resume progress, and overlay sync - Add per-stream subtitle delay persistence and auto timeline-offset correction - Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload - Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports - Keep Play vs Resume distinct to avoid early seek race on normal play - Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress - Deduplicate show/hide overlay commands using recorded visibility state - Rewrite docs-site Jellyfin page around cast-to-device UX * test: update lifecycle cleanup assertion * fix: clear aborted playback state, fix overlay passthrough, and guard du - Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item - Record visible overlay action only after command succeeds, not before - Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering) - Defer activeParsedSubtitleMediaPath assignment until after prefetch completes - Move autoplay gate release into the hide branch of toggleVisibleOverlay - Clear active Jellyfin playback when stopping media that never loaded - Reset managed subtitle delay and delay key when no external tracks are available - Await async removeDir in subtitle cache cleanup - Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs - Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
336 lines
8.9 KiB
Lua
336 lines
8.9 KiB
Lua
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
|
|
|
|
if type(key.code) ~= "string" then
|
|
return nil
|
|
end
|
|
if type(key.modifiers) ~= "table" then
|
|
return nil
|
|
end
|
|
|
|
local shifted_letter = key.code:match("^Key([A-Z])$")
|
|
local has_shift = false
|
|
for _, modifier in ipairs(key.modifiers) do
|
|
if modifier == "shift" then
|
|
has_shift = true
|
|
break
|
|
end
|
|
end
|
|
|
|
local key_name = key_code_to_mpv_name(key.code)
|
|
if shifted_letter and has_shift then
|
|
key_name = shifted_letter
|
|
end
|
|
if not key_name then
|
|
return nil
|
|
end
|
|
|
|
local parts = {}
|
|
for _, modifier in ipairs(key.modifiers) do
|
|
if not (modifier == "shift" and shifted_letter) then
|
|
local mapped = MODIFIER_MAP[modifier]
|
|
if mapped then
|
|
parts[#parts + 1] = mapped
|
|
end
|
|
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 == "toggleStatsOverlay" then
|
|
return { "--toggle-stats-overlay" }
|
|
elseif action_id == "copySubtitle" then
|
|
return { "--copy-subtitle" }
|
|
elseif action_id == "copySubtitleMultiple" then
|
|
if payload and payload.count then
|
|
return { "--copy-subtitle-count", tostring(payload.count) }
|
|
end
|
|
return { "--copy-subtitle-multiple" }
|
|
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
|
|
if payload and payload.count then
|
|
return { "--mine-sentence-count", tostring(payload.count) }
|
|
end
|
|
return { "--mine-sentence-multiple" }
|
|
elseif action_id == "toggleSecondarySub" then
|
|
return { "--toggle-secondary-sub" }
|
|
elseif action_id == "toggleSubtitleSidebar" then
|
|
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
|
|
return { "--open-jimaku" }
|
|
elseif action_id == "openYoutubePicker" then
|
|
return { "--open-youtube-picker" }
|
|
elseif action_id == "openSessionHelp" then
|
|
return { "--open-session-help" }
|
|
elseif action_id == "openCharacterDictionary" then
|
|
return { "--open-character-dictionary" }
|
|
elseif action_id == "openControllerSelect" then
|
|
return { "--open-controller-select" }
|
|
elseif action_id == "openControllerDebug" then
|
|
return { "--open-controller-debug" }
|
|
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" }
|
|
elseif action_id == "cycleRuntimeOption" then
|
|
local runtime_option_id = payload and payload.runtimeOptionId or nil
|
|
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
|
|
return nil
|
|
end
|
|
local direction = payload and payload.direction == -1 and "prev" or "next"
|
|
return { "--cycle-runtime-option", runtime_option_id .. ":" .. direction }
|
|
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
|
|
local runner = process.run_binary_command_async
|
|
if type(runner) ~= "function" then
|
|
runner = function(binary_args, callback)
|
|
mp.command_native_async({
|
|
name = "subprocess",
|
|
args = binary_args,
|
|
playback_only = false,
|
|
capture_stdout = true,
|
|
capture_stderr = true,
|
|
}, function(success, result, error)
|
|
local ok = success and (result == nil or result.status == 0)
|
|
if callback then
|
|
callback(ok, result, error)
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
runner(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 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)
|
|
if binding.actionType == "mpv-command" then
|
|
execute_mpv_command(binding.command)
|
|
return
|
|
end
|
|
|
|
if binding.actionId == "toggleVisibleOverlay" and type(process.toggle_overlay) == "function" then
|
|
process.toggle_overlay()
|
|
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()
|
|
remove_binding_names(state.session_binding_names)
|
|
end
|
|
|
|
local function register_bindings()
|
|
local artifact, load_error = load_artifact()
|
|
if not artifact then
|
|
subminer_log("warn", "session-bindings", load_error)
|
|
return false
|
|
end
|
|
|
|
local previous_binding_names = state.session_binding_names
|
|
local next_binding_names = {}
|
|
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
|
local generation = state.session_binding_generation
|
|
|
|
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(generation) .. "-" .. tostring(index)
|
|
next_binding_names[#next_binding_names + 1] = name
|
|
mp.add_forced_key_binding(key_name, name, function()
|
|
handle_binding(binding)
|
|
end)
|
|
else
|
|
subminer_log(
|
|
"warn",
|
|
"session-bindings",
|
|
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
|
|
)
|
|
end
|
|
end
|
|
|
|
remove_binding_names(previous_binding_names)
|
|
state.session_binding_names = next_binding_names
|
|
|
|
subminer_log(
|
|
"info",
|
|
"session-bindings",
|
|
"Registered " .. tostring(#next_binding_names) .. " shared session bindings"
|
|
)
|
|
return true
|
|
end
|
|
|
|
local function reload_bindings()
|
|
return register_bindings()
|
|
end
|
|
|
|
return {
|
|
register_bindings = register_bindings,
|
|
reload_bindings = reload_bindings,
|
|
clear_bindings = clear_bindings,
|
|
}
|
|
end
|
|
|
|
return M
|