diff --git a/plugin/subminer.lua b/plugin/subminer.lua index dc5af5a..7409254 100644 --- a/plugin/subminer.lua +++ b/plugin/subminer.lua @@ -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 diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 88f14cd..ee51c6c 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -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: () => {}, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 77fc5d5..1aa8d91 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -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(); }); diff --git a/src/main.ts b/src/main.ts index 2a1beaa..140bc07 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { return runSubsyncManualFromIpcHandler(request) as Promise; } diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 04ab53e..2ab12f3 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -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, diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index e798290..51596dc 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -56,6 +56,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b getAnkiConnectStatus: () => false, getRuntimeOptions: () => [], reportOverlayContentBounds: () => {}, + reportHoveredSubtitleToken: () => {}, getAnilistStatus: () => ({}) as never, clearAnilistToken: () => {}, openAnilistSetup: () => {}, diff --git a/src/main/runtime/mpv-hover-highlight.test.ts b/src/main/runtime/mpv-hover-highlight.test.ts new file mode 100644 index 0000000..33ec83b --- /dev/null +++ b/src/main/runtime/mpv-hover-highlight.test.ts @@ -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); +}); diff --git a/src/main/runtime/mpv-hover-highlight.ts b/src/main/runtime/mpv-hover-highlight.ts new file mode 100644 index 0000000..9be13f1 --- /dev/null +++ b/src/main/runtime/mpv-hover-highlight.ts @@ -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) }); + }; +} diff --git a/src/main/state.ts b/src/main/state.ts index ed0bfe9..7f15553 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -4,6 +4,7 @@ import type { Keybinding, MpvSubtitleRenderMetrics, SecondarySubMode, + SubtitleData, SubtitlePosition, KikuFieldGroupingChoice, JlptLevel, @@ -152,6 +153,9 @@ export interface AppState { reconnectTimer: ReturnType | 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, diff --git a/src/preload.ts b/src/preload.ts index a51a81c..9e664ca 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -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, diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index e5d7d50..b5095a1 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -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 | 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('.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, diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 81efe11..6e88923 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -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 { ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave); mouseHandlers.setupInvisibleHoverSelection(); + mouseHandlers.setupInvisibleTokenHoverReporter(); positioning.setupInvisiblePositionEditHud(); mouseHandlers.setupResizeHandler(); mouseHandlers.setupSelectionObserver(); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 4601407..add747c 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -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', diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index fa0d1a6..9498f2b 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -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; } diff --git a/src/types.ts b/src/types.ts index b2e4e0b..c30b2e8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; + reportHoveredSubtitleToken: (tokenIndex: number | null) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; }