mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
fix(mpv): stabilize hover token subtitle highlighting
# Conflicts: # src/core/services/ipc.ts # src/main.ts
This commit is contained in:
@@ -77,8 +77,23 @@ local state = {
|
|||||||
binary_path = nil,
|
binary_path = nil,
|
||||||
detected_backend = nil,
|
detected_backend = nil,
|
||||||
invisible_overlay_visible = false,
|
invisible_overlay_visible = false,
|
||||||
|
hover_highlight = {
|
||||||
|
revision = -1,
|
||||||
|
payload = nil,
|
||||||
|
saved_sub_visibility = nil,
|
||||||
|
saved_secondary_sub_visibility = nil,
|
||||||
|
overlay_active = false,
|
||||||
|
cached_ass = nil,
|
||||||
|
clear_timer = nil,
|
||||||
|
last_hover_update_ts = 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local HOVER_MESSAGE_NAME = "subminer-hover-token"
|
||||||
|
local HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token"
|
||||||
|
local DEFAULT_HOVER_BASE_COLOR = "FFFFFF"
|
||||||
|
local DEFAULT_HOVER_COLOR = "E7C06A"
|
||||||
|
|
||||||
local LOG_LEVEL_PRIORITY = {
|
local LOG_LEVEL_PRIORITY = {
|
||||||
debug = 10,
|
debug = 10,
|
||||||
info = 20,
|
info = 20,
|
||||||
@@ -123,6 +138,435 @@ local function show_osd(message)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function to_hex_color(input)
|
||||||
|
if type(input) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local hex = input:gsub("[%#%']", ""):gsub("^0x", "")
|
||||||
|
if #hex ~= 6 and #hex ~= 3 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #hex == 3 then
|
||||||
|
return hex:sub(1, 1) .. hex:sub(1, 1) .. hex:sub(2, 2) .. hex:sub(2, 2) .. hex:sub(3, 3) .. hex:sub(3, 3)
|
||||||
|
end
|
||||||
|
return hex
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fix_ass_color(input, fallback)
|
||||||
|
local hex = to_hex_color(input)
|
||||||
|
if not hex then
|
||||||
|
return fallback or DEFAULT_HOVER_BASE_COLOR
|
||||||
|
end
|
||||||
|
local r, g, b = hex:sub(1, 2), hex:sub(3, 4), hex:sub(5, 6)
|
||||||
|
return b .. g .. r
|
||||||
|
end
|
||||||
|
|
||||||
|
local function escape_ass_text(text)
|
||||||
|
return (text or "")
|
||||||
|
:gsub("\\", "\\\\")
|
||||||
|
:gsub("{", "\\{")
|
||||||
|
:gsub("}", "\\}")
|
||||||
|
:gsub("\n", "\\N")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_osd_dimensions()
|
||||||
|
local width = mp.get_property_number("osd-width", 0) or 0
|
||||||
|
local height = mp.get_property_number("osd-height", 0) or 0
|
||||||
|
|
||||||
|
if width <= 0 or height <= 0 then
|
||||||
|
local osd_dims = mp.get_property_native("osd-dimensions")
|
||||||
|
if type(osd_dims) == "table" and type(osd_dims.w) == "number" and osd_dims.w > 0 then
|
||||||
|
width = osd_dims.w
|
||||||
|
end
|
||||||
|
if type(osd_dims) == "table" and type(osd_dims.h) == "number" and osd_dims.h > 0 then
|
||||||
|
height = osd_dims.h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if width <= 0 then
|
||||||
|
width = 1280
|
||||||
|
end
|
||||||
|
if height <= 0 then
|
||||||
|
height = 720
|
||||||
|
end
|
||||||
|
|
||||||
|
return width, height
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_metrics()
|
||||||
|
local sub_font_size = mp.get_property_number("sub-font-size", 36) or 36
|
||||||
|
local sub_scale = mp.get_property_number("sub-scale", 1) or 1
|
||||||
|
local sub_scale_by_window = mp.get_property_bool("sub-scale-by-window", true) == true
|
||||||
|
local sub_pos = mp.get_property_number("sub-pos", 100) or 100
|
||||||
|
local sub_margin_y = mp.get_property_number("sub-margin-y", 0) or 0
|
||||||
|
local sub_font = mp.get_property("sub-font", "sans-serif") or "sans-serif"
|
||||||
|
local sub_spacing = mp.get_property_number("sub-spacing", 0) or 0
|
||||||
|
local sub_bold = mp.get_property_bool("sub-bold", false) == true
|
||||||
|
local sub_italic = mp.get_property_bool("sub-italic", false) == true
|
||||||
|
local sub_border_size = mp.get_property_number("sub-border-size", 2) or 2
|
||||||
|
local sub_shadow_offset = mp.get_property_number("sub-shadow-offset", 0) or 0
|
||||||
|
local osd_w, osd_h = resolve_osd_dimensions()
|
||||||
|
local window_scale = 1
|
||||||
|
if sub_scale_by_window and osd_h > 0 then
|
||||||
|
window_scale = osd_h / 720
|
||||||
|
end
|
||||||
|
local effective_margin_y = sub_margin_y * window_scale
|
||||||
|
|
||||||
|
return {
|
||||||
|
font_size = sub_font_size * (sub_scale > 0 and sub_scale or 1) * window_scale,
|
||||||
|
pos = sub_pos,
|
||||||
|
margin_y = effective_margin_y,
|
||||||
|
font = sub_font,
|
||||||
|
spacing = sub_spacing,
|
||||||
|
bold = sub_bold,
|
||||||
|
italic = sub_italic,
|
||||||
|
border = sub_border_size * window_scale,
|
||||||
|
shadow = sub_shadow_offset * window_scale,
|
||||||
|
base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR),
|
||||||
|
hover_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_COLOR),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_subtitle_ass_property()
|
||||||
|
local ass_text = mp.get_property("sub-text/ass")
|
||||||
|
if type(ass_text) == "string" and ass_text ~= "" then
|
||||||
|
return ass_text
|
||||||
|
end
|
||||||
|
|
||||||
|
ass_text = mp.get_property("sub-text-ass")
|
||||||
|
if type(ass_text) == "string" and ass_text ~= "" then
|
||||||
|
return ass_text
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function plain_text_and_ass_map(text)
|
||||||
|
local plain = {}
|
||||||
|
local map = {}
|
||||||
|
local plain_len = 0
|
||||||
|
local i = 1
|
||||||
|
local text_len = #text
|
||||||
|
|
||||||
|
while i <= text_len do
|
||||||
|
local ch = text:sub(i, i)
|
||||||
|
if ch == "{" then
|
||||||
|
local close = text:find("}", i + 1, true)
|
||||||
|
if not close then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
i = close + 1
|
||||||
|
elseif ch == "\\" then
|
||||||
|
local esc = text:sub(i + 1, i + 1)
|
||||||
|
if esc == "N" or esc == "n" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "\n"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "h" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = " "
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "{" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "{"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "}" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "}"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "\\" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "\\"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
else
|
||||||
|
local seq_end = i + 1
|
||||||
|
while seq_end <= text_len and text:sub(seq_end, seq_end):match("[%a]") do
|
||||||
|
seq_end = seq_end + 1
|
||||||
|
end
|
||||||
|
if text:sub(seq_end, seq_end) == "(" then
|
||||||
|
local close = text:find(")", seq_end, true)
|
||||||
|
if close then
|
||||||
|
i = close + 1
|
||||||
|
else
|
||||||
|
i = seq_end + 1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
i = seq_end + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = ch
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(plain), map
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_hover_span(payload, plain)
|
||||||
|
local source_len = #plain
|
||||||
|
local cursor = 1
|
||||||
|
for _, token in ipairs(payload.tokens or {}) do
|
||||||
|
if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local token_text = token.text
|
||||||
|
local start_pos = nil
|
||||||
|
local end_pos = nil
|
||||||
|
|
||||||
|
if type(token.startPos) == "number" and type(token.endPos) == "number" then
|
||||||
|
if token.startPos >= 0 and token.endPos >= token.startPos then
|
||||||
|
local candidate_start = token.startPos + 1
|
||||||
|
local candidate_stop = token.endPos
|
||||||
|
if
|
||||||
|
candidate_start >= 1
|
||||||
|
and candidate_stop <= source_len
|
||||||
|
and candidate_stop >= candidate_start
|
||||||
|
and plain:sub(candidate_start, candidate_stop) == token_text
|
||||||
|
then
|
||||||
|
start_pos = candidate_start
|
||||||
|
end_pos = candidate_stop
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not start_pos or not end_pos then
|
||||||
|
local fallback_start, fallback_stop = plain:find(token_text, cursor, true)
|
||||||
|
if not fallback_start then
|
||||||
|
fallback_start, fallback_stop = plain:find(token_text, 1, true)
|
||||||
|
end
|
||||||
|
start_pos, end_pos = fallback_start, fallback_stop
|
||||||
|
end
|
||||||
|
|
||||||
|
if start_pos and end_pos then
|
||||||
|
if token.index == payload.hoveredTokenIndex then
|
||||||
|
return start_pos, end_pos
|
||||||
|
end
|
||||||
|
cursor = end_pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inject_hover_color_to_ass(raw_ass, plain_map, hover_start, hover_end, hover_color, base_color)
|
||||||
|
if hover_start == nil or hover_end == nil then
|
||||||
|
return raw_ass
|
||||||
|
end
|
||||||
|
|
||||||
|
local raw_open_idx = plain_map[hover_start] or 1
|
||||||
|
local raw_close_idx = plain_map[hover_end + 1] or (#raw_ass + 1)
|
||||||
|
if raw_open_idx < 1 then
|
||||||
|
raw_open_idx = 1
|
||||||
|
end
|
||||||
|
if raw_close_idx < 1 then
|
||||||
|
raw_close_idx = 1
|
||||||
|
end
|
||||||
|
if raw_open_idx > #raw_ass + 1 then
|
||||||
|
raw_open_idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
if raw_close_idx > #raw_ass + 1 then
|
||||||
|
raw_close_idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local open_tag = string.format("{\\1c&H%s&}", hover_color)
|
||||||
|
local close_tag = string.format("{\\1c&H%s&}", base_color)
|
||||||
|
local changes = {
|
||||||
|
{ idx = raw_open_idx, tag = open_tag },
|
||||||
|
{ idx = raw_close_idx, tag = close_tag },
|
||||||
|
}
|
||||||
|
table.sort(changes, function(a, b)
|
||||||
|
return a.idx < b.idx
|
||||||
|
end)
|
||||||
|
|
||||||
|
local output = {}
|
||||||
|
local cursor = 1
|
||||||
|
for _, change in ipairs(changes) do
|
||||||
|
if change.idx > #raw_ass + 1 then
|
||||||
|
change.idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
if change.idx < 1 then
|
||||||
|
change.idx = 1
|
||||||
|
end
|
||||||
|
if change.idx > cursor then
|
||||||
|
output[#output + 1] = raw_ass:sub(cursor, change.idx - 1)
|
||||||
|
end
|
||||||
|
output[#output + 1] = change.tag
|
||||||
|
cursor = change.idx
|
||||||
|
end
|
||||||
|
if cursor <= #raw_ass then
|
||||||
|
output[#output + 1] = raw_ass:sub(cursor)
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(output)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_hover_subtitle_content(payload)
|
||||||
|
local source_ass = get_subtitle_ass_property()
|
||||||
|
if type(source_ass) == "string" and source_ass ~= "" then
|
||||||
|
state.hover_highlight.cached_ass = source_ass
|
||||||
|
else
|
||||||
|
source_ass = state.hover_highlight.cached_ass
|
||||||
|
end
|
||||||
|
if type(source_ass) ~= "string" or source_ass == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local plain_source, plain_map = plain_text_and_ass_map(source_ass)
|
||||||
|
if type(plain_source) ~= "string" or plain_source == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local hover_start, hover_end = find_hover_span(payload, plain_source)
|
||||||
|
if not hover_start or not hover_end then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local metrics = resolve_metrics()
|
||||||
|
local hover_color = fix_ass_color(payload.colors and payload.colors.hover or nil, metrics.hover_color)
|
||||||
|
local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color)
|
||||||
|
return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_hover_overlay()
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
if state.hover_highlight.overlay_active then
|
||||||
|
if type(state.hover_highlight.saved_sub_visibility) == "string" then
|
||||||
|
mp.set_property("sub-visibility", state.hover_highlight.saved_sub_visibility)
|
||||||
|
else
|
||||||
|
mp.set_property("sub-visibility", "yes")
|
||||||
|
end
|
||||||
|
if type(state.hover_highlight.saved_secondary_sub_visibility) == "string" then
|
||||||
|
mp.set_property("secondary-sub-visibility", state.hover_highlight.saved_secondary_sub_visibility)
|
||||||
|
end
|
||||||
|
state.hover_highlight.saved_sub_visibility = nil
|
||||||
|
state.hover_highlight.saved_secondary_sub_visibility = nil
|
||||||
|
state.hover_highlight.overlay_active = false
|
||||||
|
end
|
||||||
|
mp.set_osd_ass(0, 0, "")
|
||||||
|
state.hover_highlight.payload = nil
|
||||||
|
state.hover_highlight.revision = -1
|
||||||
|
state.hover_highlight.cached_ass = nil
|
||||||
|
state.hover_highlight.last_hover_update_ts = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function schedule_hover_clear(delay_seconds)
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
state.hover_highlight.clear_timer = mp.add_timeout(delay_seconds or 0.08, function()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render_hover_overlay(payload)
|
||||||
|
if not payload or payload.hoveredTokenIndex == nil or payload.subtitle == nil then
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local ass = build_hover_subtitle_content(payload)
|
||||||
|
if not ass then
|
||||||
|
-- Transient parse/mapping miss; keep previous frame to avoid flicker.
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local osd_w, osd_h = resolve_osd_dimensions()
|
||||||
|
local metrics = resolve_metrics()
|
||||||
|
local osd_dims = mp.get_property_native("osd-dimensions")
|
||||||
|
local ml = (type(osd_dims) == "table" and type(osd_dims.ml) == "number") and osd_dims.ml or 0
|
||||||
|
local mr = (type(osd_dims) == "table" and type(osd_dims.mr) == "number") and osd_dims.mr or 0
|
||||||
|
local mt = (type(osd_dims) == "table" and type(osd_dims.mt) == "number") and osd_dims.mt or 0
|
||||||
|
local mb = (type(osd_dims) == "table" and type(osd_dims.mb) == "number") and osd_dims.mb or 0
|
||||||
|
local usable_w = math.max(1, osd_w - ml - mr)
|
||||||
|
local usable_h = math.max(1, osd_h - mt - mb)
|
||||||
|
local anchor_x = math.floor(ml + usable_w / 2)
|
||||||
|
local baseline_adjust = (metrics.border + metrics.shadow) * 5
|
||||||
|
local anchor_y = math.floor(mt + (usable_h * metrics.pos / 100) - metrics.margin_y + baseline_adjust)
|
||||||
|
local font_size = math.max(8, metrics.font_size)
|
||||||
|
local anchor_tag = string.format(
|
||||||
|
"{\\an2\\q2\\pos(%d,%d)\\fn%s\\fs%g\\b%d\\i%d\\fsp%g\\bord%g\\shad%g\\1c&H%s&}",
|
||||||
|
anchor_x,
|
||||||
|
anchor_y,
|
||||||
|
escape_ass_text(metrics.font),
|
||||||
|
font_size,
|
||||||
|
metrics.bold and 1 or 0,
|
||||||
|
metrics.italic and 1 or 0,
|
||||||
|
metrics.spacing,
|
||||||
|
metrics.border,
|
||||||
|
metrics.shadow,
|
||||||
|
metrics.base_color
|
||||||
|
)
|
||||||
|
if not state.hover_highlight.overlay_active then
|
||||||
|
state.hover_highlight.saved_sub_visibility = mp.get_property("sub-visibility")
|
||||||
|
state.hover_highlight.saved_secondary_sub_visibility = mp.get_property("secondary-sub-visibility")
|
||||||
|
mp.set_property("sub-visibility", "no")
|
||||||
|
mp.set_property("secondary-sub-visibility", "no")
|
||||||
|
state.hover_highlight.overlay_active = true
|
||||||
|
end
|
||||||
|
mp.set_osd_ass(osd_w, osd_h, anchor_tag .. ass)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handle_hover_message(payload_json)
|
||||||
|
local parsed, parse_error = utils.parse_json(payload_json)
|
||||||
|
if not parsed then
|
||||||
|
msg.warn("Invalid hover-highlight payload: " .. tostring(parse_error))
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(parsed.revision) ~= "number" then
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if parsed.revision < state.hover_highlight.revision then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(parsed.hoveredTokenIndex) == "number" and type(parsed.tokens) == "table" then
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
state.hover_highlight.revision = parsed.revision
|
||||||
|
state.hover_highlight.payload = parsed
|
||||||
|
state.hover_highlight.last_hover_update_ts = mp.get_time() or 0
|
||||||
|
render_hover_overlay(state.hover_highlight.payload)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = mp.get_time() or 0
|
||||||
|
local elapsed_since_hover = now - (state.hover_highlight.last_hover_update_ts or 0)
|
||||||
|
state.hover_highlight.revision = parsed.revision
|
||||||
|
state.hover_highlight.payload = nil
|
||||||
|
if state.hover_highlight.overlay_active then
|
||||||
|
if elapsed_since_hover > 0.35 then
|
||||||
|
-- Ignore stale null-hover updates while pointer is stationary.
|
||||||
|
return
|
||||||
|
end
|
||||||
|
schedule_hover_clear(0.08)
|
||||||
|
else
|
||||||
|
clear_hover_overlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function detect_backend()
|
local function detect_backend()
|
||||||
if state.detected_backend then
|
if state.detected_backend then
|
||||||
return state.detected_backend
|
return state.detected_backend
|
||||||
@@ -819,6 +1263,12 @@ local function register_script_messages()
|
|||||||
mp.register_script_message("subminer-options", open_options)
|
mp.register_script_message("subminer-options", open_options)
|
||||||
mp.register_script_message("subminer-restart", restart_overlay)
|
mp.register_script_message("subminer-restart", restart_overlay)
|
||||||
mp.register_script_message("subminer-status", check_status)
|
mp.register_script_message("subminer-status", check_status)
|
||||||
|
mp.register_script_message(HOVER_MESSAGE_NAME, function(payload_json)
|
||||||
|
handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
mp.register_script_message(HOVER_MESSAGE_NAME_LEGACY, function(payload_json)
|
||||||
|
handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function init()
|
local function init()
|
||||||
@@ -827,6 +1277,12 @@ local function init()
|
|||||||
|
|
||||||
mp.register_event("file-loaded", on_file_loaded)
|
mp.register_event("file-loaded", on_file_loaded)
|
||||||
mp.register_event("shutdown", on_shutdown)
|
mp.register_event("shutdown", on_shutdown)
|
||||||
|
mp.register_event("file-loaded", clear_hover_overlay)
|
||||||
|
mp.register_event("end-file", clear_hover_overlay)
|
||||||
|
mp.register_event("shutdown", clear_hover_overlay)
|
||||||
|
mp.observe_property("sub-start", "native", function()
|
||||||
|
clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
|
||||||
subminer_log("info", "lifecycle", "SubMiner plugin loaded")
|
subminer_log("info", "lifecycle", "SubMiner plugin loaded")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
setRuntimeOption: () => ({ ok: true }),
|
setRuntimeOption: () => ({ ok: true }),
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
reportOverlayContentBounds: () => {},
|
reportOverlayContentBounds: () => {},
|
||||||
|
reportHoveredSubtitleToken: () => {},
|
||||||
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
|
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
|
||||||
clearAnilistToken: () => {
|
clearAnilistToken: () => {
|
||||||
calls.push('clearAnilistToken');
|
calls.push('clearAnilistToken');
|
||||||
@@ -137,6 +138,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
reportOverlayContentBounds: () => {},
|
reportOverlayContentBounds: () => {},
|
||||||
|
reportHoveredSubtitleToken: () => {},
|
||||||
getAnilistStatus: () => ({}),
|
getAnilistStatus: () => ({}),
|
||||||
clearAnilistToken: () => {},
|
clearAnilistToken: () => {},
|
||||||
openAnilistSetup: () => {},
|
openAnilistSetup: () => {},
|
||||||
@@ -212,6 +214,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
setRuntimeOption: () => ({ ok: true }),
|
setRuntimeOption: () => ({ ok: true }),
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
reportOverlayContentBounds: () => {},
|
reportOverlayContentBounds: () => {},
|
||||||
|
reportHoveredSubtitleToken: () => {},
|
||||||
getAnilistStatus: () => ({}),
|
getAnilistStatus: () => ({}),
|
||||||
clearAnilistToken: () => {},
|
clearAnilistToken: () => {},
|
||||||
openAnilistSetup: () => {},
|
openAnilistSetup: () => {},
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface IpcServiceDeps {
|
|||||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||||
reportOverlayContentBounds: (payload: unknown) => void;
|
reportOverlayContentBounds: (payload: unknown) => void;
|
||||||
|
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||||
getAnilistStatus: () => unknown;
|
getAnilistStatus: () => unknown;
|
||||||
clearAnilistToken: () => void;
|
clearAnilistToken: () => void;
|
||||||
openAnilistSetup: () => void;
|
openAnilistSetup: () => void;
|
||||||
@@ -118,6 +119,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||||
reportOverlayContentBounds: (payload: unknown) => void;
|
reportOverlayContentBounds: (payload: unknown) => void;
|
||||||
|
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||||
getAnilistStatus: () => unknown;
|
getAnilistStatus: () => unknown;
|
||||||
clearAnilistToken: () => void;
|
clearAnilistToken: () => void;
|
||||||
openAnilistSetup: () => void;
|
openAnilistSetup: () => void;
|
||||||
@@ -180,6 +182,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
setRuntimeOption: options.setRuntimeOption,
|
setRuntimeOption: options.setRuntimeOption,
|
||||||
cycleRuntimeOption: options.cycleRuntimeOption,
|
cycleRuntimeOption: options.cycleRuntimeOption,
|
||||||
reportOverlayContentBounds: options.reportOverlayContentBounds,
|
reportOverlayContentBounds: options.reportOverlayContentBounds,
|
||||||
|
reportHoveredSubtitleToken: options.reportHoveredSubtitleToken,
|
||||||
getAnilistStatus: options.getAnilistStatus,
|
getAnilistStatus: options.getAnilistStatus,
|
||||||
clearAnilistToken: options.clearAnilistToken,
|
clearAnilistToken: options.clearAnilistToken,
|
||||||
openAnilistSetup: options.openAnilistSetup,
|
openAnilistSetup: options.openAnilistSetup,
|
||||||
@@ -355,6 +358,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
deps.reportOverlayContentBounds(payload);
|
deps.reportOverlayContentBounds(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.on('subtitle-token-hover:set', (_event: unknown, tokenIndex: unknown) => {
|
||||||
|
if (tokenIndex === null) {
|
||||||
|
deps.reportHoveredSubtitleToken(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(tokenIndex) || (tokenIndex as number) < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.reportHoveredSubtitleToken(tokenIndex as number);
|
||||||
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
|
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
|
||||||
return deps.getAnilistStatus();
|
return deps.getAnilistStatus();
|
||||||
});
|
});
|
||||||
|
|||||||
36
src/main.ts
36
src/main.ts
@@ -92,8 +92,16 @@ import { createBuildRefreshAnilistClientSecretStateMainDepsHandler } from './mai
|
|||||||
import {
|
import {
|
||||||
getConfiguredJellyfinSession,
|
getConfiguredJellyfinSession,
|
||||||
type ActiveJellyfinRemotePlaybackState,
|
type ActiveJellyfinRemotePlaybackState,
|
||||||
|
createReportJellyfinRemoteProgressHandler,
|
||||||
|
createReportJellyfinRemoteStoppedHandler,
|
||||||
|
createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler,
|
||||||
|
createBuildHandleJellyfinRemotePlayMainDepsHandler,
|
||||||
|
createBuildHandleJellyfinRemotePlaystateMainDepsHandler,
|
||||||
|
createBuildReportJellyfinRemoteProgressMainDepsHandler,
|
||||||
|
createBuildReportJellyfinRemoteStoppedMainDepsHandler,
|
||||||
} from './main/runtime/domains/jellyfin';
|
} from './main/runtime/domains/jellyfin';
|
||||||
import { createBuildSubtitleProcessingControllerMainDepsHandler } from './main/runtime/domains/startup';
|
import { createBuildSubtitleProcessingControllerMainDepsHandler } from './main/runtime/domains/startup';
|
||||||
|
import { createApplyHoveredTokenOverlayHandler } from './main/runtime/mpv-hover-highlight';
|
||||||
import {
|
import {
|
||||||
createBuildAnilistStateRuntimeMainDepsHandler,
|
createBuildAnilistStateRuntimeMainDepsHandler,
|
||||||
createBuildConfigDerivedRuntimeMainDepsHandler,
|
createBuildConfigDerivedRuntimeMainDepsHandler,
|
||||||
@@ -641,6 +649,12 @@ const appState = createAppState({
|
|||||||
mpvSocketPath: getDefaultSocketPath(),
|
mpvSocketPath: getDefaultSocketPath(),
|
||||||
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||||
});
|
});
|
||||||
|
const applyHoveredTokenOverlay = createApplyHoveredTokenOverlayHandler({
|
||||||
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
getCurrentSubtitleData: () => appState.currentSubtitleData,
|
||||||
|
getHoveredTokenIndex: () => appState.hoveredSubtitleTokenIndex,
|
||||||
|
getHoveredSubtitleRevision: () => appState.hoveredSubtitleRevision,
|
||||||
|
});
|
||||||
const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({
|
const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({
|
||||||
getResolvedConfig: () => getResolvedConfig(),
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH,
|
defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH,
|
||||||
@@ -710,6 +724,15 @@ const buildSubtitleProcessingControllerMainDepsHandler =
|
|||||||
return await tokenizeSubtitle(text);
|
return await tokenizeSubtitle(text);
|
||||||
},
|
},
|
||||||
emitSubtitle: (payload) => {
|
emitSubtitle: (payload) => {
|
||||||
|
const previousSubtitleText = appState.currentSubtitleData?.text ?? null;
|
||||||
|
const nextSubtitleText = payload?.text ?? null;
|
||||||
|
const subtitleChanged = previousSubtitleText !== nextSubtitleText;
|
||||||
|
appState.currentSubtitleData = payload;
|
||||||
|
if (subtitleChanged) {
|
||||||
|
appState.hoveredSubtitleTokenIndex = null;
|
||||||
|
appState.hoveredSubtitleRevision += 1;
|
||||||
|
applyHoveredTokenOverlay();
|
||||||
|
}
|
||||||
broadcastToOverlayWindows('subtitle:set', payload);
|
broadcastToOverlayWindows('subtitle:set', payload);
|
||||||
subtitleWsService.broadcast(payload, {
|
subtitleWsService.broadcast(payload, {
|
||||||
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||||
@@ -721,8 +744,9 @@ const buildSubtitleProcessingControllerMainDepsHandler =
|
|||||||
logger.debug(`[subtitle-processing] ${message}`);
|
logger.debug(`[subtitle-processing] ${message}`);
|
||||||
},
|
},
|
||||||
now: () => Date.now(),
|
now: () => Date.now(),
|
||||||
});
|
});
|
||||||
const subtitleProcessingControllerMainDeps = buildSubtitleProcessingControllerMainDepsHandler();
|
const subtitleProcessingControllerMainDeps =
|
||||||
|
buildSubtitleProcessingControllerMainDepsHandler();
|
||||||
const subtitleProcessingController = createSubtitleProcessingController(
|
const subtitleProcessingController = createSubtitleProcessingController(
|
||||||
subtitleProcessingControllerMainDeps,
|
subtitleProcessingControllerMainDeps,
|
||||||
);
|
);
|
||||||
@@ -2671,6 +2695,9 @@ const {
|
|||||||
reportOverlayContentBounds: (payload: unknown) => {
|
reportOverlayContentBounds: (payload: unknown) => {
|
||||||
overlayContentMeasurementStore.report(payload);
|
overlayContentMeasurementStore.report(payload);
|
||||||
},
|
},
|
||||||
|
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
|
||||||
|
reportHoveredSubtitleToken(tokenIndex);
|
||||||
|
},
|
||||||
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
|
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
|
||||||
clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
|
clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
|
||||||
openAnilistSetup: () => openAnilistSetupWindow(),
|
openAnilistSetup: () => openAnilistSetupWindow(),
|
||||||
@@ -2967,6 +2994,11 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void {
|
|||||||
handleMpvCommandFromIpcHandler(command);
|
handleMpvCommandFromIpcHandler(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reportHoveredSubtitleToken(tokenIndex: number | null): void {
|
||||||
|
appState.hoveredSubtitleTokenIndex = tokenIndex;
|
||||||
|
applyHoveredTokenOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
|
async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
|
||||||
return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>;
|
return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption'];
|
setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption'];
|
||||||
cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption'];
|
cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption'];
|
||||||
reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds'];
|
reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds'];
|
||||||
|
reportHoveredSubtitleToken: IpcDepsRuntimeOptions['reportHoveredSubtitleToken'];
|
||||||
getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus'];
|
getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus'];
|
||||||
clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken'];
|
clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken'];
|
||||||
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
|
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
|
||||||
@@ -219,6 +220,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
setRuntimeOption: params.setRuntimeOption,
|
setRuntimeOption: params.setRuntimeOption,
|
||||||
cycleRuntimeOption: params.cycleRuntimeOption,
|
cycleRuntimeOption: params.cycleRuntimeOption,
|
||||||
reportOverlayContentBounds: params.reportOverlayContentBounds,
|
reportOverlayContentBounds: params.reportOverlayContentBounds,
|
||||||
|
reportHoveredSubtitleToken: params.reportHoveredSubtitleToken,
|
||||||
getAnilistStatus: params.getAnilistStatus,
|
getAnilistStatus: params.getAnilistStatus,
|
||||||
clearAnilistToken: params.clearAnilistToken,
|
clearAnilistToken: params.clearAnilistToken,
|
||||||
openAnilistSetup: params.openAnilistSetup,
|
openAnilistSetup: params.openAnilistSetup,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
getAnkiConnectStatus: () => false,
|
getAnkiConnectStatus: () => false,
|
||||||
getRuntimeOptions: () => [],
|
getRuntimeOptions: () => [],
|
||||||
reportOverlayContentBounds: () => {},
|
reportOverlayContentBounds: () => {},
|
||||||
|
reportHoveredSubtitleToken: () => {},
|
||||||
getAnilistStatus: () => ({}) as never,
|
getAnilistStatus: () => ({}) as never,
|
||||||
clearAnilistToken: () => {},
|
clearAnilistToken: () => {},
|
||||||
openAnilistSetup: () => {},
|
openAnilistSetup: () => {},
|
||||||
|
|||||||
147
src/main/runtime/mpv-hover-highlight.test.ts
Normal file
147
src/main/runtime/mpv-hover-highlight.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { PartOfSpeech, type SubtitleData } from '../../types';
|
||||||
|
import {
|
||||||
|
HOVER_TOKEN_MESSAGE,
|
||||||
|
HOVER_SCRIPT_NAME,
|
||||||
|
buildHoveredTokenMessageCommand,
|
||||||
|
buildHoveredTokenPayload,
|
||||||
|
createApplyHoveredTokenOverlayHandler,
|
||||||
|
} from './mpv-hover-highlight';
|
||||||
|
|
||||||
|
const SUBTITLE: SubtitleData = {
|
||||||
|
text: '昨日は雨だった。',
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
surface: '昨日',
|
||||||
|
reading: 'きのう',
|
||||||
|
headword: '昨日',
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 2,
|
||||||
|
partOfSpeech: PartOfSpeech.noun,
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: 'は',
|
||||||
|
reading: 'は',
|
||||||
|
headword: 'は',
|
||||||
|
startPos: 2,
|
||||||
|
endPos: 3,
|
||||||
|
partOfSpeech: PartOfSpeech.particle,
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: true,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: '雨',
|
||||||
|
reading: 'あめ',
|
||||||
|
headword: '雨',
|
||||||
|
startPos: 3,
|
||||||
|
endPos: 4,
|
||||||
|
partOfSpeech: PartOfSpeech.noun,
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: 'だった。',
|
||||||
|
reading: 'だった。',
|
||||||
|
headword: 'だ',
|
||||||
|
startPos: 4,
|
||||||
|
endPos: 8,
|
||||||
|
partOfSpeech: PartOfSpeech.other,
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('buildHoveredTokenPayload normalizes metadata and strips empty tokens', () => {
|
||||||
|
const payload = buildHoveredTokenPayload({
|
||||||
|
subtitle: SUBTITLE,
|
||||||
|
hoveredTokenIndex: 2,
|
||||||
|
revision: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(payload.revision, 5);
|
||||||
|
assert.equal(payload.subtitle, '昨日は雨だった。');
|
||||||
|
assert.equal(payload.hoveredTokenIndex, 2);
|
||||||
|
assert.equal(payload.tokens.length, 4);
|
||||||
|
assert.equal(payload.tokens[0]?.text, '昨日');
|
||||||
|
assert.equal(payload.tokens[0]?.index, 0);
|
||||||
|
assert.equal(payload.tokens[1]?.index, 1);
|
||||||
|
assert.equal(payload.colors.hover, 'E7C06A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildHoveredTokenMessageCommand sends script-message-to subminer payload', () => {
|
||||||
|
const payload = buildHoveredTokenPayload({
|
||||||
|
subtitle: SUBTITLE,
|
||||||
|
hoveredTokenIndex: 0,
|
||||||
|
revision: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = buildHoveredTokenMessageCommand(payload);
|
||||||
|
|
||||||
|
assert.equal(command[0], 'script-message-to');
|
||||||
|
assert.equal(command[1], HOVER_SCRIPT_NAME);
|
||||||
|
assert.equal(command[2], HOVER_TOKEN_MESSAGE);
|
||||||
|
|
||||||
|
const raw = command[3] as string;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
assert.equal(parsed.revision, 1);
|
||||||
|
assert.equal(parsed.hoveredTokenIndex, 0);
|
||||||
|
assert.equal(parsed.subtitle, '昨日は雨だった。');
|
||||||
|
assert.equal(parsed.tokens.length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createApplyHoveredTokenOverlayHandler sends clear payload when hovered token is missing', () => {
|
||||||
|
const commands: Array<(string | number)[]> = [];
|
||||||
|
const apply = createApplyHoveredTokenOverlayHandler({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
send: ({ command }: { command: (string | number)[] }) => {
|
||||||
|
commands.push(command);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getCurrentSubtitleData: () => SUBTITLE,
|
||||||
|
getHoveredTokenIndex: () => null,
|
||||||
|
getHoveredSubtitleRevision: () => 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
apply();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(commands[0]?.[3] as string);
|
||||||
|
assert.equal(parsed.hoveredTokenIndex, null);
|
||||||
|
assert.equal(parsed.subtitle, null);
|
||||||
|
assert.equal(parsed.tokens.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createApplyHoveredTokenOverlayHandler sends highlight payload when hover is active', () => {
|
||||||
|
const commands: Array<(string | number)[]> = [];
|
||||||
|
const apply = createApplyHoveredTokenOverlayHandler({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
send: ({ command }: { command: (string | number)[] }) => {
|
||||||
|
commands.push(command);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getCurrentSubtitleData: () => SUBTITLE,
|
||||||
|
getHoveredTokenIndex: () => 0,
|
||||||
|
getHoveredSubtitleRevision: () => 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
apply();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(commands[0]?.[3] as string);
|
||||||
|
assert.equal(parsed.hoveredTokenIndex, 0);
|
||||||
|
assert.equal(parsed.subtitle, '昨日は雨だった。');
|
||||||
|
assert.equal(parsed.tokens.length, 4);
|
||||||
|
assert.equal(commands[0]?.[0], 'script-message-to');
|
||||||
|
assert.equal(commands[0]?.[1], HOVER_SCRIPT_NAME);
|
||||||
|
});
|
||||||
126
src/main/runtime/mpv-hover-highlight.ts
Normal file
126
src/main/runtime/mpv-hover-highlight.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { SubtitleData } from '../../types';
|
||||||
|
|
||||||
|
export const HOVER_SCRIPT_NAME = 'subminer';
|
||||||
|
export const HOVER_TOKEN_MESSAGE = 'subminer-hover-token';
|
||||||
|
|
||||||
|
const DEFAULT_HOVER_TOKEN_COLOR = 'E7C06A';
|
||||||
|
const DEFAULT_TOKEN_COLOR = 'FFFFFF';
|
||||||
|
|
||||||
|
export type HoverPayloadToken = {
|
||||||
|
text: string;
|
||||||
|
index: number;
|
||||||
|
startPos: number | null;
|
||||||
|
endPos: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HoverTokenPayload = {
|
||||||
|
revision: number;
|
||||||
|
subtitle: string | null;
|
||||||
|
hoveredTokenIndex: number | null;
|
||||||
|
tokens: HoverPayloadToken[];
|
||||||
|
colors: {
|
||||||
|
base: string;
|
||||||
|
hover: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type HoverTokenInput = {
|
||||||
|
subtitle: SubtitleData | null;
|
||||||
|
hoveredTokenIndex: number | null;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizeSubtitleText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\\N/g, '\n')
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\{[^}]*\}/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTokenSurface(surface: unknown): string {
|
||||||
|
return typeof surface === 'string' ? surface : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasHoveredToken(subtitle: SubtitleData | null, hoveredTokenIndex: number | null): boolean {
|
||||||
|
if (!subtitle || hoveredTokenIndex === null || hoveredTokenIndex < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtitle.tokens?.some((token, index) => index === hoveredTokenIndex) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHoveredTokenPayload(input: HoverTokenInput): HoverTokenPayload {
|
||||||
|
const { subtitle, hoveredTokenIndex, revision } = input;
|
||||||
|
|
||||||
|
const tokens: HoverPayloadToken[] = [];
|
||||||
|
|
||||||
|
if (subtitle?.tokens && subtitle.tokens.length > 0) {
|
||||||
|
for (let tokenIndex = 0; tokenIndex < subtitle.tokens.length; tokenIndex += 1) {
|
||||||
|
const token = subtitle.tokens[tokenIndex];
|
||||||
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const surface = sanitizeTokenSurface(token?.surface);
|
||||||
|
if (!surface || surface.trim().length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push({
|
||||||
|
text: surface,
|
||||||
|
index: tokenIndex,
|
||||||
|
startPos: Number.isFinite(token.startPos) ? token.startPos : null,
|
||||||
|
endPos: Number.isFinite(token.endPos) ? token.endPos : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
revision,
|
||||||
|
subtitle: subtitle ? sanitizeSubtitleText(subtitle.text) : null,
|
||||||
|
hoveredTokenIndex:
|
||||||
|
hoveredTokenIndex !== null && hoveredTokenIndex >= 0 ? hoveredTokenIndex : null,
|
||||||
|
tokens,
|
||||||
|
colors: {
|
||||||
|
base: DEFAULT_TOKEN_COLOR,
|
||||||
|
hover: DEFAULT_HOVER_TOKEN_COLOR,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHoveredTokenMessageCommand(payload: HoverTokenPayload): (string | number)[] {
|
||||||
|
return [
|
||||||
|
'script-message-to',
|
||||||
|
HOVER_SCRIPT_NAME,
|
||||||
|
HOVER_TOKEN_MESSAGE,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApplyHoveredTokenOverlayHandler(deps: {
|
||||||
|
getMpvClient: () => {
|
||||||
|
connected: boolean;
|
||||||
|
send: (payload: { command: (string | number)[] }) => boolean;
|
||||||
|
} | null;
|
||||||
|
getCurrentSubtitleData: () => SubtitleData | null;
|
||||||
|
getHoveredTokenIndex: () => number | null;
|
||||||
|
getHoveredSubtitleRevision: () => number;
|
||||||
|
}) {
|
||||||
|
return (): void => {
|
||||||
|
const mpvClient = deps.getMpvClient();
|
||||||
|
if (!mpvClient || !mpvClient.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitle = deps.getCurrentSubtitleData();
|
||||||
|
const hoveredTokenIndex = deps.getHoveredTokenIndex();
|
||||||
|
const revision = deps.getHoveredSubtitleRevision();
|
||||||
|
const payload = buildHoveredTokenPayload({
|
||||||
|
subtitle: subtitle && hasHoveredToken(subtitle, hoveredTokenIndex) ? subtitle : null,
|
||||||
|
hoveredTokenIndex: hoveredTokenIndex,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
|
||||||
|
mpvClient.send({ command: buildHoveredTokenMessageCommand(payload) });
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
Keybinding,
|
Keybinding,
|
||||||
MpvSubtitleRenderMetrics,
|
MpvSubtitleRenderMetrics,
|
||||||
SecondarySubMode,
|
SecondarySubMode,
|
||||||
|
SubtitleData,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
JlptLevel,
|
JlptLevel,
|
||||||
@@ -152,6 +153,9 @@ export interface AppState {
|
|||||||
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||||
currentSubText: string;
|
currentSubText: string;
|
||||||
currentSubAssText: string;
|
currentSubAssText: string;
|
||||||
|
currentSubtitleData: SubtitleData | null;
|
||||||
|
hoveredSubtitleTokenIndex: number | null;
|
||||||
|
hoveredSubtitleRevision: number;
|
||||||
windowTracker: BaseWindowTracker | null;
|
windowTracker: BaseWindowTracker | null;
|
||||||
subtitlePosition: SubtitlePosition | null;
|
subtitlePosition: SubtitlePosition | null;
|
||||||
currentMediaPath: string | null;
|
currentMediaPath: string | null;
|
||||||
@@ -221,6 +225,9 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
|||||||
reconnectTimer: null,
|
reconnectTimer: null,
|
||||||
currentSubText: '',
|
currentSubText: '',
|
||||||
currentSubAssText: '',
|
currentSubAssText: '',
|
||||||
|
currentSubtitleData: null,
|
||||||
|
hoveredSubtitleTokenIndex: null,
|
||||||
|
hoveredSubtitleRevision: 0,
|
||||||
windowTracker: null,
|
windowTracker: null,
|
||||||
subtitlePosition: null,
|
subtitlePosition: null,
|
||||||
currentMediaPath: null,
|
currentMediaPath: null,
|
||||||
|
|||||||
@@ -257,6 +257,9 @@ const electronAPI: ElectronAPI = {
|
|||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
|
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
|
||||||
},
|
},
|
||||||
|
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
|
||||||
|
ipcRenderer.send('subtitle-token-hover:set', tokenIndex);
|
||||||
|
},
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
IPC_CHANNELS.event.configHotReload,
|
IPC_CHANNELS.event.configHotReload,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function createMouseHandlers(
|
|||||||
applyYPercent: (yPercent: number) => void;
|
applyYPercent: (yPercent: number) => void;
|
||||||
getCurrentYPercent: () => number;
|
getCurrentYPercent: () => number;
|
||||||
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
||||||
|
reportHoveredTokenIndex: (tokenIndex: number | null) => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const wordSegmenter =
|
const wordSegmenter =
|
||||||
@@ -191,6 +192,57 @@ export function createMouseHandlers(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupInvisibleTokenHoverReporter(): void {
|
||||||
|
if (!ctx.platform.isInvisibleLayer) return;
|
||||||
|
|
||||||
|
let pendingNullHoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const clearPendingNullHoverTimer = (): void => {
|
||||||
|
if (pendingNullHoverTimer !== null) {
|
||||||
|
clearTimeout(pendingNullHoverTimer);
|
||||||
|
pendingNullHoverTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportHoveredToken = (tokenIndex: number | null): void => {
|
||||||
|
if (ctx.state.lastHoveredTokenIndex === tokenIndex) return;
|
||||||
|
ctx.state.lastHoveredTokenIndex = tokenIndex;
|
||||||
|
options.reportHoveredTokenIndex(tokenIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueNullHoveredToken = (): void => {
|
||||||
|
if (pendingNullHoverTimer !== null) return;
|
||||||
|
pendingNullHoverTimer = setTimeout(() => {
|
||||||
|
pendingNullHoverTimer = null;
|
||||||
|
reportHoveredToken(null);
|
||||||
|
}, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => {
|
||||||
|
if (!(event.target instanceof Element)) {
|
||||||
|
queueNullHoveredToken();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target.closest<HTMLElement>('.word[data-token-index]');
|
||||||
|
if (!target || !ctx.dom.subtitleRoot.contains(target)) {
|
||||||
|
queueNullHoveredToken();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawTokenIndex = target.dataset.tokenIndex;
|
||||||
|
const tokenIndex = rawTokenIndex ? Number.parseInt(rawTokenIndex, 10) : Number.NaN;
|
||||||
|
if (!Number.isInteger(tokenIndex) || tokenIndex < 0) {
|
||||||
|
queueNullHoveredToken();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPendingNullHoverTimer();
|
||||||
|
reportHoveredToken(tokenIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.dom.subtitleRoot.addEventListener('mouseleave', () => {
|
||||||
|
clearPendingNullHoverTimer();
|
||||||
|
reportHoveredToken(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setupResizeHandler(): void {
|
function setupResizeHandler(): void {
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
if (ctx.platform.isInvisibleLayer) {
|
if (ctx.platform.isInvisibleLayer) {
|
||||||
@@ -268,6 +320,7 @@ export function createMouseHandlers(
|
|||||||
handleMouseLeave,
|
handleMouseLeave,
|
||||||
setupDragging,
|
setupDragging,
|
||||||
setupInvisibleHoverSelection,
|
setupInvisibleHoverSelection,
|
||||||
|
setupInvisibleTokenHoverReporter,
|
||||||
setupResizeHandler,
|
setupResizeHandler,
|
||||||
setupSelectionObserver,
|
setupSelectionObserver,
|
||||||
setupYomitanObserver,
|
setupYomitanObserver,
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ const mouseHandlers = createMouseHandlers(ctx, {
|
|||||||
applyYPercent: positioning.applyYPercent,
|
applyYPercent: positioning.applyYPercent,
|
||||||
getCurrentYPercent: positioning.getCurrentYPercent,
|
getCurrentYPercent: positioning.getCurrentYPercent,
|
||||||
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
|
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
|
||||||
|
reportHoveredTokenIndex: (tokenIndex: number | null) => {
|
||||||
|
window.electronAPI.reportHoveredSubtitleToken(tokenIndex);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastSubtitlePreview = '';
|
let lastSubtitlePreview = '';
|
||||||
@@ -307,6 +310,7 @@ async function init(): Promise<void> {
|
|||||||
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
|
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
|
||||||
|
|
||||||
mouseHandlers.setupInvisibleHoverSelection();
|
mouseHandlers.setupInvisibleHoverSelection();
|
||||||
|
mouseHandlers.setupInvisibleTokenHoverReporter();
|
||||||
positioning.setupInvisiblePositionEditHud();
|
positioning.setupInvisiblePositionEditHud();
|
||||||
mouseHandlers.setupResizeHandler();
|
mouseHandlers.setupResizeHandler();
|
||||||
mouseHandlers.setupSelectionObserver();
|
mouseHandlers.setupSelectionObserver();
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export type RendererState = {
|
|||||||
|
|
||||||
lastHoverSelectionKey: string;
|
lastHoverSelectionKey: string;
|
||||||
lastHoverSelectionNode: Text | null;
|
lastHoverSelectionNode: Text | null;
|
||||||
|
lastHoveredTokenIndex: number | null;
|
||||||
|
|
||||||
knownWordColor: string;
|
knownWordColor: string;
|
||||||
nPlusOneColor: string;
|
nPlusOneColor: string;
|
||||||
@@ -148,6 +149,7 @@ export function createRendererState(): RendererState {
|
|||||||
|
|
||||||
lastHoverSelectionKey: '',
|
lastHoverSelectionKey: '',
|
||||||
lastHoverSelectionNode: null,
|
lastHoverSelectionNode: null,
|
||||||
|
lastHoveredTokenIndex: null,
|
||||||
|
|
||||||
knownWordColor: '#a6da95',
|
knownWordColor: '#a6da95',
|
||||||
nPlusOneColor: '#c6a0f6',
|
nPlusOneColor: '#c6a0f6',
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ function renderWithTokens(
|
|||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
||||||
span.textContent = token.surface;
|
span.textContent = token.surface;
|
||||||
|
span.dataset.tokenIndex = String(segment.tokenIndex);
|
||||||
if (token.reading) span.dataset.reading = token.reading;
|
if (token.reading) span.dataset.reading = token.reading;
|
||||||
if (token.headword) span.dataset.headword = token.headword;
|
if (token.headword) span.dataset.headword = token.headword;
|
||||||
fragment.appendChild(span);
|
fragment.appendChild(span);
|
||||||
@@ -143,7 +144,11 @@ function renderWithTokens(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const token of tokens) {
|
for (let index = 0; index < tokens.length; index += 1) {
|
||||||
|
const token = tokens[index];
|
||||||
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const surface = token.surface.replace(/\n/g, ' ');
|
const surface = token.surface.replace(/\n/g, ' ');
|
||||||
if (!surface) {
|
if (!surface) {
|
||||||
continue;
|
continue;
|
||||||
@@ -157,6 +162,7 @@ function renderWithTokens(
|
|||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
||||||
span.textContent = surface;
|
span.textContent = surface;
|
||||||
|
span.dataset.tokenIndex = String(index);
|
||||||
if (token.reading) span.dataset.reading = token.reading;
|
if (token.reading) span.dataset.reading = token.reading;
|
||||||
if (token.headword) span.dataset.headword = token.headword;
|
if (token.headword) span.dataset.headword = token.headword;
|
||||||
fragment.appendChild(span);
|
fragment.appendChild(span);
|
||||||
@@ -165,7 +171,9 @@ function renderWithTokens(
|
|||||||
root.appendChild(fragment);
|
root.appendChild(fragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubtitleRenderSegment = { kind: 'text'; text: string } | { kind: 'token'; token: MergedToken };
|
type SubtitleRenderSegment =
|
||||||
|
| { kind: 'text'; text: string }
|
||||||
|
| { kind: 'token'; token: MergedToken; tokenIndex: number };
|
||||||
|
|
||||||
export function alignTokensToSourceText(
|
export function alignTokensToSourceText(
|
||||||
tokens: MergedToken[],
|
tokens: MergedToken[],
|
||||||
@@ -178,7 +186,11 @@ export function alignTokensToSourceText(
|
|||||||
const segments: SubtitleRenderSegment[] = [];
|
const segments: SubtitleRenderSegment[] = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
for (const token of tokens) {
|
for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) {
|
||||||
|
const token = tokens[tokenIndex];
|
||||||
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const surface = token.surface;
|
const surface = token.surface;
|
||||||
if (!surface || isWhitespaceOnly(surface)) {
|
if (!surface || isWhitespaceOnly(surface)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -195,7 +207,7 @@ export function alignTokensToSourceText(
|
|||||||
segments.push({ kind: 'text', text: sourceText.slice(cursor, foundIndex) });
|
segments.push({ kind: 'text', text: sourceText.slice(cursor, foundIndex) });
|
||||||
}
|
}
|
||||||
|
|
||||||
segments.push({ kind: 'token', token });
|
segments.push({ kind: 'token', token, tokenIndex });
|
||||||
cursor = foundIndex + surface.length;
|
cursor = foundIndex + surface.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +294,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
|||||||
ctx.dom.subtitleRoot.innerHTML = '';
|
ctx.dom.subtitleRoot.innerHTML = '';
|
||||||
ctx.state.lastHoverSelectionKey = '';
|
ctx.state.lastHoverSelectionKey = '';
|
||||||
ctx.state.lastHoverSelectionNode = null;
|
ctx.state.lastHoverSelectionNode = null;
|
||||||
|
ctx.state.lastHoveredTokenIndex = null;
|
||||||
|
|
||||||
let text: string;
|
let text: string;
|
||||||
let tokens: MergedToken[] | null;
|
let tokens: MergedToken[] | null;
|
||||||
@@ -304,7 +317,17 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
|||||||
1,
|
1,
|
||||||
normalizedInvisible.split('\n').length,
|
normalizedInvisible.split('\n').length,
|
||||||
);
|
);
|
||||||
|
if (tokens && tokens.length > 0) {
|
||||||
|
renderWithTokens(
|
||||||
|
ctx.dom.subtitleRoot,
|
||||||
|
tokens,
|
||||||
|
getFrequencyRenderSettings(),
|
||||||
|
text,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -710,6 +710,10 @@ export interface ConfigHotReloadPayload {
|
|||||||
secondarySubMode: SecondarySubMode;
|
secondarySubMode: SecondarySubMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubtitleHoverTokenPayload {
|
||||||
|
tokenIndex: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
getOverlayLayer: () => 'visible' | 'invisible' | null;
|
getOverlayLayer: () => 'visible' | 'invisible' | null;
|
||||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||||
@@ -765,6 +769,7 @@ export interface ElectronAPI {
|
|||||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void;
|
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void;
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
|
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user