From 5ef5da2f8c5c74ab928bcd5f9b003202950dc34e Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 9 Feb 2026 21:44:28 -0800 Subject: [PATCH] refactor: extract mpv, tokenizer, and yomitan loader services --- src/core/services/mpv-service.ts | 761 +++++++++++ src/core/services/tokenizer-service.ts | 305 +++++ .../yomitan-extension-loader-service.ts | 109 ++ src/main.ts | 1146 ++--------------- 4 files changed, 1261 insertions(+), 1060 deletions(-) create mode 100644 src/core/services/mpv-service.ts create mode 100644 src/core/services/tokenizer-service.ts create mode 100644 src/core/services/yomitan-extension-loader-service.ts diff --git a/src/core/services/mpv-service.ts b/src/core/services/mpv-service.ts new file mode 100644 index 0000000..3c5c54c --- /dev/null +++ b/src/core/services/mpv-service.ts @@ -0,0 +1,761 @@ +import * as net from "net"; +import { + Config, + MpvClient, + MpvSubtitleRenderMetrics, + SubtitleData, +} from "../../types"; +import { asBoolean, asFiniteNumber } from "../utils/coerce"; + +interface MpvMessage { + event?: string; + name?: string; + data?: unknown; + request_id?: number; + error?: string; +} + +const MPV_REQUEST_ID_SUBTEXT = 101; +const MPV_REQUEST_ID_PATH = 102; +const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103; +export const MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY = 104; +const MPV_REQUEST_ID_AID = 105; +const MPV_REQUEST_ID_SUB_POS = 106; +const MPV_REQUEST_ID_SUB_FONT_SIZE = 107; +const MPV_REQUEST_ID_SUB_SCALE = 108; +const MPV_REQUEST_ID_SUB_MARGIN_Y = 109; +const MPV_REQUEST_ID_SUB_MARGIN_X = 110; +const MPV_REQUEST_ID_SUB_FONT = 111; +const MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW = 112; +const MPV_REQUEST_ID_OSD_HEIGHT = 113; +const MPV_REQUEST_ID_OSD_DIMENSIONS = 114; +const MPV_REQUEST_ID_SUBTEXT_ASS = 115; +const MPV_REQUEST_ID_SUB_SPACING = 116; +const MPV_REQUEST_ID_SUB_BOLD = 117; +const MPV_REQUEST_ID_SUB_ITALIC = 118; +const MPV_REQUEST_ID_SUB_BORDER_SIZE = 119; +const MPV_REQUEST_ID_SUB_SHADOW_OFFSET = 120; +const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121; +const MPV_REQUEST_ID_SUB_USE_MARGINS = 122; +const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200; +const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; + +interface SubtitleTimingTrackerLike { + recordSubtitle: (text: string, start: number, end: number) => void; +} + +export interface MpvIpcClientDeps { + getResolvedConfig: () => Config; + autoStartOverlay: boolean; + setOverlayVisible: (visible: boolean) => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isVisibleOverlayVisible: () => boolean; + getReconnectTimer: () => ReturnType | null; + setReconnectTimer: (timer: ReturnType | null) => void; + getCurrentSubText: () => string; + setCurrentSubText: (text: string) => void; + setCurrentSubAssText: (text: string) => void; + getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null; + subtitleWsBroadcast: (text: string) => void; + getOverlayWindowsCount: () => number; + tokenizeSubtitle: (text: string) => Promise; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; + updateCurrentMediaPath: (mediaPath: unknown) => void; + updateMpvSubtitleRenderMetrics: ( + patch: Partial, + ) => void; + getMpvSubtitleRenderMetrics: () => MpvSubtitleRenderMetrics; + setPreviousSecondarySubVisibility: (value: boolean | null) => void; + showMpvOsd: (text: string) => void; +} + +export class MpvIpcClient implements MpvClient { + private socketPath: string; + private deps: MpvIpcClientDeps; + public socket: net.Socket | null = null; + private buffer = ""; + public connected = false; + private connecting = false; + private reconnectAttempt = 0; + private firstConnection = true; + private hasConnectedOnce = false; + public currentVideoPath = ""; + public currentTimePos = 0; + public currentSubStart = 0; + public currentSubEnd = 0; + public currentSubText = ""; + public currentSecondarySubText = ""; + public currentAudioStreamIndex: number | null = null; + private currentAudioTrackId: number | null = null; + private pauseAtTime: number | null = null; + private pendingPauseAtSubEnd = false; + private nextDynamicRequestId = 1000; + private pendingRequests = new Map void>(); + + constructor(socketPath: string, deps: MpvIpcClientDeps) { + this.socketPath = socketPath; + this.deps = deps; + } + + setSocketPath(socketPath: string): void { + this.socketPath = socketPath; + } + + connect(): void { + if (this.connected || this.connecting) { + return; + } + + if (this.socket) { + this.socket.destroy(); + } + + this.connecting = true; + this.socket = new net.Socket(); + + this.socket.on("connect", () => { + console.log("Connected to MPV socket"); + this.connected = true; + this.connecting = false; + this.reconnectAttempt = 0; + this.hasConnectedOnce = true; + this.subscribeToProperties(); + this.getInitialState(); + + const shouldAutoStart = + this.deps.autoStartOverlay || + this.deps.getResolvedConfig().auto_start_overlay === true; + if (this.firstConnection && shouldAutoStart) { + console.log("Auto-starting overlay, hiding mpv subtitles"); + setTimeout(() => { + this.deps.setOverlayVisible(true); + }, 100); + } else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) { + this.setSubVisibility(!this.deps.isVisibleOverlayVisible()); + } + + this.firstConnection = false; + }); + + this.socket.on("data", (data: Buffer) => { + this.buffer += data.toString(); + this.processBuffer(); + }); + + this.socket.on("error", (err: Error) => { + console.error("MPV socket error:", err.message); + this.connected = false; + this.connecting = false; + this.failPendingRequests(); + }); + + this.socket.on("close", () => { + console.log("MPV socket closed"); + this.connected = false; + this.connecting = false; + this.failPendingRequests(); + this.scheduleReconnect(); + }); + + this.socket.connect(this.socketPath); + } + + private scheduleReconnect(): void { + const reconnectTimer = this.deps.getReconnectTimer(); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + const attempt = this.reconnectAttempt++; + let delay: number; + if (this.hasConnectedOnce) { + if (attempt < 2) { + delay = 1000; + } else if (attempt < 4) { + delay = 2000; + } else if (attempt < 7) { + delay = 5000; + } else { + delay = 10000; + } + } else { + if (attempt < 2) { + delay = 200; + } else if (attempt < 4) { + delay = 500; + } else if (attempt < 6) { + delay = 1000; + } else { + delay = 2000; + } + } + this.deps.setReconnectTimer( + setTimeout(() => { + console.log( + `Attempting to reconnect to MPV (attempt ${attempt + 1}, delay ${delay}ms)...`, + ); + this.connect(); + }, delay), + ); + } + + private processBuffer(): void { + const lines = this.buffer.split("\n"); + this.buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line) as MpvMessage; + this.handleMessage(msg); + } catch (e) { + console.error("Failed to parse MPV message:", line, e); + } + } + } + + private async handleMessage(msg: MpvMessage): Promise { + if (msg.event === "property-change") { + if (msg.name === "sub-text") { + const nextSubText = (msg.data as string) || ""; + this.deps.setCurrentSubText(nextSubText); + this.currentSubText = nextSubText; + const subtitleTimingTracker = this.deps.getSubtitleTimingTracker(); + if ( + subtitleTimingTracker && + this.currentSubStart !== undefined && + this.currentSubEnd !== undefined + ) { + subtitleTimingTracker.recordSubtitle( + nextSubText, + this.currentSubStart, + this.currentSubEnd, + ); + } + this.deps.subtitleWsBroadcast(nextSubText); + if (this.deps.getOverlayWindowsCount() > 0) { + const subtitleData = await this.deps.tokenizeSubtitle(nextSubText); + this.deps.broadcastToOverlayWindows("subtitle:set", subtitleData); + } + } else if (msg.name === "sub-text-ass") { + const nextSubAssText = (msg.data as string) || ""; + this.deps.setCurrentSubAssText(nextSubAssText); + this.deps.broadcastToOverlayWindows("subtitle-ass:set", nextSubAssText); + } else if (msg.name === "sub-start") { + this.currentSubStart = (msg.data as number) || 0; + const subtitleTimingTracker = this.deps.getSubtitleTimingTracker(); + if (subtitleTimingTracker && this.deps.getCurrentSubText()) { + subtitleTimingTracker.recordSubtitle( + this.deps.getCurrentSubText(), + this.currentSubStart, + this.currentSubEnd, + ); + } + } else if (msg.name === "sub-end") { + this.currentSubEnd = (msg.data as number) || 0; + if (this.pendingPauseAtSubEnd && this.currentSubEnd > 0) { + this.pauseAtTime = this.currentSubEnd; + this.pendingPauseAtSubEnd = false; + this.send({ command: ["set_property", "pause", false] }); + } + const subtitleTimingTracker = this.deps.getSubtitleTimingTracker(); + if (subtitleTimingTracker && this.deps.getCurrentSubText()) { + subtitleTimingTracker.recordSubtitle( + this.deps.getCurrentSubText(), + this.currentSubStart, + this.currentSubEnd, + ); + } + } else if (msg.name === "secondary-sub-text") { + this.currentSecondarySubText = (msg.data as string) || ""; + this.deps.broadcastToOverlayWindows( + "secondary-subtitle:set", + this.currentSecondarySubText, + ); + } else if (msg.name === "aid") { + this.currentAudioTrackId = + typeof msg.data === "number" ? (msg.data as number) : null; + this.syncCurrentAudioStreamIndex(); + } else if (msg.name === "time-pos") { + this.currentTimePos = (msg.data as number) || 0; + if ( + this.pauseAtTime !== null && + this.currentTimePos >= this.pauseAtTime + ) { + this.pauseAtTime = null; + this.send({ command: ["set_property", "pause", true] }); + } + } else if (msg.name === "path") { + this.currentVideoPath = (msg.data as string) || ""; + this.deps.updateCurrentMediaPath(msg.data); + this.autoLoadSecondarySubTrack(); + this.syncCurrentAudioStreamIndex(); + } else if (msg.name === "sub-pos") { + this.deps.updateMpvSubtitleRenderMetrics({ subPos: msg.data as number }); + } else if (msg.name === "sub-font-size") { + this.deps.updateMpvSubtitleRenderMetrics({ + subFontSize: msg.data as number, + }); + } else if (msg.name === "sub-scale") { + this.deps.updateMpvSubtitleRenderMetrics({ subScale: msg.data as number }); + } else if (msg.name === "sub-margin-y") { + this.deps.updateMpvSubtitleRenderMetrics({ + subMarginY: msg.data as number, + }); + } else if (msg.name === "sub-margin-x") { + this.deps.updateMpvSubtitleRenderMetrics({ + subMarginX: msg.data as number, + }); + } else if (msg.name === "sub-font") { + this.deps.updateMpvSubtitleRenderMetrics({ subFont: msg.data as string }); + } else if (msg.name === "sub-spacing") { + this.deps.updateMpvSubtitleRenderMetrics({ + subSpacing: msg.data as number, + }); + } else if (msg.name === "sub-bold") { + this.deps.updateMpvSubtitleRenderMetrics({ + subBold: asBoolean(msg.data, this.deps.getMpvSubtitleRenderMetrics().subBold), + }); + } else if (msg.name === "sub-italic") { + this.deps.updateMpvSubtitleRenderMetrics({ + subItalic: asBoolean( + msg.data, + this.deps.getMpvSubtitleRenderMetrics().subItalic, + ), + }); + } else if (msg.name === "sub-border-size") { + this.deps.updateMpvSubtitleRenderMetrics({ + subBorderSize: msg.data as number, + }); + } else if (msg.name === "sub-shadow-offset") { + this.deps.updateMpvSubtitleRenderMetrics({ + subShadowOffset: msg.data as number, + }); + } else if (msg.name === "sub-ass-override") { + this.deps.updateMpvSubtitleRenderMetrics({ + subAssOverride: msg.data as string, + }); + } else if (msg.name === "sub-scale-by-window") { + this.deps.updateMpvSubtitleRenderMetrics({ + subScaleByWindow: asBoolean( + msg.data, + this.deps.getMpvSubtitleRenderMetrics().subScaleByWindow, + ), + }); + } else if (msg.name === "sub-use-margins") { + this.deps.updateMpvSubtitleRenderMetrics({ + subUseMargins: asBoolean( + msg.data, + this.deps.getMpvSubtitleRenderMetrics().subUseMargins, + ), + }); + } else if (msg.name === "osd-height") { + this.deps.updateMpvSubtitleRenderMetrics({ + osdHeight: msg.data as number, + }); + } else if (msg.name === "osd-dimensions") { + const dims = msg.data as Record | null; + if (!dims) { + this.deps.updateMpvSubtitleRenderMetrics({ osdDimensions: null }); + } else { + this.deps.updateMpvSubtitleRenderMetrics({ + osdDimensions: { + w: asFiniteNumber(dims.w, 0), + h: asFiniteNumber(dims.h, 0), + ml: asFiniteNumber(dims.ml, 0), + mr: asFiniteNumber(dims.mr, 0), + mt: asFiniteNumber(dims.mt, 0), + mb: asFiniteNumber(dims.mb, 0), + }, + }); + } + } + } else if (msg.request_id) { + const pending = this.pendingRequests.get(msg.request_id); + if (pending) { + this.pendingRequests.delete(msg.request_id); + pending(msg); + return; + } + + if (msg.data === undefined) { + return; + } + + if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY) { + const tracks = msg.data as Array<{ + type: string; + lang?: string; + id: number; + }>; + if (Array.isArray(tracks)) { + const config = this.deps.getResolvedConfig(); + const languages = config.secondarySub?.secondarySubLanguages || []; + const subTracks = tracks.filter((t) => t.type === "sub"); + for (const lang of languages) { + const match = subTracks.find((t) => t.lang === lang); + if (match) { + this.send({ + command: ["set_property", "secondary-sid", match.id], + }); + this.deps.showMpvOsd( + `Secondary subtitle: ${lang} (track ${match.id})`, + ); + break; + } + } + } + } else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) { + this.updateCurrentAudioStreamIndex( + msg.data as Array<{ + type?: string; + id?: number; + selected?: boolean; + "ff-index"?: number; + }>, + ); + } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) { + const nextSubText = (msg.data as string) || ""; + this.deps.setCurrentSubText(nextSubText); + this.currentSubText = nextSubText; + this.deps.subtitleWsBroadcast(nextSubText); + if (this.deps.getOverlayWindowsCount() > 0) { + this.deps.tokenizeSubtitle(nextSubText).then((subtitleData) => { + this.deps.broadcastToOverlayWindows("subtitle:set", subtitleData); + }); + } + } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT_ASS) { + const nextSubAssText = (msg.data as string) || ""; + this.deps.setCurrentSubAssText(nextSubAssText); + this.deps.broadcastToOverlayWindows("subtitle-ass:set", nextSubAssText); + } else if (msg.request_id === MPV_REQUEST_ID_PATH) { + this.deps.updateCurrentMediaPath(msg.data); + } else if (msg.request_id === MPV_REQUEST_ID_AID) { + this.currentAudioTrackId = + typeof msg.data === "number" ? (msg.data as number) : null; + this.syncCurrentAudioStreamIndex(); + } else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUBTEXT) { + this.currentSecondarySubText = (msg.data as string) || ""; + this.deps.broadcastToOverlayWindows( + "secondary-subtitle:set", + this.currentSecondarySubText, + ); + } else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY) { + if (!this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) { + this.deps.setPreviousSecondarySubVisibility(null); + return; + } + this.deps.setPreviousSecondarySubVisibility( + msg.data === true || msg.data === "yes", + ); + this.send({ + command: ["set_property", "secondary-sub-visibility", "no"], + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_POS) { + this.deps.updateMpvSubtitleRenderMetrics({ subPos: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT_SIZE) { + this.deps.updateMpvSubtitleRenderMetrics({ + subFontSize: msg.data as number, + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE) { + this.deps.updateMpvSubtitleRenderMetrics({ subScale: msg.data as number }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_Y) { + this.deps.updateMpvSubtitleRenderMetrics({ + subMarginY: msg.data as number, + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_X) { + this.deps.updateMpvSubtitleRenderMetrics({ + subMarginX: msg.data as number, + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT) { + this.deps.updateMpvSubtitleRenderMetrics({ subFont: msg.data as string }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SPACING) { + this.deps.updateMpvSubtitleRenderMetrics({ + subSpacing: msg.data as number, + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_BOLD) { + this.deps.updateMpvSubtitleRenderMetrics({ + subBold: asBoolean(msg.data, this.deps.getMpvSubtitleRenderMetrics().subBold), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_ITALIC) { + this.deps.updateMpvSubtitleRenderMetrics({ + subItalic: asBoolean( + msg.data, + this.deps.getMpvSubtitleRenderMetrics().subItalic, + ), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_BORDER_SIZE) { + this.deps.updateMpvSubtitleRenderMetrics({ + subBorderSize: msg.data as number, + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SHADOW_OFFSET) { + this.deps.updateMpvSubtitleRenderMetrics({ + subShadowOffset: msg.data as number, + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_ASS_OVERRIDE) { + this.deps.updateMpvSubtitleRenderMetrics({ + subAssOverride: msg.data as string, + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW) { + this.deps.updateMpvSubtitleRenderMetrics({ + subScaleByWindow: asBoolean( + msg.data, + this.deps.getMpvSubtitleRenderMetrics().subScaleByWindow, + ), + }); + } else if (msg.request_id === MPV_REQUEST_ID_SUB_USE_MARGINS) { + this.deps.updateMpvSubtitleRenderMetrics({ + subUseMargins: asBoolean( + msg.data, + this.deps.getMpvSubtitleRenderMetrics().subUseMargins, + ), + }); + } else if (msg.request_id === MPV_REQUEST_ID_OSD_HEIGHT) { + this.deps.updateMpvSubtitleRenderMetrics({ + osdHeight: msg.data as number, + }); + } else if (msg.request_id === MPV_REQUEST_ID_OSD_DIMENSIONS) { + const dims = msg.data as Record | null; + if (!dims) { + this.deps.updateMpvSubtitleRenderMetrics({ osdDimensions: null }); + } else { + this.deps.updateMpvSubtitleRenderMetrics({ + osdDimensions: { + w: asFiniteNumber(dims.w, 0), + h: asFiniteNumber(dims.h, 0), + ml: asFiniteNumber(dims.ml, 0), + mr: asFiniteNumber(dims.mr, 0), + mt: asFiniteNumber(dims.mt, 0), + mb: asFiniteNumber(dims.mb, 0), + }, + }); + } + } + } + } + + private autoLoadSecondarySubTrack(): void { + const config = this.deps.getResolvedConfig(); + if (!config.secondarySub?.autoLoadSecondarySub) return; + const languages = config.secondarySub.secondarySubLanguages; + if (!languages || languages.length === 0) return; + + setTimeout(() => { + this.send({ + command: ["get_property", "track-list"], + request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY, + }); + }, 500); + } + + private syncCurrentAudioStreamIndex(): void { + this.send({ + command: ["get_property", "track-list"], + request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO, + }); + } + + private updateCurrentAudioStreamIndex( + tracks: Array<{ + type?: string; + id?: number; + selected?: boolean; + "ff-index"?: number; + }>, + ): void { + if (!Array.isArray(tracks)) { + this.currentAudioStreamIndex = null; + return; + } + + const audioTracks = tracks.filter((track) => track.type === "audio"); + const activeTrack = + audioTracks.find((track) => track.id === this.currentAudioTrackId) || + audioTracks.find((track) => track.selected === true); + + const ffIndex = activeTrack?.["ff-index"]; + this.currentAudioStreamIndex = + typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0 + ? ffIndex + : null; + } + + send(command: { command: unknown[]; request_id?: number }): boolean { + if (!this.connected || !this.socket) { + return false; + } + const msg = JSON.stringify(command) + "\n"; + this.socket.write(msg); + return true; + } + + request(command: unknown[]): Promise { + return new Promise((resolve, reject) => { + if (!this.connected || !this.socket) { + reject(new Error("MPV not connected")); + return; + } + + const requestId = this.nextDynamicRequestId++; + this.pendingRequests.set(requestId, resolve); + const sent = this.send({ command, request_id: requestId }); + if (!sent) { + this.pendingRequests.delete(requestId); + reject(new Error("Failed to send MPV request")); + return; + } + + setTimeout(() => { + if (this.pendingRequests.delete(requestId)) { + reject(new Error("MPV request timed out")); + } + }, 4000); + }); + } + + async requestProperty(name: string): Promise { + const response = await this.request(["get_property", name]); + if (response.error && response.error !== "success") { + throw new Error( + `Failed to read MPV property '${name}': ${response.error}`, + ); + } + return response.data; + } + + private failPendingRequests(): void { + for (const [requestId, resolve] of this.pendingRequests.entries()) { + resolve({ request_id: requestId, error: "disconnected" }); + } + this.pendingRequests.clear(); + } + + private subscribeToProperties(): void { + this.send({ command: ["observe_property", 1, "sub-text"] }); + this.send({ command: ["observe_property", 2, "path"] }); + this.send({ command: ["observe_property", 3, "sub-start"] }); + this.send({ command: ["observe_property", 4, "sub-end"] }); + this.send({ command: ["observe_property", 5, "time-pos"] }); + this.send({ command: ["observe_property", 6, "secondary-sub-text"] }); + this.send({ command: ["observe_property", 7, "aid"] }); + this.send({ command: ["observe_property", 8, "sub-pos"] }); + this.send({ command: ["observe_property", 9, "sub-font-size"] }); + this.send({ command: ["observe_property", 10, "sub-scale"] }); + this.send({ command: ["observe_property", 11, "sub-margin-y"] }); + this.send({ command: ["observe_property", 12, "sub-margin-x"] }); + this.send({ command: ["observe_property", 13, "sub-font"] }); + this.send({ command: ["observe_property", 14, "sub-spacing"] }); + this.send({ command: ["observe_property", 15, "sub-bold"] }); + this.send({ command: ["observe_property", 16, "sub-italic"] }); + this.send({ command: ["observe_property", 17, "sub-scale-by-window"] }); + this.send({ command: ["observe_property", 18, "osd-height"] }); + this.send({ command: ["observe_property", 19, "osd-dimensions"] }); + this.send({ command: ["observe_property", 20, "sub-text-ass"] }); + this.send({ command: ["observe_property", 21, "sub-border-size"] }); + this.send({ command: ["observe_property", 22, "sub-shadow-offset"] }); + this.send({ command: ["observe_property", 23, "sub-ass-override"] }); + this.send({ command: ["observe_property", 24, "sub-use-margins"] }); + } + + private getInitialState(): void { + this.send({ + command: ["get_property", "sub-text"], + request_id: MPV_REQUEST_ID_SUBTEXT, + }); + this.send({ + command: ["get_property", "sub-text-ass"], + request_id: MPV_REQUEST_ID_SUBTEXT_ASS, + }); + this.send({ + command: ["get_property", "path"], + request_id: MPV_REQUEST_ID_PATH, + }); + this.send({ + command: ["get_property", "secondary-sub-text"], + request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT, + }); + this.send({ + command: ["get_property", "aid"], + request_id: MPV_REQUEST_ID_AID, + }); + this.send({ + command: ["get_property", "sub-pos"], + request_id: MPV_REQUEST_ID_SUB_POS, + }); + this.send({ + command: ["get_property", "sub-font-size"], + request_id: MPV_REQUEST_ID_SUB_FONT_SIZE, + }); + this.send({ + command: ["get_property", "sub-scale"], + request_id: MPV_REQUEST_ID_SUB_SCALE, + }); + this.send({ + command: ["get_property", "sub-margin-y"], + request_id: MPV_REQUEST_ID_SUB_MARGIN_Y, + }); + this.send({ + command: ["get_property", "sub-margin-x"], + request_id: MPV_REQUEST_ID_SUB_MARGIN_X, + }); + this.send({ + command: ["get_property", "sub-font"], + request_id: MPV_REQUEST_ID_SUB_FONT, + }); + this.send({ + command: ["get_property", "sub-spacing"], + request_id: MPV_REQUEST_ID_SUB_SPACING, + }); + this.send({ + command: ["get_property", "sub-bold"], + request_id: MPV_REQUEST_ID_SUB_BOLD, + }); + this.send({ + command: ["get_property", "sub-italic"], + request_id: MPV_REQUEST_ID_SUB_ITALIC, + }); + this.send({ + command: ["get_property", "sub-scale-by-window"], + request_id: MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW, + }); + this.send({ + command: ["get_property", "osd-height"], + request_id: MPV_REQUEST_ID_OSD_HEIGHT, + }); + this.send({ + command: ["get_property", "osd-dimensions"], + request_id: MPV_REQUEST_ID_OSD_DIMENSIONS, + }); + this.send({ + command: ["get_property", "sub-border-size"], + request_id: MPV_REQUEST_ID_SUB_BORDER_SIZE, + }); + this.send({ + command: ["get_property", "sub-shadow-offset"], + request_id: MPV_REQUEST_ID_SUB_SHADOW_OFFSET, + }); + this.send({ + command: ["get_property", "sub-ass-override"], + request_id: MPV_REQUEST_ID_SUB_ASS_OVERRIDE, + }); + this.send({ + command: ["get_property", "sub-use-margins"], + request_id: MPV_REQUEST_ID_SUB_USE_MARGINS, + }); + } + + setSubVisibility(visible: boolean): void { + this.send({ + command: ["set_property", "sub-visibility", visible ? "yes" : "no"], + }); + } + + replayCurrentSubtitle(): void { + this.pendingPauseAtSubEnd = true; + this.send({ command: ["sub-seek", 0] }); + } + + playNextSubtitle(): void { + this.pendingPauseAtSubEnd = true; + this.send({ command: ["sub-seek", 1] }); + } +} diff --git a/src/core/services/tokenizer-service.ts b/src/core/services/tokenizer-service.ts new file mode 100644 index 0000000..57d7c87 --- /dev/null +++ b/src/core/services/tokenizer-service.ts @@ -0,0 +1,305 @@ +import { BrowserWindow, Extension, session } from "electron"; +import { MergedToken, PartOfSpeech, SubtitleData } from "../../types"; + +interface YomitanParseHeadword { + term?: unknown; +} + +interface YomitanParseSegment { + text?: unknown; + reading?: unknown; + headwords?: unknown; +} + +interface YomitanParseResultItem { + source?: unknown; + index?: unknown; + content?: unknown; +} + +export interface TokenizerServiceDeps { + getYomitanExt: () => Extension | null; + getYomitanParserWindow: () => BrowserWindow | null; + setYomitanParserWindow: (window: BrowserWindow | null) => void; + getYomitanParserReadyPromise: () => Promise | null; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + getYomitanParserInitPromise: () => Promise | null; + setYomitanParserInitPromise: (promise: Promise | null) => void; + tokenizeWithMecab: (text: string) => Promise; +} + +function extractYomitanHeadword(segment: YomitanParseSegment): string { + const headwords = segment.headwords; + if (!Array.isArray(headwords) || headwords.length === 0) { + return ""; + } + + const firstGroup = headwords[0]; + if (!Array.isArray(firstGroup) || firstGroup.length === 0) { + return ""; + } + + const firstHeadword = firstGroup[0] as YomitanParseHeadword; + return typeof firstHeadword?.term === "string" ? firstHeadword.term : ""; +} + +function mapYomitanParseResultsToMergedTokens( + parseResults: unknown, +): MergedToken[] | null { + if (!Array.isArray(parseResults) || parseResults.length === 0) { + return null; + } + + const scanningItems = parseResults.filter((item) => { + const resultItem = item as YomitanParseResultItem; + return ( + resultItem && + resultItem.source === "scanning-parser" && + Array.isArray(resultItem.content) + ); + }) as YomitanParseResultItem[]; + + if (scanningItems.length === 0) { + return null; + } + + const primaryItem = + scanningItems.find((item) => item.index === 0) || scanningItems[0]; + const content = primaryItem.content; + if (!Array.isArray(content)) { + return null; + } + + const tokens: MergedToken[] = []; + let charOffset = 0; + + for (const line of content) { + if (!Array.isArray(line)) { + continue; + } + + let surface = ""; + let reading = ""; + let headword = ""; + + for (const rawSegment of line) { + const segment = rawSegment as YomitanParseSegment; + if (!segment || typeof segment !== "object") { + continue; + } + + const segmentText = segment.text; + if (typeof segmentText !== "string" || segmentText.length === 0) { + continue; + } + + surface += segmentText; + + if (typeof segment.reading === "string") { + reading += segment.reading; + } + + if (!headword) { + headword = extractYomitanHeadword(segment); + } + } + + if (!surface) { + continue; + } + + const start = charOffset; + const end = start + surface.length; + charOffset = end; + + tokens.push({ + surface, + reading, + headword: headword || surface, + startPos: start, + endPos: end, + partOfSpeech: PartOfSpeech.other, + isMerged: true, + }); + } + + return tokens.length > 0 ? tokens : null; +} + +async function ensureYomitanParserWindow( + deps: TokenizerServiceDeps, +): Promise { + const yomitanExt = deps.getYomitanExt(); + if (!yomitanExt) { + return false; + } + + const currentWindow = deps.getYomitanParserWindow(); + if (currentWindow && !currentWindow.isDestroyed()) { + return true; + } + + const existingInitPromise = deps.getYomitanParserInitPromise(); + if (existingInitPromise) { + return existingInitPromise; + } + + const initPromise = (async () => { + const parserWindow = new BrowserWindow({ + show: false, + width: 800, + height: 600, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + session: session.defaultSession, + }, + }); + deps.setYomitanParserWindow(parserWindow); + + deps.setYomitanParserReadyPromise( + new Promise((resolve, reject) => { + parserWindow.webContents.once("did-finish-load", () => resolve()); + parserWindow.webContents.once( + "did-fail-load", + (_event, _errorCode, errorDescription) => { + reject(new Error(errorDescription)); + }, + ); + }), + ); + + parserWindow.on("closed", () => { + if (deps.getYomitanParserWindow() === parserWindow) { + deps.setYomitanParserWindow(null); + deps.setYomitanParserReadyPromise(null); + } + }); + + try { + await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`); + const readyPromise = deps.getYomitanParserReadyPromise(); + if (readyPromise) { + await readyPromise; + } + return true; + } catch (err) { + console.error( + "Failed to initialize Yomitan parser window:", + (err as Error).message, + ); + if (!parserWindow.isDestroyed()) { + parserWindow.destroy(); + } + if (deps.getYomitanParserWindow() === parserWindow) { + deps.setYomitanParserWindow(null); + deps.setYomitanParserReadyPromise(null); + } + return false; + } finally { + deps.setYomitanParserInitPromise(null); + } + })(); + + deps.setYomitanParserInitPromise(initPromise); + return initPromise; +} + +async function parseWithYomitanInternalParser( + text: string, + deps: TokenizerServiceDeps, +): Promise { + const yomitanExt = deps.getYomitanExt(); + if (!text || !yomitanExt) { + return null; + } + + const isReady = await ensureYomitanParserWindow(deps); + const parserWindow = deps.getYomitanParserWindow(); + if (!isReady || !parserWindow || parserWindow.isDestroyed()) { + return null; + } + + const script = ` + (async () => { + const invoke = (action, params) => + new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ action, params }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!response || typeof response !== "object") { + reject(new Error("Invalid response from Yomitan backend")); + return; + } + if (response.error) { + reject(new Error(response.error.message || "Yomitan backend error")); + return; + } + resolve(response.result); + }); + }); + + const optionsFull = await invoke("optionsGetFull", undefined); + const profileIndex = optionsFull.profileCurrent; + const scanLength = + optionsFull.profiles?.[profileIndex]?.options?.scanning?.length ?? 40; + + return await invoke("parseText", { + text: ${JSON.stringify(text)}, + optionsContext: { index: profileIndex }, + scanLength, + useInternalParser: true, + useMecabParser: false + }); + })(); + `; + + try { + const parseResults = await parserWindow.webContents.executeJavaScript( + script, + true, + ); + return mapYomitanParseResultsToMergedTokens(parseResults); + } catch (err) { + console.error("Yomitan parser request failed:", (err as Error).message); + return null; + } +} + +export async function tokenizeSubtitleService( + text: string, + deps: TokenizerServiceDeps, +): Promise { + const displayText = text + .replace(/\r\n/g, "\n") + .replace(/\\N/g, "\n") + .replace(/\\n/g, "\n") + .trim(); + + if (!displayText) { + return { text, tokens: null }; + } + + const tokenizeText = displayText + .replace(/\n/g, " ") + .replace(/\s+/g, " ") + .trim(); + + const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps); + if (yomitanTokens && yomitanTokens.length > 0) { + return { text: displayText, tokens: yomitanTokens }; + } + + try { + const mecabTokens = await deps.tokenizeWithMecab(tokenizeText); + if (mecabTokens && mecabTokens.length > 0) { + return { text: displayText, tokens: mecabTokens }; + } + } catch (err) { + console.error("Tokenization error:", (err as Error).message); + } + + return { text: displayText, tokens: null }; +} diff --git a/src/core/services/yomitan-extension-loader-service.ts b/src/core/services/yomitan-extension-loader-service.ts new file mode 100644 index 0000000..79edda5 --- /dev/null +++ b/src/core/services/yomitan-extension-loader-service.ts @@ -0,0 +1,109 @@ +import { BrowserWindow, Extension, session } from "electron"; +import * as fs from "fs"; +import * as path from "path"; + +export interface YomitanExtensionLoaderDeps { + userDataPath: string; + getYomitanParserWindow: () => BrowserWindow | null; + setYomitanParserWindow: (window: BrowserWindow | null) => void; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + setYomitanParserInitPromise: (promise: Promise | null) => void; + setYomitanExtension: (extension: Extension | null) => void; +} + +function ensureExtensionCopy(sourceDir: string, userDataPath: string): string { + if (process.platform === "win32") { + return sourceDir; + } + + const extensionsRoot = path.join(userDataPath, "extensions"); + const targetDir = path.join(extensionsRoot, "yomitan"); + + const sourceManifest = path.join(sourceDir, "manifest.json"); + const targetManifest = path.join(targetDir, "manifest.json"); + + let shouldCopy = !fs.existsSync(targetDir); + if ( + !shouldCopy && + fs.existsSync(sourceManifest) && + fs.existsSync(targetManifest) + ) { + try { + const sourceVersion = ( + JSON.parse(fs.readFileSync(sourceManifest, "utf-8")) as { + version: string; + } + ).version; + const targetVersion = ( + JSON.parse(fs.readFileSync(targetManifest, "utf-8")) as { + version: string; + } + ).version; + shouldCopy = sourceVersion !== targetVersion; + } catch { + shouldCopy = true; + } + } + + if (shouldCopy) { + fs.mkdirSync(extensionsRoot, { recursive: true }); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.cpSync(sourceDir, targetDir, { recursive: true }); + console.log(`Copied yomitan extension to ${targetDir}`); + } + + return targetDir; +} + +export async function loadYomitanExtensionService( + deps: YomitanExtensionLoaderDeps, +): Promise { + const searchPaths = [ + path.join(__dirname, "..", "..", "..", "vendor", "yomitan"), + path.join(process.resourcesPath, "yomitan"), + "/usr/share/SubMiner/yomitan", + path.join(deps.userDataPath, "yomitan"), + ]; + + let extPath: string | null = null; + for (const p of searchPaths) { + if (fs.existsSync(p)) { + extPath = p; + break; + } + } + + if (!extPath) { + console.error("Yomitan extension not found in any search path"); + console.error("Install Yomitan to one of:", searchPaths); + return null; + } + + extPath = ensureExtensionCopy(extPath, deps.userDataPath); + + const parserWindow = deps.getYomitanParserWindow(); + if (parserWindow && !parserWindow.isDestroyed()) { + parserWindow.destroy(); + } + deps.setYomitanParserWindow(null); + deps.setYomitanParserReadyPromise(null); + deps.setYomitanParserInitPromise(null); + + try { + const extensions = session.defaultSession.extensions; + const extension = extensions + ? await extensions.loadExtension(extPath, { + allowFileAccess: true, + }) + : await session.defaultSession.loadExtension(extPath, { + allowFileAccess: true, + }); + deps.setYomitanExtension(extension); + return extension; + } catch (err) { + console.error("Failed to load Yomitan extension:", (err as Error).message); + console.error("Full error:", err); + deps.setYomitanExtension(null); + return null; + } +} diff --git a/src/main.ts b/src/main.ts index 34c74d4..1b22765 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,6 @@ import { app, BrowserWindow, - session, globalShortcut, clipboard, shell, @@ -41,7 +40,6 @@ protocol.registerSchemesAsPrivileged([ ]); import * as path from "path"; -import * as net from "net"; import * as http from "http"; import * as https from "https"; import * as os from "os"; @@ -52,8 +50,6 @@ import { mergeTokens } from "./token-merger"; import { createWindowTracker, BaseWindowTracker } from "./window-trackers"; import { Config, - PartOfSpeech, - MergedToken, JimakuApiResponse, JimakuDownloadResult, JimakuEntry, @@ -66,7 +62,6 @@ import { Keybinding, WindowGeometry, SecondarySubMode, - MpvClient, SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult, @@ -129,6 +124,12 @@ import { import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-fallback-runner"; import { showDesktopNotification } from "./core/utils/notification"; import { openYomitanSettingsWindow } from "./core/services/yomitan-settings-service"; +import { tokenizeSubtitleService } from "./core/services/tokenizer-service"; +import { loadYomitanExtensionService } from "./core/services/yomitan-extension-loader-service"; +import { + MpvIpcClient, + MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, +} from "./core/services/mpv-service"; import { handleMpvCommandFromIpcService, runSubsyncManualFromIpcService, @@ -597,7 +598,47 @@ if (initialArgs.generateConfig && !shouldStartApp(initialArgs)) { loadSubtitlePosition(); keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); - mpvClient = new MpvIpcClient(mpvSocketPath); + mpvClient = new MpvIpcClient(mpvSocketPath, { + getResolvedConfig: () => getResolvedConfig(), + autoStartOverlay, + setOverlayVisible: (visible) => setOverlayVisible(visible), + shouldBindVisibleOverlayToMpvSubVisibility: () => + shouldBindVisibleOverlayToMpvSubVisibility(), + isVisibleOverlayVisible: () => visibleOverlayVisible, + getReconnectTimer: () => reconnectTimer, + setReconnectTimer: (timer) => { + reconnectTimer = timer; + }, + getCurrentSubText: () => currentSubText, + setCurrentSubText: (text) => { + currentSubText = text; + }, + setCurrentSubAssText: (text) => { + currentSubAssText = text; + }, + getSubtitleTimingTracker: () => subtitleTimingTracker, + subtitleWsBroadcast: (text) => { + subtitleWsService.broadcast(text); + }, + getOverlayWindowsCount: () => getOverlayWindows().length, + tokenizeSubtitle: (text) => tokenizeSubtitle(text), + broadcastToOverlayWindows: (channel, ...args) => { + broadcastToOverlayWindows(channel, ...args); + }, + updateCurrentMediaPath: (mediaPath) => { + updateCurrentMediaPath(mediaPath); + }, + updateMpvSubtitleRenderMetrics: (patch) => { + updateMpvSubtitleRenderMetrics(patch); + }, + getMpvSubtitleRenderMetrics: () => mpvSubtitleRenderMetrics, + setPreviousSecondarySubVisibility: (value) => { + previousSecondarySubVisibility = value; + }, + showMpvOsd: (text) => { + showMpvOsd(text); + }, + }); configService.reloadConfig(); const config = getResolvedConfig(); @@ -972,971 +1013,32 @@ function updateMpvSubtitleRenderMetrics( ); } -interface MpvMessage { - event?: string; - name?: string; - data?: unknown; - request_id?: number; - error?: string; -} - -const MPV_REQUEST_ID_SUBTEXT = 101; -const MPV_REQUEST_ID_PATH = 102; -const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103; -const MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY = 104; -const MPV_REQUEST_ID_AID = 105; -const MPV_REQUEST_ID_SUB_POS = 106; -const MPV_REQUEST_ID_SUB_FONT_SIZE = 107; -const MPV_REQUEST_ID_SUB_SCALE = 108; -const MPV_REQUEST_ID_SUB_MARGIN_Y = 109; -const MPV_REQUEST_ID_SUB_MARGIN_X = 110; -const MPV_REQUEST_ID_SUB_FONT = 111; -const MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW = 112; -const MPV_REQUEST_ID_OSD_HEIGHT = 113; -const MPV_REQUEST_ID_OSD_DIMENSIONS = 114; -const MPV_REQUEST_ID_SUBTEXT_ASS = 115; -const MPV_REQUEST_ID_SUB_SPACING = 116; -const MPV_REQUEST_ID_SUB_BOLD = 117; -const MPV_REQUEST_ID_SUB_ITALIC = 118; -const MPV_REQUEST_ID_SUB_BORDER_SIZE = 119; -const MPV_REQUEST_ID_SUB_SHADOW_OFFSET = 120; -const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121; -const MPV_REQUEST_ID_SUB_USE_MARGINS = 122; -const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200; -const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201; - -class MpvIpcClient implements MpvClient { - private socketPath: string; - public socket: net.Socket | null = null; - private buffer = ""; - public connected = false; - private connecting = false; - private reconnectAttempt = 0; - private firstConnection = true; - private hasConnectedOnce = false; - public currentVideoPath = ""; - public currentTimePos = 0; - public currentSubStart = 0; - public currentSubEnd = 0; - public currentSubText = ""; - public currentSecondarySubText = ""; - public currentAudioStreamIndex: number | null = null; - private currentAudioTrackId: number | null = null; - private pauseAtTime: number | null = null; - private pendingPauseAtSubEnd = false; - private nextDynamicRequestId = 1000; - private pendingRequests = new Map void>(); - - constructor(socketPath: string) { - this.socketPath = socketPath; - } - - setSocketPath(socketPath: string): void { - this.socketPath = socketPath; - } - - connect(): void { - if (this.connected || this.connecting) { - return; - } - - if (this.socket) { - this.socket.destroy(); - } - - this.connecting = true; - this.socket = new net.Socket(); - - this.socket.on("connect", () => { - console.log("Connected to MPV socket"); - this.connected = true; - this.connecting = false; - this.reconnectAttempt = 0; - this.hasConnectedOnce = true; - this.subscribeToProperties(); - this.getInitialState(); - - const shouldAutoStart = - autoStartOverlay || getResolvedConfig().auto_start_overlay === true; - if (this.firstConnection && shouldAutoStart) { - console.log("Auto-starting overlay, hiding mpv subtitles"); - setTimeout(() => { - setOverlayVisible(true); - }, 100); - } else if (shouldBindVisibleOverlayToMpvSubVisibility()) { - this.setSubVisibility(!visibleOverlayVisible); - } - - this.firstConnection = false; - }); - - this.socket.on("data", (data: Buffer) => { - this.buffer += data.toString(); - this.processBuffer(); - }); - - this.socket.on("error", (err: Error) => { - console.error("MPV socket error:", err.message); - this.connected = false; - this.connecting = false; - this.failPendingRequests(); - }); - - this.socket.on("close", () => { - console.log("MPV socket closed"); - this.connected = false; - this.connecting = false; - this.failPendingRequests(); - this.scheduleReconnect(); - }); - - this.socket.connect(this.socketPath); - } - - private scheduleReconnect(): void { - if (reconnectTimer) { - clearTimeout(reconnectTimer); - } - const attempt = this.reconnectAttempt++; - let delay: number; - if (this.hasConnectedOnce) { - if (attempt < 2) { - delay = 1000; - } else if (attempt < 4) { - delay = 2000; - } else if (attempt < 7) { - delay = 5000; - } else { - delay = 10000; - } - } else { - if (attempt < 2) { - delay = 200; - } else if (attempt < 4) { - delay = 500; - } else if (attempt < 6) { - delay = 1000; - } else { - delay = 2000; - } - } - reconnectTimer = setTimeout(() => { - console.log( - `Attempting to reconnect to MPV (attempt ${attempt + 1}, delay ${delay}ms)...`, - ); - this.connect(); - }, delay); - } - - private processBuffer(): void { - const lines = this.buffer.split("\n"); - this.buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.trim()) continue; - try { - const msg = JSON.parse(line) as MpvMessage; - this.handleMessage(msg); - } catch (e) { - console.error("Failed to parse MPV message:", line, e); - } - } - } - - private async handleMessage(msg: MpvMessage): Promise { - if (msg.event === "property-change") { - if (msg.name === "sub-text") { - currentSubText = (msg.data as string) || ""; - this.currentSubText = currentSubText; - if ( - subtitleTimingTracker && - this.currentSubStart !== undefined && - this.currentSubEnd !== undefined - ) { - subtitleTimingTracker.recordSubtitle( - currentSubText, - this.currentSubStart, - this.currentSubEnd, - ); - } - subtitleWsService.broadcast(currentSubText); - if (getOverlayWindows().length > 0) { - const subtitleData = await tokenizeSubtitle(currentSubText); - broadcastToOverlayWindows("subtitle:set", subtitleData); - } - } else if (msg.name === "sub-text-ass") { - currentSubAssText = (msg.data as string) || ""; - broadcastToOverlayWindows("subtitle-ass:set", currentSubAssText); - } else if (msg.name === "sub-start") { - this.currentSubStart = (msg.data as number) || 0; - if (subtitleTimingTracker && currentSubText) { - subtitleTimingTracker.recordSubtitle( - currentSubText, - this.currentSubStart, - this.currentSubEnd, - ); - } - } else if (msg.name === "sub-end") { - this.currentSubEnd = (msg.data as number) || 0; - if (this.pendingPauseAtSubEnd && this.currentSubEnd > 0) { - this.pauseAtTime = this.currentSubEnd; - this.pendingPauseAtSubEnd = false; - this.send({ command: ["set_property", "pause", false] }); - } - if (subtitleTimingTracker && currentSubText) { - subtitleTimingTracker.recordSubtitle( - currentSubText, - this.currentSubStart, - this.currentSubEnd, - ); - } - } else if (msg.name === "secondary-sub-text") { - this.currentSecondarySubText = (msg.data as string) || ""; - broadcastToOverlayWindows( - "secondary-subtitle:set", - this.currentSecondarySubText, - ); - } else if (msg.name === "aid") { - this.currentAudioTrackId = - typeof msg.data === "number" ? (msg.data as number) : null; - this.syncCurrentAudioStreamIndex(); - } else if (msg.name === "time-pos") { - this.currentTimePos = (msg.data as number) || 0; - if ( - this.pauseAtTime !== null && - this.currentTimePos >= this.pauseAtTime - ) { - this.pauseAtTime = null; - this.send({ command: ["set_property", "pause", true] }); - } - } else if (msg.name === "path") { - this.currentVideoPath = (msg.data as string) || ""; - updateCurrentMediaPath(msg.data); - this.autoLoadSecondarySubTrack(); - this.syncCurrentAudioStreamIndex(); - } else if (msg.name === "sub-pos") { - updateMpvSubtitleRenderMetrics({ subPos: msg.data as number }); - } else if (msg.name === "sub-font-size") { - updateMpvSubtitleRenderMetrics({ subFontSize: msg.data as number }); - } else if (msg.name === "sub-scale") { - updateMpvSubtitleRenderMetrics({ subScale: msg.data as number }); - } else if (msg.name === "sub-margin-y") { - updateMpvSubtitleRenderMetrics({ subMarginY: msg.data as number }); - } else if (msg.name === "sub-margin-x") { - updateMpvSubtitleRenderMetrics({ subMarginX: msg.data as number }); - } else if (msg.name === "sub-font") { - updateMpvSubtitleRenderMetrics({ subFont: msg.data as string }); - } else if (msg.name === "sub-spacing") { - updateMpvSubtitleRenderMetrics({ subSpacing: msg.data as number }); - } else if (msg.name === "sub-bold") { - updateMpvSubtitleRenderMetrics({ - subBold: asBoolean(msg.data, mpvSubtitleRenderMetrics.subBold), - }); - } else if (msg.name === "sub-italic") { - updateMpvSubtitleRenderMetrics({ - subItalic: asBoolean(msg.data, mpvSubtitleRenderMetrics.subItalic), - }); - } else if (msg.name === "sub-border-size") { - updateMpvSubtitleRenderMetrics({ - subBorderSize: msg.data as number, - }); - } else if (msg.name === "sub-shadow-offset") { - updateMpvSubtitleRenderMetrics({ - subShadowOffset: msg.data as number, - }); - } else if (msg.name === "sub-ass-override") { - updateMpvSubtitleRenderMetrics({ - subAssOverride: msg.data as string, - }); - } else if (msg.name === "sub-scale-by-window") { - updateMpvSubtitleRenderMetrics({ - subScaleByWindow: asBoolean( - msg.data, - mpvSubtitleRenderMetrics.subScaleByWindow, - ), - }); - } else if (msg.name === "sub-use-margins") { - updateMpvSubtitleRenderMetrics({ - subUseMargins: asBoolean( - msg.data, - mpvSubtitleRenderMetrics.subUseMargins, - ), - }); - } else if (msg.name === "osd-height") { - updateMpvSubtitleRenderMetrics({ osdHeight: msg.data as number }); - } else if (msg.name === "osd-dimensions") { - const dims = msg.data as Record | null; - if (!dims) { - updateMpvSubtitleRenderMetrics({ osdDimensions: null }); - } else { - updateMpvSubtitleRenderMetrics({ - osdDimensions: { - w: asFiniteNumber(dims.w, 0), - h: asFiniteNumber(dims.h, 0), - ml: asFiniteNumber(dims.ml, 0), - mr: asFiniteNumber(dims.mr, 0), - mt: asFiniteNumber(dims.mt, 0), - mb: asFiniteNumber(dims.mb, 0), - }, - }); - } - } - } else if (msg.request_id) { - const pending = this.pendingRequests.get(msg.request_id); - if (pending) { - this.pendingRequests.delete(msg.request_id); - pending(msg); - return; - } - - if (msg.data === undefined) { - return; - } - - if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY) { - const tracks = msg.data as Array<{ - type: string; - lang?: string; - id: number; - }>; - if (Array.isArray(tracks)) { - const config = getResolvedConfig(); - const languages = config.secondarySub?.secondarySubLanguages || []; - const subTracks = tracks.filter((t) => t.type === "sub"); - for (const lang of languages) { - const match = subTracks.find((t) => t.lang === lang); - if (match) { - this.send({ - command: ["set_property", "secondary-sid", match.id], - }); - showMpvOsd(`Secondary subtitle: ${lang} (track ${match.id})`); - break; - } - } - } - } else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) { - this.updateCurrentAudioStreamIndex( - msg.data as Array<{ - type?: string; - id?: number; - selected?: boolean; - "ff-index"?: number; - }>, - ); - } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) { - currentSubText = (msg.data as string) || ""; - if (mpvClient) { - mpvClient.currentSubText = currentSubText; - } - subtitleWsService.broadcast(currentSubText); - if (getOverlayWindows().length > 0) { - tokenizeSubtitle(currentSubText).then((subtitleData) => { - broadcastToOverlayWindows("subtitle:set", subtitleData); - }); - } - } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT_ASS) { - currentSubAssText = (msg.data as string) || ""; - broadcastToOverlayWindows("subtitle-ass:set", currentSubAssText); - } else if (msg.request_id === MPV_REQUEST_ID_PATH) { - updateCurrentMediaPath(msg.data); - } else if (msg.request_id === MPV_REQUEST_ID_AID) { - this.currentAudioTrackId = - typeof msg.data === "number" ? (msg.data as number) : null; - this.syncCurrentAudioStreamIndex(); - } else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUBTEXT) { - this.currentSecondarySubText = (msg.data as string) || ""; - broadcastToOverlayWindows( - "secondary-subtitle:set", - this.currentSecondarySubText, - ); - } else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY) { - if (!shouldBindVisibleOverlayToMpvSubVisibility()) { - previousSecondarySubVisibility = null; - return; - } - previousSecondarySubVisibility = - msg.data === true || msg.data === "yes"; - this.send({ - command: ["set_property", "secondary-sub-visibility", "no"], - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_POS) { - updateMpvSubtitleRenderMetrics({ subPos: msg.data as number }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT_SIZE) { - updateMpvSubtitleRenderMetrics({ subFontSize: msg.data as number }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE) { - updateMpvSubtitleRenderMetrics({ subScale: msg.data as number }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_Y) { - updateMpvSubtitleRenderMetrics({ subMarginY: msg.data as number }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_X) { - updateMpvSubtitleRenderMetrics({ subMarginX: msg.data as number }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT) { - updateMpvSubtitleRenderMetrics({ subFont: msg.data as string }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_SPACING) { - updateMpvSubtitleRenderMetrics({ subSpacing: msg.data as number }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_BOLD) { - updateMpvSubtitleRenderMetrics({ - subBold: asBoolean(msg.data, mpvSubtitleRenderMetrics.subBold), - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_ITALIC) { - updateMpvSubtitleRenderMetrics({ - subItalic: asBoolean(msg.data, mpvSubtitleRenderMetrics.subItalic), - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_BORDER_SIZE) { - updateMpvSubtitleRenderMetrics({ - subBorderSize: msg.data as number, - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_SHADOW_OFFSET) { - updateMpvSubtitleRenderMetrics({ - subShadowOffset: msg.data as number, - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_ASS_OVERRIDE) { - updateMpvSubtitleRenderMetrics({ - subAssOverride: msg.data as string, - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW) { - updateMpvSubtitleRenderMetrics({ - subScaleByWindow: asBoolean( - msg.data, - mpvSubtitleRenderMetrics.subScaleByWindow, - ), - }); - } else if (msg.request_id === MPV_REQUEST_ID_SUB_USE_MARGINS) { - updateMpvSubtitleRenderMetrics({ - subUseMargins: asBoolean( - msg.data, - mpvSubtitleRenderMetrics.subUseMargins, - ), - }); - } else if (msg.request_id === MPV_REQUEST_ID_OSD_HEIGHT) { - updateMpvSubtitleRenderMetrics({ osdHeight: msg.data as number }); - } else if (msg.request_id === MPV_REQUEST_ID_OSD_DIMENSIONS) { - const dims = msg.data as Record | null; - if (!dims) { - updateMpvSubtitleRenderMetrics({ osdDimensions: null }); - } else { - updateMpvSubtitleRenderMetrics({ - osdDimensions: { - w: asFiniteNumber(dims.w, 0), - h: asFiniteNumber(dims.h, 0), - ml: asFiniteNumber(dims.ml, 0), - mr: asFiniteNumber(dims.mr, 0), - mt: asFiniteNumber(dims.mt, 0), - mb: asFiniteNumber(dims.mb, 0), - }, - }); - } - } - } - } - - private autoLoadSecondarySubTrack(): void { - const config = getResolvedConfig(); - if (!config.secondarySub?.autoLoadSecondarySub) return; - const languages = config.secondarySub.secondarySubLanguages; - if (!languages || languages.length === 0) return; - - setTimeout(() => { - this.send({ - command: ["get_property", "track-list"], - request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY, - }); - }, 500); - } - - private syncCurrentAudioStreamIndex(): void { - this.send({ - command: ["get_property", "track-list"], - request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO, - }); - } - - private updateCurrentAudioStreamIndex( - tracks: Array<{ - type?: string; - id?: number; - selected?: boolean; - "ff-index"?: number; - }>, - ): void { - if (!Array.isArray(tracks)) { - this.currentAudioStreamIndex = null; - return; - } - - const audioTracks = tracks.filter((track) => track.type === "audio"); - const activeTrack = - audioTracks.find((track) => track.id === this.currentAudioTrackId) || - audioTracks.find((track) => track.selected === true); - - const ffIndex = activeTrack?.["ff-index"]; - this.currentAudioStreamIndex = - typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0 - ? ffIndex - : null; - } - - send(command: { command: unknown[]; request_id?: number }): boolean { - if (!this.connected || !this.socket) { - return false; - } - const msg = JSON.stringify(command) + "\n"; - this.socket.write(msg); - return true; - } - - request(command: unknown[]): Promise { - return new Promise((resolve, reject) => { - if (!this.connected || !this.socket) { - reject(new Error("MPV not connected")); - return; - } - - const requestId = this.nextDynamicRequestId++; - this.pendingRequests.set(requestId, resolve); - const sent = this.send({ command, request_id: requestId }); - if (!sent) { - this.pendingRequests.delete(requestId); - reject(new Error("Failed to send MPV request")); - return; - } - - setTimeout(() => { - if (this.pendingRequests.delete(requestId)) { - reject(new Error("MPV request timed out")); - } - }, 4000); - }); - } - - async requestProperty(name: string): Promise { - const response = await this.request(["get_property", name]); - if (response.error && response.error !== "success") { - throw new Error( - `Failed to read MPV property '${name}': ${response.error}`, - ); - } - return response.data; - } - - private failPendingRequests(): void { - for (const [requestId, resolve] of this.pendingRequests.entries()) { - resolve({ request_id: requestId, error: "disconnected" }); - } - this.pendingRequests.clear(); - } - - private subscribeToProperties(): void { - this.send({ command: ["observe_property", 1, "sub-text"] }); - this.send({ command: ["observe_property", 2, "path"] }); - this.send({ command: ["observe_property", 3, "sub-start"] }); - this.send({ command: ["observe_property", 4, "sub-end"] }); - this.send({ command: ["observe_property", 5, "time-pos"] }); - this.send({ command: ["observe_property", 6, "secondary-sub-text"] }); - this.send({ command: ["observe_property", 7, "aid"] }); - this.send({ command: ["observe_property", 8, "sub-pos"] }); - this.send({ command: ["observe_property", 9, "sub-font-size"] }); - this.send({ command: ["observe_property", 10, "sub-scale"] }); - this.send({ command: ["observe_property", 11, "sub-margin-y"] }); - this.send({ command: ["observe_property", 12, "sub-margin-x"] }); - this.send({ command: ["observe_property", 13, "sub-font"] }); - this.send({ command: ["observe_property", 14, "sub-spacing"] }); - this.send({ command: ["observe_property", 15, "sub-bold"] }); - this.send({ command: ["observe_property", 16, "sub-italic"] }); - this.send({ command: ["observe_property", 17, "sub-scale-by-window"] }); - this.send({ command: ["observe_property", 18, "osd-height"] }); - this.send({ command: ["observe_property", 19, "osd-dimensions"] }); - this.send({ command: ["observe_property", 20, "sub-text-ass"] }); - this.send({ command: ["observe_property", 21, "sub-border-size"] }); - this.send({ command: ["observe_property", 22, "sub-shadow-offset"] }); - this.send({ command: ["observe_property", 23, "sub-ass-override"] }); - this.send({ command: ["observe_property", 24, "sub-use-margins"] }); - } - - private getInitialState(): void { - this.send({ - command: ["get_property", "sub-text"], - request_id: MPV_REQUEST_ID_SUBTEXT, - }); - this.send({ - command: ["get_property", "sub-text-ass"], - request_id: MPV_REQUEST_ID_SUBTEXT_ASS, - }); - this.send({ - command: ["get_property", "path"], - request_id: MPV_REQUEST_ID_PATH, - }); - this.send({ - command: ["get_property", "secondary-sub-text"], - request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT, - }); - this.send({ - command: ["get_property", "aid"], - request_id: MPV_REQUEST_ID_AID, - }); - this.send({ - command: ["get_property", "sub-pos"], - request_id: MPV_REQUEST_ID_SUB_POS, - }); - this.send({ - command: ["get_property", "sub-font-size"], - request_id: MPV_REQUEST_ID_SUB_FONT_SIZE, - }); - this.send({ - command: ["get_property", "sub-scale"], - request_id: MPV_REQUEST_ID_SUB_SCALE, - }); - this.send({ - command: ["get_property", "sub-margin-y"], - request_id: MPV_REQUEST_ID_SUB_MARGIN_Y, - }); - this.send({ - command: ["get_property", "sub-margin-x"], - request_id: MPV_REQUEST_ID_SUB_MARGIN_X, - }); - this.send({ - command: ["get_property", "sub-font"], - request_id: MPV_REQUEST_ID_SUB_FONT, - }); - this.send({ - command: ["get_property", "sub-spacing"], - request_id: MPV_REQUEST_ID_SUB_SPACING, - }); - this.send({ - command: ["get_property", "sub-bold"], - request_id: MPV_REQUEST_ID_SUB_BOLD, - }); - this.send({ - command: ["get_property", "sub-italic"], - request_id: MPV_REQUEST_ID_SUB_ITALIC, - }); - this.send({ - command: ["get_property", "sub-scale-by-window"], - request_id: MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW, - }); - this.send({ - command: ["get_property", "osd-height"], - request_id: MPV_REQUEST_ID_OSD_HEIGHT, - }); - this.send({ - command: ["get_property", "osd-dimensions"], - request_id: MPV_REQUEST_ID_OSD_DIMENSIONS, - }); - this.send({ - command: ["get_property", "sub-border-size"], - request_id: MPV_REQUEST_ID_SUB_BORDER_SIZE, - }); - this.send({ - command: ["get_property", "sub-shadow-offset"], - request_id: MPV_REQUEST_ID_SUB_SHADOW_OFFSET, - }); - this.send({ - command: ["get_property", "sub-ass-override"], - request_id: MPV_REQUEST_ID_SUB_ASS_OVERRIDE, - }); - this.send({ - command: ["get_property", "sub-use-margins"], - request_id: MPV_REQUEST_ID_SUB_USE_MARGINS, - }); - } - - setSubVisibility(visible: boolean): void { - this.send({ - command: ["set_property", "sub-visibility", visible ? "yes" : "no"], - }); - } - - replayCurrentSubtitle(): void { - this.pendingPauseAtSubEnd = true; - this.send({ command: ["sub-seek", 0] }); - } - - playNextSubtitle(): void { - this.pendingPauseAtSubEnd = true; - this.send({ command: ["sub-seek", 1] }); - } -} - async function tokenizeSubtitle(text: string): Promise { - const displayText = text - .replace(/\r\n/g, "\n") - .replace(/\\N/g, "\n") - .replace(/\\n/g, "\n") - .trim(); - - if (!displayText) { - return { text, tokens: null }; - } - - const tokenizeText = displayText - .replace(/\n/g, " ") - .replace(/\s+/g, " ") - .trim(); - - const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText); - if (yomitanTokens && yomitanTokens.length > 0) { - return { text: displayText, tokens: yomitanTokens }; - } - - if (!mecabTokenizer) { - return { text: displayText, tokens: null }; - } - - try { - const rawTokens = await mecabTokenizer.tokenize(tokenizeText); - - if (rawTokens && rawTokens.length > 0) { - const mergedTokens = mergeTokens(rawTokens); - return { text: displayText, tokens: mergedTokens }; - } - } catch (err) { - console.error("Tokenization error:", (err as Error).message); - } - - return { text: displayText, tokens: null }; -} - -interface YomitanParseHeadword { - term?: unknown; -} - -interface YomitanParseSegment { - text?: unknown; - reading?: unknown; - headwords?: unknown; -} - -interface YomitanParseResultItem { - source?: unknown; - index?: unknown; - content?: unknown; -} - -function extractYomitanHeadword(segment: YomitanParseSegment): string { - const headwords = segment.headwords; - if (!Array.isArray(headwords) || headwords.length === 0) { - return ""; - } - - const firstGroup = headwords[0]; - if (!Array.isArray(firstGroup) || firstGroup.length === 0) { - return ""; - } - - const firstHeadword = firstGroup[0] as YomitanParseHeadword; - return typeof firstHeadword?.term === "string" ? firstHeadword.term : ""; -} - -function mapYomitanParseResultsToMergedTokens( - parseResults: unknown, -): MergedToken[] | null { - if (!Array.isArray(parseResults) || parseResults.length === 0) { - return null; - } - - const scanningItems = parseResults.filter((item) => { - const resultItem = item as YomitanParseResultItem; - return ( - resultItem && - resultItem.source === "scanning-parser" && - Array.isArray(resultItem.content) - ); - }) as YomitanParseResultItem[]; - - if (scanningItems.length === 0) { - return null; - } - - const primaryItem = - scanningItems.find((item) => item.index === 0) || scanningItems[0]; - const content = primaryItem.content; - if (!Array.isArray(content)) { - return null; - } - - const tokens: MergedToken[] = []; - let charOffset = 0; - - for (const line of content) { - if (!Array.isArray(line)) { - continue; - } - - let surface = ""; - let reading = ""; - let headword = ""; - - for (const rawSegment of line) { - const segment = rawSegment as YomitanParseSegment; - if (!segment || typeof segment !== "object") { - continue; + return tokenizeSubtitleService(text, { + getYomitanExt: () => yomitanExt, + getYomitanParserWindow: () => yomitanParserWindow, + setYomitanParserWindow: (window) => { + yomitanParserWindow = window; + }, + getYomitanParserReadyPromise: () => yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + yomitanParserInitPromise = promise; + }, + tokenizeWithMecab: async (tokenizeText) => { + if (!mecabTokenizer) { + return null; } - - const segmentText = segment.text; - if (typeof segmentText !== "string" || segmentText.length === 0) { - continue; + const rawTokens = await mecabTokenizer.tokenize(tokenizeText); + if (!rawTokens || rawTokens.length === 0) { + return null; } - - surface += segmentText; - - if (typeof segment.reading === "string") { - reading += segment.reading; - } - - if (!headword) { - headword = extractYomitanHeadword(segment); - } - } - - if (!surface) { - continue; - } - - const start = charOffset; - const end = start + surface.length; - charOffset = end; - - tokens.push({ - surface, - reading, - headword: headword || surface, - startPos: start, - endPos: end, - partOfSpeech: PartOfSpeech.other, - isMerged: true, - }); - } - - return tokens.length > 0 ? tokens : null; -} - -async function ensureYomitanParserWindow(): Promise { - if (!yomitanExt) { - return false; - } - - if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { - return true; - } - - if (yomitanParserInitPromise) { - return yomitanParserInitPromise; - } - - yomitanParserInitPromise = (async () => { - const parserWindow = new BrowserWindow({ - show: false, - width: 800, - height: 600, - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - session: session.defaultSession, - }, - }); - yomitanParserWindow = parserWindow; - - yomitanParserReadyPromise = new Promise((resolve, reject) => { - parserWindow.webContents.once("did-finish-load", () => resolve()); - parserWindow.webContents.once( - "did-fail-load", - (_event, _errorCode, errorDescription) => { - reject(new Error(errorDescription)); - }, - ); - }); - - parserWindow.on("closed", () => { - if (yomitanParserWindow === parserWindow) { - yomitanParserWindow = null; - yomitanParserReadyPromise = null; - } - }); - - try { - await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`); - if (yomitanParserReadyPromise) { - await yomitanParserReadyPromise; - } - return true; - } catch (err) { - console.error( - "Failed to initialize Yomitan parser window:", - (err as Error).message, - ); - if (!parserWindow.isDestroyed()) { - parserWindow.destroy(); - } - if (yomitanParserWindow === parserWindow) { - yomitanParserWindow = null; - yomitanParserReadyPromise = null; - } - return false; - } finally { - yomitanParserInitPromise = null; - } - })(); - - return yomitanParserInitPromise; -} - -async function parseWithYomitanInternalParser( - text: string, -): Promise { - if (!text || !yomitanExt) { - return null; - } - - const isReady = await ensureYomitanParserWindow(); - if (!isReady || !yomitanParserWindow || yomitanParserWindow.isDestroyed()) { - return null; - } - - const script = ` - (async () => { - const invoke = (action, params) => - new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ action, params }, (response) => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - return; - } - if (!response || typeof response !== "object") { - reject(new Error("Invalid response from Yomitan backend")); - return; - } - if (response.error) { - reject(new Error(response.error.message || "Yomitan backend error")); - return; - } - resolve(response.result); - }); - }); - - const optionsFull = await invoke("optionsGetFull", undefined); - const profileIndex = optionsFull.profileCurrent; - const scanLength = - optionsFull.profiles?.[profileIndex]?.options?.scanning?.length ?? 40; - - return await invoke("parseText", { - text: ${JSON.stringify(text)}, - optionsContext: { index: profileIndex }, - scanLength, - useInternalParser: true, - useMecabParser: false - }); - })(); - `; - - try { - const parseResults = await yomitanParserWindow.webContents.executeJavaScript( - script, - true, - ); - return mapYomitanParseResultsToMergedTokens(parseResults); - } catch (err) { - console.error("Yomitan parser request failed:", (err as Error).message); - return null; - } + return mergeTokens(rawTokens); + }, + }); } function updateOverlayBounds(geometry: WindowGeometry): void { @@ -1970,99 +1072,23 @@ function enforceOverlayLayerOrder(): void { mainWindow.moveTop(); } -function ensureExtensionCopy(sourceDir: string): string { - if (process.platform === "win32") { - return sourceDir; - } - - const extensionsRoot = path.join(USER_DATA_PATH, "extensions"); - const targetDir = path.join(extensionsRoot, "yomitan"); - - const sourceManifest = path.join(sourceDir, "manifest.json"); - const targetManifest = path.join(targetDir, "manifest.json"); - - let shouldCopy = !fs.existsSync(targetDir); - if ( - !shouldCopy && - fs.existsSync(sourceManifest) && - fs.existsSync(targetManifest) - ) { - try { - const sourceVersion = ( - JSON.parse(fs.readFileSync(sourceManifest, "utf-8")) as { - version: string; - } - ).version; - const targetVersion = ( - JSON.parse(fs.readFileSync(targetManifest, "utf-8")) as { - version: string; - } - ).version; - shouldCopy = sourceVersion !== targetVersion; - } catch (e) { - shouldCopy = true; - } - } - - if (shouldCopy) { - fs.mkdirSync(extensionsRoot, { recursive: true }); - fs.rmSync(targetDir, { recursive: true, force: true }); - fs.cpSync(sourceDir, targetDir, { recursive: true }); - console.log(`Copied yomitan extension to ${targetDir}`); - } - - return targetDir; -} - async function loadYomitanExtension(): Promise { - const searchPaths = [ - path.join(__dirname, "..", "vendor", "yomitan"), - path.join(process.resourcesPath, "yomitan"), - "/usr/share/SubMiner/yomitan", - path.join(USER_DATA_PATH, "yomitan"), - ]; - - let extPath: string | null = null; - for (const p of searchPaths) { - if (fs.existsSync(p)) { - extPath = p; - break; - } - } - - - if (!extPath) { - console.error("Yomitan extension not found in any search path"); - console.error("Install Yomitan to one of:", searchPaths); - return null; - } - - extPath = ensureExtensionCopy(extPath); - - if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { - yomitanParserWindow.destroy(); - } - yomitanParserWindow = null; - yomitanParserReadyPromise = null; - yomitanParserInitPromise = null; - - try { - const extensions = session.defaultSession.extensions; - if (extensions) { - yomitanExt = await extensions.loadExtension(extPath, { - allowFileAccess: true, - }); - } else { - yomitanExt = await session.defaultSession.loadExtension(extPath, { - allowFileAccess: true, - }); - } - return yomitanExt; - } catch (err) { - console.error("Failed to load Yomitan extension:", (err as Error).message); - console.error("Full error:", err); - return null; - } + return loadYomitanExtensionService({ + userDataPath: USER_DATA_PATH, + getYomitanParserWindow: () => yomitanParserWindow, + setYomitanParserWindow: (window) => { + yomitanParserWindow = window; + }, + setYomitanParserReadyPromise: (promise) => { + yomitanParserReadyPromise = promise; + }, + setYomitanParserInitPromise: (promise) => { + yomitanParserInitPromise = promise; + }, + setYomitanExtension: (extension) => { + yomitanExt = extension; + }, + }); } function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow {