/* * SubMiner - All-in-one sentence mining overlay * Copyright (C) 2024 sudacode * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ interface MergedToken { surface: string; reading: string; headword: string; startPos: number; endPos: number; partOfSpeech: string; isMerged: boolean; } interface SubtitleData { text: string; tokens: MergedToken[] | null; } interface AssInlineOverrides { fontFamily?: string; fontSize?: number; letterSpacing?: number; scaleX?: number; scaleY?: number; bold?: boolean; italic?: boolean; borderSize?: number; shadowOffset?: number; alignment?: number; } interface MpvSubtitleRenderMetrics { subPos: number; subFontSize: number; subScale: number; subMarginY: number; subMarginX: number; subFont: string; subSpacing: number; subBold: boolean; subItalic: boolean; subBorderSize: number; subShadowOffset: number; subAssOverride: string; subScaleByWindow: boolean; subUseMargins: boolean; osdHeight: number; osdDimensions: { w: number; h: number; ml: number; mr: number; mt: number; mb: number; } | null; } interface Keybinding { key: string; command: (string | number)[] | null; } interface SubtitlePosition { yPercent: number; } type SecondarySubMode = "hidden" | "visible" | "hover"; interface SubtitleStyleConfig { fontFamily?: string; fontSize?: number; fontColor?: string; fontWeight?: string; fontStyle?: string; backgroundColor?: string; secondary?: { fontFamily?: string; fontSize?: number; fontColor?: string; fontWeight?: string; fontStyle?: string; backgroundColor?: string; }; } type JimakuConfidence = "high" | "medium" | "low"; interface JimakuMediaInfo { title: string; season: number | null; episode: number | null; confidence: JimakuConfidence; filename: string; rawTitle: string; } interface JimakuEntryFlags { anime?: boolean; movie?: boolean; adult?: boolean; external?: boolean; unverified?: boolean; } interface JimakuEntry { id: number; name: string; english_name?: string | null; japanese_name?: string | null; flags?: JimakuEntryFlags; last_modified?: string; } interface JimakuFileEntry { name: string; url: string; size: number; last_modified: string; } interface JimakuApiError { error: string; code?: number; retryAfter?: number; } type JimakuApiResponse = | { ok: true; data: T } | { ok: false; error: JimakuApiError }; type JimakuDownloadResult = | { ok: true; path: string } | { ok: false; error: JimakuApiError }; interface KikuDuplicateCardInfo { noteId: number; expression: string; sentencePreview: string; hasAudio: boolean; hasImage: boolean; isOriginal: boolean; } interface KikuFieldGroupingChoice { keepNoteId: number; deleteNoteId: number; deleteDuplicate: boolean; cancelled: boolean; } interface KikuMergePreviewResponse { ok: boolean; compact?: Record; full?: Record; error?: string; } type RuntimeOptionId = "anki.autoUpdateNewCards" | "anki.kikuFieldGrouping"; type RuntimeOptionValue = boolean | string; type RuntimeOptionValueType = "boolean" | "enum"; interface RuntimeOptionState { id: RuntimeOptionId; label: string; scope: "ankiConnect"; valueType: RuntimeOptionValueType; value: RuntimeOptionValue; allowedValues: RuntimeOptionValue[]; requiresRestart: boolean; } interface RuntimeOptionApplyResult { ok: boolean; option?: RuntimeOptionState; osdMessage?: string; requiresRestart?: boolean; error?: string; } interface SubsyncSourceTrack { id: number; label: string; } interface SubsyncManualPayload { sourceTracks: SubsyncSourceTrack[]; } type KikuModalStep = "select" | "preview"; type KikuPreviewMode = "compact" | "full"; const subtitleRoot = document.getElementById("subtitleRoot")!; const subtitleContainer = document.getElementById("subtitleContainer")!; const overlay = document.getElementById("overlay")!; const secondarySubContainer = document.getElementById("secondarySubContainer")!; const secondarySubRoot = document.getElementById("secondarySubRoot")!; const jimakuModal = document.getElementById("jimakuModal") as HTMLDivElement; const jimakuTitleInput = document.getElementById( "jimakuTitle", ) as HTMLInputElement; const jimakuSeasonInput = document.getElementById( "jimakuSeason", ) as HTMLInputElement; const jimakuEpisodeInput = document.getElementById( "jimakuEpisode", ) as HTMLInputElement; const jimakuSearchButton = document.getElementById( "jimakuSearch", ) as HTMLButtonElement; const jimakuCloseButton = document.getElementById( "jimakuClose", ) as HTMLButtonElement; const jimakuStatus = document.getElementById("jimakuStatus") as HTMLDivElement; const jimakuEntriesSection = document.getElementById( "jimakuEntriesSection", ) as HTMLDivElement; const jimakuEntriesList = document.getElementById( "jimakuEntries", ) as HTMLUListElement; const jimakuFilesSection = document.getElementById( "jimakuFilesSection", ) as HTMLDivElement; const jimakuFilesList = document.getElementById( "jimakuFiles", ) as HTMLUListElement; const jimakuBroadenButton = document.getElementById( "jimakuBroaden", ) as HTMLButtonElement; const kikuModal = document.getElementById( "kikuFieldGroupingModal", ) as HTMLDivElement; const kikuCard1 = document.getElementById("kikuCard1") as HTMLDivElement; const kikuCard2 = document.getElementById("kikuCard2") as HTMLDivElement; const kikuCard1Expression = document.getElementById( "kikuCard1Expression", ) as HTMLDivElement; const kikuCard2Expression = document.getElementById( "kikuCard2Expression", ) as HTMLDivElement; const kikuCard1Sentence = document.getElementById( "kikuCard1Sentence", ) as HTMLDivElement; const kikuCard2Sentence = document.getElementById( "kikuCard2Sentence", ) as HTMLDivElement; const kikuCard1Meta = document.getElementById( "kikuCard1Meta", ) as HTMLDivElement; const kikuCard2Meta = document.getElementById( "kikuCard2Meta", ) as HTMLDivElement; const kikuConfirmButton = document.getElementById( "kikuConfirmButton", ) as HTMLButtonElement; const kikuCancelButton = document.getElementById( "kikuCancelButton", ) as HTMLButtonElement; const kikuDeleteDuplicateCheckbox = document.getElementById( "kikuDeleteDuplicate", ) as HTMLInputElement; const kikuSelectionStep = document.getElementById( "kikuSelectionStep", ) as HTMLDivElement; const kikuPreviewStep = document.getElementById( "kikuPreviewStep", ) as HTMLDivElement; const kikuPreviewJson = document.getElementById( "kikuPreviewJson", ) as HTMLPreElement; const kikuPreviewCompactButton = document.getElementById( "kikuPreviewCompact", ) as HTMLButtonElement; const kikuPreviewFullButton = document.getElementById( "kikuPreviewFull", ) as HTMLButtonElement; const kikuPreviewError = document.getElementById( "kikuPreviewError", ) as HTMLDivElement; const kikuBackButton = document.getElementById( "kikuBackButton", ) as HTMLButtonElement; const kikuFinalConfirmButton = document.getElementById( "kikuFinalConfirmButton", ) as HTMLButtonElement; const kikuFinalCancelButton = document.getElementById( "kikuFinalCancelButton", ) as HTMLButtonElement; const kikuHint = document.getElementById("kikuHint") as HTMLDivElement; const runtimeOptionsModal = document.getElementById( "runtimeOptionsModal", ) as HTMLDivElement; const runtimeOptionsClose = document.getElementById( "runtimeOptionsClose", ) as HTMLButtonElement; const runtimeOptionsList = document.getElementById( "runtimeOptionsList", ) as HTMLUListElement; const runtimeOptionsStatus = document.getElementById( "runtimeOptionsStatus", ) as HTMLDivElement; const subsyncModal = document.getElementById("subsyncModal") as HTMLDivElement; const subsyncCloseButton = document.getElementById( "subsyncClose", ) as HTMLButtonElement; const subsyncEngineAlass = document.getElementById( "subsyncEngineAlass", ) as HTMLInputElement; const subsyncEngineFfsubsync = document.getElementById( "subsyncEngineFfsubsync", ) as HTMLInputElement; const subsyncSourceLabel = document.getElementById( "subsyncSourceLabel", ) as HTMLLabelElement; const subsyncSourceSelect = document.getElementById( "subsyncSourceSelect", ) as HTMLSelectElement; const subsyncRunButton = document.getElementById( "subsyncRun", ) as HTMLButtonElement; const subsyncStatus = document.getElementById( "subsyncStatus", ) as HTMLDivElement; const overlayLayerFromPreload = window.electronAPI.getOverlayLayer(); const overlayLayerFromQuery = new URLSearchParams(window.location.search).get("layer") === "invisible" ? "invisible" : "visible"; const overlayLayer = overlayLayerFromPreload === "visible" || overlayLayerFromPreload === "invisible" ? overlayLayerFromPreload : overlayLayerFromQuery; const isInvisibleLayer = overlayLayer === "invisible"; const isLinuxPlatform = navigator.platform.toLowerCase().includes("linux"); // Linux passthrough forwarding is not reliable for this overlay; keep pointer // routing local so hover lookup, drag-reposition, and key handling remain usable. const shouldToggleMouseIgnore = !isLinuxPlatform; let isOverSubtitle = false; let isDragging = false; let dragStartY = 0; let startYPercent = 0; let currentYPercent: number | null = null; let jimakuModalOpen = false; let jimakuEntries: JimakuEntry[] = []; let jimakuFiles: JimakuFileEntry[] = []; let selectedEntryIndex = 0; let selectedFileIndex = 0; let currentEpisodeFilter: number | null = null; let currentEntryId: number | null = null; let kikuModalOpen = false; let kikuSelectedCard: 1 | 2 = 1; let kikuOriginalData: KikuDuplicateCardInfo | null = null; let kikuDuplicateData: KikuDuplicateCardInfo | null = null; let kikuModalStep: KikuModalStep = "select"; let kikuPreviewMode: KikuPreviewMode = "compact"; let kikuPendingChoice: KikuFieldGroupingChoice | null = null; let kikuPreviewCompactData: Record | null = null; let kikuPreviewFullData: Record | null = null; let runtimeOptionsModalOpen = false; let runtimeOptions: RuntimeOptionState[] = []; let runtimeOptionSelectedIndex = 0; let runtimeOptionDraftValues = new Map(); let subsyncModalOpen = false; let subsyncSourceTracks: SubsyncSourceTrack[] = []; let subsyncSubmitting = false; const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = { subPos: 100, subFontSize: 38, subScale: 1, subMarginY: 34, subMarginX: 19, subFont: "sans-serif", subSpacing: 0, subBold: false, subItalic: false, subBorderSize: 2.5, subShadowOffset: 0, subAssOverride: "yes", subScaleByWindow: true, subUseMargins: true, osdHeight: 720, osdDimensions: null, }; let mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = { ...DEFAULT_MPV_SUBTITLE_RENDER_METRICS, }; let currentSubtitleAss = ""; function isAnySettingsModalOpen(): boolean { return runtimeOptionsModalOpen || subsyncModalOpen || kikuModalOpen; } function syncSettingsModalSubtitleSuppression(): void { const suppressSubtitles = isAnySettingsModalOpen(); document.body.classList.toggle("settings-modal-open", suppressSubtitles); if (suppressSubtitles) { isOverSubtitle = false; } } function normalizeSubtitle(text: string, trim = true): string { if (!text) return ""; let normalized = text.replace(/\\N/g, "\n").replace(/\\n/g, "\n"); normalized = normalized.replace(/\{[^}]*\}/g, ""); return trim ? normalized.trim() : normalized; } function renderWithTokens(tokens: MergedToken[]): void { const fragment = document.createDocumentFragment(); for (const token of tokens) { const surface = token.surface; if (surface.includes("\n")) { const parts = surface.split("\n"); for (let i = 0; i < parts.length; i++) { if (parts[i]) { const span = document.createElement("span"); span.className = "word"; span.textContent = parts[i]; if (token.reading) { span.dataset.reading = token.reading; } if (token.headword) { span.dataset.headword = token.headword; } fragment.appendChild(span); } if (i < parts.length - 1) { fragment.appendChild(document.createElement("br")); } } } else { const span = document.createElement("span"); span.className = "word"; span.textContent = surface; if (token.reading) { span.dataset.reading = token.reading; } if (token.headword) { span.dataset.headword = token.headword; } fragment.appendChild(span); } } subtitleRoot.appendChild(fragment); } function renderCharacterLevel(text: string): void { const fragment = document.createDocumentFragment(); for (const char of text) { if (char === "\n") { fragment.appendChild(document.createElement("br")); } else { const span = document.createElement("span"); span.className = "c"; span.textContent = char; fragment.appendChild(span); } } subtitleRoot.appendChild(fragment); } function renderPlainTextPreserveLineBreaks(text: string): void { const lines = text.split("\n"); const fragment = document.createDocumentFragment(); for (let i = 0; i < lines.length; i += 1) { fragment.appendChild(document.createTextNode(lines[i])); if (i < lines.length - 1) { fragment.appendChild(document.createElement("br")); } } subtitleRoot.appendChild(fragment); } function renderSubtitle(data: SubtitleData | string): void { subtitleRoot.innerHTML = ""; let text: string; let tokens: MergedToken[] | null; if (typeof data === "string") { text = data; tokens = null; } else if (data && typeof data === "object") { text = data.text; tokens = data.tokens; } else { return; } if (!text) { return; } if (isInvisibleLayer) { // Keep natural kerning/shaping for accurate hitbox alignment with mpv/libass. renderPlainTextPreserveLineBreaks(normalizeSubtitle(text, false)); return; } const normalized = normalizeSubtitle(text); if (tokens && tokens.length > 0) { renderWithTokens(tokens); } else { renderCharacterLevel(normalized); } } function handleMouseEnter(): void { isOverSubtitle = true; overlay.classList.add("interactive"); if (shouldToggleMouseIgnore) { window.electronAPI.setIgnoreMouseEvents(false); } } function handleMouseLeave(): void { isOverSubtitle = false; const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]'); if ( !yomitanPopup && !jimakuModalOpen && !kikuModalOpen && !runtimeOptionsModalOpen && !subsyncModalOpen ) { overlay.classList.remove("interactive"); if (shouldToggleMouseIgnore) { window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); } } } function clampYPercent(yPercent: number): number { return Math.max(2, Math.min(80, yPercent)); } function getCurrentYPercent(): number { if (currentYPercent !== null) { return currentYPercent; } const marginBottom = parseFloat(subtitleContainer.style.marginBottom) || 60; const windowHeight = window.innerHeight; currentYPercent = clampYPercent((marginBottom / windowHeight) * 100); return currentYPercent; } function applyYPercent(yPercent: number): void { const clampedPercent = clampYPercent(yPercent); currentYPercent = clampedPercent; const marginBottom = (clampedPercent / 100) * window.innerHeight; subtitleContainer.style.position = ""; subtitleContainer.style.left = ""; subtitleContainer.style.top = ""; subtitleContainer.style.right = ""; subtitleContainer.style.transform = ""; subtitleContainer.style.marginBottom = `${marginBottom}px`; } function applyStoredSubtitlePosition( position: SubtitlePosition | null, source: string, ): void { if (position && position.yPercent !== undefined) { applyYPercent(position.yPercent); console.log( "Applied subtitle position from", source, ":", position.yPercent, "%", ); } else { const defaultMarginBottom = 60; const defaultYPercent = (defaultMarginBottom / window.innerHeight) * 100; applyYPercent(defaultYPercent); console.log("Applied default subtitle position from", source); } } function applySubtitleFontSize(fontSize: number): void { const clampedSize = Math.max(10, fontSize); subtitleRoot.style.fontSize = `${clampedSize}px`; document.documentElement.style.setProperty( "--subtitle-font-size", `${clampedSize}px`, ); } function coerceFiniteNumber( value: unknown, fallback: number, min?: number, max?: number, ): number { if (typeof value !== "number" || !Number.isFinite(value)) { return fallback; } let next = value; if (typeof min === "number") next = Math.max(min, next); if (typeof max === "number") next = Math.min(max, next); return next; } function sanitizeMpvSubtitleRenderMetrics( metrics: Partial | null | undefined, ): MpvSubtitleRenderMetrics { const dims = metrics?.osdDimensions; const nextOsdDimensions = dims && typeof dims.w === "number" && typeof dims.h === "number" && typeof dims.ml === "number" && typeof dims.mr === "number" && typeof dims.mt === "number" && typeof dims.mb === "number" ? { w: coerceFiniteNumber(dims.w, 0, 1, 100000), h: coerceFiniteNumber(dims.h, 0, 1, 100000), ml: coerceFiniteNumber(dims.ml, 0, 0, 100000), mr: coerceFiniteNumber(dims.mr, 0, 0, 100000), mt: coerceFiniteNumber(dims.mt, 0, 0, 100000), mb: coerceFiniteNumber(dims.mb, 0, 0, 100000), } : dims === null ? null : mpvSubtitleRenderMetrics.osdDimensions; return { subPos: coerceFiniteNumber( metrics?.subPos, mpvSubtitleRenderMetrics.subPos, 0, 150, ), subFontSize: coerceFiniteNumber( metrics?.subFontSize, mpvSubtitleRenderMetrics.subFontSize, 1, 200, ), subScale: coerceFiniteNumber( metrics?.subScale, mpvSubtitleRenderMetrics.subScale, 0.1, 10, ), subMarginY: coerceFiniteNumber( metrics?.subMarginY, mpvSubtitleRenderMetrics.subMarginY, 0, 200, ), subMarginX: coerceFiniteNumber( metrics?.subMarginX, mpvSubtitleRenderMetrics.subMarginX, 0, 200, ), subFont: typeof metrics?.subFont === "string" && metrics.subFont.trim().length > 0 ? metrics.subFont.trim() : mpvSubtitleRenderMetrics.subFont, subSpacing: coerceFiniteNumber( metrics?.subSpacing, mpvSubtitleRenderMetrics.subSpacing, -100, 100, ), subBold: typeof metrics?.subBold === "boolean" ? metrics.subBold : mpvSubtitleRenderMetrics.subBold, subItalic: typeof metrics?.subItalic === "boolean" ? metrics.subItalic : mpvSubtitleRenderMetrics.subItalic, subBorderSize: coerceFiniteNumber( metrics?.subBorderSize, mpvSubtitleRenderMetrics.subBorderSize, 0, 100, ), subShadowOffset: coerceFiniteNumber( metrics?.subShadowOffset, mpvSubtitleRenderMetrics.subShadowOffset, 0, 100, ), subAssOverride: typeof metrics?.subAssOverride === "string" && metrics.subAssOverride.trim().length > 0 ? metrics.subAssOverride.trim() : mpvSubtitleRenderMetrics.subAssOverride, subScaleByWindow: typeof metrics?.subScaleByWindow === "boolean" ? metrics.subScaleByWindow : mpvSubtitleRenderMetrics.subScaleByWindow, subUseMargins: typeof metrics?.subUseMargins === "boolean" ? metrics.subUseMargins : mpvSubtitleRenderMetrics.subUseMargins, osdHeight: coerceFiniteNumber( metrics?.osdHeight, mpvSubtitleRenderMetrics.osdHeight, 1, 10000, ), osdDimensions: nextOsdDimensions, }; } function getLastMatch(text: string, pattern: RegExp): string | undefined { let last: string | undefined; let match: RegExpExecArray | null; // eslint-disable-next-line no-cond-assign while ((match = pattern.exec(text)) !== null) { last = match[1]; } return last; } function parseAssInlineOverrides(assText: string): AssInlineOverrides { if (!assText) return {}; const result: AssInlineOverrides = {}; const fontFamily = getLastMatch(assText, /\\fn([^\\}]+)/g); if (fontFamily && fontFamily.trim()) { result.fontFamily = fontFamily.trim(); } const fontSize = getLastMatch(assText, /\\fs(-?\d+(?:\.\d+)?)/g); if (fontSize) { const parsed = Number.parseFloat(fontSize); if (Number.isFinite(parsed) && parsed > 0) { result.fontSize = parsed; } } const letterSpacing = getLastMatch(assText, /\\fsp(-?\d+(?:\.\d+)?)/g); if (letterSpacing) { const parsed = Number.parseFloat(letterSpacing); if (Number.isFinite(parsed)) { result.letterSpacing = parsed; } } const scaleX = getLastMatch(assText, /\\fscx(-?\d+(?:\.\d+)?)/g); if (scaleX) { const parsed = Number.parseFloat(scaleX); if (Number.isFinite(parsed) && parsed > 0) { result.scaleX = parsed / 100; } } const scaleY = getLastMatch(assText, /\\fscy(-?\d+(?:\.\d+)?)/g); if (scaleY) { const parsed = Number.parseFloat(scaleY); if (Number.isFinite(parsed) && parsed > 0) { result.scaleY = parsed / 100; } } const bold = getLastMatch(assText, /\\b(-?\d+)/g); if (bold) { const parsed = Number.parseInt(bold, 10); if (!Number.isNaN(parsed)) { result.bold = parsed !== 0; } } const italic = getLastMatch(assText, /\\i(-?\d+)/g); if (italic) { const parsed = Number.parseInt(italic, 10); if (!Number.isNaN(parsed)) { result.italic = parsed !== 0; } } const borderSize = getLastMatch(assText, /\\bord(-?\d+(?:\.\d+)?)/g); if (borderSize) { const parsed = Number.parseFloat(borderSize); if (Number.isFinite(parsed) && parsed >= 0) { result.borderSize = parsed; } } const shadowOffset = getLastMatch(assText, /\\shad(-?\d+(?:\.\d+)?)/g); if (shadowOffset) { const parsed = Number.parseFloat(shadowOffset); if (Number.isFinite(parsed) && parsed >= 0) { result.shadowOffset = parsed; } } const alignment = getLastMatch(assText, /\\an(\d)/g); if (alignment) { const parsed = Number.parseInt(alignment, 10); if (parsed >= 1 && parsed <= 9) { result.alignment = parsed; } } return result; } function applyInvisibleSubtitleLayoutFromMpvMetrics( metrics: Partial | null | undefined, source: string, ): void { mpvSubtitleRenderMetrics = sanitizeMpvSubtitleRenderMetrics(metrics); const dims = mpvSubtitleRenderMetrics.osdDimensions; const renderAreaHeight = dims?.h ?? window.innerHeight; const renderAreaWidth = dims?.w ?? window.innerWidth; const videoLeftInset = dims?.ml ?? 0; const videoRightInset = dims?.mr ?? 0; const videoTopInset = dims?.mt ?? 0; const videoBottomInset = dims?.mb ?? 0; const anchorToVideoArea = !mpvSubtitleRenderMetrics.subUseMargins; const leftInset = anchorToVideoArea ? videoLeftInset : 0; const rightInset = anchorToVideoArea ? videoRightInset : 0; const topInset = anchorToVideoArea ? videoTopInset : 0; const bottomInset = anchorToVideoArea ? videoBottomInset : 0; // Match mpv subtitle sizing from scaled pixels in mpv's OSD space. const osdReferenceHeight = mpvSubtitleRenderMetrics.subScaleByWindow ? mpvSubtitleRenderMetrics.osdHeight : 720; const pxPerScaledPixel = Math.max(0.1, renderAreaHeight / osdReferenceHeight); const computedFontSize = mpvSubtitleRenderMetrics.subFontSize * mpvSubtitleRenderMetrics.subScale * pxPerScaledPixel; const rawAssOverrides = parseAssInlineOverrides(currentSubtitleAss); // When sub-ass-override is "yes" (default), "force", or "strip", mpv ignores // ASS inline style tags (\fn, \fs, \b, \i, \bord, \shad, \fsp, \fscx, \fscy) // and uses its own sub-* settings instead. Only positioning tags like \an and // \pos are always respected. Since sub-text-ass returns the raw ASS text // regardless of this setting, we must skip style overrides when mpv is // overriding them. const assOverrideMode = mpvSubtitleRenderMetrics.subAssOverride; const mpvOverridesAssStyles = assOverrideMode === "yes" || assOverrideMode === "force" || assOverrideMode === "strip"; const assOverrides: AssInlineOverrides = mpvOverridesAssStyles ? {} : rawAssOverrides; const effectiveFontSize = assOverrides.fontSize && assOverrides.fontSize > 0 ? assOverrides.fontSize * pxPerScaledPixel : computedFontSize; applySubtitleFontSize(effectiveFontSize); // \an is a positioning tag — always respected regardless of sub-ass-override const alignment = rawAssOverrides.alignment ?? 2; const hAlign = ((alignment - 1) % 3) as 0 | 1 | 2; // 0=left, 1=center, 2=right const vAlign = Math.floor((alignment - 1) / 3) as 0 | 1 | 2; // 0=bottom, 1=middle, 2=top const marginY = mpvSubtitleRenderMetrics.subMarginY * pxPerScaledPixel; const marginX = Math.max( 0, mpvSubtitleRenderMetrics.subMarginX * pxPerScaledPixel, ); const horizontalAvailable = Math.max( 0, renderAreaWidth - leftInset - rightInset - Math.round(marginX * 2), ); subtitleContainer.style.position = "absolute"; subtitleContainer.style.maxWidth = `${horizontalAvailable}px`; subtitleContainer.style.width = `${horizontalAvailable}px`; subtitleContainer.style.padding = "0"; subtitleContainer.style.background = "transparent"; subtitleContainer.style.marginBottom = "0"; // Horizontal positioning based on \an alignment. // All alignments position the container at the left margin with full available // width and use text-align for alignment. This avoids translateX(-50%) which // can cause subpixel rounding artifacts from GPU rasterization. subtitleContainer.style.left = `${leftInset + marginX}px`; subtitleContainer.style.right = ""; subtitleContainer.style.transform = ""; if (hAlign === 0) { subtitleRoot.style.textAlign = "left"; } else if (hAlign === 2) { subtitleRoot.style.textAlign = "right"; } else { subtitleRoot.style.textAlign = "center"; } // Vertical positioning based on \an alignment if (vAlign === 2) { subtitleContainer.style.top = `${topInset + marginY}px`; subtitleContainer.style.bottom = ""; } else if (vAlign === 1) { subtitleContainer.style.top = "50%"; subtitleContainer.style.bottom = ""; subtitleContainer.style.transform = "translateY(-50%)"; } else { const subPosOffset = ((100 - mpvSubtitleRenderMetrics.subPos) / 100) * renderAreaHeight; const bottomPx = Math.max(0, bottomInset + marginY + subPosOffset); subtitleContainer.style.top = ""; subtitleContainer.style.bottom = `${bottomPx}px`; } subtitleRoot.style.fontFamily = assOverrides.fontFamily || mpvSubtitleRenderMetrics.subFont; const effectiveSpacing = typeof assOverrides.letterSpacing === "number" ? assOverrides.letterSpacing : mpvSubtitleRenderMetrics.subSpacing; subtitleRoot.style.letterSpacing = Math.abs(effectiveSpacing) > 0.0001 ? `${effectiveSpacing * pxPerScaledPixel}px` : "normal"; const effectiveBold = assOverrides.bold ?? mpvSubtitleRenderMetrics.subBold; const effectiveItalic = assOverrides.italic ?? mpvSubtitleRenderMetrics.subItalic; subtitleRoot.style.fontWeight = effectiveBold ? "700" : "400"; subtitleRoot.style.fontStyle = effectiveItalic ? "italic" : "normal"; const scaleX = assOverrides.scaleX ?? 1; const scaleY = assOverrides.scaleY ?? 1; if (Math.abs(scaleX - 1) > 0.0001 || Math.abs(scaleY - 1) > 0.0001) { subtitleRoot.style.transform = `scale(${scaleX}, ${scaleY})`; subtitleRoot.style.transformOrigin = "50% 100%"; } else { subtitleRoot.style.transform = ""; subtitleRoot.style.transformOrigin = ""; } // CSS line-height: normal adds "half-leading" — extra space equally distributed // above and below text within each line box. This means the text's visual bottom // is above the element's bottom edge by halfLeading pixels. We must compensate // by shifting the element down by that amount so glyph positions match mpv/libass. const computedLineHeight = parseFloat( getComputedStyle(subtitleRoot).lineHeight, ); if ( Number.isFinite(computedLineHeight) && computedLineHeight > effectiveFontSize ) { const halfLeading = (computedLineHeight - effectiveFontSize) / 2; if (vAlign === 0 && halfLeading > 0.5) { const currentBottom = parseFloat(subtitleContainer.style.bottom); if (Number.isFinite(currentBottom)) { subtitleContainer.style.bottom = `${Math.max(0, currentBottom - halfLeading)}px`; } } else if (vAlign === 2 && halfLeading > 0.5) { const currentTop = parseFloat(subtitleContainer.style.top); if (Number.isFinite(currentTop)) { subtitleContainer.style.top = `${Math.max(0, currentTop - halfLeading)}px`; } } } console.log( "[invisible-overlay] Applied mpv subtitle render metrics from", source, mpvSubtitleRenderMetrics, assOverrides, ); } function setJimakuStatus(message: string, isError = false): void { jimakuStatus.textContent = message; jimakuStatus.style.color = isError ? "rgba(255, 120, 120, 0.95)" : "rgba(255, 255, 255, 0.8)"; } function resetJimakuLists(): void { jimakuEntries = []; jimakuFiles = []; selectedEntryIndex = 0; selectedFileIndex = 0; currentEntryId = null; jimakuEntriesList.innerHTML = ""; jimakuFilesList.innerHTML = ""; jimakuEntriesSection.classList.add("hidden"); jimakuFilesSection.classList.add("hidden"); jimakuBroadenButton.classList.add("hidden"); } function openJimakuModal(): void { if (isInvisibleLayer) return; if (jimakuModalOpen) return; jimakuModalOpen = true; overlay.classList.add("interactive"); jimakuModal.classList.remove("hidden"); jimakuModal.setAttribute("aria-hidden", "false"); setJimakuStatus("Loading media info..."); resetJimakuLists(); window.electronAPI .getJimakuMediaInfo() .then((info: JimakuMediaInfo) => { jimakuTitleInput.value = info.title || ""; jimakuSeasonInput.value = info.season ? String(info.season) : ""; jimakuEpisodeInput.value = info.episode ? String(info.episode) : ""; currentEpisodeFilter = info.episode ?? null; if (info.confidence === "high" && info.title && info.episode) { performJimakuSearch(); } else if (info.title) { setJimakuStatus("Check title/season/episode and press Search."); } else { setJimakuStatus("Enter title/season/episode and press Search."); } }) .catch(() => { setJimakuStatus("Failed to load media info.", true); }); } function closeJimakuModal(): void { if (!jimakuModalOpen) return; jimakuModalOpen = false; jimakuModal.classList.add("hidden"); jimakuModal.setAttribute("aria-hidden", "true"); if ( !isOverSubtitle && !kikuModalOpen && !runtimeOptionsModalOpen && !subsyncModalOpen ) { overlay.classList.remove("interactive"); } resetJimakuLists(); } function formatRuntimeOptionValue(value: RuntimeOptionValue): string { if (typeof value === "boolean") { return value ? "On" : "Off"; } return value; } function setRuntimeOptionsStatus(message: string, isError = false): void { runtimeOptionsStatus.textContent = message; runtimeOptionsStatus.classList.toggle("error", isError); } function getRuntimeOptionDisplayValue( option: RuntimeOptionState, ): RuntimeOptionValue { return runtimeOptionDraftValues.get(option.id) ?? option.value; } function renderRuntimeOptionsList(): void { runtimeOptionsList.innerHTML = ""; runtimeOptions.forEach((option, index) => { const li = document.createElement("li"); li.className = "runtime-options-item"; li.classList.toggle("active", index === runtimeOptionSelectedIndex); const label = document.createElement("div"); label.className = "runtime-options-label"; label.textContent = option.label; const value = document.createElement("div"); value.className = "runtime-options-value"; value.textContent = `Value: ${formatRuntimeOptionValue(getRuntimeOptionDisplayValue(option))}`; value.title = "Click to cycle value, right-click to cycle backward"; const allowed = document.createElement("div"); allowed.className = "runtime-options-allowed"; allowed.textContent = `Allowed: ${option.allowedValues .map((entry) => formatRuntimeOptionValue(entry)) .join(" | ")}`; li.appendChild(label); li.appendChild(value); li.appendChild(allowed); li.addEventListener("click", () => { runtimeOptionSelectedIndex = index; renderRuntimeOptionsList(); }); li.addEventListener("dblclick", () => { runtimeOptionSelectedIndex = index; void applySelectedRuntimeOption(); }); value.addEventListener("click", (event) => { event.stopPropagation(); runtimeOptionSelectedIndex = index; cycleRuntimeDraftValue(1); }); value.addEventListener("contextmenu", (event) => { event.preventDefault(); event.stopPropagation(); runtimeOptionSelectedIndex = index; cycleRuntimeDraftValue(-1); }); runtimeOptionsList.appendChild(li); }); } function updateRuntimeOptions(options: RuntimeOptionState[]): void { const previousId = runtimeOptions[runtimeOptionSelectedIndex]?.id ?? runtimeOptions[0]?.id; runtimeOptions = options; runtimeOptionDraftValues.clear(); for (const option of runtimeOptions) { runtimeOptionDraftValues.set(option.id, option.value); } const nextIndex = runtimeOptions.findIndex( (option) => option.id === previousId, ); runtimeOptionSelectedIndex = nextIndex >= 0 ? nextIndex : 0; renderRuntimeOptionsList(); } function closeRuntimeOptionsModal(): void { if (!runtimeOptionsModalOpen) return; runtimeOptionsModalOpen = false; syncSettingsModalSubtitleSuppression(); runtimeOptionsModal.classList.add("hidden"); runtimeOptionsModal.setAttribute("aria-hidden", "true"); window.electronAPI.notifyOverlayModalClosed("runtime-options"); setRuntimeOptionsStatus(""); if ( !isOverSubtitle && !jimakuModalOpen && !kikuModalOpen && !subsyncModalOpen ) { overlay.classList.remove("interactive"); } } async function openRuntimeOptionsModal(): Promise { if (isInvisibleLayer) return; const options = await window.electronAPI.getRuntimeOptions(); updateRuntimeOptions(options); runtimeOptionsModalOpen = true; syncSettingsModalSubtitleSuppression(); overlay.classList.add("interactive"); runtimeOptionsModal.classList.remove("hidden"); runtimeOptionsModal.setAttribute("aria-hidden", "false"); setRuntimeOptionsStatus( "Use arrow keys. Click value to cycle. Enter or double-click to apply.", ); } function getSelectedRuntimeOption(): RuntimeOptionState | null { if (runtimeOptions.length === 0) return null; if (runtimeOptionSelectedIndex < 0) return null; if (runtimeOptionSelectedIndex >= runtimeOptions.length) return null; return runtimeOptions[runtimeOptionSelectedIndex]; } function cycleRuntimeDraftValue(direction: 1 | -1): void { const option = getSelectedRuntimeOption(); if (!option || option.allowedValues.length === 0) return; const currentValue = getRuntimeOptionDisplayValue(option); const currentIndex = option.allowedValues.findIndex( (value) => value === currentValue, ); const safeIndex = currentIndex >= 0 ? currentIndex : 0; const nextIndex = direction === 1 ? (safeIndex + 1) % option.allowedValues.length : (safeIndex - 1 + option.allowedValues.length) % option.allowedValues.length; runtimeOptionDraftValues.set(option.id, option.allowedValues[nextIndex]); renderRuntimeOptionsList(); setRuntimeOptionsStatus( `Selected ${option.label}: ${formatRuntimeOptionValue(option.allowedValues[nextIndex])}`, ); } async function applySelectedRuntimeOption(): Promise { const option = getSelectedRuntimeOption(); if (!option) return; const nextValue = getRuntimeOptionDisplayValue(option); const result: RuntimeOptionApplyResult = await window.electronAPI.setRuntimeOptionValue(option.id, nextValue); if (!result.ok) { setRuntimeOptionsStatus(result.error || "Failed to apply option", true); return; } if (result.option) { runtimeOptionDraftValues.set(result.option.id, result.option.value); } const latest = await window.electronAPI.getRuntimeOptions(); updateRuntimeOptions(latest); setRuntimeOptionsStatus(result.osdMessage || "Option applied."); } function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean { if (e.key === "Escape") { e.preventDefault(); closeRuntimeOptionsModal(); return true; } if ( e.key === "ArrowDown" || e.key === "j" || e.key === "J" || (e.ctrlKey && (e.key === "n" || e.key === "N")) ) { e.preventDefault(); if (runtimeOptions.length > 0) { runtimeOptionSelectedIndex = Math.min( runtimeOptions.length - 1, runtimeOptionSelectedIndex + 1, ); renderRuntimeOptionsList(); } return true; } if ( e.key === "ArrowUp" || e.key === "k" || e.key === "K" || (e.ctrlKey && (e.key === "p" || e.key === "P")) ) { e.preventDefault(); if (runtimeOptions.length > 0) { runtimeOptionSelectedIndex = Math.max(0, runtimeOptionSelectedIndex - 1); renderRuntimeOptionsList(); } return true; } if (e.key === "ArrowRight" || e.key === "l" || e.key === "L") { e.preventDefault(); cycleRuntimeDraftValue(1); return true; } if (e.key === "ArrowLeft" || e.key === "h" || e.key === "H") { e.preventDefault(); cycleRuntimeDraftValue(-1); return true; } if (e.key === "Enter") { e.preventDefault(); void applySelectedRuntimeOption(); return true; } return true; } function setSubsyncStatus(message: string, isError = false): void { subsyncStatus.textContent = message; subsyncStatus.classList.toggle("error", isError); } function updateSubsyncSourceVisibility(): void { const useAlass = subsyncEngineAlass.checked; subsyncSourceLabel.classList.toggle("hidden", !useAlass); } function renderSubsyncSourceTracks(): void { subsyncSourceSelect.innerHTML = ""; for (const track of subsyncSourceTracks) { const option = document.createElement("option"); option.value = String(track.id); option.textContent = track.label; subsyncSourceSelect.appendChild(option); } subsyncSourceSelect.disabled = subsyncSourceTracks.length === 0; } function closeSubsyncModal(): void { if (!subsyncModalOpen) return; subsyncModalOpen = false; syncSettingsModalSubtitleSuppression(); subsyncModal.classList.add("hidden"); subsyncModal.setAttribute("aria-hidden", "true"); window.electronAPI.notifyOverlayModalClosed("subsync"); if ( !isOverSubtitle && !jimakuModalOpen && !kikuModalOpen && !runtimeOptionsModalOpen ) { overlay.classList.remove("interactive"); } } function openSubsyncModal(payload: SubsyncManualPayload): void { if (isInvisibleLayer) return; subsyncSubmitting = false; subsyncRunButton.disabled = false; subsyncSourceTracks = payload.sourceTracks; const hasSources = subsyncSourceTracks.length > 0; subsyncEngineAlass.checked = hasSources; subsyncEngineFfsubsync.checked = !hasSources; renderSubsyncSourceTracks(); updateSubsyncSourceVisibility(); setSubsyncStatus( hasSources ? "Choose engine and source, then run." : "No source subtitles available for alass. Use ffsubsync.", false, ); subsyncModalOpen = true; syncSettingsModalSubtitleSuppression(); overlay.classList.add("interactive"); subsyncModal.classList.remove("hidden"); subsyncModal.setAttribute("aria-hidden", "false"); } async function runSubsyncManualFromModal(): Promise { if (subsyncSubmitting) return; const engine = subsyncEngineAlass.checked ? "alass" : "ffsubsync"; const sourceTrackId = engine === "alass" && subsyncSourceSelect.value ? Number.parseInt(subsyncSourceSelect.value, 10) : null; if (engine === "alass" && !Number.isFinite(sourceTrackId)) { setSubsyncStatus("Select a source subtitle track for alass.", true); return; } subsyncSubmitting = true; subsyncRunButton.disabled = true; closeSubsyncModal(); try { await window.electronAPI.runSubsyncManual({ engine, sourceTrackId, }); } finally { subsyncSubmitting = false; subsyncRunButton.disabled = false; } } function handleSubsyncKeydown(e: KeyboardEvent): boolean { if (e.key === "Escape") { e.preventDefault(); closeSubsyncModal(); return true; } if (e.key === "Enter") { e.preventDefault(); void runSubsyncManualFromModal(); return true; } return true; } function formatMediaMeta(card: KikuDuplicateCardInfo): string { const parts: string[] = []; parts.push(card.hasAudio ? "Audio: Yes" : "Audio: No"); parts.push(card.hasImage ? "Image: Yes" : "Image: No"); return parts.join(" | "); } function updateKikuCardSelection(): void { kikuCard1.classList.toggle("active", kikuSelectedCard === 1); kikuCard2.classList.toggle("active", kikuSelectedCard === 2); } function setKikuModalStep(step: KikuModalStep): void { kikuModalStep = step; const isSelect = step === "select"; kikuSelectionStep.classList.toggle("hidden", !isSelect); kikuPreviewStep.classList.toggle("hidden", isSelect); kikuHint.textContent = isSelect ? "Press 1 or 2 to select · Enter to continue · Esc to cancel" : "Enter to confirm merge · Backspace to go back · Esc to cancel"; } function updateKikuPreviewToggle(): void { kikuPreviewCompactButton.classList.toggle( "active", kikuPreviewMode === "compact", ); kikuPreviewFullButton.classList.toggle("active", kikuPreviewMode === "full"); } function renderKikuPreview(): void { const payload = kikuPreviewMode === "compact" ? kikuPreviewCompactData : kikuPreviewFullData; kikuPreviewJson.textContent = payload ? JSON.stringify(payload, null, 2) : "{}"; updateKikuPreviewToggle(); } function setKikuPreviewError(message: string | null): void { if (!message) { kikuPreviewError.textContent = ""; kikuPreviewError.classList.add("hidden"); return; } kikuPreviewError.textContent = message; kikuPreviewError.classList.remove("hidden"); } function openKikuFieldGroupingModal(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo; }): void { if (isInvisibleLayer) return; if (kikuModalOpen) return; kikuModalOpen = true; kikuOriginalData = data.original; kikuDuplicateData = data.duplicate; kikuSelectedCard = 1; kikuCard1Expression.textContent = data.original.expression; kikuCard1Sentence.textContent = data.original.sentencePreview || "(no sentence)"; kikuCard1Meta.textContent = formatMediaMeta(data.original); kikuCard2Expression.textContent = data.duplicate.expression; kikuCard2Sentence.textContent = data.duplicate.sentencePreview || "(current subtitle)"; kikuCard2Meta.textContent = formatMediaMeta(data.duplicate); kikuDeleteDuplicateCheckbox.checked = true; kikuPendingChoice = null; kikuPreviewCompactData = null; kikuPreviewFullData = null; kikuPreviewMode = "compact"; renderKikuPreview(); setKikuPreviewError(null); setKikuModalStep("select"); updateKikuCardSelection(); syncSettingsModalSubtitleSuppression(); overlay.classList.add("interactive"); kikuModal.classList.remove("hidden"); kikuModal.setAttribute("aria-hidden", "false"); } function closeKikuFieldGroupingModal(): void { if (!kikuModalOpen) return; kikuModalOpen = false; syncSettingsModalSubtitleSuppression(); kikuModal.classList.add("hidden"); kikuModal.setAttribute("aria-hidden", "true"); setKikuPreviewError(null); kikuPreviewJson.textContent = ""; kikuPendingChoice = null; kikuPreviewCompactData = null; kikuPreviewFullData = null; kikuPreviewMode = "compact"; setKikuModalStep("select"); kikuOriginalData = null; kikuDuplicateData = null; if ( !isOverSubtitle && !jimakuModalOpen && !runtimeOptionsModalOpen && !subsyncModalOpen ) { overlay.classList.remove("interactive"); } } async function confirmKikuSelection(): Promise { if (!kikuOriginalData || !kikuDuplicateData) return; const keepData = kikuSelectedCard === 1 ? kikuOriginalData : kikuDuplicateData; const deleteData = kikuSelectedCard === 1 ? kikuDuplicateData : kikuOriginalData; const choice: KikuFieldGroupingChoice = { keepNoteId: keepData.noteId, deleteNoteId: deleteData.noteId, deleteDuplicate: kikuDeleteDuplicateCheckbox.checked, cancelled: false, }; kikuPendingChoice = choice; setKikuPreviewError(null); kikuConfirmButton.disabled = true; try { const preview: KikuMergePreviewResponse = await window.electronAPI.kikuBuildMergePreview({ keepNoteId: choice.keepNoteId, deleteNoteId: choice.deleteNoteId, deleteDuplicate: choice.deleteDuplicate, }); if (!preview.ok) { setKikuPreviewError(preview.error || "Failed to build merge preview"); return; } kikuPreviewCompactData = preview.compact || {}; kikuPreviewFullData = preview.full || {}; kikuPreviewMode = "compact"; renderKikuPreview(); setKikuModalStep("preview"); } finally { kikuConfirmButton.disabled = false; } } function confirmKikuMerge(): void { if (!kikuPendingChoice) return; window.electronAPI.kikuFieldGroupingRespond(kikuPendingChoice); closeKikuFieldGroupingModal(); } function goBackFromKikuPreview(): void { setKikuPreviewError(null); setKikuModalStep("select"); } function cancelKikuFieldGrouping(): void { const choice: KikuFieldGroupingChoice = { keepNoteId: 0, deleteNoteId: 0, deleteDuplicate: true, cancelled: true, }; window.electronAPI.kikuFieldGroupingRespond(choice); closeKikuFieldGroupingModal(); } function handleKikuKeydown(e: KeyboardEvent): boolean { if (kikuModalStep === "preview") { if (e.key === "Escape") { e.preventDefault(); cancelKikuFieldGrouping(); return true; } if (e.key === "Backspace") { e.preventDefault(); goBackFromKikuPreview(); return true; } if (e.key === "Enter") { e.preventDefault(); confirmKikuMerge(); return true; } return true; } if (e.key === "Escape") { e.preventDefault(); cancelKikuFieldGrouping(); return true; } if (e.key === "1") { e.preventDefault(); kikuSelectedCard = 1; updateKikuCardSelection(); return true; } if (e.key === "2") { e.preventDefault(); kikuSelectedCard = 2; updateKikuCardSelection(); return true; } if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); kikuSelectedCard = kikuSelectedCard === 1 ? 2 : 1; updateKikuCardSelection(); return true; } if (e.key === "Enter") { e.preventDefault(); void confirmKikuSelection(); return true; } return true; } function formatEntryLabel(entry: JimakuEntry): string { if (entry.english_name && entry.english_name !== entry.name) { return `${entry.name} / ${entry.english_name}`; } return entry.name; } function renderEntries(): void { jimakuEntriesList.innerHTML = ""; if (jimakuEntries.length === 0) { jimakuEntriesSection.classList.add("hidden"); return; } jimakuEntriesSection.classList.remove("hidden"); jimakuEntries.forEach((entry, index) => { const li = document.createElement("li"); li.textContent = formatEntryLabel(entry); if (entry.japanese_name) { const sub = document.createElement("div"); sub.className = "jimaku-subtext"; sub.textContent = entry.japanese_name; li.appendChild(sub); } if (index === selectedEntryIndex) { li.classList.add("active"); } li.addEventListener("click", () => { selectEntry(index); }); jimakuEntriesList.appendChild(li); }); } function formatBytes(size: number): string { if (!Number.isFinite(size)) return ""; const units = ["B", "KB", "MB", "GB"]; let value = size; let idx = 0; while (value >= 1024 && idx < units.length - 1) { value /= 1024; idx += 1; } return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`; } function renderFiles(): void { jimakuFilesList.innerHTML = ""; if (jimakuFiles.length === 0) { jimakuFilesSection.classList.add("hidden"); return; } jimakuFilesSection.classList.remove("hidden"); jimakuFiles.forEach((file, index) => { const li = document.createElement("li"); li.textContent = file.name; const sub = document.createElement("div"); sub.className = "jimaku-subtext"; sub.textContent = `${formatBytes(file.size)} • ${file.last_modified}`; li.appendChild(sub); if (index === selectedFileIndex) { li.classList.add("active"); } li.addEventListener("click", () => { selectFile(index); }); jimakuFilesList.appendChild(li); }); } function getSearchQuery(): { query: string; episode: number | null } { const title = jimakuTitleInput.value.trim(); const episode = jimakuEpisodeInput.value ? Number.parseInt(jimakuEpisodeInput.value, 10) : null; const query = title; return { query, episode: Number.isFinite(episode) ? episode : null }; } async function performJimakuSearch(): Promise { const { query, episode } = getSearchQuery(); if (!query) { setJimakuStatus("Enter a title before searching.", true); return; } resetJimakuLists(); setJimakuStatus("Searching Jimaku..."); currentEpisodeFilter = episode; const response: JimakuApiResponse = await window.electronAPI.jimakuSearchEntries({ query }); if (!response.ok) { const retry = response.error.retryAfter ? ` Retry after ${response.error.retryAfter.toFixed(1)}s.` : ""; setJimakuStatus(`${response.error.error}${retry}`, true); return; } jimakuEntries = response.data; selectedEntryIndex = 0; if (jimakuEntries.length === 0) { setJimakuStatus("No entries found."); return; } setJimakuStatus("Select an entry."); renderEntries(); if (jimakuEntries.length === 1) { selectEntry(0); } } async function loadFiles( entryId: number, episode: number | null, ): Promise { setJimakuStatus("Loading files..."); jimakuFiles = []; selectedFileIndex = 0; jimakuFilesList.innerHTML = ""; jimakuFilesSection.classList.add("hidden"); const response: JimakuApiResponse = await window.electronAPI.jimakuListFiles({ entryId, episode, }); if (!response.ok) { const retry = response.error.retryAfter ? ` Retry after ${response.error.retryAfter.toFixed(1)}s.` : ""; setJimakuStatus(`${response.error.error}${retry}`, true); return; } jimakuFiles = response.data; if (jimakuFiles.length === 0) { if (episode !== null) { setJimakuStatus("No files found for this episode."); jimakuBroadenButton.classList.remove("hidden"); } else { setJimakuStatus("No files found."); } return; } jimakuBroadenButton.classList.add("hidden"); setJimakuStatus("Select a subtitle file."); renderFiles(); if (jimakuFiles.length === 1) { selectFile(0); } } function selectEntry(index: number): void { if (index < 0 || index >= jimakuEntries.length) return; selectedEntryIndex = index; currentEntryId = jimakuEntries[index].id; renderEntries(); if (currentEntryId !== null) { loadFiles(currentEntryId, currentEpisodeFilter); } } async function selectFile(index: number): Promise { if (index < 0 || index >= jimakuFiles.length) return; selectedFileIndex = index; renderFiles(); if (currentEntryId === null) { setJimakuStatus("Select an entry first.", true); return; } const file = jimakuFiles[index]; setJimakuStatus("Downloading subtitle..."); const result: JimakuDownloadResult = await window.electronAPI.jimakuDownloadFile({ entryId: currentEntryId, url: file.url, name: file.name, }); if (result.ok) { setJimakuStatus(`Downloaded and loaded: ${result.path}`); } else { const retry = result.error.retryAfter ? ` Retry after ${result.error.retryAfter.toFixed(1)}s.` : ""; setJimakuStatus(`${result.error.error}${retry}`, true); } } function isTextInputFocused(): boolean { const active = document.activeElement; if (!active) return false; const tag = active.tagName.toLowerCase(); return tag === "input" || tag === "textarea"; } function handleJimakuKeydown(e: KeyboardEvent): boolean { if (e.key === "Escape") { e.preventDefault(); closeJimakuModal(); return true; } if (isTextInputFocused()) { if (e.key === "Enter") { e.preventDefault(); performJimakuSearch(); return true; } return true; } if (e.key === "ArrowDown") { e.preventDefault(); if (jimakuFiles.length > 0) { selectedFileIndex = Math.min( jimakuFiles.length - 1, selectedFileIndex + 1, ); renderFiles(); } else if (jimakuEntries.length > 0) { selectedEntryIndex = Math.min( jimakuEntries.length - 1, selectedEntryIndex + 1, ); renderEntries(); } return true; } if (e.key === "ArrowUp") { e.preventDefault(); if (jimakuFiles.length > 0) { selectedFileIndex = Math.max(0, selectedFileIndex - 1); renderFiles(); } else if (jimakuEntries.length > 0) { selectedEntryIndex = Math.max(0, selectedEntryIndex - 1); renderEntries(); } return true; } if (e.key === "Enter") { e.preventDefault(); if (jimakuFiles.length > 0) { selectFile(selectedFileIndex); } else if (jimakuEntries.length > 0) { selectEntry(selectedEntryIndex); } else { performJimakuSearch(); } return true; } return true; } function setupDragging(): void { subtitleContainer.addEventListener("mousedown", (e: MouseEvent) => { if (e.button === 2) { e.preventDefault(); isDragging = true; dragStartY = e.clientY; startYPercent = getCurrentYPercent(); subtitleContainer.style.cursor = "grabbing"; } }); document.addEventListener("mousemove", (e: MouseEvent) => { if (!isDragging) return; const deltaY = dragStartY - e.clientY; const deltaPercent = (deltaY / window.innerHeight) * 100; const newYPercent = startYPercent + deltaPercent; applyYPercent(newYPercent); }); document.addEventListener("mouseup", (e: MouseEvent) => { if (isDragging && e.button === 2) { isDragging = false; subtitleContainer.style.cursor = ""; const yPercent = getCurrentYPercent(); window.electronAPI.saveSubtitlePosition({ yPercent }); } }); subtitleContainer.addEventListener("contextmenu", (e: Event) => { e.preventDefault(); }); } function isInteractiveTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false; if (target.closest(".modal")) return true; if (subtitleContainer.contains(target)) return true; if ( target.tagName === "IFRAME" && target.id && target.id.startsWith("yomitan-popup") ) return true; if (target.closest && target.closest('iframe[id^="yomitan-popup"]')) return true; return false; } function keyEventToString(e: KeyboardEvent): string { const parts: string[] = []; if (e.ctrlKey) parts.push("Ctrl"); if (e.altKey) parts.push("Alt"); if (e.shiftKey) parts.push("Shift"); if (e.metaKey) parts.push("Meta"); parts.push(e.code); return parts.join("+"); } let keybindingsMap = new Map(); type ChordAction = | { type: "mpv"; command: string[] } | { type: "electron"; action: () => void } | { type: "noop" }; const CHORD_MAP = new Map([ ["KeyS", { type: "mpv", command: ["script-message", "subminer-start"] }], ["Shift+KeyS", { type: "mpv", command: ["script-message", "subminer-stop"] }], ["KeyT", { type: "mpv", command: ["script-message", "subminer-toggle"] }], [ "KeyI", { type: "mpv", command: ["script-message", "subminer-toggle-invisible"] }, ], [ "Shift+KeyI", { type: "mpv", command: ["script-message", "subminer-show-invisible"] }, ], [ "KeyU", { type: "mpv", command: ["script-message", "subminer-hide-invisible"] }, ], ["KeyO", { type: "mpv", command: ["script-message", "subminer-options"] }], ["KeyR", { type: "mpv", command: ["script-message", "subminer-restart"] }], ["KeyC", { type: "mpv", command: ["script-message", "subminer-status"] }], ["KeyY", { type: "mpv", command: ["script-message", "subminer-menu"] }], [ "KeyD", { type: "electron", action: () => window.electronAPI.toggleDevTools() }, ], ]); let chordPending = false; let chordTimeout: ReturnType | null = null; function resetChord(): void { chordPending = false; if (chordTimeout !== null) { clearTimeout(chordTimeout); chordTimeout = null; } } async function setupMpvInputForwarding(): Promise { const keybindings: Keybinding[] = await window.electronAPI.getKeybindings(); keybindingsMap = new Map(); for (const binding of keybindings) { if (binding.command) { keybindingsMap.set(binding.key, binding.command); } } document.addEventListener("keydown", (e: KeyboardEvent) => { const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]'); if (yomitanPopup) return; if (runtimeOptionsModalOpen) { handleRuntimeOptionsKeydown(e); return; } if (subsyncModalOpen) { handleSubsyncKeydown(e); return; } if (kikuModalOpen) { handleKikuKeydown(e); return; } if (jimakuModalOpen) { handleJimakuKeydown(e); return; } if (chordPending) { const modifierKeys = [ "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight", ]; if (modifierKeys.includes(e.code)) { return; } e.preventDefault(); const secondKey = keyEventToString(e); const action = CHORD_MAP.get(secondKey); resetChord(); if (action) { if (action.type === "mpv") { window.electronAPI.sendMpvCommand(action.command); } else if (action.type === "electron") { action.action(); } } return; } if ( e.code === "KeyY" && !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !e.repeat ) { e.preventDefault(); chordPending = true; chordTimeout = setTimeout(() => { resetChord(); }, 1000); return; } const keyString = keyEventToString(e); const command = keybindingsMap.get(keyString); if (command) { e.preventDefault(); window.electronAPI.sendMpvCommand(command); } }); document.addEventListener("mousedown", (e: MouseEvent) => { if (e.button === 2 && !isInteractiveTarget(e.target)) { e.preventDefault(); window.electronAPI.sendMpvCommand(["cycle", "pause"]); } }); document.addEventListener("contextmenu", (e: Event) => { if (!isInteractiveTarget(e.target)) { e.preventDefault(); } }); } function setupResizeHandler(): void { window.addEventListener("resize", () => { if (isInvisibleLayer) { applyInvisibleSubtitleLayoutFromMpvMetrics( mpvSubtitleRenderMetrics, "resize", ); return; } applyYPercent(getCurrentYPercent()); }); } async function restoreSubtitlePosition(): Promise { const position = await window.electronAPI.getSubtitlePosition(); applyStoredSubtitlePosition(position, "startup"); } async function restoreSubtitleFontSize(): Promise { const style = await window.electronAPI.getSubtitleStyle(); if (style && style.fontSize !== undefined) { applySubtitleFontSize(style.fontSize); console.log("Applied subtitle font size:", style.fontSize); } } function setupSelectionObserver(): void { document.addEventListener("selectionchange", () => { const selection = window.getSelection(); const hasSelection = selection && selection.rangeCount > 0 && !selection.isCollapsed; if (hasSelection) { subtitleRoot.classList.add("has-selection"); } else { subtitleRoot.classList.remove("has-selection"); } }); } function setupYomitanObserver(): void { const observer = new MutationObserver((mutations: MutationRecord[]) => { for (const mutation of mutations) { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; if ( element.tagName === "IFRAME" && element.id && element.id.startsWith("yomitan-popup") ) { overlay.classList.add("interactive"); if (shouldToggleMouseIgnore) { window.electronAPI.setIgnoreMouseEvents(false); } } } }); mutation.removedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; if ( element.tagName === "IFRAME" && element.id && element.id.startsWith("yomitan-popup") ) { if ( !isOverSubtitle && !jimakuModalOpen && !kikuModalOpen && !runtimeOptionsModalOpen && !subsyncModalOpen ) { overlay.classList.remove("interactive"); if (shouldToggleMouseIgnore) { window.electronAPI.setIgnoreMouseEvents(true, { forward: true, }); } } } } }); } }); observer.observe(document.body, { childList: true, subtree: true, }); } function renderSecondarySub(text: string): void { secondarySubRoot.innerHTML = ""; if (!text) return; let normalized = text .replace(/\\N/g, "\n") .replace(/\\n/g, "\n") .replace(/\{[^}]*\}/g, "") .trim(); if (!normalized) return; const lines = normalized.split("\n"); for (let i = 0; i < lines.length; i++) { if (lines[i]) { const textNode = document.createTextNode(lines[i]); secondarySubRoot.appendChild(textNode); } if (i < lines.length - 1) { secondarySubRoot.appendChild(document.createElement("br")); } } } function updateSecondarySubMode(mode: SecondarySubMode): void { secondarySubContainer.classList.remove( "secondary-sub-hidden", "secondary-sub-visible", "secondary-sub-hover", ); secondarySubContainer.classList.add(`secondary-sub-${mode}`); } async function applySubtitleStyle(): Promise { const style = await window.electronAPI.getSubtitleStyle(); if (!style) return; if (style.fontFamily) { subtitleRoot.style.fontFamily = style.fontFamily; } if (style.fontSize) { subtitleRoot.style.fontSize = `${style.fontSize}px`; } if (style.fontColor) { subtitleRoot.style.color = style.fontColor; } if (style.fontWeight) { subtitleRoot.style.fontWeight = style.fontWeight; } if (style.fontStyle) { subtitleRoot.style.fontStyle = style.fontStyle; } if (style.backgroundColor) { subtitleContainer.style.background = style.backgroundColor; } const sec = style.secondary; if (sec) { if (sec.fontFamily) { secondarySubRoot.style.fontFamily = sec.fontFamily; } if (sec.fontSize) { secondarySubRoot.style.fontSize = `${sec.fontSize}px`; } if (sec.fontColor) { secondarySubRoot.style.color = sec.fontColor; } if (sec.fontWeight) { secondarySubRoot.style.fontWeight = sec.fontWeight; } if (sec.fontStyle) { secondarySubRoot.style.fontStyle = sec.fontStyle; } if (sec.backgroundColor) { secondarySubContainer.style.background = sec.backgroundColor; } } } async function init(): Promise { document.body.classList.add(`layer-${overlayLayer}`); window.electronAPI.onSubtitle((data: SubtitleData) => { renderSubtitle(data); }); if (isInvisibleLayer) { window.electronAPI.onSubtitleAss((assText: string) => { currentSubtitleAss = assText || ""; applyInvisibleSubtitleLayoutFromMpvMetrics( mpvSubtitleRenderMetrics, "subtitle-ass", ); }); } if (!isInvisibleLayer) { window.electronAPI.onSubtitlePosition( (position: SubtitlePosition | null) => { applyStoredSubtitlePosition(position, "media-change"); }, ); } if (isInvisibleLayer) { window.electronAPI.onMpvSubtitleRenderMetrics( (metrics: MpvSubtitleRenderMetrics) => { applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "event"); }, ); window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => { document.body.classList.toggle("debug-invisible-visualization", enabled); }); } if (isInvisibleLayer) { currentSubtitleAss = await window.electronAPI.getCurrentSubtitleAss(); } const initialSubtitle = await window.electronAPI.getCurrentSubtitle(); renderSubtitle(initialSubtitle); window.electronAPI.onSecondarySub((text: string) => { renderSecondarySub(text); }); window.electronAPI.onSecondarySubMode((mode: SecondarySubMode) => { updateSecondarySubMode(mode); }); const initialMode = await window.electronAPI.getSecondarySubMode(); updateSecondarySubMode(initialMode); const initialSecondary = await window.electronAPI.getCurrentSecondarySub(); renderSecondarySub(initialSecondary); subtitleContainer.addEventListener("mouseenter", handleMouseEnter); subtitleContainer.addEventListener("mouseleave", handleMouseLeave); secondarySubContainer.addEventListener("mouseenter", handleMouseEnter); secondarySubContainer.addEventListener("mouseleave", handleMouseLeave); jimakuSearchButton.addEventListener("click", () => { performJimakuSearch(); }); jimakuCloseButton.addEventListener("click", () => { closeJimakuModal(); }); jimakuBroadenButton.addEventListener("click", () => { if (currentEntryId !== null) { jimakuBroadenButton.classList.add("hidden"); loadFiles(currentEntryId, null); } }); kikuCard1.addEventListener("click", () => { kikuSelectedCard = 1; updateKikuCardSelection(); }); kikuCard1.addEventListener("dblclick", () => { kikuSelectedCard = 1; void confirmKikuSelection(); }); kikuCard2.addEventListener("click", () => { kikuSelectedCard = 2; updateKikuCardSelection(); }); kikuCard2.addEventListener("dblclick", () => { kikuSelectedCard = 2; void confirmKikuSelection(); }); kikuConfirmButton.addEventListener("click", () => { void confirmKikuSelection(); }); kikuCancelButton.addEventListener("click", () => { cancelKikuFieldGrouping(); }); kikuBackButton.addEventListener("click", () => { goBackFromKikuPreview(); }); kikuFinalConfirmButton.addEventListener("click", () => { confirmKikuMerge(); }); kikuFinalCancelButton.addEventListener("click", () => { cancelKikuFieldGrouping(); }); kikuPreviewCompactButton.addEventListener("click", () => { kikuPreviewMode = "compact"; renderKikuPreview(); }); kikuPreviewFullButton.addEventListener("click", () => { kikuPreviewMode = "full"; renderKikuPreview(); }); runtimeOptionsClose.addEventListener("click", () => { closeRuntimeOptionsModal(); }); subsyncCloseButton.addEventListener("click", () => { closeSubsyncModal(); }); subsyncEngineAlass.addEventListener("change", () => { updateSubsyncSourceVisibility(); }); subsyncEngineFfsubsync.addEventListener("change", () => { updateSubsyncSourceVisibility(); }); subsyncRunButton.addEventListener("click", () => { void runSubsyncManualFromModal(); }); window.electronAPI.onRuntimeOptionsChanged( (options: RuntimeOptionState[]) => { updateRuntimeOptions(options); }, ); window.electronAPI.onOpenRuntimeOptions(() => { openRuntimeOptionsModal().catch(() => { setRuntimeOptionsStatus("Failed to load runtime options", true); window.electronAPI.notifyOverlayModalClosed("runtime-options"); syncSettingsModalSubtitleSuppression(); }); }); window.electronAPI.onOpenJimaku(() => { openJimakuModal(); }); window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => { openSubsyncModal(payload); }); window.electronAPI.onKikuFieldGroupingRequest( (data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo; }) => { openKikuFieldGroupingModal(data); }, ); if (!isInvisibleLayer) { setupDragging(); } await setupMpvInputForwarding(); setupResizeHandler(); if (isInvisibleLayer) { const metrics = await window.electronAPI.getMpvSubtitleRenderMetrics(); applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "startup"); } else { await restoreSubtitlePosition(); await restoreSubtitleFontSize(); await applySubtitleStyle(); } setupYomitanObserver(); setupSelectionObserver(); if (shouldToggleMouseIgnore) { window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); }