mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
Fix renderer overlay loading and modularize renderer
This commit is contained in:
14
src/renderer/context.ts
Normal file
14
src/renderer/context.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { RendererState } from "./state";
|
||||
import type { RendererDom } from "./utils/dom";
|
||||
import type { PlatformInfo } from "./utils/platform";
|
||||
|
||||
export type RendererContext = {
|
||||
dom: RendererDom;
|
||||
platform: PlatformInfo;
|
||||
state: RendererState;
|
||||
};
|
||||
|
||||
export type ModalStateReader = {
|
||||
isAnySettingsModalOpen: () => boolean;
|
||||
isAnyModalOpen: () => boolean;
|
||||
};
|
||||
238
src/renderer/handlers/keyboard.ts
Normal file
238
src/renderer/handlers/keyboard.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Keybinding } from "../../types";
|
||||
import type { RendererContext } from "../context";
|
||||
|
||||
export function createKeyboardHandlers(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
saveInvisiblePositionEdit: () => void;
|
||||
cancelInvisiblePositionEdit: () => void;
|
||||
setInvisiblePositionEditMode: (enabled: boolean) => void;
|
||||
applyInvisibleSubtitleOffsetPosition: () => void;
|
||||
updateInvisiblePositionEditHud: () => void;
|
||||
},
|
||||
) {
|
||||
const CHORD_MAP = new Map<string, { type: "mpv" | "electron"; command?: string[]; action?: () => void }>([
|
||||
["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() },
|
||||
],
|
||||
]);
|
||||
|
||||
function isInteractiveTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
if (target.closest(".modal")) return true;
|
||||
if (ctx.dom.subtitleContainer.contains(target)) return true;
|
||||
if (target.tagName === "IFRAME" && 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("+");
|
||||
}
|
||||
|
||||
function isInvisiblePositionToggleShortcut(e: KeyboardEvent): boolean {
|
||||
return (
|
||||
e.code === ctx.platform.invisiblePositionEditToggleCode &&
|
||||
!e.altKey &&
|
||||
e.shiftKey &&
|
||||
(e.ctrlKey || e.metaKey)
|
||||
);
|
||||
}
|
||||
|
||||
function handleInvisiblePositionEditKeydown(e: KeyboardEvent): boolean {
|
||||
if (!ctx.platform.isInvisibleLayer) return false;
|
||||
|
||||
if (isInvisiblePositionToggleShortcut(e)) {
|
||||
e.preventDefault();
|
||||
if (ctx.state.invisiblePositionEditMode) {
|
||||
options.cancelInvisiblePositionEdit();
|
||||
} else {
|
||||
options.setInvisiblePositionEditMode(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ctx.state.invisiblePositionEditMode) return false;
|
||||
|
||||
const step = e.shiftKey
|
||||
? ctx.platform.invisiblePositionStepFastPx
|
||||
: ctx.platform.invisiblePositionStepPx;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
options.cancelInvisiblePositionEdit();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" || ((e.ctrlKey || e.metaKey) && e.code === "KeyS")) {
|
||||
e.preventDefault();
|
||||
options.saveInvisiblePositionEdit();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === "ArrowUp" ||
|
||||
e.key === "ArrowDown" ||
|
||||
e.key === "ArrowLeft" ||
|
||||
e.key === "ArrowRight" ||
|
||||
e.key === "h" ||
|
||||
e.key === "j" ||
|
||||
e.key === "k" ||
|
||||
e.key === "l" ||
|
||||
e.key === "H" ||
|
||||
e.key === "J" ||
|
||||
e.key === "K" ||
|
||||
e.key === "L"
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp" || e.key === "k" || e.key === "K") {
|
||||
ctx.state.invisibleSubtitleOffsetYPx += step;
|
||||
} else if (e.key === "ArrowDown" || e.key === "j" || e.key === "J") {
|
||||
ctx.state.invisibleSubtitleOffsetYPx -= step;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "h" || e.key === "H") {
|
||||
ctx.state.invisibleSubtitleOffsetXPx -= step;
|
||||
} else if (e.key === "ArrowRight" || e.key === "l" || e.key === "L") {
|
||||
ctx.state.invisibleSubtitleOffsetXPx += step;
|
||||
}
|
||||
options.applyInvisibleSubtitleOffsetPosition();
|
||||
options.updateInvisiblePositionEditHud();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetChord(): void {
|
||||
ctx.state.chordPending = false;
|
||||
if (ctx.state.chordTimeout !== null) {
|
||||
clearTimeout(ctx.state.chordTimeout);
|
||||
ctx.state.chordTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setupMpvInputForwarding(): Promise<void> {
|
||||
const keybindings: Keybinding[] = await window.electronAPI.getKeybindings();
|
||||
ctx.state.keybindingsMap = new Map();
|
||||
for (const binding of keybindings) {
|
||||
if (binding.command) {
|
||||
ctx.state.keybindingsMap.set(binding.key, binding.command);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
|
||||
if (yomitanPopup) return;
|
||||
if (handleInvisiblePositionEditKeydown(e)) return;
|
||||
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
options.handleRuntimeOptionsKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.subsyncModalOpen) {
|
||||
options.handleSubsyncKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.kikuModalOpen) {
|
||||
options.handleKikuKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.jimakuModalOpen) {
|
||||
options.handleJimakuKeydown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.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" && action.command) {
|
||||
window.electronAPI.sendMpvCommand(action.command);
|
||||
} else if (action.type === "electron" && action.action) {
|
||||
action.action();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
e.code === "KeyY" &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.repeat
|
||||
) {
|
||||
e.preventDefault();
|
||||
ctx.state.chordPending = true;
|
||||
ctx.state.chordTimeout = setTimeout(() => {
|
||||
resetChord();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const keyString = keyEventToString(e);
|
||||
const command = ctx.state.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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
setupMpvInputForwarding,
|
||||
};
|
||||
}
|
||||
271
src/renderer/handlers/mouse.ts
Normal file
271
src/renderer/handlers/mouse.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { ModalStateReader, RendererContext } from "../context";
|
||||
|
||||
export function createMouseHandlers(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: ModalStateReader;
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics: (metrics: any, source: string) => void;
|
||||
applyYPercent: (yPercent: number) => void;
|
||||
getCurrentYPercent: () => number;
|
||||
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
|
||||
},
|
||||
) {
|
||||
const wordSegmenter =
|
||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: "word" })
|
||||
: null;
|
||||
|
||||
function handleMouseEnter(): void {
|
||||
ctx.state.isOverSubtitle = true;
|
||||
ctx.dom.overlay.classList.add("interactive");
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave(): void {
|
||||
ctx.state.isOverSubtitle = false;
|
||||
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
|
||||
if (!yomitanPopup && !options.modalStateReader.isAnyModalOpen() && !ctx.state.invisiblePositionEditMode) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupDragging(): void {
|
||||
ctx.dom.subtitleContainer.addEventListener("mousedown", (e: MouseEvent) => {
|
||||
if (e.button === 2) {
|
||||
e.preventDefault();
|
||||
ctx.state.isDragging = true;
|
||||
ctx.state.dragStartY = e.clientY;
|
||||
ctx.state.startYPercent = options.getCurrentYPercent();
|
||||
ctx.dom.subtitleContainer.style.cursor = "grabbing";
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (e: MouseEvent) => {
|
||||
if (!ctx.state.isDragging) return;
|
||||
|
||||
const deltaY = ctx.state.dragStartY - e.clientY;
|
||||
const deltaPercent = (deltaY / window.innerHeight) * 100;
|
||||
const newYPercent = ctx.state.startYPercent + deltaPercent;
|
||||
|
||||
options.applyYPercent(newYPercent);
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", (e: MouseEvent) => {
|
||||
if (ctx.state.isDragging && e.button === 2) {
|
||||
ctx.state.isDragging = false;
|
||||
ctx.dom.subtitleContainer.style.cursor = "";
|
||||
|
||||
const yPercent = options.getCurrentYPercent();
|
||||
options.persistSubtitlePositionPatch({ yPercent });
|
||||
}
|
||||
});
|
||||
|
||||
ctx.dom.subtitleContainer.addEventListener("contextmenu", (e: Event) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
function getCaretTextPointRange(clientX: number, clientY: number): Range | null {
|
||||
const documentWithCaretApi = document as Document & {
|
||||
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
||||
caretPositionFromPoint?: (
|
||||
x: number,
|
||||
y: number,
|
||||
) => { offsetNode: Node; offset: number } | null;
|
||||
};
|
||||
|
||||
if (typeof documentWithCaretApi.caretRangeFromPoint === "function") {
|
||||
return documentWithCaretApi.caretRangeFromPoint(clientX, clientY);
|
||||
}
|
||||
|
||||
if (typeof documentWithCaretApi.caretPositionFromPoint === "function") {
|
||||
const caretPosition = documentWithCaretApi.caretPositionFromPoint(clientX, clientY);
|
||||
if (!caretPosition) return null;
|
||||
const range = document.createRange();
|
||||
range.setStart(caretPosition.offsetNode, caretPosition.offset);
|
||||
range.collapse(true);
|
||||
return range;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getWordBoundsAtOffset(
|
||||
text: string,
|
||||
offset: number,
|
||||
): { start: number; end: number } | null {
|
||||
if (!text || text.length === 0) return null;
|
||||
|
||||
const clampedOffset = Math.max(0, Math.min(offset, text.length));
|
||||
const probeIndex =
|
||||
clampedOffset >= text.length ? Math.max(0, text.length - 1) : clampedOffset;
|
||||
|
||||
if (wordSegmenter) {
|
||||
for (const part of wordSegmenter.segment(text)) {
|
||||
const start = part.index;
|
||||
const end = start + part.segment.length;
|
||||
if (probeIndex >= start && probeIndex < end) {
|
||||
if (part.isWordLike === false) return null;
|
||||
return { start, end };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isBoundary = (char: string): boolean =>
|
||||
/[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(char);
|
||||
|
||||
const probeChar = text[probeIndex];
|
||||
if (!probeChar || isBoundary(probeChar)) return null;
|
||||
|
||||
let start = probeIndex;
|
||||
while (start > 0 && !isBoundary(text[start - 1])) {
|
||||
start -= 1;
|
||||
}
|
||||
|
||||
let end = probeIndex + 1;
|
||||
while (end < text.length && !isBoundary(text[end])) {
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if (end <= start) return null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function updateHoverWordSelection(event: MouseEvent): void {
|
||||
if (!ctx.platform.isInvisibleLayer || !ctx.platform.isMacOSPlatform) return;
|
||||
if (event.buttons !== 0) return;
|
||||
if (!(event.target instanceof Node)) return;
|
||||
if (!ctx.dom.subtitleRoot.contains(event.target)) return;
|
||||
|
||||
const caretRange = getCaretTextPointRange(event.clientX, event.clientY);
|
||||
if (!caretRange) return;
|
||||
if (caretRange.startContainer.nodeType !== Node.TEXT_NODE) return;
|
||||
if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) return;
|
||||
|
||||
const textNode = caretRange.startContainer as Text;
|
||||
const wordBounds = getWordBoundsAtOffset(textNode.data, caretRange.startOffset);
|
||||
if (!wordBounds) return;
|
||||
|
||||
const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice(
|
||||
wordBounds.start,
|
||||
wordBounds.end,
|
||||
)}`;
|
||||
if (
|
||||
selectionKey === ctx.state.lastHoverSelectionKey &&
|
||||
textNode === ctx.state.lastHoverSelectionNode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, wordBounds.start);
|
||||
range.setEnd(textNode, wordBounds.end);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
ctx.state.lastHoverSelectionKey = selectionKey;
|
||||
ctx.state.lastHoverSelectionNode = textNode;
|
||||
}
|
||||
|
||||
function setupInvisibleHoverSelection(): void {
|
||||
if (!ctx.platform.isInvisibleLayer || !ctx.platform.isMacOSPlatform) return;
|
||||
|
||||
ctx.dom.subtitleRoot.addEventListener("mousemove", (event: MouseEvent) => {
|
||||
updateHoverWordSelection(event);
|
||||
});
|
||||
|
||||
ctx.dom.subtitleRoot.addEventListener("mouseleave", () => {
|
||||
ctx.state.lastHoverSelectionKey = "";
|
||||
ctx.state.lastHoverSelectionNode = null;
|
||||
});
|
||||
}
|
||||
|
||||
function setupResizeHandler(): void {
|
||||
window.addEventListener("resize", () => {
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
if (!ctx.state.mpvSubtitleRenderMetrics) return;
|
||||
options.applyInvisibleSubtitleLayoutFromMpvMetrics(
|
||||
ctx.state.mpvSubtitleRenderMetrics,
|
||||
"resize",
|
||||
);
|
||||
return;
|
||||
}
|
||||
options.applyYPercent(options.getCurrentYPercent());
|
||||
});
|
||||
}
|
||||
|
||||
function setupSelectionObserver(): void {
|
||||
document.addEventListener("selectionchange", () => {
|
||||
const selection = window.getSelection();
|
||||
const hasSelection =
|
||||
selection && selection.rangeCount > 0 && !selection.isCollapsed;
|
||||
|
||||
if (hasSelection) {
|
||||
ctx.dom.subtitleRoot.classList.add("has-selection");
|
||||
} else {
|
||||
ctx.dom.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) return;
|
||||
const element = node as Element;
|
||||
if (
|
||||
element.tagName === "IFRAME" &&
|
||||
element.id &&
|
||||
element.id.startsWith("yomitan-popup")
|
||||
) {
|
||||
ctx.dom.overlay.classList.add("interactive");
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const element = node as Element;
|
||||
if (
|
||||
element.tagName === "IFRAME" &&
|
||||
element.id &&
|
||||
element.id.startsWith("yomitan-popup")
|
||||
) {
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
setupDragging,
|
||||
setupInvisibleHoverSelection,
|
||||
setupResizeHandler,
|
||||
setupSelectionObserver,
|
||||
setupYomitanObserver,
|
||||
};
|
||||
}
|
||||
@@ -260,6 +260,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="renderer.js"></script>
|
||||
<script type="module" src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
378
src/renderer/modals/jimaku.ts
Normal file
378
src/renderer/modals/jimaku.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import type {
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadResult,
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
JimakuMediaInfo,
|
||||
} from "../../types";
|
||||
import type { ModalStateReader, RendererContext } from "../context";
|
||||
|
||||
export function createJimakuModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
|
||||
},
|
||||
) {
|
||||
function setJimakuStatus(message: string, isError = false): void {
|
||||
ctx.dom.jimakuStatus.textContent = message;
|
||||
ctx.dom.jimakuStatus.style.color = isError
|
||||
? "rgba(255, 120, 120, 0.95)"
|
||||
: "rgba(255, 255, 255, 0.8)";
|
||||
}
|
||||
|
||||
function resetJimakuLists(): void {
|
||||
ctx.state.jimakuEntries = [];
|
||||
ctx.state.jimakuFiles = [];
|
||||
ctx.state.selectedEntryIndex = 0;
|
||||
ctx.state.selectedFileIndex = 0;
|
||||
ctx.state.currentEntryId = null;
|
||||
|
||||
ctx.dom.jimakuEntriesList.innerHTML = "";
|
||||
ctx.dom.jimakuFilesList.innerHTML = "";
|
||||
ctx.dom.jimakuEntriesSection.classList.add("hidden");
|
||||
ctx.dom.jimakuFilesSection.classList.add("hidden");
|
||||
ctx.dom.jimakuBroadenButton.classList.add("hidden");
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx.dom.jimakuEntriesList.innerHTML = "";
|
||||
if (ctx.state.jimakuEntries.length === 0) {
|
||||
ctx.dom.jimakuEntriesSection.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.jimakuEntriesSection.classList.remove("hidden");
|
||||
ctx.state.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 === ctx.state.selectedEntryIndex) {
|
||||
li.classList.add("active");
|
||||
}
|
||||
|
||||
li.addEventListener("click", () => {
|
||||
selectEntry(index);
|
||||
});
|
||||
|
||||
ctx.dom.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 {
|
||||
ctx.dom.jimakuFilesList.innerHTML = "";
|
||||
if (ctx.state.jimakuFiles.length === 0) {
|
||||
ctx.dom.jimakuFilesSection.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.jimakuFilesSection.classList.remove("hidden");
|
||||
ctx.state.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 === ctx.state.selectedFileIndex) {
|
||||
li.classList.add("active");
|
||||
}
|
||||
|
||||
li.addEventListener("click", () => {
|
||||
void selectFile(index);
|
||||
});
|
||||
|
||||
ctx.dom.jimakuFilesList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchQuery(): { query: string; episode: number | null } {
|
||||
const title = ctx.dom.jimakuTitleInput.value.trim();
|
||||
const episode = ctx.dom.jimakuEpisodeInput.value
|
||||
? Number.parseInt(ctx.dom.jimakuEpisodeInput.value, 10)
|
||||
: null;
|
||||
return { query: title, 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...");
|
||||
ctx.state.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;
|
||||
}
|
||||
|
||||
ctx.state.jimakuEntries = response.data;
|
||||
ctx.state.selectedEntryIndex = 0;
|
||||
|
||||
if (ctx.state.jimakuEntries.length === 0) {
|
||||
setJimakuStatus("No entries found.");
|
||||
return;
|
||||
}
|
||||
|
||||
setJimakuStatus("Select an entry.");
|
||||
renderEntries();
|
||||
if (ctx.state.jimakuEntries.length === 1) {
|
||||
void selectEntry(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles(entryId: number, episode: number | null): Promise<void> {
|
||||
setJimakuStatus("Loading files...");
|
||||
ctx.state.jimakuFiles = [];
|
||||
ctx.state.selectedFileIndex = 0;
|
||||
|
||||
ctx.dom.jimakuFilesList.innerHTML = "";
|
||||
ctx.dom.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;
|
||||
}
|
||||
|
||||
ctx.state.jimakuFiles = response.data;
|
||||
if (ctx.state.jimakuFiles.length === 0) {
|
||||
if (episode !== null) {
|
||||
setJimakuStatus("No files found for this episode.");
|
||||
ctx.dom.jimakuBroadenButton.classList.remove("hidden");
|
||||
} else {
|
||||
setJimakuStatus("No files found.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.jimakuBroadenButton.classList.add("hidden");
|
||||
setJimakuStatus("Select a subtitle file.");
|
||||
renderFiles();
|
||||
if (ctx.state.jimakuFiles.length === 1) {
|
||||
await selectFile(0);
|
||||
}
|
||||
}
|
||||
|
||||
function selectEntry(index: number): void {
|
||||
if (index < 0 || index >= ctx.state.jimakuEntries.length) return;
|
||||
|
||||
ctx.state.selectedEntryIndex = index;
|
||||
ctx.state.currentEntryId = ctx.state.jimakuEntries[index].id;
|
||||
renderEntries();
|
||||
|
||||
if (ctx.state.currentEntryId !== null) {
|
||||
void loadFiles(ctx.state.currentEntryId, ctx.state.currentEpisodeFilter);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile(index: number): Promise<void> {
|
||||
if (index < 0 || index >= ctx.state.jimakuFiles.length) return;
|
||||
|
||||
ctx.state.selectedFileIndex = index;
|
||||
renderFiles();
|
||||
|
||||
if (ctx.state.currentEntryId === null) {
|
||||
setJimakuStatus("Select an entry first.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = ctx.state.jimakuFiles[index];
|
||||
setJimakuStatus("Downloading subtitle...");
|
||||
|
||||
const result: JimakuDownloadResult = await window.electronAPI.jimakuDownloadFile({
|
||||
entryId: ctx.state.currentEntryId,
|
||||
url: file.url,
|
||||
name: file.name,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
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 openJimakuModal(): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
if (ctx.state.jimakuModalOpen) return;
|
||||
|
||||
ctx.state.jimakuModalOpen = true;
|
||||
ctx.dom.overlay.classList.add("interactive");
|
||||
ctx.dom.jimakuModal.classList.remove("hidden");
|
||||
ctx.dom.jimakuModal.setAttribute("aria-hidden", "false");
|
||||
|
||||
setJimakuStatus("Loading media info...");
|
||||
resetJimakuLists();
|
||||
|
||||
window.electronAPI
|
||||
.getJimakuMediaInfo()
|
||||
.then((info: JimakuMediaInfo) => {
|
||||
ctx.dom.jimakuTitleInput.value = info.title || "";
|
||||
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : "";
|
||||
ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : "";
|
||||
ctx.state.currentEpisodeFilter = info.episode ?? null;
|
||||
|
||||
if (info.confidence === "high" && info.title && info.episode) {
|
||||
void 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 (!ctx.state.jimakuModalOpen) return;
|
||||
|
||||
ctx.state.jimakuModalOpen = false;
|
||||
ctx.dom.jimakuModal.classList.add("hidden");
|
||||
ctx.dom.jimakuModal.setAttribute("aria-hidden", "true");
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
}
|
||||
|
||||
resetJimakuLists();
|
||||
}
|
||||
|
||||
function handleJimakuKeydown(e: KeyboardEvent): boolean {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeJimakuModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTextInputFocused()) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void performJimakuSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
ctx.state.selectedFileIndex = Math.min(
|
||||
ctx.state.jimakuFiles.length - 1,
|
||||
ctx.state.selectedFileIndex + 1,
|
||||
);
|
||||
renderFiles();
|
||||
} else if (ctx.state.jimakuEntries.length > 0) {
|
||||
ctx.state.selectedEntryIndex = Math.min(
|
||||
ctx.state.jimakuEntries.length - 1,
|
||||
ctx.state.selectedEntryIndex + 1,
|
||||
);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
ctx.state.selectedFileIndex = Math.max(0, ctx.state.selectedFileIndex - 1);
|
||||
renderFiles();
|
||||
} else if (ctx.state.jimakuEntries.length > 0) {
|
||||
ctx.state.selectedEntryIndex = Math.max(0, ctx.state.selectedEntryIndex - 1);
|
||||
renderEntries();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (ctx.state.jimakuFiles.length > 0) {
|
||||
void selectFile(ctx.state.selectedFileIndex);
|
||||
} else if (ctx.state.jimakuEntries.length > 0) {
|
||||
selectEntry(ctx.state.selectedEntryIndex);
|
||||
} else {
|
||||
void performJimakuSearch();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.jimakuSearchButton.addEventListener("click", () => {
|
||||
void performJimakuSearch();
|
||||
});
|
||||
ctx.dom.jimakuCloseButton.addEventListener("click", () => {
|
||||
closeJimakuModal();
|
||||
});
|
||||
ctx.dom.jimakuBroadenButton.addEventListener("click", () => {
|
||||
if (ctx.state.currentEntryId !== null) {
|
||||
ctx.dom.jimakuBroadenButton.classList.add("hidden");
|
||||
void loadFiles(ctx.state.currentEntryId, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeJimakuModal,
|
||||
handleJimakuKeydown,
|
||||
openJimakuModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
307
src/renderer/modals/kiku.ts
Normal file
307
src/renderer/modals/kiku.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import type {
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuMergePreviewResponse,
|
||||
} from "../../types";
|
||||
import type { ModalStateReader, RendererContext } from "../context";
|
||||
|
||||
export function createKikuModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
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 {
|
||||
ctx.dom.kikuCard1.classList.toggle("active", ctx.state.kikuSelectedCard === 1);
|
||||
ctx.dom.kikuCard2.classList.toggle("active", ctx.state.kikuSelectedCard === 2);
|
||||
}
|
||||
|
||||
function setKikuModalStep(step: "select" | "preview"): void {
|
||||
ctx.state.kikuModalStep = step;
|
||||
const isSelect = step === "select";
|
||||
ctx.dom.kikuSelectionStep.classList.toggle("hidden", !isSelect);
|
||||
ctx.dom.kikuPreviewStep.classList.toggle("hidden", isSelect);
|
||||
ctx.dom.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 {
|
||||
ctx.dom.kikuPreviewCompactButton.classList.toggle(
|
||||
"active",
|
||||
ctx.state.kikuPreviewMode === "compact",
|
||||
);
|
||||
ctx.dom.kikuPreviewFullButton.classList.toggle(
|
||||
"active",
|
||||
ctx.state.kikuPreviewMode === "full",
|
||||
);
|
||||
}
|
||||
|
||||
function renderKikuPreview(): void {
|
||||
const payload =
|
||||
ctx.state.kikuPreviewMode === "compact"
|
||||
? ctx.state.kikuPreviewCompactData
|
||||
: ctx.state.kikuPreviewFullData;
|
||||
ctx.dom.kikuPreviewJson.textContent = payload ? JSON.stringify(payload, null, 2) : "{}";
|
||||
updateKikuPreviewToggle();
|
||||
}
|
||||
|
||||
function setKikuPreviewError(message: string | null): void {
|
||||
if (!message) {
|
||||
ctx.dom.kikuPreviewError.textContent = "";
|
||||
ctx.dom.kikuPreviewError.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.kikuPreviewError.textContent = message;
|
||||
ctx.dom.kikuPreviewError.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function openKikuFieldGroupingModal(data: {
|
||||
original: KikuDuplicateCardInfo;
|
||||
duplicate: KikuDuplicateCardInfo;
|
||||
}): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
if (ctx.state.kikuModalOpen) return;
|
||||
|
||||
ctx.state.kikuModalOpen = true;
|
||||
ctx.state.kikuOriginalData = data.original;
|
||||
ctx.state.kikuDuplicateData = data.duplicate;
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
|
||||
ctx.dom.kikuCard1Expression.textContent = data.original.expression;
|
||||
ctx.dom.kikuCard1Sentence.textContent = data.original.sentencePreview || "(no sentence)";
|
||||
ctx.dom.kikuCard1Meta.textContent = formatMediaMeta(data.original);
|
||||
|
||||
ctx.dom.kikuCard2Expression.textContent = data.duplicate.expression;
|
||||
ctx.dom.kikuCard2Sentence.textContent =
|
||||
data.duplicate.sentencePreview || "(current subtitle)";
|
||||
ctx.dom.kikuCard2Meta.textContent = formatMediaMeta(data.duplicate);
|
||||
|
||||
ctx.dom.kikuDeleteDuplicateCheckbox.checked = true;
|
||||
ctx.state.kikuPendingChoice = null;
|
||||
ctx.state.kikuPreviewCompactData = null;
|
||||
ctx.state.kikuPreviewFullData = null;
|
||||
ctx.state.kikuPreviewMode = "compact";
|
||||
|
||||
renderKikuPreview();
|
||||
setKikuPreviewError(null);
|
||||
setKikuModalStep("select");
|
||||
updateKikuCardSelection();
|
||||
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add("interactive");
|
||||
ctx.dom.kikuModal.classList.remove("hidden");
|
||||
ctx.dom.kikuModal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeKikuFieldGroupingModal(): void {
|
||||
if (!ctx.state.kikuModalOpen) return;
|
||||
|
||||
ctx.state.kikuModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.kikuModal.classList.add("hidden");
|
||||
ctx.dom.kikuModal.setAttribute("aria-hidden", "true");
|
||||
|
||||
setKikuPreviewError(null);
|
||||
ctx.dom.kikuPreviewJson.textContent = "";
|
||||
|
||||
ctx.state.kikuPendingChoice = null;
|
||||
ctx.state.kikuPreviewCompactData = null;
|
||||
ctx.state.kikuPreviewFullData = null;
|
||||
ctx.state.kikuPreviewMode = "compact";
|
||||
setKikuModalStep("select");
|
||||
ctx.state.kikuOriginalData = null;
|
||||
ctx.state.kikuDuplicateData = null;
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmKikuSelection(): Promise<void> {
|
||||
if (!ctx.state.kikuOriginalData || !ctx.state.kikuDuplicateData) return;
|
||||
|
||||
const keepData =
|
||||
ctx.state.kikuSelectedCard === 1
|
||||
? ctx.state.kikuOriginalData
|
||||
: ctx.state.kikuDuplicateData;
|
||||
const deleteData =
|
||||
ctx.state.kikuSelectedCard === 1
|
||||
? ctx.state.kikuDuplicateData
|
||||
: ctx.state.kikuOriginalData;
|
||||
|
||||
const choice: KikuFieldGroupingChoice = {
|
||||
keepNoteId: keepData.noteId,
|
||||
deleteNoteId: deleteData.noteId,
|
||||
deleteDuplicate: ctx.dom.kikuDeleteDuplicateCheckbox.checked,
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
ctx.state.kikuPendingChoice = choice;
|
||||
setKikuPreviewError(null);
|
||||
ctx.dom.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;
|
||||
}
|
||||
|
||||
ctx.state.kikuPreviewCompactData = preview.compact || {};
|
||||
ctx.state.kikuPreviewFullData = preview.full || {};
|
||||
ctx.state.kikuPreviewMode = "compact";
|
||||
renderKikuPreview();
|
||||
setKikuModalStep("preview");
|
||||
} finally {
|
||||
ctx.dom.kikuConfirmButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmKikuMerge(): void {
|
||||
if (!ctx.state.kikuPendingChoice) return;
|
||||
window.electronAPI.kikuFieldGroupingRespond(ctx.state.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 (ctx.state.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();
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
updateKikuCardSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "2") {
|
||||
e.preventDefault();
|
||||
ctx.state.kikuSelectedCard = 2;
|
||||
updateKikuCardSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
ctx.state.kikuSelectedCard = ctx.state.kikuSelectedCard === 1 ? 2 : 1;
|
||||
updateKikuCardSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void confirmKikuSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.kikuCard1.addEventListener("click", () => {
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
updateKikuCardSelection();
|
||||
});
|
||||
ctx.dom.kikuCard1.addEventListener("dblclick", () => {
|
||||
ctx.state.kikuSelectedCard = 1;
|
||||
void confirmKikuSelection();
|
||||
});
|
||||
|
||||
ctx.dom.kikuCard2.addEventListener("click", () => {
|
||||
ctx.state.kikuSelectedCard = 2;
|
||||
updateKikuCardSelection();
|
||||
});
|
||||
ctx.dom.kikuCard2.addEventListener("dblclick", () => {
|
||||
ctx.state.kikuSelectedCard = 2;
|
||||
void confirmKikuSelection();
|
||||
});
|
||||
|
||||
ctx.dom.kikuConfirmButton.addEventListener("click", () => {
|
||||
void confirmKikuSelection();
|
||||
});
|
||||
ctx.dom.kikuCancelButton.addEventListener("click", () => {
|
||||
cancelKikuFieldGrouping();
|
||||
});
|
||||
ctx.dom.kikuBackButton.addEventListener("click", () => {
|
||||
goBackFromKikuPreview();
|
||||
});
|
||||
ctx.dom.kikuFinalConfirmButton.addEventListener("click", () => {
|
||||
confirmKikuMerge();
|
||||
});
|
||||
ctx.dom.kikuFinalCancelButton.addEventListener("click", () => {
|
||||
cancelKikuFieldGrouping();
|
||||
});
|
||||
|
||||
ctx.dom.kikuPreviewCompactButton.addEventListener("click", () => {
|
||||
ctx.state.kikuPreviewMode = "compact";
|
||||
renderKikuPreview();
|
||||
});
|
||||
ctx.dom.kikuPreviewFullButton.addEventListener("click", () => {
|
||||
ctx.state.kikuPreviewMode = "full";
|
||||
renderKikuPreview();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeKikuFieldGroupingModal,
|
||||
handleKikuKeydown,
|
||||
openKikuFieldGroupingModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
262
src/renderer/modals/runtime-options.ts
Normal file
262
src/renderer/modals/runtime-options.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import type {
|
||||
RuntimeOptionApplyResult,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
} from "../../types";
|
||||
import type { ModalStateReader, RendererContext } from "../context";
|
||||
|
||||
export function createRuntimeOptionsModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function formatRuntimeOptionValue(value: RuntimeOptionValue): string {
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "On" : "Off";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function setRuntimeOptionsStatus(message: string, isError = false): void {
|
||||
ctx.dom.runtimeOptionsStatus.textContent = message;
|
||||
ctx.dom.runtimeOptionsStatus.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
function getRuntimeOptionDisplayValue(option: RuntimeOptionState): RuntimeOptionValue {
|
||||
return ctx.state.runtimeOptionDraftValues.get(option.id) ?? option.value;
|
||||
}
|
||||
|
||||
function getSelectedRuntimeOption(): RuntimeOptionState | null {
|
||||
if (ctx.state.runtimeOptions.length === 0) return null;
|
||||
if (ctx.state.runtimeOptionSelectedIndex < 0) return null;
|
||||
if (ctx.state.runtimeOptionSelectedIndex >= ctx.state.runtimeOptions.length) {
|
||||
return null;
|
||||
}
|
||||
return ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex];
|
||||
}
|
||||
|
||||
function renderRuntimeOptionsList(): void {
|
||||
ctx.dom.runtimeOptionsList.innerHTML = "";
|
||||
ctx.state.runtimeOptions.forEach((option, index) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "runtime-options-item";
|
||||
li.classList.toggle("active", index === ctx.state.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", () => {
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
renderRuntimeOptionsList();
|
||||
});
|
||||
li.addEventListener("dblclick", () => {
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
void applySelectedRuntimeOption();
|
||||
});
|
||||
|
||||
value.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
cycleRuntimeDraftValue(1);
|
||||
});
|
||||
value.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ctx.state.runtimeOptionSelectedIndex = index;
|
||||
cycleRuntimeDraftValue(-1);
|
||||
});
|
||||
|
||||
ctx.dom.runtimeOptionsList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function updateRuntimeOptions(optionsList: RuntimeOptionState[]): void {
|
||||
const previousId =
|
||||
ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex]?.id ??
|
||||
ctx.state.runtimeOptions[0]?.id;
|
||||
|
||||
ctx.state.runtimeOptions = optionsList;
|
||||
ctx.state.runtimeOptionDraftValues.clear();
|
||||
|
||||
for (const option of ctx.state.runtimeOptions) {
|
||||
ctx.state.runtimeOptionDraftValues.set(option.id, option.value);
|
||||
}
|
||||
|
||||
const nextIndex = ctx.state.runtimeOptions.findIndex(
|
||||
(option) => option.id === previousId,
|
||||
);
|
||||
ctx.state.runtimeOptionSelectedIndex = nextIndex >= 0 ? nextIndex : 0;
|
||||
|
||||
renderRuntimeOptionsList();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
ctx.state.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) {
|
||||
ctx.state.runtimeOptionDraftValues.set(result.option.id, result.option.value);
|
||||
}
|
||||
|
||||
const latest = await window.electronAPI.getRuntimeOptions();
|
||||
updateRuntimeOptions(latest);
|
||||
setRuntimeOptionsStatus(result.osdMessage || "Option applied.");
|
||||
}
|
||||
|
||||
function closeRuntimeOptionsModal(): void {
|
||||
if (!ctx.state.runtimeOptionsModalOpen) return;
|
||||
|
||||
ctx.state.runtimeOptionsModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.runtimeOptionsModal.classList.add("hidden");
|
||||
ctx.dom.runtimeOptionsModal.setAttribute("aria-hidden", "true");
|
||||
window.electronAPI.notifyOverlayModalClosed("runtime-options");
|
||||
|
||||
setRuntimeOptionsStatus("");
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
}
|
||||
}
|
||||
|
||||
async function openRuntimeOptionsModal(): Promise<void> {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
|
||||
const optionsList = await window.electronAPI.getRuntimeOptions();
|
||||
updateRuntimeOptions(optionsList);
|
||||
|
||||
ctx.state.runtimeOptionsModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.overlay.classList.add("interactive");
|
||||
ctx.dom.runtimeOptionsModal.classList.remove("hidden");
|
||||
ctx.dom.runtimeOptionsModal.setAttribute("aria-hidden", "false");
|
||||
|
||||
setRuntimeOptionsStatus(
|
||||
"Use arrow keys. Click value to cycle. Enter or double-click to apply.",
|
||||
);
|
||||
}
|
||||
|
||||
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 (ctx.state.runtimeOptions.length > 0) {
|
||||
ctx.state.runtimeOptionSelectedIndex = Math.min(
|
||||
ctx.state.runtimeOptions.length - 1,
|
||||
ctx.state.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 (ctx.state.runtimeOptions.length > 0) {
|
||||
ctx.state.runtimeOptionSelectedIndex = Math.max(
|
||||
0,
|
||||
ctx.state.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 wireDomEvents(): void {
|
||||
ctx.dom.runtimeOptionsClose.addEventListener("click", () => {
|
||||
closeRuntimeOptionsModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeRuntimeOptionsModal,
|
||||
handleRuntimeOptionsKeydown,
|
||||
openRuntimeOptionsModal,
|
||||
setRuntimeOptionsStatus,
|
||||
updateRuntimeOptions,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
142
src/renderer/modals/subsync.ts
Normal file
142
src/renderer/modals/subsync.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { SubsyncManualPayload } from "../../types";
|
||||
import type { ModalStateReader, RendererContext } from "../context";
|
||||
|
||||
export function createSubsyncModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, "isAnyModalOpen">;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function setSubsyncStatus(message: string, isError = false): void {
|
||||
ctx.dom.subsyncStatus.textContent = message;
|
||||
ctx.dom.subsyncStatus.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
function updateSubsyncSourceVisibility(): void {
|
||||
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
||||
ctx.dom.subsyncSourceLabel.classList.toggle("hidden", !useAlass);
|
||||
}
|
||||
|
||||
function renderSubsyncSourceTracks(): void {
|
||||
ctx.dom.subsyncSourceSelect.innerHTML = "";
|
||||
for (const track of ctx.state.subsyncSourceTracks) {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(track.id);
|
||||
option.textContent = track.label;
|
||||
ctx.dom.subsyncSourceSelect.appendChild(option);
|
||||
}
|
||||
ctx.dom.subsyncSourceSelect.disabled = ctx.state.subsyncSourceTracks.length === 0;
|
||||
}
|
||||
|
||||
function closeSubsyncModal(): void {
|
||||
if (!ctx.state.subsyncModalOpen) return;
|
||||
|
||||
ctx.state.subsyncModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.subsyncModal.classList.add("hidden");
|
||||
ctx.dom.subsyncModal.setAttribute("aria-hidden", "true");
|
||||
window.electronAPI.notifyOverlayModalClosed("subsync");
|
||||
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
}
|
||||
}
|
||||
|
||||
function openSubsyncModal(payload: SubsyncManualPayload): void {
|
||||
if (ctx.platform.isInvisibleLayer) return;
|
||||
|
||||
ctx.state.subsyncSubmitting = false;
|
||||
ctx.dom.subsyncRunButton.disabled = false;
|
||||
ctx.state.subsyncSourceTracks = payload.sourceTracks;
|
||||
|
||||
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
|
||||
ctx.dom.subsyncEngineAlass.checked = hasSources;
|
||||
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources;
|
||||
|
||||
renderSubsyncSourceTracks();
|
||||
updateSubsyncSourceVisibility();
|
||||
|
||||
setSubsyncStatus(
|
||||
hasSources
|
||||
? "Choose engine and source, then run."
|
||||
: "No source subtitles available for alass. Use ffsubsync.",
|
||||
false,
|
||||
);
|
||||
|
||||
ctx.state.subsyncModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
ctx.dom.overlay.classList.add("interactive");
|
||||
ctx.dom.subsyncModal.classList.remove("hidden");
|
||||
ctx.dom.subsyncModal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
async function runSubsyncManualFromModal(): Promise<void> {
|
||||
if (ctx.state.subsyncSubmitting) return;
|
||||
|
||||
const engine = ctx.dom.subsyncEngineAlass.checked ? "alass" : "ffsubsync";
|
||||
const sourceTrackId =
|
||||
engine === "alass" && ctx.dom.subsyncSourceSelect.value
|
||||
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
|
||||
: null;
|
||||
|
||||
if (engine === "alass" && !Number.isFinite(sourceTrackId)) {
|
||||
setSubsyncStatus("Select a source subtitle track for alass.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.subsyncSubmitting = true;
|
||||
ctx.dom.subsyncRunButton.disabled = true;
|
||||
|
||||
closeSubsyncModal();
|
||||
try {
|
||||
await window.electronAPI.runSubsyncManual({
|
||||
engine,
|
||||
sourceTrackId,
|
||||
});
|
||||
} finally {
|
||||
ctx.state.subsyncSubmitting = false;
|
||||
ctx.dom.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 wireDomEvents(): void {
|
||||
ctx.dom.subsyncCloseButton.addEventListener("click", () => {
|
||||
closeSubsyncModal();
|
||||
});
|
||||
ctx.dom.subsyncEngineAlass.addEventListener("change", () => {
|
||||
updateSubsyncSourceVisibility();
|
||||
});
|
||||
ctx.dom.subsyncEngineFfsubsync.addEventListener("change", () => {
|
||||
updateSubsyncSourceVisibility();
|
||||
});
|
||||
ctx.dom.subsyncRunButton.addEventListener("click", () => {
|
||||
void runSubsyncManualFromModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeSubsyncModal,
|
||||
handleSubsyncKeydown,
|
||||
openSubsyncModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
498
src/renderer/positioning.ts
Normal file
498
src/renderer/positioning.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import type { MpvSubtitleRenderMetrics, SubtitlePosition } from "../types";
|
||||
import type { ModalStateReader, RendererContext } from "./context";
|
||||
|
||||
function clampYPercent(yPercent: number): number {
|
||||
return Math.max(2, Math.min(80, yPercent));
|
||||
}
|
||||
|
||||
export function createPositioningController(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, "isAnySettingsModalOpen">;
|
||||
applySubtitleFontSize: (fontSize: number) => void;
|
||||
},
|
||||
) {
|
||||
function getCurrentYPercent(): number {
|
||||
if (ctx.state.currentYPercent !== null) {
|
||||
return ctx.state.currentYPercent;
|
||||
}
|
||||
const marginBottom = parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60;
|
||||
ctx.state.currentYPercent = clampYPercent((marginBottom / window.innerHeight) * 100);
|
||||
return ctx.state.currentYPercent;
|
||||
}
|
||||
|
||||
function applyYPercent(yPercent: number): void {
|
||||
const clampedPercent = clampYPercent(yPercent);
|
||||
ctx.state.currentYPercent = clampedPercent;
|
||||
const marginBottom = (clampedPercent / 100) * window.innerHeight;
|
||||
|
||||
ctx.dom.subtitleContainer.style.position = "";
|
||||
ctx.dom.subtitleContainer.style.left = "";
|
||||
ctx.dom.subtitleContainer.style.top = "";
|
||||
ctx.dom.subtitleContainer.style.right = "";
|
||||
ctx.dom.subtitleContainer.style.transform = "";
|
||||
|
||||
ctx.dom.subtitleContainer.style.marginBottom = `${marginBottom}px`;
|
||||
}
|
||||
|
||||
function updatePersistedSubtitlePosition(position: SubtitlePosition | null): void {
|
||||
const nextYPercent =
|
||||
position &&
|
||||
typeof position.yPercent === "number" &&
|
||||
Number.isFinite(position.yPercent)
|
||||
? position.yPercent
|
||||
: ctx.state.persistedSubtitlePosition.yPercent;
|
||||
const nextXOffset =
|
||||
position &&
|
||||
typeof position.invisibleOffsetXPx === "number" &&
|
||||
Number.isFinite(position.invisibleOffsetXPx)
|
||||
? position.invisibleOffsetXPx
|
||||
: 0;
|
||||
const nextYOffset =
|
||||
position &&
|
||||
typeof position.invisibleOffsetYPx === "number" &&
|
||||
Number.isFinite(position.invisibleOffsetYPx)
|
||||
? position.invisibleOffsetYPx
|
||||
: 0;
|
||||
|
||||
ctx.state.persistedSubtitlePosition = {
|
||||
yPercent: nextYPercent,
|
||||
invisibleOffsetXPx: nextXOffset,
|
||||
invisibleOffsetYPx: nextYOffset,
|
||||
};
|
||||
}
|
||||
|
||||
function persistSubtitlePositionPatch(patch: Partial<SubtitlePosition>): void {
|
||||
const nextPosition: SubtitlePosition = {
|
||||
yPercent:
|
||||
typeof patch.yPercent === "number" && Number.isFinite(patch.yPercent)
|
||||
? patch.yPercent
|
||||
: ctx.state.persistedSubtitlePosition.yPercent,
|
||||
invisibleOffsetXPx:
|
||||
typeof patch.invisibleOffsetXPx === "number" &&
|
||||
Number.isFinite(patch.invisibleOffsetXPx)
|
||||
? patch.invisibleOffsetXPx
|
||||
: ctx.state.persistedSubtitlePosition.invisibleOffsetXPx ?? 0,
|
||||
invisibleOffsetYPx:
|
||||
typeof patch.invisibleOffsetYPx === "number" &&
|
||||
Number.isFinite(patch.invisibleOffsetYPx)
|
||||
? patch.invisibleOffsetYPx
|
||||
: ctx.state.persistedSubtitlePosition.invisibleOffsetYPx ?? 0,
|
||||
};
|
||||
|
||||
ctx.state.persistedSubtitlePosition = nextPosition;
|
||||
window.electronAPI.saveSubtitlePosition(nextPosition);
|
||||
}
|
||||
|
||||
function applyStoredSubtitlePosition(
|
||||
position: SubtitlePosition | null,
|
||||
source: string,
|
||||
): void {
|
||||
updatePersistedSubtitlePosition(position);
|
||||
if (position && position.yPercent !== undefined) {
|
||||
applyYPercent(position.yPercent);
|
||||
console.log(
|
||||
"Applied subtitle position from",
|
||||
source,
|
||||
":",
|
||||
position.yPercent,
|
||||
"%",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultMarginBottom = 60;
|
||||
const defaultYPercent = (defaultMarginBottom / window.innerHeight) * 100;
|
||||
applyYPercent(defaultYPercent);
|
||||
console.log("Applied default subtitle position from", source);
|
||||
}
|
||||
|
||||
function applyInvisibleSubtitleOffsetPosition(): void {
|
||||
const nextLeft =
|
||||
ctx.state.invisibleLayoutBaseLeftPx + ctx.state.invisibleSubtitleOffsetXPx;
|
||||
ctx.dom.subtitleContainer.style.left = `${nextLeft}px`;
|
||||
|
||||
if (ctx.state.invisibleLayoutBaseBottomPx !== null) {
|
||||
ctx.dom.subtitleContainer.style.bottom = `${Math.max(
|
||||
0,
|
||||
ctx.state.invisibleLayoutBaseBottomPx + ctx.state.invisibleSubtitleOffsetYPx,
|
||||
)}px`;
|
||||
ctx.dom.subtitleContainer.style.top = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.invisibleLayoutBaseTopPx !== null) {
|
||||
ctx.dom.subtitleContainer.style.top = `${Math.max(
|
||||
0,
|
||||
ctx.state.invisibleLayoutBaseTopPx - ctx.state.invisibleSubtitleOffsetYPx,
|
||||
)}px`;
|
||||
ctx.dom.subtitleContainer.style.bottom = "";
|
||||
}
|
||||
}
|
||||
|
||||
function updateInvisiblePositionEditHud(): void {
|
||||
if (!ctx.state.invisiblePositionEditHud) return;
|
||||
ctx.state.invisiblePositionEditHud.textContent =
|
||||
`Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(ctx.state.invisibleSubtitleOffsetXPx)} y:${Math.round(ctx.state.invisibleSubtitleOffsetYPx)}`;
|
||||
}
|
||||
|
||||
function setInvisiblePositionEditMode(enabled: boolean): void {
|
||||
if (!ctx.platform.isInvisibleLayer) return;
|
||||
if (ctx.state.invisiblePositionEditMode === enabled) return;
|
||||
|
||||
ctx.state.invisiblePositionEditMode = enabled;
|
||||
document.body.classList.toggle("invisible-position-edit", enabled);
|
||||
|
||||
if (enabled) {
|
||||
ctx.state.invisiblePositionEditStartX = ctx.state.invisibleSubtitleOffsetXPx;
|
||||
ctx.state.invisiblePositionEditStartY = ctx.state.invisibleSubtitleOffsetYPx;
|
||||
ctx.dom.overlay.classList.add("interactive");
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
} else if (
|
||||
!ctx.state.isOverSubtitle &&
|
||||
!options.modalStateReader.isAnySettingsModalOpen()
|
||||
) {
|
||||
ctx.dom.overlay.classList.remove("interactive");
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
|
||||
updateInvisiblePositionEditHud();
|
||||
}
|
||||
|
||||
function applyInvisibleStoredSubtitlePosition(
|
||||
position: SubtitlePosition | null,
|
||||
source: string,
|
||||
): void {
|
||||
updatePersistedSubtitlePosition(position);
|
||||
ctx.state.invisibleSubtitleOffsetXPx =
|
||||
ctx.state.persistedSubtitlePosition.invisibleOffsetXPx ?? 0;
|
||||
ctx.state.invisibleSubtitleOffsetYPx =
|
||||
ctx.state.persistedSubtitlePosition.invisibleOffsetYPx ?? 0;
|
||||
applyInvisibleSubtitleOffsetPosition();
|
||||
console.log(
|
||||
"[invisible-overlay] Applied subtitle offset from",
|
||||
source,
|
||||
`${ctx.state.invisibleSubtitleOffsetXPx}px`,
|
||||
`${ctx.state.invisibleSubtitleOffsetYPx}px`,
|
||||
);
|
||||
updateInvisiblePositionEditHud();
|
||||
}
|
||||
|
||||
function computeOsdToCssScale(metrics: MpvSubtitleRenderMetrics): number {
|
||||
const dims = metrics.osdDimensions;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (!ctx.platform.isMacOSPlatform || !dims) {
|
||||
return dpr;
|
||||
}
|
||||
|
||||
const ratios = [
|
||||
dims.w / Math.max(1, window.innerWidth),
|
||||
dims.h / Math.max(1, window.innerHeight),
|
||||
].filter((value) => Number.isFinite(value) && value > 0);
|
||||
|
||||
const avgRatio =
|
||||
ratios.length > 0
|
||||
? ratios.reduce((sum, value) => sum + value, 0) / ratios.length
|
||||
: dpr;
|
||||
|
||||
return avgRatio > 1.25 ? avgRatio : 1;
|
||||
}
|
||||
|
||||
function applySubtitleContainerBaseLayout(params: {
|
||||
horizontalAvailable: number;
|
||||
leftInset: number;
|
||||
marginX: number;
|
||||
hAlign: 0 | 1 | 2;
|
||||
}): void {
|
||||
ctx.dom.subtitleContainer.style.position = "absolute";
|
||||
ctx.dom.subtitleContainer.style.maxWidth = `${params.horizontalAvailable}px`;
|
||||
ctx.dom.subtitleContainer.style.width = `${params.horizontalAvailable}px`;
|
||||
ctx.dom.subtitleContainer.style.padding = "0";
|
||||
ctx.dom.subtitleContainer.style.background = "transparent";
|
||||
ctx.dom.subtitleContainer.style.marginBottom = "0";
|
||||
ctx.dom.subtitleContainer.style.pointerEvents = "none";
|
||||
|
||||
ctx.dom.subtitleContainer.style.left = `${params.leftInset + params.marginX}px`;
|
||||
ctx.dom.subtitleContainer.style.right = "";
|
||||
ctx.dom.subtitleContainer.style.transform = "";
|
||||
ctx.dom.subtitleContainer.style.textAlign = "";
|
||||
|
||||
if (params.hAlign === 0) {
|
||||
ctx.dom.subtitleContainer.style.textAlign = "left";
|
||||
ctx.dom.subtitleRoot.style.textAlign = "left";
|
||||
} else if (params.hAlign === 2) {
|
||||
ctx.dom.subtitleContainer.style.textAlign = "right";
|
||||
ctx.dom.subtitleRoot.style.textAlign = "right";
|
||||
} else {
|
||||
ctx.dom.subtitleContainer.style.textAlign = "center";
|
||||
ctx.dom.subtitleRoot.style.textAlign = "center";
|
||||
}
|
||||
|
||||
ctx.dom.subtitleRoot.style.display = "inline-block";
|
||||
ctx.dom.subtitleRoot.style.maxWidth = "100%";
|
||||
ctx.dom.subtitleRoot.style.pointerEvents = "auto";
|
||||
}
|
||||
|
||||
function applySubtitleVerticalPosition(params: {
|
||||
metrics: MpvSubtitleRenderMetrics;
|
||||
renderAreaHeight: number;
|
||||
topInset: number;
|
||||
bottomInset: number;
|
||||
marginY: number;
|
||||
effectiveFontSize: number;
|
||||
vAlign: 0 | 1 | 2;
|
||||
}): void {
|
||||
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
|
||||
const multiline = lineCount > 1;
|
||||
const baselineCompensationFactor =
|
||||
lineCount >= 3 ? 0.46 : multiline ? 0.58 : 0.7;
|
||||
const baselineCompensationPx = Math.max(
|
||||
0,
|
||||
params.effectiveFontSize * baselineCompensationFactor,
|
||||
);
|
||||
|
||||
if (params.vAlign === 2) {
|
||||
ctx.dom.subtitleContainer.style.top = `${Math.max(
|
||||
0,
|
||||
params.topInset + params.marginY - baselineCompensationPx,
|
||||
)}px`;
|
||||
ctx.dom.subtitleContainer.style.bottom = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.vAlign === 1) {
|
||||
ctx.dom.subtitleContainer.style.top = "50%";
|
||||
ctx.dom.subtitleContainer.style.bottom = "";
|
||||
ctx.dom.subtitleContainer.style.transform = "translateY(-50%)";
|
||||
return;
|
||||
}
|
||||
|
||||
const subPosMargin =
|
||||
((100 - params.metrics.subPos) / 100) * params.renderAreaHeight;
|
||||
const effectiveMargin = Math.max(params.marginY, subPosMargin);
|
||||
const bottomPx = Math.max(
|
||||
0,
|
||||
params.bottomInset + effectiveMargin + baselineCompensationPx,
|
||||
);
|
||||
|
||||
ctx.dom.subtitleContainer.style.top = "";
|
||||
ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`;
|
||||
}
|
||||
|
||||
function applySubtitleTypography(params: {
|
||||
metrics: MpvSubtitleRenderMetrics;
|
||||
pxPerScaledPixel: number;
|
||||
effectiveFontSize: number;
|
||||
}): void {
|
||||
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
|
||||
const multiline = lineCount > 1;
|
||||
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"line-height",
|
||||
ctx.platform.isMacOSPlatform
|
||||
? lineCount >= 3
|
||||
? "1.18"
|
||||
: multiline
|
||||
? "1.08"
|
||||
: "0.86"
|
||||
: "normal",
|
||||
ctx.platform.isMacOSPlatform ? "important" : "",
|
||||
);
|
||||
|
||||
const rawFont = params.metrics.subFont;
|
||||
const strippedFont = rawFont
|
||||
.replace(
|
||||
/\s+(Regular|Bold|Italic|Light|Medium|Semi\s*Bold|Extra\s*Bold|Extra\s*Light|Thin|Black|Heavy|Demi\s*Bold|Book|Condensed)\s*$/i,
|
||||
"",
|
||||
)
|
||||
.trim();
|
||||
|
||||
ctx.dom.subtitleRoot.style.fontFamily =
|
||||
strippedFont !== rawFont
|
||||
? `"${rawFont}", "${strippedFont}", sans-serif`
|
||||
: `"${rawFont}", sans-serif`;
|
||||
|
||||
const effectiveSpacing = params.metrics.subSpacing;
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"letter-spacing",
|
||||
Math.abs(effectiveSpacing) > 0.0001
|
||||
? `${effectiveSpacing * params.pxPerScaledPixel * (ctx.platform.isMacOSPlatform ? 0.7 : 1)}px`
|
||||
: ctx.platform.isMacOSPlatform
|
||||
? "-0.02em"
|
||||
: "0px",
|
||||
ctx.platform.isMacOSPlatform ? "important" : "",
|
||||
);
|
||||
|
||||
ctx.dom.subtitleRoot.style.fontKerning = ctx.platform.isMacOSPlatform
|
||||
? "auto"
|
||||
: "none";
|
||||
ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold ? "700" : "400";
|
||||
ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic
|
||||
? "italic"
|
||||
: "normal";
|
||||
|
||||
const scaleX = 1;
|
||||
const scaleY = 1;
|
||||
if (Math.abs(scaleX - 1) > 0.0001 || Math.abs(scaleY - 1) > 0.0001) {
|
||||
ctx.dom.subtitleRoot.style.transform = `scale(${scaleX}, ${scaleY})`;
|
||||
ctx.dom.subtitleRoot.style.transformOrigin = "50% 100%";
|
||||
} else {
|
||||
ctx.dom.subtitleRoot.style.transform = "";
|
||||
ctx.dom.subtitleRoot.style.transformOrigin = "";
|
||||
}
|
||||
|
||||
const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight);
|
||||
if (
|
||||
Number.isFinite(computedLineHeight) &&
|
||||
computedLineHeight > params.effectiveFontSize
|
||||
) {
|
||||
const halfLeading = (computedLineHeight - params.effectiveFontSize) / 2;
|
||||
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
|
||||
const currentTop = parseFloat(ctx.dom.subtitleContainer.style.top);
|
||||
|
||||
if (halfLeading > 0.5 && Number.isFinite(currentBottom)) {
|
||||
ctx.dom.subtitleContainer.style.bottom = `${Math.max(
|
||||
0,
|
||||
currentBottom - halfLeading,
|
||||
)}px`;
|
||||
}
|
||||
|
||||
if (halfLeading > 0.5 && Number.isFinite(currentTop)) {
|
||||
ctx.dom.subtitleContainer.style.top = `${Math.max(0, currentTop - halfLeading)}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyInvisibleSubtitleLayoutFromMpvMetrics(
|
||||
metrics: MpvSubtitleRenderMetrics,
|
||||
source: string,
|
||||
): void {
|
||||
ctx.state.mpvSubtitleRenderMetrics = metrics;
|
||||
|
||||
const dims = metrics.osdDimensions;
|
||||
const osdToCssScale = computeOsdToCssScale(metrics);
|
||||
const renderAreaHeight = dims ? dims.h / osdToCssScale : window.innerHeight;
|
||||
const renderAreaWidth = dims ? dims.w / osdToCssScale : window.innerWidth;
|
||||
const videoLeftInset = dims ? dims.ml / osdToCssScale : 0;
|
||||
const videoRightInset = dims ? dims.mr / osdToCssScale : 0;
|
||||
const videoTopInset = dims ? dims.mt / osdToCssScale : 0;
|
||||
const videoBottomInset = dims ? dims.mb / osdToCssScale : 0;
|
||||
|
||||
const anchorToVideoArea = !metrics.subUseMargins;
|
||||
const leftInset = anchorToVideoArea ? videoLeftInset : 0;
|
||||
const rightInset = anchorToVideoArea ? videoRightInset : 0;
|
||||
const topInset = anchorToVideoArea ? videoTopInset : 0;
|
||||
const bottomInset = anchorToVideoArea ? videoBottomInset : 0;
|
||||
|
||||
const videoHeight = renderAreaHeight - videoTopInset - videoBottomInset;
|
||||
const scaleRefHeight = metrics.subScaleByWindow ? renderAreaHeight : videoHeight;
|
||||
const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720);
|
||||
const computedFontSize =
|
||||
metrics.subFontSize *
|
||||
metrics.subScale *
|
||||
(ctx.platform.isLinuxPlatform ? 1 : pxPerScaledPixel);
|
||||
|
||||
const effectiveFontSize =
|
||||
computedFontSize * (ctx.platform.isMacOSPlatform ? 0.87 : 1);
|
||||
options.applySubtitleFontSize(effectiveFontSize);
|
||||
|
||||
const marginY = metrics.subMarginY * pxPerScaledPixel;
|
||||
const marginX = Math.max(0, metrics.subMarginX * pxPerScaledPixel);
|
||||
const horizontalAvailable = Math.max(
|
||||
0,
|
||||
renderAreaWidth - leftInset - rightInset - Math.round(marginX * 2),
|
||||
);
|
||||
|
||||
const effectiveBorderSize = metrics.subBorderSize * pxPerScaledPixel;
|
||||
document.documentElement.style.setProperty(
|
||||
"--sub-border-size",
|
||||
`${effectiveBorderSize}px`,
|
||||
);
|
||||
|
||||
const alignment = 2;
|
||||
const hAlign = ((alignment - 1) % 3) as 0 | 1 | 2;
|
||||
const vAlign = Math.floor((alignment - 1) / 3) as 0 | 1 | 2;
|
||||
|
||||
applySubtitleContainerBaseLayout({
|
||||
horizontalAvailable,
|
||||
leftInset,
|
||||
marginX,
|
||||
hAlign,
|
||||
});
|
||||
|
||||
applySubtitleVerticalPosition({
|
||||
metrics,
|
||||
renderAreaHeight,
|
||||
topInset,
|
||||
bottomInset,
|
||||
marginY,
|
||||
effectiveFontSize,
|
||||
vAlign,
|
||||
});
|
||||
|
||||
applySubtitleTypography({ metrics, pxPerScaledPixel, effectiveFontSize });
|
||||
|
||||
ctx.state.invisibleLayoutBaseLeftPx =
|
||||
parseFloat(ctx.dom.subtitleContainer.style.left) || 0;
|
||||
|
||||
const parsedBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
|
||||
ctx.state.invisibleLayoutBaseBottomPx = Number.isFinite(parsedBottom)
|
||||
? parsedBottom
|
||||
: null;
|
||||
|
||||
const parsedTop = parseFloat(ctx.dom.subtitleContainer.style.top);
|
||||
ctx.state.invisibleLayoutBaseTopPx = Number.isFinite(parsedTop) ? parsedTop : null;
|
||||
|
||||
applyInvisibleSubtitleOffsetPosition();
|
||||
updateInvisiblePositionEditHud();
|
||||
|
||||
console.log(
|
||||
"[invisible-overlay] Applied mpv subtitle render metrics from",
|
||||
source,
|
||||
);
|
||||
}
|
||||
|
||||
function saveInvisiblePositionEdit(): void {
|
||||
persistSubtitlePositionPatch({
|
||||
invisibleOffsetXPx: ctx.state.invisibleSubtitleOffsetXPx,
|
||||
invisibleOffsetYPx: ctx.state.invisibleSubtitleOffsetYPx,
|
||||
});
|
||||
setInvisiblePositionEditMode(false);
|
||||
}
|
||||
|
||||
function cancelInvisiblePositionEdit(): void {
|
||||
ctx.state.invisibleSubtitleOffsetXPx = ctx.state.invisiblePositionEditStartX;
|
||||
ctx.state.invisibleSubtitleOffsetYPx = ctx.state.invisiblePositionEditStartY;
|
||||
applyInvisibleSubtitleOffsetPosition();
|
||||
setInvisiblePositionEditMode(false);
|
||||
}
|
||||
|
||||
function setupInvisiblePositionEditHud(): void {
|
||||
if (!ctx.platform.isInvisibleLayer) return;
|
||||
const hud = document.createElement("div");
|
||||
hud.id = "invisiblePositionEditHud";
|
||||
hud.className = "invisible-position-edit-hud";
|
||||
ctx.dom.overlay.appendChild(hud);
|
||||
ctx.state.invisiblePositionEditHud = hud;
|
||||
updateInvisiblePositionEditHud();
|
||||
}
|
||||
|
||||
return {
|
||||
applyInvisibleStoredSubtitlePosition,
|
||||
applyInvisibleSubtitleLayoutFromMpvMetrics,
|
||||
applyInvisibleSubtitleOffsetPosition,
|
||||
applyStoredSubtitlePosition,
|
||||
applyYPercent,
|
||||
cancelInvisiblePositionEdit,
|
||||
getCurrentYPercent,
|
||||
persistSubtitlePositionPatch,
|
||||
saveInvisiblePositionEdit,
|
||||
setInvisiblePositionEditMode,
|
||||
setupInvisiblePositionEditHud,
|
||||
updateInvisiblePositionEditHud,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
132
src/renderer/state.ts
Normal file
132
src/renderer/state.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
JimakuEntry,
|
||||
JimakuFileEntry,
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
MpvSubtitleRenderMetrics,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
SubtitlePosition,
|
||||
SubsyncSourceTrack,
|
||||
} from "../types";
|
||||
|
||||
export type KikuModalStep = "select" | "preview";
|
||||
export type KikuPreviewMode = "compact" | "full";
|
||||
|
||||
export type ChordAction =
|
||||
| { type: "mpv"; command: string[] }
|
||||
| { type: "electron"; action: () => void }
|
||||
| { type: "noop" };
|
||||
|
||||
export type RendererState = {
|
||||
isOverSubtitle: boolean;
|
||||
isDragging: boolean;
|
||||
dragStartY: number;
|
||||
startYPercent: number;
|
||||
currentYPercent: number | null;
|
||||
persistedSubtitlePosition: SubtitlePosition;
|
||||
|
||||
jimakuModalOpen: boolean;
|
||||
jimakuEntries: JimakuEntry[];
|
||||
jimakuFiles: JimakuFileEntry[];
|
||||
selectedEntryIndex: number;
|
||||
selectedFileIndex: number;
|
||||
currentEpisodeFilter: number | null;
|
||||
currentEntryId: number | null;
|
||||
|
||||
kikuModalOpen: boolean;
|
||||
kikuSelectedCard: 1 | 2;
|
||||
kikuOriginalData: KikuDuplicateCardInfo | null;
|
||||
kikuDuplicateData: KikuDuplicateCardInfo | null;
|
||||
kikuModalStep: KikuModalStep;
|
||||
kikuPreviewMode: KikuPreviewMode;
|
||||
kikuPendingChoice: KikuFieldGroupingChoice | null;
|
||||
kikuPreviewCompactData: Record<string, unknown> | null;
|
||||
kikuPreviewFullData: Record<string, unknown> | null;
|
||||
|
||||
runtimeOptionsModalOpen: boolean;
|
||||
runtimeOptions: RuntimeOptionState[];
|
||||
runtimeOptionSelectedIndex: number;
|
||||
runtimeOptionDraftValues: Map<RuntimeOptionId, RuntimeOptionValue>;
|
||||
|
||||
subsyncModalOpen: boolean;
|
||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
|
||||
mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics | null;
|
||||
invisiblePositionEditMode: boolean;
|
||||
invisiblePositionEditStartX: number;
|
||||
invisiblePositionEditStartY: number;
|
||||
invisibleSubtitleOffsetXPx: number;
|
||||
invisibleSubtitleOffsetYPx: number;
|
||||
invisibleLayoutBaseLeftPx: number;
|
||||
invisibleLayoutBaseBottomPx: number | null;
|
||||
invisibleLayoutBaseTopPx: number | null;
|
||||
invisiblePositionEditHud: HTMLDivElement | null;
|
||||
currentInvisibleSubtitleLineCount: number;
|
||||
|
||||
lastHoverSelectionKey: string;
|
||||
lastHoverSelectionNode: Text | null;
|
||||
|
||||
keybindingsMap: Map<string, (string | number)[]>;
|
||||
chordPending: boolean;
|
||||
chordTimeout: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
export function createRendererState(): RendererState {
|
||||
return {
|
||||
isOverSubtitle: false,
|
||||
isDragging: false,
|
||||
dragStartY: 0,
|
||||
startYPercent: 0,
|
||||
currentYPercent: null,
|
||||
persistedSubtitlePosition: { yPercent: 10 },
|
||||
|
||||
jimakuModalOpen: false,
|
||||
jimakuEntries: [],
|
||||
jimakuFiles: [],
|
||||
selectedEntryIndex: 0,
|
||||
selectedFileIndex: 0,
|
||||
currentEpisodeFilter: null,
|
||||
currentEntryId: null,
|
||||
|
||||
kikuModalOpen: false,
|
||||
kikuSelectedCard: 1,
|
||||
kikuOriginalData: null,
|
||||
kikuDuplicateData: null,
|
||||
kikuModalStep: "select",
|
||||
kikuPreviewMode: "compact",
|
||||
kikuPendingChoice: null,
|
||||
kikuPreviewCompactData: null,
|
||||
kikuPreviewFullData: null,
|
||||
|
||||
runtimeOptionsModalOpen: false,
|
||||
runtimeOptions: [],
|
||||
runtimeOptionSelectedIndex: 0,
|
||||
runtimeOptionDraftValues: new Map(),
|
||||
|
||||
subsyncModalOpen: false,
|
||||
subsyncSourceTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
mpvSubtitleRenderMetrics: null,
|
||||
invisiblePositionEditMode: false,
|
||||
invisiblePositionEditStartX: 0,
|
||||
invisiblePositionEditStartY: 0,
|
||||
invisibleSubtitleOffsetXPx: 0,
|
||||
invisibleSubtitleOffsetYPx: 0,
|
||||
invisibleLayoutBaseLeftPx: 0,
|
||||
invisibleLayoutBaseBottomPx: null,
|
||||
invisibleLayoutBaseTopPx: null,
|
||||
invisiblePositionEditHud: null,
|
||||
currentInvisibleSubtitleLineCount: 1,
|
||||
|
||||
lastHoverSelectionKey: "",
|
||||
lastHoverSelectionNode: null,
|
||||
|
||||
keybindingsMap: new Map(),
|
||||
chordPending: false,
|
||||
chordTimeout: null,
|
||||
};
|
||||
}
|
||||
206
src/renderer/subtitle-render.ts
Normal file
206
src/renderer/subtitle-render.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
MergedToken,
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
SubtitleStyleConfig,
|
||||
} from "../types";
|
||||
import type { RendererContext } from "./context";
|
||||
|
||||
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(root: HTMLElement, 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 += 1) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
function renderCharacterLevel(root: HTMLElement, text: string): void {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (const char of text) {
|
||||
if (char === "\n") {
|
||||
fragment.appendChild(document.createElement("br"));
|
||||
continue;
|
||||
}
|
||||
const span = document.createElement("span");
|
||||
span.className = "c";
|
||||
span.textContent = char;
|
||||
fragment.appendChild(span);
|
||||
}
|
||||
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
function renderPlainTextPreserveLineBreaks(root: HTMLElement, 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"));
|
||||
}
|
||||
}
|
||||
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
function renderSubtitle(data: SubtitleData | string): void {
|
||||
ctx.dom.subtitleRoot.innerHTML = "";
|
||||
ctx.state.lastHoverSelectionKey = "";
|
||||
ctx.state.lastHoverSelectionNode = null;
|
||||
|
||||
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 (ctx.platform.isInvisibleLayer) {
|
||||
const normalizedInvisible = normalizeSubtitle(text, false);
|
||||
ctx.state.currentInvisibleSubtitleLineCount = Math.max(
|
||||
1,
|
||||
normalizedInvisible.split("\n").length,
|
||||
);
|
||||
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeSubtitle(text);
|
||||
if (tokens && tokens.length > 0) {
|
||||
renderWithTokens(ctx.dom.subtitleRoot, tokens);
|
||||
return;
|
||||
}
|
||||
renderCharacterLevel(ctx.dom.subtitleRoot, normalized);
|
||||
}
|
||||
|
||||
function renderSecondarySub(text: string): void {
|
||||
ctx.dom.secondarySubRoot.innerHTML = "";
|
||||
if (!text) return;
|
||||
|
||||
const 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 += 1) {
|
||||
if (lines[i]) {
|
||||
ctx.dom.secondarySubRoot.appendChild(document.createTextNode(lines[i]));
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
ctx.dom.secondarySubRoot.appendChild(document.createElement("br"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSecondarySubMode(mode: SecondarySubMode): void {
|
||||
ctx.dom.secondarySubContainer.classList.remove(
|
||||
"secondary-sub-hidden",
|
||||
"secondary-sub-visible",
|
||||
"secondary-sub-hover",
|
||||
);
|
||||
ctx.dom.secondarySubContainer.classList.add(`secondary-sub-${mode}`);
|
||||
}
|
||||
|
||||
function applySubtitleFontSize(fontSize: number): void {
|
||||
const clampedSize = Math.max(10, fontSize);
|
||||
ctx.dom.subtitleRoot.style.fontSize = `${clampedSize}px`;
|
||||
document.documentElement.style.setProperty(
|
||||
"--subtitle-font-size",
|
||||
`${clampedSize}px`,
|
||||
);
|
||||
}
|
||||
|
||||
function applySubtitleStyle(style: SubtitleStyleConfig | null): void {
|
||||
if (!style) return;
|
||||
|
||||
if (style.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily;
|
||||
if (style.fontSize) ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`;
|
||||
if (style.fontColor) ctx.dom.subtitleRoot.style.color = style.fontColor;
|
||||
if (style.fontWeight) ctx.dom.subtitleRoot.style.fontWeight = style.fontWeight;
|
||||
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
|
||||
if (style.backgroundColor) {
|
||||
ctx.dom.subtitleContainer.style.background = style.backgroundColor;
|
||||
}
|
||||
|
||||
const secondaryStyle = style.secondary;
|
||||
if (!secondaryStyle) return;
|
||||
|
||||
if (secondaryStyle.fontFamily) {
|
||||
ctx.dom.secondarySubRoot.style.fontFamily = secondaryStyle.fontFamily;
|
||||
}
|
||||
if (secondaryStyle.fontSize) {
|
||||
ctx.dom.secondarySubRoot.style.fontSize = `${secondaryStyle.fontSize}px`;
|
||||
}
|
||||
if (secondaryStyle.fontColor) {
|
||||
ctx.dom.secondarySubRoot.style.color = secondaryStyle.fontColor;
|
||||
}
|
||||
if (secondaryStyle.fontWeight) {
|
||||
ctx.dom.secondarySubRoot.style.fontWeight = secondaryStyle.fontWeight;
|
||||
}
|
||||
if (secondaryStyle.fontStyle) {
|
||||
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
|
||||
}
|
||||
if (secondaryStyle.backgroundColor) {
|
||||
ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applySubtitleFontSize,
|
||||
applySubtitleStyle,
|
||||
renderSecondarySub,
|
||||
renderSubtitle,
|
||||
updateSecondarySubMode,
|
||||
};
|
||||
}
|
||||
131
src/renderer/utils/dom.ts
Normal file
131
src/renderer/utils/dom.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
export type RendererDom = {
|
||||
subtitleRoot: HTMLElement;
|
||||
subtitleContainer: HTMLElement;
|
||||
overlay: HTMLElement;
|
||||
secondarySubContainer: HTMLElement;
|
||||
secondarySubRoot: HTMLElement;
|
||||
|
||||
jimakuModal: HTMLDivElement;
|
||||
jimakuTitleInput: HTMLInputElement;
|
||||
jimakuSeasonInput: HTMLInputElement;
|
||||
jimakuEpisodeInput: HTMLInputElement;
|
||||
jimakuSearchButton: HTMLButtonElement;
|
||||
jimakuCloseButton: HTMLButtonElement;
|
||||
jimakuStatus: HTMLDivElement;
|
||||
jimakuEntriesSection: HTMLDivElement;
|
||||
jimakuEntriesList: HTMLUListElement;
|
||||
jimakuFilesSection: HTMLDivElement;
|
||||
jimakuFilesList: HTMLUListElement;
|
||||
jimakuBroadenButton: HTMLButtonElement;
|
||||
|
||||
kikuModal: HTMLDivElement;
|
||||
kikuCard1: HTMLDivElement;
|
||||
kikuCard2: HTMLDivElement;
|
||||
kikuCard1Expression: HTMLDivElement;
|
||||
kikuCard2Expression: HTMLDivElement;
|
||||
kikuCard1Sentence: HTMLDivElement;
|
||||
kikuCard2Sentence: HTMLDivElement;
|
||||
kikuCard1Meta: HTMLDivElement;
|
||||
kikuCard2Meta: HTMLDivElement;
|
||||
kikuConfirmButton: HTMLButtonElement;
|
||||
kikuCancelButton: HTMLButtonElement;
|
||||
kikuDeleteDuplicateCheckbox: HTMLInputElement;
|
||||
kikuSelectionStep: HTMLDivElement;
|
||||
kikuPreviewStep: HTMLDivElement;
|
||||
kikuPreviewJson: HTMLPreElement;
|
||||
kikuPreviewCompactButton: HTMLButtonElement;
|
||||
kikuPreviewFullButton: HTMLButtonElement;
|
||||
kikuPreviewError: HTMLDivElement;
|
||||
kikuBackButton: HTMLButtonElement;
|
||||
kikuFinalConfirmButton: HTMLButtonElement;
|
||||
kikuFinalCancelButton: HTMLButtonElement;
|
||||
kikuHint: HTMLDivElement;
|
||||
|
||||
runtimeOptionsModal: HTMLDivElement;
|
||||
runtimeOptionsClose: HTMLButtonElement;
|
||||
runtimeOptionsList: HTMLUListElement;
|
||||
runtimeOptionsStatus: HTMLDivElement;
|
||||
|
||||
subsyncModal: HTMLDivElement;
|
||||
subsyncCloseButton: HTMLButtonElement;
|
||||
subsyncEngineAlass: HTMLInputElement;
|
||||
subsyncEngineFfsubsync: HTMLInputElement;
|
||||
subsyncSourceLabel: HTMLLabelElement;
|
||||
subsyncSourceSelect: HTMLSelectElement;
|
||||
subsyncRunButton: HTMLButtonElement;
|
||||
subsyncStatus: HTMLDivElement;
|
||||
};
|
||||
|
||||
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`Missing required DOM element #${id}`);
|
||||
}
|
||||
return element as T;
|
||||
}
|
||||
|
||||
export function resolveRendererDom(): RendererDom {
|
||||
return {
|
||||
subtitleRoot: getRequiredElement<HTMLElement>("subtitleRoot"),
|
||||
subtitleContainer: getRequiredElement<HTMLElement>("subtitleContainer"),
|
||||
overlay: getRequiredElement<HTMLElement>("overlay"),
|
||||
secondarySubContainer:
|
||||
getRequiredElement<HTMLElement>("secondarySubContainer"),
|
||||
secondarySubRoot: getRequiredElement<HTMLElement>("secondarySubRoot"),
|
||||
|
||||
jimakuModal: getRequiredElement<HTMLDivElement>("jimakuModal"),
|
||||
jimakuTitleInput: getRequiredElement<HTMLInputElement>("jimakuTitle"),
|
||||
jimakuSeasonInput: getRequiredElement<HTMLInputElement>("jimakuSeason"),
|
||||
jimakuEpisodeInput: getRequiredElement<HTMLInputElement>("jimakuEpisode"),
|
||||
jimakuSearchButton: getRequiredElement<HTMLButtonElement>("jimakuSearch"),
|
||||
jimakuCloseButton: getRequiredElement<HTMLButtonElement>("jimakuClose"),
|
||||
jimakuStatus: getRequiredElement<HTMLDivElement>("jimakuStatus"),
|
||||
jimakuEntriesSection: getRequiredElement<HTMLDivElement>("jimakuEntriesSection"),
|
||||
jimakuEntriesList: getRequiredElement<HTMLUListElement>("jimakuEntries"),
|
||||
jimakuFilesSection: getRequiredElement<HTMLDivElement>("jimakuFilesSection"),
|
||||
jimakuFilesList: getRequiredElement<HTMLUListElement>("jimakuFiles"),
|
||||
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>("jimakuBroaden"),
|
||||
|
||||
kikuModal: getRequiredElement<HTMLDivElement>("kikuFieldGroupingModal"),
|
||||
kikuCard1: getRequiredElement<HTMLDivElement>("kikuCard1"),
|
||||
kikuCard2: getRequiredElement<HTMLDivElement>("kikuCard2"),
|
||||
kikuCard1Expression: getRequiredElement<HTMLDivElement>("kikuCard1Expression"),
|
||||
kikuCard2Expression: getRequiredElement<HTMLDivElement>("kikuCard2Expression"),
|
||||
kikuCard1Sentence: getRequiredElement<HTMLDivElement>("kikuCard1Sentence"),
|
||||
kikuCard2Sentence: getRequiredElement<HTMLDivElement>("kikuCard2Sentence"),
|
||||
kikuCard1Meta: getRequiredElement<HTMLDivElement>("kikuCard1Meta"),
|
||||
kikuCard2Meta: getRequiredElement<HTMLDivElement>("kikuCard2Meta"),
|
||||
kikuConfirmButton: getRequiredElement<HTMLButtonElement>("kikuConfirmButton"),
|
||||
kikuCancelButton: getRequiredElement<HTMLButtonElement>("kikuCancelButton"),
|
||||
kikuDeleteDuplicateCheckbox:
|
||||
getRequiredElement<HTMLInputElement>("kikuDeleteDuplicate"),
|
||||
kikuSelectionStep: getRequiredElement<HTMLDivElement>("kikuSelectionStep"),
|
||||
kikuPreviewStep: getRequiredElement<HTMLDivElement>("kikuPreviewStep"),
|
||||
kikuPreviewJson: getRequiredElement<HTMLPreElement>("kikuPreviewJson"),
|
||||
kikuPreviewCompactButton:
|
||||
getRequiredElement<HTMLButtonElement>("kikuPreviewCompact"),
|
||||
kikuPreviewFullButton: getRequiredElement<HTMLButtonElement>("kikuPreviewFull"),
|
||||
kikuPreviewError: getRequiredElement<HTMLDivElement>("kikuPreviewError"),
|
||||
kikuBackButton: getRequiredElement<HTMLButtonElement>("kikuBackButton"),
|
||||
kikuFinalConfirmButton:
|
||||
getRequiredElement<HTMLButtonElement>("kikuFinalConfirmButton"),
|
||||
kikuFinalCancelButton:
|
||||
getRequiredElement<HTMLButtonElement>("kikuFinalCancelButton"),
|
||||
kikuHint: getRequiredElement<HTMLDivElement>("kikuHint"),
|
||||
|
||||
runtimeOptionsModal: getRequiredElement<HTMLDivElement>("runtimeOptionsModal"),
|
||||
runtimeOptionsClose: getRequiredElement<HTMLButtonElement>("runtimeOptionsClose"),
|
||||
runtimeOptionsList: getRequiredElement<HTMLUListElement>("runtimeOptionsList"),
|
||||
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>("runtimeOptionsStatus"),
|
||||
|
||||
subsyncModal: getRequiredElement<HTMLDivElement>("subsyncModal"),
|
||||
subsyncCloseButton: getRequiredElement<HTMLButtonElement>("subsyncClose"),
|
||||
subsyncEngineAlass: getRequiredElement<HTMLInputElement>("subsyncEngineAlass"),
|
||||
subsyncEngineFfsubsync:
|
||||
getRequiredElement<HTMLInputElement>("subsyncEngineFfsubsync"),
|
||||
subsyncSourceLabel: getRequiredElement<HTMLLabelElement>("subsyncSourceLabel"),
|
||||
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>("subsyncSourceSelect"),
|
||||
subsyncRunButton: getRequiredElement<HTMLButtonElement>("subsyncRun"),
|
||||
subsyncStatus: getRequiredElement<HTMLDivElement>("subsyncStatus"),
|
||||
};
|
||||
}
|
||||
43
src/renderer/utils/platform.ts
Normal file
43
src/renderer/utils/platform.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type OverlayLayer = "visible" | "invisible";
|
||||
|
||||
export type PlatformInfo = {
|
||||
overlayLayer: OverlayLayer;
|
||||
isInvisibleLayer: boolean;
|
||||
isLinuxPlatform: boolean;
|
||||
isMacOSPlatform: boolean;
|
||||
shouldToggleMouseIgnore: boolean;
|
||||
invisiblePositionEditToggleCode: string;
|
||||
invisiblePositionStepPx: number;
|
||||
invisiblePositionStepFastPx: number;
|
||||
};
|
||||
|
||||
export function resolvePlatformInfo(): PlatformInfo {
|
||||
const overlayLayerFromPreload = window.electronAPI.getOverlayLayer();
|
||||
const overlayLayerFromQuery =
|
||||
new URLSearchParams(window.location.search).get("layer") === "invisible"
|
||||
? "invisible"
|
||||
: "visible";
|
||||
|
||||
const overlayLayer: OverlayLayer =
|
||||
overlayLayerFromPreload === "visible" ||
|
||||
overlayLayerFromPreload === "invisible"
|
||||
? overlayLayerFromPreload
|
||||
: overlayLayerFromQuery;
|
||||
|
||||
const isInvisibleLayer = overlayLayer === "invisible";
|
||||
const isLinuxPlatform = navigator.platform.toLowerCase().includes("linux");
|
||||
const isMacOSPlatform =
|
||||
navigator.platform.toLowerCase().includes("mac") ||
|
||||
/mac/i.test(navigator.userAgent);
|
||||
|
||||
return {
|
||||
overlayLayer,
|
||||
isInvisibleLayer,
|
||||
isLinuxPlatform,
|
||||
isMacOSPlatform,
|
||||
shouldToggleMouseIgnore: !isLinuxPlatform,
|
||||
invisiblePositionEditToggleCode: "KeyP",
|
||||
invisiblePositionStepPx: 1,
|
||||
invisiblePositionStepFastPx: 4,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user