fix(mpv): stabilize hover token subtitle highlighting

# Conflicts:
#	src/core/services/ipc.ts
#	src/main.ts
This commit is contained in:
2026-02-21 22:28:09 -08:00
parent 75c3b15792
commit 8b8a99dc79
15 changed files with 903 additions and 25 deletions

View File

@@ -77,8 +77,23 @@ local state = {
binary_path = nil,
detected_backend = nil,
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 = {
debug = 10,
info = 20,
@@ -123,6 +138,435 @@ local function show_osd(message)
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()
if state.detected_backend then
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-restart", restart_overlay)
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
local function init()
@@ -827,6 +1277,12 @@ local function init()
mp.register_event("file-loaded", on_file_loaded)
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")
end

View File

@@ -64,6 +64,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
clearAnilistToken: () => {
calls.push('clearAnilistToken');
@@ -137,6 +138,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
return { ok: true };
},
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
@@ -212,6 +214,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},

View File

@@ -54,6 +54,7 @@ export interface IpcServiceDeps {
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
@@ -118,6 +119,7 @@ export interface IpcDepsRuntimeOptions {
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
@@ -180,6 +182,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
setRuntimeOption: options.setRuntimeOption,
cycleRuntimeOption: options.cycleRuntimeOption,
reportOverlayContentBounds: options.reportOverlayContentBounds,
reportHoveredSubtitleToken: options.reportHoveredSubtitleToken,
getAnilistStatus: options.getAnilistStatus,
clearAnilistToken: options.clearAnilistToken,
openAnilistSetup: options.openAnilistSetup,
@@ -355,6 +358,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
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, () => {
return deps.getAnilistStatus();
});

View File

@@ -92,8 +92,16 @@ import { createBuildRefreshAnilistClientSecretStateMainDepsHandler } from './mai
import {
getConfiguredJellyfinSession,
type ActiveJellyfinRemotePlaybackState,
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler,
createBuildHandleJellyfinRemotePlayMainDepsHandler,
createBuildHandleJellyfinRemotePlaystateMainDepsHandler,
createBuildReportJellyfinRemoteProgressMainDepsHandler,
createBuildReportJellyfinRemoteStoppedMainDepsHandler,
} from './main/runtime/domains/jellyfin';
import { createBuildSubtitleProcessingControllerMainDepsHandler } from './main/runtime/domains/startup';
import { createApplyHoveredTokenOverlayHandler } from './main/runtime/mpv-hover-highlight';
import {
createBuildAnilistStateRuntimeMainDepsHandler,
createBuildConfigDerivedRuntimeMainDepsHandler,
@@ -641,6 +649,12 @@ const appState = createAppState({
mpvSocketPath: getDefaultSocketPath(),
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
});
const applyHoveredTokenOverlay = createApplyHoveredTokenOverlayHandler({
getMpvClient: () => appState.mpvClient,
getCurrentSubtitleData: () => appState.currentSubtitleData,
getHoveredTokenIndex: () => appState.hoveredSubtitleTokenIndex,
getHoveredSubtitleRevision: () => appState.hoveredSubtitleRevision,
});
const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({
getResolvedConfig: () => getResolvedConfig(),
defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH,
@@ -703,26 +717,36 @@ const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsH
let appTray: Tray | null = null;
const buildSubtitleProcessingControllerMainDepsHandler =
createBuildSubtitleProcessingControllerMainDepsHandler({
tokenizeSubtitle: async (text: string) => {
if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) {
return null;
}
return await tokenizeSubtitle(text);
},
emitSubtitle: (payload) => {
broadcastToOverlayWindows('subtitle:set', payload);
subtitleWsService.broadcast(payload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
});
},
logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`);
},
now: () => Date.now(),
});
const subtitleProcessingControllerMainDeps = buildSubtitleProcessingControllerMainDepsHandler();
tokenizeSubtitle: async (text: string) => {
if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) {
return null;
}
return await tokenizeSubtitle(text);
},
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);
subtitleWsService.broadcast(payload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
});
},
logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`);
},
now: () => Date.now(),
});
const subtitleProcessingControllerMainDeps =
buildSubtitleProcessingControllerMainDepsHandler();
const subtitleProcessingController = createSubtitleProcessingController(
subtitleProcessingControllerMainDeps,
);
@@ -2671,6 +2695,9 @@ const {
reportOverlayContentBounds: (payload: unknown) => {
overlayContentMeasurementStore.report(payload);
},
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
reportHoveredSubtitleToken(tokenIndex);
},
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
openAnilistSetup: () => openAnilistSetupWindow(),
@@ -2967,6 +2994,11 @@ function handleMpvCommandFromIpc(command: (string | number)[]): void {
handleMpvCommandFromIpcHandler(command);
}
function reportHoveredSubtitleToken(tokenIndex: number | null): void {
appState.hoveredSubtitleTokenIndex = tokenIndex;
applyHoveredTokenOverlay();
}
async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
return runSubsyncManualFromIpcHandler(request) as Promise<SubsyncResult>;
}

View File

@@ -81,6 +81,7 @@ export interface MainIpcRuntimeServiceDepsParams {
setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption'];
cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption'];
reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds'];
reportHoveredSubtitleToken: IpcDepsRuntimeOptions['reportHoveredSubtitleToken'];
getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus'];
clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken'];
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
@@ -219,6 +220,7 @@ export function createMainIpcRuntimeServiceDeps(
setRuntimeOption: params.setRuntimeOption,
cycleRuntimeOption: params.cycleRuntimeOption,
reportOverlayContentBounds: params.reportOverlayContentBounds,
reportHoveredSubtitleToken: params.reportHoveredSubtitleToken,
getAnilistStatus: params.getAnilistStatus,
clearAnilistToken: params.clearAnilistToken,
openAnilistSetup: params.openAnilistSetup,

View File

@@ -56,6 +56,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
reportOverlayContentBounds: () => {},
reportHoveredSubtitleToken: () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetup: () => {},

View 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);
});

View 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) });
};
}

View File

@@ -4,6 +4,7 @@ import type {
Keybinding,
MpvSubtitleRenderMetrics,
SecondarySubMode,
SubtitleData,
SubtitlePosition,
KikuFieldGroupingChoice,
JlptLevel,
@@ -152,6 +153,9 @@ export interface AppState {
reconnectTimer: ReturnType<typeof setTimeout> | null;
currentSubText: string;
currentSubAssText: string;
currentSubtitleData: SubtitleData | null;
hoveredSubtitleTokenIndex: number | null;
hoveredSubtitleRevision: number;
windowTracker: BaseWindowTracker | null;
subtitlePosition: SubtitlePosition | null;
currentMediaPath: string | null;
@@ -221,6 +225,9 @@ export function createAppState(values: AppStateInitialValues): AppState {
reconnectTimer: null,
currentSubText: '',
currentSubAssText: '',
currentSubtitleData: null,
hoveredSubtitleTokenIndex: null,
hoveredSubtitleRevision: 0,
windowTracker: null,
subtitlePosition: null,
currentMediaPath: null,

View File

@@ -257,6 +257,9 @@ const electronAPI: ElectronAPI = {
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
},
reportHoveredSubtitleToken: (tokenIndex: number | null) => {
ipcRenderer.send('subtitle-token-hover:set', tokenIndex);
},
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.configHotReload,

View File

@@ -8,6 +8,7 @@ export function createMouseHandlers(
applyYPercent: (yPercent: number) => void;
getCurrentYPercent: () => number;
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
reportHoveredTokenIndex: (tokenIndex: number | null) => void;
},
) {
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 {
window.addEventListener('resize', () => {
if (ctx.platform.isInvisibleLayer) {
@@ -268,6 +320,7 @@ export function createMouseHandlers(
handleMouseLeave,
setupDragging,
setupInvisibleHoverSelection,
setupInvisibleTokenHoverReporter,
setupResizeHandler,
setupSelectionObserver,
setupYomitanObserver,

View File

@@ -131,6 +131,9 @@ const mouseHandlers = createMouseHandlers(ctx, {
applyYPercent: positioning.applyYPercent,
getCurrentYPercent: positioning.getCurrentYPercent,
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
reportHoveredTokenIndex: (tokenIndex: number | null) => {
window.electronAPI.reportHoveredSubtitleToken(tokenIndex);
},
});
let lastSubtitlePreview = '';
@@ -307,6 +310,7 @@ async function init(): Promise<void> {
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
mouseHandlers.setupInvisibleHoverSelection();
mouseHandlers.setupInvisibleTokenHoverReporter();
positioning.setupInvisiblePositionEditHud();
mouseHandlers.setupResizeHandler();
mouseHandlers.setupSelectionObserver();

View File

@@ -71,6 +71,7 @@ export type RendererState = {
lastHoverSelectionKey: string;
lastHoverSelectionNode: Text | null;
lastHoveredTokenIndex: number | null;
knownWordColor: string;
nPlusOneColor: string;
@@ -148,6 +149,7 @@ export function createRendererState(): RendererState {
lastHoverSelectionKey: '',
lastHoverSelectionNode: null,
lastHoveredTokenIndex: null,
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',

View File

@@ -134,6 +134,7 @@ function renderWithTokens(
const span = document.createElement('span');
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
span.textContent = token.surface;
span.dataset.tokenIndex = String(segment.tokenIndex);
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
fragment.appendChild(span);
@@ -143,7 +144,11 @@ function renderWithTokens(
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, ' ');
if (!surface) {
continue;
@@ -157,6 +162,7 @@ function renderWithTokens(
const span = document.createElement('span');
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
span.textContent = surface;
span.dataset.tokenIndex = String(index);
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
fragment.appendChild(span);
@@ -165,7 +171,9 @@ function renderWithTokens(
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(
tokens: MergedToken[],
@@ -178,7 +186,11 @@ export function alignTokensToSourceText(
const segments: SubtitleRenderSegment[] = [];
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;
if (!surface || isWhitespaceOnly(surface)) {
continue;
@@ -195,7 +207,7 @@ export function alignTokensToSourceText(
segments.push({ kind: 'text', text: sourceText.slice(cursor, foundIndex) });
}
segments.push({ kind: 'token', token });
segments.push({ kind: 'token', token, tokenIndex });
cursor = foundIndex + surface.length;
}
@@ -282,6 +294,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.dom.subtitleRoot.innerHTML = '';
ctx.state.lastHoverSelectionKey = '';
ctx.state.lastHoverSelectionNode = null;
ctx.state.lastHoveredTokenIndex = null;
let text: string;
let tokens: MergedToken[] | null;
@@ -304,7 +317,17 @@ export function createSubtitleRenderer(ctx: RendererContext) {
1,
normalizedInvisible.split('\n').length,
);
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
if (tokens && tokens.length > 0) {
renderWithTokens(
ctx.dom.subtitleRoot,
tokens,
getFrequencyRenderSettings(),
text,
true,
);
} else {
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
}
return;
}

View File

@@ -710,6 +710,10 @@ export interface ConfigHotReloadPayload {
secondarySubMode: SecondarySubMode;
}
export interface SubtitleHoverTokenPayload {
tokenIndex: number | null;
}
export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'invisible' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void;
@@ -765,6 +769,7 @@ export interface ElectronAPI {
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
}