mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
2465 lines
69 KiB
TypeScript
2465 lines
69 KiB
TypeScript
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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<T> =
|
|
| { 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<string, unknown>;
|
|
full?: Record<string, unknown>;
|
|
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<string, unknown> | null = null;
|
|
let kikuPreviewFullData: Record<string, unknown> | null = null;
|
|
let runtimeOptionsModalOpen = false;
|
|
let runtimeOptions: RuntimeOptionState[] = [];
|
|
let runtimeOptionSelectedIndex = 0;
|
|
let runtimeOptionDraftValues = new Map<RuntimeOptionId, RuntimeOptionValue>();
|
|
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<MpvSubtitleRenderMetrics> | 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<MpvSubtitleRenderMetrics> | 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const { query, episode } = getSearchQuery();
|
|
if (!query) {
|
|
setJimakuStatus("Enter a title before searching.", true);
|
|
return;
|
|
}
|
|
resetJimakuLists();
|
|
setJimakuStatus("Searching Jimaku...");
|
|
currentEpisodeFilter = episode;
|
|
|
|
const response: JimakuApiResponse<JimakuEntry[]> =
|
|
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<void> {
|
|
setJimakuStatus("Loading files...");
|
|
jimakuFiles = [];
|
|
selectedFileIndex = 0;
|
|
jimakuFilesList.innerHTML = "";
|
|
jimakuFilesSection.classList.add("hidden");
|
|
|
|
const response: JimakuApiResponse<JimakuFileEntry[]> =
|
|
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<void> {
|
|
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<string, (string | number)[]>();
|
|
|
|
type ChordAction =
|
|
| { type: "mpv"; command: string[] }
|
|
| { type: "electron"; action: () => void }
|
|
| { type: "noop" };
|
|
|
|
const CHORD_MAP = new Map<string, ChordAction>([
|
|
["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<typeof setTimeout> | null = null;
|
|
|
|
function resetChord(): void {
|
|
chordPending = false;
|
|
if (chordTimeout !== null) {
|
|
clearTimeout(chordTimeout);
|
|
chordTimeout = null;
|
|
}
|
|
}
|
|
|
|
async function setupMpvInputForwarding(): Promise<void> {
|
|
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<void> {
|
|
const position = await window.electronAPI.getSubtitlePosition();
|
|
applyStoredSubtitlePosition(position, "startup");
|
|
}
|
|
|
|
async function restoreSubtitleFontSize(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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();
|
|
}
|