feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

14
src/renderer/context.ts Normal file
View 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;
};

View File

@@ -0,0 +1,192 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRendererRecoveryController } from './error-recovery.js';
import { resolvePlatformInfo } from './utils/platform.js';
test('handleError logs context and recovers overlay state', () => {
const payloads: unknown[] = [];
let dismissed = 0;
let restored = 0;
const shown: string[] = [];
const controller = createRendererRecoveryController({
dismissActiveUi: () => {
dismissed += 1;
},
restoreOverlayInteraction: () => {
restored += 1;
},
showToast: (message) => {
shown.push(message);
},
getSnapshot: () => ({
activeModal: 'jimaku',
subtitlePreview: '字幕テキスト',
secondarySubtitlePreview: 'secondary',
isOverlayInteractive: true,
isOverSubtitle: true,
invisiblePositionEditMode: false,
overlayLayer: 'visible',
}),
logError: (payload) => {
payloads.push(payload);
},
});
controller.handleError(new Error('renderer boom'), {
source: 'callback',
action: 'onSubtitle',
});
assert.equal(dismissed, 1);
assert.equal(restored, 1);
assert.equal(shown.length, 1);
assert.match(shown[0]!, /recovered/i);
assert.equal(payloads.length, 1);
const payload = payloads[0] as {
context: { action: string };
error: { message: string; stack: string | null };
snapshot: { activeModal: string | null; subtitlePreview: string };
};
assert.equal(payload.context.action, 'onSubtitle');
assert.equal(payload.snapshot.activeModal, 'jimaku');
assert.equal(payload.snapshot.subtitlePreview, '字幕テキスト');
assert.equal(payload.error.message, 'renderer boom');
assert.ok(
typeof payload.error.stack === 'string' && payload.error.stack.includes('renderer boom'),
);
});
test('handleError normalizes non-Error values', () => {
const payloads: unknown[] = [];
const controller = createRendererRecoveryController({
dismissActiveUi: () => {},
restoreOverlayInteraction: () => {},
showToast: () => {},
getSnapshot: () => ({
activeModal: null,
subtitlePreview: '',
secondarySubtitlePreview: '',
isOverlayInteractive: false,
isOverSubtitle: false,
invisiblePositionEditMode: false,
overlayLayer: 'invisible',
}),
logError: (payload) => {
payloads.push(payload);
},
});
controller.handleError({ code: 500, reason: 'timeout' }, { source: 'callback', action: 'modal' });
const payload = payloads[0] as { error: { message: string; stack: string | null } };
assert.equal(payload.error.message, JSON.stringify({ code: 500, reason: 'timeout' }));
assert.equal(payload.error.stack, null);
});
test('nested recovery errors are ignored while current recovery is active', () => {
const payloads: unknown[] = [];
let restored = 0;
let controllerRef: ReturnType<typeof createRendererRecoveryController> | null = null;
const controller = createRendererRecoveryController({
dismissActiveUi: () => {
controllerRef?.handleError(new Error('nested'), { source: 'callback', action: 'nested' });
},
restoreOverlayInteraction: () => {
restored += 1;
},
showToast: () => {},
getSnapshot: () => ({
activeModal: 'runtime-options',
subtitlePreview: '',
secondarySubtitlePreview: '',
isOverlayInteractive: true,
isOverSubtitle: false,
invisiblePositionEditMode: true,
overlayLayer: 'visible',
}),
logError: (payload) => {
payloads.push(payload);
},
});
controllerRef = controller;
controller.handleError(new Error('outer'), { source: 'callback', action: 'outer' });
assert.equal(payloads.length, 1);
assert.equal(restored, 1);
});
test('resolvePlatformInfo prefers query layer over preload layer', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'invisible',
},
location: { search: '?layer=visible' },
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
platform: 'MacIntel',
userAgent: 'Mozilla/5.0 (Macintosh)',
},
});
try {
const info = resolvePlatformInfo();
assert.equal(info.overlayLayer, 'visible');
assert.equal(info.isInvisibleLayer, false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});
test('resolvePlatformInfo supports secondary layer and disables mouse-ignore toggles', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'secondary',
},
location: { search: '' },
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
platform: 'MacIntel',
userAgent: 'Mozilla/5.0 (Macintosh)',
},
});
try {
const info = resolvePlatformInfo();
assert.equal(info.overlayLayer, 'secondary');
assert.equal(info.isSecondaryLayer, true);
assert.equal(info.shouldToggleMouseIgnore, false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});

View File

@@ -0,0 +1,177 @@
export type RendererErrorSource =
| 'callback'
| 'window.onerror'
| 'window.unhandledrejection'
| 'bootstrap';
export type RendererRecoveryContext = {
source: RendererErrorSource;
action: string;
details?: Record<string, unknown>;
};
export type RendererRecoverySnapshot = {
activeModal: string | null;
subtitlePreview: string;
secondarySubtitlePreview: string;
isOverlayInteractive: boolean;
isOverSubtitle: boolean;
invisiblePositionEditMode: boolean;
overlayLayer: 'visible' | 'invisible' | 'secondary';
};
type NormalizedRendererError = {
message: string;
stack: string | null;
};
export type RendererRecoveryLogPayload = {
kind: 'renderer-overlay-recovery';
context: RendererRecoveryContext;
error: NormalizedRendererError;
snapshot: RendererRecoverySnapshot;
timestamp: string;
};
type RendererRecoveryDeps = {
dismissActiveUi: () => void;
restoreOverlayInteraction: () => void;
showToast: (message: string) => void;
getSnapshot: () => RendererRecoverySnapshot;
logError: (payload: RendererRecoveryLogPayload) => void;
toastMessage?: string;
};
type RendererRecoveryController = {
handleError: (error: unknown, context: RendererRecoveryContext) => void;
};
type RendererRecoveryWindow = Pick<Window, 'addEventListener' | 'removeEventListener'>;
const DEFAULT_TOAST_MESSAGE = 'Renderer error recovered. Overlay is still running.';
function normalizeRendererError(error: unknown): NormalizedRendererError {
if (error instanceof Error) {
return {
message: error.message || 'Unknown renderer error',
stack: typeof error.stack === 'string' ? error.stack : null,
};
}
if (typeof error === 'string') {
return {
message: error,
stack: null,
};
}
if (typeof error === 'object' && error !== null) {
try {
return {
message: JSON.stringify(error),
stack: null,
};
} catch {
return {
message: '[unserializable error object]',
stack: null,
};
}
}
return {
message: String(error),
stack: null,
};
}
export function createRendererRecoveryController(
deps: RendererRecoveryDeps,
): RendererRecoveryController {
let inRecovery = false;
const toastMessage = deps.toastMessage ?? DEFAULT_TOAST_MESSAGE;
const invokeRecoveryStep = (
step: 'dismissActiveUi' | 'restoreOverlayInteraction' | 'showToast',
fn: () => void,
): void => {
try {
fn();
} catch (error) {
try {
deps.logError({
kind: 'renderer-overlay-recovery',
context: {
source: 'callback',
action: `recovery-step:${step}`,
},
error: normalizeRendererError(error),
snapshot: deps.getSnapshot(),
timestamp: new Date().toISOString(),
});
} catch {
// Avoid recursive failures from logging inside the recovery path.
}
}
};
const handleError = (error: unknown, context: RendererRecoveryContext): void => {
if (inRecovery) {
return;
}
inRecovery = true;
try {
deps.logError({
kind: 'renderer-overlay-recovery',
context,
error: normalizeRendererError(error),
snapshot: deps.getSnapshot(),
timestamp: new Date().toISOString(),
});
invokeRecoveryStep('dismissActiveUi', deps.dismissActiveUi);
invokeRecoveryStep('restoreOverlayInteraction', deps.restoreOverlayInteraction);
invokeRecoveryStep('showToast', () => deps.showToast(toastMessage));
} finally {
inRecovery = false;
}
};
return { handleError };
}
export function registerRendererGlobalErrorHandlers(
recoveryWindow: RendererRecoveryWindow,
controller: RendererRecoveryController,
): () => void {
const onError = (event: Event): void => {
const errorEvent = event as ErrorEvent;
controller.handleError(errorEvent.error ?? errorEvent.message, {
source: 'window.onerror',
action: 'global-error',
details: {
filename: errorEvent.filename,
lineno: errorEvent.lineno,
colno: errorEvent.colno,
},
});
};
const onUnhandledRejection = (event: Event): void => {
const rejectionEvent = event as PromiseRejectionEvent;
controller.handleError(rejectionEvent.reason, {
source: 'window.unhandledrejection',
action: 'global-unhandledrejection',
});
};
recoveryWindow.addEventListener('error', onError);
recoveryWindow.addEventListener('unhandledrejection', onUnhandledRejection);
return () => {
recoveryWindow.removeEventListener('error', onError);
recoveryWindow.removeEventListener('unhandledrejection', onUnhandledRejection);
};
}

Binary file not shown.

View File

@@ -0,0 +1,303 @@
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;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: {
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
fallbackUnavailable: boolean;
}) => void;
saveInvisiblePositionEdit: () => void;
cancelInvisiblePositionEdit: () => void;
setInvisiblePositionEditMode: (enabled: boolean) => void;
applyInvisibleSubtitleOffsetPosition: () => void;
updateInvisiblePositionEditHud: () => void;
appendClipboardVideoToQueue: () => void;
},
) {
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
const CHORD_TIMEOUT_MS = 1000;
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 resolveSessionHelpChordBinding(): {
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
fallbackUnavailable: boolean;
} {
const firstChoice = 'KeyH';
if (!ctx.state.keybindingsMap.has('KeyH')) {
return {
bindingKey: firstChoice,
fallbackUsed: false,
fallbackUnavailable: false,
};
}
if (ctx.state.keybindingsMap.has('KeyK')) {
return {
bindingKey: 'KeyK',
fallbackUsed: true,
fallbackUnavailable: true,
};
}
return {
bindingKey: 'KeyK',
fallbackUsed: true,
fallbackUnavailable: false,
};
}
function applySessionHelpChordBinding(): void {
CHORD_MAP.delete('KeyH');
CHORD_MAP.delete('KeyK');
const info = resolveSessionHelpChordBinding();
CHORD_MAP.set(info.bindingKey, {
type: 'electron',
action: () => {
options.openSessionHelpModal(info);
},
});
}
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> {
updateKeybindings(await window.electronAPI.getKeybindings());
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.sessionHelpModalOpen) {
options.handleSessionHelpKeydown(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();
applySessionHelpChordBinding();
ctx.state.chordPending = true;
ctx.state.chordTimeout = setTimeout(() => {
resetChord();
}, CHORD_TIMEOUT_MS);
return;
}
if (
(e.ctrlKey || e.metaKey) &&
!e.altKey &&
!e.shiftKey &&
e.code === 'KeyA' &&
!e.repeat
) {
e.preventDefault();
options.appendClipboardVideoToQueue();
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();
}
});
}
function updateKeybindings(keybindings: Keybinding[]): void {
ctx.state.keybindingsMap = new Map();
for (const binding of keybindings) {
if (binding.command) {
ctx.state.keybindingsMap.set(binding.key, binding.command);
}
}
}
return {
setupMpvInputForwarding,
updateKeybindings,
};
}

View File

@@ -0,0 +1,328 @@
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;
reportHoveredTokenIndex: (tokenIndex: number | null) => 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 setupInvisibleTokenHoverReporter(): void {
if (!ctx.platform.isInvisibleLayer) return;
let pendingNullHoverTimer: ReturnType<typeof setTimeout> | null = null;
const clearPendingNullHoverTimer = (): void => {
if (pendingNullHoverTimer !== null) {
clearTimeout(pendingNullHoverTimer);
pendingNullHoverTimer = null;
}
};
const reportHoveredToken = (tokenIndex: number | null): void => {
if (ctx.state.lastHoveredTokenIndex === tokenIndex) return;
ctx.state.lastHoveredTokenIndex = tokenIndex;
options.reportHoveredTokenIndex(tokenIndex);
};
const queueNullHoveredToken = (): void => {
if (pendingNullHoverTimer !== null) return;
pendingNullHoverTimer = setTimeout(() => {
pendingNullHoverTimer = null;
reportHoveredToken(null);
}, 120);
};
ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => {
if (!(event.target instanceof Element)) {
queueNullHoveredToken();
return;
}
const target = event.target.closest<HTMLElement>('.word[data-token-index]');
if (!target || !ctx.dom.subtitleRoot.contains(target)) {
queueNullHoveredToken();
return;
}
const rawTokenIndex = target.dataset.tokenIndex;
const tokenIndex = rawTokenIndex ? Number.parseInt(rawTokenIndex, 10) : Number.NaN;
if (!Number.isInteger(tokenIndex) || tokenIndex < 0) {
queueNullHoveredToken();
return;
}
clearPendingNullHoverTimer();
reportHoveredToken(tokenIndex);
});
ctx.dom.subtitleRoot.addEventListener('mouseleave', () => {
clearPendingNullHoverTimer();
reportHoveredToken(null);
});
}
function setupResizeHandler(): void {
window.addEventListener('resize', () => {
if (ctx.platform.isInvisibleLayer) {
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,
setupInvisibleTokenHoverReporter,
setupResizeHandler,
setupSelectionObserver,
setupYomitanObserver,
};
}

220
src/renderer/index.html Normal file
View File

@@ -0,0 +1,220 @@
<!--
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; worker-src 'self' blob:;"
/>
<title>SubMiner</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- Programmatic focus fallback target for Electron/window focus management. -->
<div id="overlay" tabindex="-1">
<div
id="overlayErrorToast"
class="overlay-error-toast hidden"
role="status"
aria-live="polite"
></div>
<div id="secondarySubContainer" class="secondary-sub-hidden">
<div id="secondarySubRoot"></div>
</div>
<div id="subtitleContainer">
<div id="subtitleRoot"></div>
</div>
<div id="jimakuModal" class="modal hidden" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">Jimaku Subtitles</div>
<button id="jimakuClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="jimaku-form">
<label class="jimaku-field">
<span>Title</span>
<input id="jimakuTitle" type="text" placeholder="Anime title" />
</label>
<label class="jimaku-field">
<span>Season</span>
<input id="jimakuSeason" type="number" min="1" placeholder="1" />
</label>
<label class="jimaku-field">
<span>Episode</span>
<input id="jimakuEpisode" type="number" min="1" placeholder="1" />
</label>
<button id="jimakuSearch" class="jimaku-button" type="button">Search</button>
</div>
<div id="jimakuStatus" class="jimaku-status"></div>
<div id="jimakuEntriesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Entries</div>
<ul id="jimakuEntries" class="jimaku-list"></ul>
</div>
<div id="jimakuFilesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Files</div>
<ul id="jimakuFiles" class="jimaku-list"></ul>
<button id="jimakuBroaden" class="jimaku-link hidden" type="button">
Broaden search (all files)
</button>
</div>
</div>
</div>
</div>
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">Duplicate Card Detected</div>
</div>
<div class="modal-body">
<div id="kikuSelectionStep">
<div class="kiku-info-text">
A card with the same expression already exists. Select which card to keep. The other
card's content will be merged using Kiku field grouping. You can choose whether to
delete the duplicate.
</div>
<div class="kiku-cards-container">
<div id="kikuCard1" class="kiku-card active" tabindex="0">
<div class="kiku-card-label">1 &mdash; Original Card</div>
<div class="kiku-card-expression" id="kikuCard1Expression"></div>
<div class="kiku-card-sentence" id="kikuCard1Sentence"></div>
<div class="kiku-card-meta" id="kikuCard1Meta"></div>
</div>
<div id="kikuCard2" class="kiku-card" tabindex="0">
<div class="kiku-card-label">2 &mdash; New Card</div>
<div class="kiku-card-expression" id="kikuCard2Expression"></div>
<div class="kiku-card-sentence" id="kikuCard2Sentence"></div>
<div class="kiku-card-meta" id="kikuCard2Meta"></div>
</div>
</div>
<div class="kiku-footer">
<label class="kiku-delete-toggle">
<input id="kikuDeleteDuplicate" type="checkbox" checked />
Delete duplicate card after merge
</label>
<button id="kikuConfirmButton" class="kiku-confirm-button" type="button">
Continue
</button>
<button id="kikuCancelButton" class="kiku-cancel-button" type="button">
Cancel
</button>
</div>
</div>
<div id="kikuPreviewStep" class="hidden">
<div class="kiku-preview-header">
<div class="kiku-preview-title">Final Merge Preview</div>
<div class="kiku-preview-toggle">
<button id="kikuPreviewCompact" type="button">Compact</button>
<button id="kikuPreviewFull" type="button">Full</button>
</div>
</div>
<div id="kikuPreviewError" class="kiku-preview-error hidden"></div>
<pre id="kikuPreviewJson" class="kiku-preview-json"></pre>
<div class="kiku-footer">
<button id="kikuBackButton" class="kiku-cancel-button" type="button">Back</button>
<button id="kikuFinalConfirmButton" class="kiku-confirm-button" type="button">
Confirm Merge
</button>
<button id="kikuFinalCancelButton" class="kiku-cancel-button" type="button">
Cancel
</button>
</div>
</div>
<div id="kikuHint" class="kiku-hint">
Press 1 or 2 to select &middot; Enter to confirm &middot; Esc to cancel
</div>
</div>
</div>
</div>
<div id="runtimeOptionsModal" class="modal hidden" aria-hidden="true">
<div class="modal-content runtime-modal-content">
<div class="modal-header">
<div class="modal-title">Runtime Options</div>
<button id="runtimeOptionsClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="runtimeOptionsHint" class="runtime-options-hint">
Arrow keys: select/change · Enter or double-click: apply · Esc: close
</div>
<ul id="runtimeOptionsList" class="runtime-options-list"></ul>
<div id="runtimeOptionsStatus" class="runtime-options-status"></div>
</div>
</div>
</div>
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
<div class="modal-content subsync-modal-content">
<div class="modal-header">
<div class="modal-title">Auto Subtitle Sync</div>
<button id="subsyncClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="subsync-form">
<div class="subsync-field">
<span>Engine</span>
<label class="subsync-radio">
<input id="subsyncEngineAlass" type="radio" name="subsyncEngine" checked />
alass
</label>
<label class="subsync-radio">
<input id="subsyncEngineFfsubsync" type="radio" name="subsyncEngine" />
ffsubsync
</label>
</div>
<label id="subsyncSourceLabel" class="subsync-field">
<span>Source Subtitle (for alass)</span>
<select id="subsyncSourceSelect"></select>
</label>
</div>
<div id="subsyncStatus" class="runtime-options-status"></div>
<div class="subsync-footer">
<button id="subsyncRun" class="kiku-confirm-button" type="button">Run Sync</button>
</div>
</div>
</div>
</div>
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
<div class="modal-content session-help-content">
<div class="modal-header">
<div class="modal-title">Session Help</div>
<button id="sessionHelpClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="sessionHelpShortcut" class="session-help-shortcut"></div>
<div id="sessionHelpWarning" class="session-help-warning"></div>
<div id="sessionHelpStatus" class="session-help-status"></div>
<input
id="sessionHelpFilter"
class="session-help-filter"
type="text"
placeholder="Type / to search shortcuts"
autocomplete="off"
spellcheck="false"
/>
<div id="sessionHelpContent" class="session-help-content-list"></div>
</div>
</div>
</div>
</div>
<script type="module" src="renderer.js"></script>
</body>
</html>

View File

@@ -0,0 +1,384 @@
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'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
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;
options.syncSettingsModalSubtitleSuppression();
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;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.jimakuModal.classList.add('hidden');
ctx.dom.jimakuModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('jimaku');
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,
};
}

299
src/renderer/modals/kiku.ts Normal file
View File

@@ -0,0 +1,299 @@
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 {
cancelKikuFieldGrouping,
closeKikuFieldGroupingModal,
handleKikuKeydown,
openKikuFieldGroupingModal,
wireDomEvents,
};
}

View File

@@ -0,0 +1,258 @@
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] ?? null;
}
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;
const nextValue = option.allowedValues[nextIndex];
if (nextValue === undefined) return;
ctx.state.runtimeOptionDraftValues.set(option.id, nextValue);
renderRuntimeOptionsList();
setRuntimeOptionsStatus(`Selected ${option.label}: ${formatRuntimeOptionValue(nextValue)}`);
}
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,
};
}

View File

@@ -0,0 +1,759 @@
import type { Keybinding } from '../../types';
import type { ShortcutsConfig } from '../../types';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import type { ModalStateReader, RendererContext } from '../context';
type SessionHelpBindingInfo = {
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
fallbackUnavailable: boolean;
};
type SessionHelpItem = {
shortcut: string;
action: string;
color?: string;
};
type SessionHelpSection = {
title: string;
rows: SessionHelpItem[];
};
type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, 'multiCopyTimeoutMs'>;
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
// Fallbacks mirror the session overlay's default subtitle/word color scheme.
const FALLBACK_COLORS = {
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f',
jlptN3Color: '#f9e2af',
jlptN4Color: '#a6e3a1',
jlptN5Color: '#8aadf4',
};
const KEY_NAME_MAP: Record<string, string> = {
Space: 'Space',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Escape: 'Esc',
Tab: 'Tab',
Enter: 'Enter',
CommandOrControl: 'Cmd/Ctrl',
Ctrl: 'Ctrl',
Control: 'Ctrl',
Command: 'Cmd',
Cmd: 'Cmd',
Shift: 'Shift',
Alt: 'Alt',
Super: 'Meta',
Meta: 'Meta',
Backspace: 'Backspace',
};
function normalizeColor(value: unknown, fallback: string): string {
if (typeof value !== 'string') return fallback;
const next = value.trim();
return HEX_COLOR_RE.test(next) ? next : fallback;
}
function normalizeKeyToken(token: string): string {
if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token];
if (token.startsWith('Key')) return token.slice(3);
if (token.startsWith('Digit')) return token.slice(5);
if (token.startsWith('Numpad')) return token.slice(6);
return token;
}
function formatKeybinding(rawBinding: string): string {
const parts = rawBinding.split('+');
const key = parts.pop();
if (!key) return rawBinding;
const normalized = [...parts, normalizeKeyToken(key)];
return normalized.join(' + ');
}
const OVERLAY_SHORTCUTS: Array<{
key: keyof RuntimeShortcutConfig;
label: string;
}> = [
{ key: 'copySubtitle', label: 'Copy subtitle' },
{ key: 'copySubtitleMultiple', label: 'Copy subtitle (multi)' },
{
key: 'updateLastCardFromClipboard',
label: 'Update last card from clipboard',
},
{ key: 'triggerFieldGrouping', label: 'Trigger field grouping' },
{ key: 'triggerSubsync', label: 'Open subtitle sync controls' },
{ key: 'mineSentence', label: 'Mine sentence' },
{ key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' },
{ key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' },
{ key: 'markAudioCard', label: 'Mark audio card' },
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
{ key: 'openJimaku', label: 'Open jimaku' },
{ key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' },
{ key: 'toggleInvisibleOverlayGlobal', label: 'Show/hide invisible overlay' },
];
function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): SessionHelpSection[] {
const rows: SessionHelpItem[] = [];
for (const shortcut of OVERLAY_SHORTCUTS) {
const keybind = shortcuts[shortcut.key];
if (typeof keybind !== 'string') continue;
if (keybind.trim().length === 0) continue;
rows.push({
shortcut: formatKeybinding(keybind),
action: shortcut.label,
});
}
if (rows.length === 0) return [];
return [{ title: 'Overlay shortcuts', rows }];
}
function describeCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Unknown action';
if (first === 'cycle' && command[1] === 'pause') return 'Toggle playback';
if (first === 'seek' && typeof command[1] === 'number') {
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
}
if (first === 'sub-seek' && typeof command[1] === 'number') {
return `Shift subtitle by ${command[1]} ms`;
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(':');
return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`;
}
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
}
function sectionForCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Other shortcuts';
if (
first === 'cycle' ||
first === 'seek' ||
first === 'sub-seek' ||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
) {
return 'Playback and navigation';
}
if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) {
return 'Visual feedback';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
return 'Subtitle sync';
}
if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
return 'Runtime settings';
}
if (first === 'quit') return 'System actions';
return 'Other shortcuts';
}
function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
const grouped = new Map<string, SessionHelpItem[]>();
for (const binding of keybindings) {
const section = sectionForCommand(binding.command ?? []);
const row: SessionHelpItem = {
shortcut: formatKeybinding(binding.key),
action: describeCommand(binding.command ?? []),
};
grouped.set(section, [...(grouped.get(section) ?? []), row]);
}
const sectionOrder = [
'Playback and navigation',
'Visual feedback',
'Subtitle sync',
'Runtime settings',
'System actions',
'Other shortcuts',
];
const sectionEntries = Array.from(grouped.entries()).sort((a, b) => {
const aIdx = sectionOrder.indexOf(a[0]);
const bIdx = sectionOrder.indexOf(b[0]);
if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]);
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
});
return sectionEntries.map(([title, rows]) => ({ title, rows }));
}
function buildColorSection(style: {
knownWordColor?: unknown;
nPlusOneColor?: unknown;
jlptColors?: {
N1?: unknown;
N2?: unknown;
N3?: unknown;
N4?: unknown;
N5?: unknown;
};
}): SessionHelpSection {
return {
title: 'Color legend',
rows: [
{
shortcut: 'Known words',
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
},
{
shortcut: 'N+1 words',
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
},
{
shortcut: 'JLPT N1',
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
},
{
shortcut: 'JLPT N2',
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
},
{
shortcut: 'JLPT N3',
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
},
{
shortcut: 'JLPT N4',
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
},
{
shortcut: 'JLPT N5',
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
},
],
};
}
function filterSections(sections: SessionHelpSection[], query: string): SessionHelpSection[] {
const normalize = (value: string): string =>
value
.toLowerCase()
.replace(/commandorcontrol/gu, 'ctrl')
.replace(/cmd\/ctrl/gu, 'ctrl')
.replace(/[\s+\-_/]/gu, '');
const normalized = normalize(query);
if (!normalized) return sections;
return sections
.map((section) => {
if (normalize(section.title).includes(normalized)) {
return section;
}
const rows = section.rows.filter(
(row) =>
normalize(row.shortcut).includes(normalized) ||
normalize(row.action).includes(normalized),
);
if (rows.length === 0) return null;
return { ...section, rows };
})
.filter((section): section is SessionHelpSection => section !== null)
.filter((section) => section.rows.length > 0);
}
function formatBindingHint(info: SessionHelpBindingInfo): string {
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
}
return 'Y-H';
}
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'session-help-item';
button.tabIndex = -1;
button.dataset.sessionHelpIndex = String(globalIndex);
const left = document.createElement('div');
left.className = 'session-help-item-left';
const shortcut = document.createElement('span');
shortcut.className = 'session-help-key';
shortcut.textContent = row.shortcut;
left.appendChild(shortcut);
const right = document.createElement('div');
right.className = 'session-help-item-right';
const action = document.createElement('span');
action.className = 'session-help-action';
action.textContent = row.action;
right.appendChild(action);
if (row.color) {
const dot = document.createElement('span');
dot.className = 'session-help-color-dot';
dot.style.backgroundColor = row.color;
right.insertBefore(dot, action);
}
button.appendChild(left);
button.appendChild(right);
return button;
}
const SECTION_ICON: Record<string, string> = {
'MPV shortcuts': '⚙',
'Playback and navigation': '▶',
'Visual feedback': '◉',
'Subtitle sync': '⟲',
'Runtime settings': '⚙',
'System actions': '◆',
'Other shortcuts': '…',
'Overlay shortcuts (configurable)': '✦',
'Overlay shortcuts': '✦',
'Color legend': '◈',
};
function createSectionNode(
section: SessionHelpSection,
sectionIndex: number,
globalIndexMap: number[],
): HTMLElement {
const sectionNode = document.createElement('section');
sectionNode.className = 'session-help-section';
const title = document.createElement('h3');
title.className = 'session-help-section-title';
const icon = SECTION_ICON[section.title] ?? '•';
title.textContent = `${icon} ${section.title}`;
sectionNode.appendChild(title);
const list = document.createElement('div');
list.className = 'session-help-item-list';
section.rows.forEach((row, rowIndex) => {
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
const button = createShortcutRow(row, globalIndex);
list.appendChild(button);
});
sectionNode.appendChild(list);
return sectionNode;
}
export function createSessionHelpModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let priorFocus: Element | null = null;
let openBinding: SessionHelpBindingInfo = {
bindingKey: 'KeyH',
fallbackUsed: false,
fallbackUnavailable: false,
};
let helpFilterValue = '';
let helpSections: SessionHelpSection[] = [];
let focusGuard: ((event: FocusEvent) => void) | null = null;
let windowFocusGuard: (() => void) | null = null;
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
let isRecoveringModalFocus = false;
let lastFocusRecoveryAt = 0;
function getItems(): HTMLButtonElement[] {
return Array.from(
ctx.dom.sessionHelpContent.querySelectorAll('.session-help-item'),
) as HTMLButtonElement[];
}
function setSelected(index: number): void {
const items = getItems();
if (items.length === 0) return;
const wrappedIndex = index % items.length;
const next = wrappedIndex < 0 ? wrappedIndex + items.length : wrappedIndex;
ctx.state.sessionHelpSelectedIndex = next;
items.forEach((item, idx) => {
item.classList.toggle('active', idx === next);
item.tabIndex = idx === next ? 0 : -1;
});
const activeItem = items[next];
if (!activeItem) return;
activeItem.focus({ preventScroll: true });
activeItem.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
}
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
return target instanceof Element && ctx.dom.sessionHelpModal.contains(target);
}
function focusFallbackTarget(): boolean {
void window.electronAPI.focusMainWindow();
const items = getItems();
const firstItem = items.find((item) => item.offsetParent !== null);
if (firstItem) {
firstItem.focus({ preventScroll: true });
return document.activeElement === firstItem;
}
if (ctx.dom.sessionHelpClose instanceof HTMLElement) {
ctx.dom.sessionHelpClose.focus({ preventScroll: true });
return document.activeElement === ctx.dom.sessionHelpClose;
}
window.focus();
return false;
}
function enforceModalFocus(): void {
if (!ctx.state.sessionHelpModalOpen) return;
if (!isSessionHelpModalFocusTarget(document.activeElement)) {
if (isRecoveringModalFocus) return;
const now = Date.now();
if (now - lastFocusRecoveryAt < 120) return;
isRecoveringModalFocus = true;
lastFocusRecoveryAt = now;
focusFallbackTarget();
window.setTimeout(() => {
isRecoveringModalFocus = false;
}, 120);
}
}
function isFilterInputFocused(): boolean {
return document.activeElement === ctx.dom.sessionHelpFilter;
}
function focusFilterInput(): void {
ctx.dom.sessionHelpFilter.focus({ preventScroll: true });
ctx.dom.sessionHelpFilter.select();
}
function applyFilterAndRender(): void {
const sections = filterSections(helpSections, helpFilterValue);
const indexOffsets: number[] = [];
let running = 0;
for (const section of sections) {
indexOffsets.push(running);
running += section.rows.length;
}
ctx.dom.sessionHelpContent.innerHTML = '';
sections.forEach((section, sectionIndex) => {
const sectionNode = createSectionNode(section, sectionIndex, indexOffsets);
ctx.dom.sessionHelpContent.appendChild(sectionNode);
});
if (getItems().length === 0) {
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
ctx.dom.sessionHelpContent.textContent = helpFilterValue
? 'No matching shortcuts found.'
: 'No active session shortcuts found.';
ctx.state.sessionHelpSelectedIndex = 0;
return;
}
ctx.dom.sessionHelpContent.classList.remove('session-help-content-no-results');
if (isFilterInputFocused()) return;
setSelected(0);
}
function requestOverlayFocus(): void {
void window.electronAPI.focusMainWindow();
}
function addPointerFocusListener(): void {
if (modalPointerFocusGuard) return;
modalPointerFocusGuard = () => {
requestOverlayFocus();
enforceModalFocus();
};
ctx.dom.sessionHelpModal.addEventListener('pointerdown', modalPointerFocusGuard);
ctx.dom.sessionHelpModal.addEventListener('click', modalPointerFocusGuard);
}
function removePointerFocusListener(): void {
if (!modalPointerFocusGuard) return;
ctx.dom.sessionHelpModal.removeEventListener('pointerdown', modalPointerFocusGuard);
ctx.dom.sessionHelpModal.removeEventListener('click', modalPointerFocusGuard);
modalPointerFocusGuard = null;
}
function startFocusRecoveryGuards(): void {
if (windowFocusGuard) return;
windowFocusGuard = () => {
requestOverlayFocus();
enforceModalFocus();
};
window.addEventListener('blur', windowFocusGuard);
window.addEventListener('focus', windowFocusGuard);
}
function stopFocusRecoveryGuards(): void {
if (!windowFocusGuard) return;
window.removeEventListener('blur', windowFocusGuard);
window.removeEventListener('focus', windowFocusGuard);
windowFocusGuard = null;
}
function showRenderError(message: string): void {
helpSections = [];
helpFilterValue = '';
ctx.dom.sessionHelpFilter.value = '';
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
ctx.dom.sessionHelpContent.textContent = message;
ctx.state.sessionHelpSelectedIndex = 0;
}
async function render(): Promise<boolean> {
try {
const [keybindings, styleConfig, shortcuts] = await Promise.all([
window.electronAPI.getKeybindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getConfiguredShortcuts(),
]);
const bindingSections = buildBindingSections(keybindings);
if (bindingSections.length > 0) {
const playback = bindingSections.find(
(section) => section.title === 'Playback and navigation',
);
if (playback) {
playback.title = 'MPV shortcuts';
}
}
const shortcutSections = buildOverlayShortcutSections(shortcuts);
if (shortcutSections.length > 0) {
shortcutSections[0]!.title = 'Overlay shortcuts (configurable)';
}
const colorSection = buildColorSection(styleConfig ?? {});
helpSections = [...bindingSections, ...shortcutSections, colorSection];
applyFilterAndRender();
return true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unable to load session help data.';
showRenderError(`Session help failed to load: ${message}`);
return false;
}
}
async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise<void> {
openBinding = opening;
priorFocus = document.activeElement;
const dataLoaded = await render();
ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`;
if (openBinding.fallbackUnavailable) {
ctx.dom.sessionHelpWarning.textContent =
'Both Y-H and Y-K are bound; Y-K remains the fallback for this session.';
} else if (openBinding.fallbackUsed) {
ctx.dom.sessionHelpWarning.textContent = 'Y-H is already bound; using Y-K as fallback.';
} else {
ctx.dom.sessionHelpWarning.textContent = '';
}
if (dataLoaded) {
ctx.dom.sessionHelpStatus.textContent =
'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.';
} else {
ctx.dom.sessionHelpStatus.textContent =
'Session help data is unavailable right now. Press Esc to close.';
ctx.dom.sessionHelpWarning.textContent =
'Unable to load latest shortcut settings from the runtime.';
}
ctx.state.sessionHelpModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.sessionHelpModal.classList.remove('hidden');
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false');
ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1');
ctx.dom.sessionHelpFilter.value = '';
helpFilterValue = '';
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
if (focusGuard === null) {
focusGuard = (event: FocusEvent) => {
if (!ctx.state.sessionHelpModalOpen) return;
if (!isSessionHelpModalFocusTarget(event.target)) {
event.preventDefault();
enforceModalFocus();
}
};
document.addEventListener('focusin', focusGuard);
}
addPointerFocusListener();
startFocusRecoveryGuards();
requestOverlayFocus();
window.focus();
enforceModalFocus();
}
function closeSessionHelpModal(): void {
if (!ctx.state.sessionHelpModalOpen) return;
ctx.state.sessionHelpModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.sessionHelpModal.classList.add('hidden');
ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
if (focusGuard) {
document.removeEventListener('focusin', focusGuard);
focusGuard = null;
}
removePointerFocusListener();
stopFocusRecoveryGuards();
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
priorFocus.focus({ preventScroll: true });
return;
}
if (ctx.dom.overlay instanceof HTMLElement) {
// Overlay remains `tabindex="-1"` to allow programmatic focus for fallback.
ctx.dom.overlay.focus({ preventScroll: true });
}
if (ctx.platform.shouldToggleMouseIgnore) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} else {
window.electronAPI.setIgnoreMouseEvents(false);
}
}
ctx.dom.sessionHelpFilter.value = '';
helpFilterValue = '';
window.focus();
}
function handleSessionHelpKeydown(e: KeyboardEvent): boolean {
if (!ctx.state.sessionHelpModalOpen) return false;
if (isFilterInputFocused()) {
if (e.key === 'Escape') {
e.preventDefault();
if (!helpFilterValue) {
closeSessionHelpModal();
return true;
}
helpFilterValue = '';
ctx.dom.sessionHelpFilter.value = '';
applyFilterAndRender();
focusFallbackTarget();
return true;
}
return false;
}
if (e.key === 'Escape') {
e.preventDefault();
closeSessionHelpModal();
return true;
}
const items = getItems();
if (items.length === 0) return true;
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
e.preventDefault();
focusFilterInput();
return true;
}
const key = e.key.toLowerCase();
if (key === 'arrowdown' || key === 'j' || key === 'l') {
e.preventDefault();
setSelected(ctx.state.sessionHelpSelectedIndex + 1);
return true;
}
if (key === 'arrowup' || key === 'k' || key === 'h') {
e.preventDefault();
setSelected(ctx.state.sessionHelpSelectedIndex - 1);
return true;
}
return true;
}
function wireDomEvents(): void {
ctx.dom.sessionHelpFilter.addEventListener('input', () => {
helpFilterValue = ctx.dom.sessionHelpFilter.value;
applyFilterAndRender();
});
ctx.dom.sessionHelpFilter.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault();
focusFallbackTarget();
}
});
ctx.dom.sessionHelpContent.addEventListener('click', (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const row = target.closest('.session-help-item') as HTMLElement | null;
if (!row) return;
const index = Number.parseInt(row.dataset.sessionHelpIndex ?? '', 10);
if (!Number.isFinite(index)) return;
setSelected(index);
});
ctx.dom.sessionHelpClose.addEventListener('click', () => {
closeSessionHelpModal();
});
}
return {
closeSessionHelpModal,
handleSessionHelpKeydown,
openSessionHelpModal,
wireDomEvents,
};
}

View 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,
};
}

View File

@@ -0,0 +1,117 @@
import type { OverlayContentMeasurement, OverlayContentRect } from '../types';
import type { RendererContext } from './context';
const MEASUREMENT_DEBOUNCE_MS = 80;
function isMeasurableOverlayLayer(layer: string): layer is 'visible' | 'invisible' {
return layer === 'visible' || layer === 'invisible';
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function toMeasuredRect(rect: DOMRect): OverlayContentRect | null {
if (!Number.isFinite(rect.left) || !Number.isFinite(rect.top)) {
return null;
}
if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) {
return null;
}
const width = Math.max(0, rect.width);
const height = Math.max(0, rect.height);
return {
x: round2(rect.left),
y: round2(rect.top),
width: round2(width),
height: round2(height),
};
}
function unionRects(a: OverlayContentRect, b: OverlayContentRect): OverlayContentRect {
const left = Math.min(a.x, b.x);
const top = Math.min(a.y, b.y);
const right = Math.max(a.x + a.width, b.x + b.width);
const bottom = Math.max(a.y + a.height, b.y + b.height);
return {
x: round2(left),
y: round2(top),
width: round2(Math.max(0, right - left)),
height: round2(Math.max(0, bottom - top)),
};
}
function hasVisibleTextContent(element: HTMLElement): boolean {
return Boolean(element.textContent && element.textContent.trim().length > 0);
}
function collectContentRect(ctx: RendererContext): OverlayContentRect | null {
let combinedRect: OverlayContentRect | null = null;
const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot);
if (subtitleHasContent) {
const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect());
if (subtitleRect) {
combinedRect = subtitleRect;
}
}
const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot);
if (secondaryHasContent) {
const secondaryRect = toMeasuredRect(ctx.dom.secondarySubContainer.getBoundingClientRect());
if (secondaryRect) {
combinedRect = combinedRect ? unionRects(combinedRect, secondaryRect) : secondaryRect;
}
}
if (!combinedRect) {
return null;
}
return {
x: combinedRect.x,
y: combinedRect.y,
width: round2(Math.max(0, combinedRect.width)),
height: round2(Math.max(0, combinedRect.height)),
};
}
export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
let debounceTimer: number | null = null;
function emitNow(): void {
if (!isMeasurableOverlayLayer(ctx.platform.overlayLayer)) {
return;
}
const measurement: OverlayContentMeasurement = {
layer: ctx.platform.overlayLayer,
measuredAtMs: Date.now(),
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
// Explicit null rect signals "no content yet", and main should use fallback bounds.
contentRect: collectContentRect(ctx),
};
window.electronAPI.reportOverlayContentBounds(measurement);
}
function schedule(): void {
if (debounceTimer !== null) {
window.clearTimeout(debounceTimer);
}
debounceTimer = window.setTimeout(() => {
debounceTimer = null;
emitNow();
}, MEASUREMENT_DEBOUNCE_MS);
}
return {
emitNow,
schedule,
};
}

View File

@@ -0,0 +1 @@
export { createPositioningController } from './positioning/controller.js';

View File

@@ -0,0 +1,36 @@
import type { ModalStateReader, RendererContext } from '../context';
import {
createInMemorySubtitlePositionController,
type SubtitlePositionController,
} from './position-state.js';
import {
createInvisibleOffsetController,
type InvisibleOffsetController,
} from './invisible-offset.js';
import {
createMpvSubtitleLayoutController,
type MpvSubtitleLayoutController,
} from './invisible-layout.js';
type PositioningControllerOptions = {
modalStateReader: Pick<ModalStateReader, 'isAnySettingsModalOpen'>;
applySubtitleFontSize: (fontSize: number) => void;
};
export function createPositioningController(
ctx: RendererContext,
options: PositioningControllerOptions,
) {
const visible = createInMemorySubtitlePositionController(ctx);
const invisibleOffset = createInvisibleOffsetController(ctx, options.modalStateReader);
const invisibleLayout = createMpvSubtitleLayoutController(ctx, options.applySubtitleFontSize, {
applyInvisibleSubtitleOffsetPosition: invisibleOffset.applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud: invisibleOffset.updateInvisiblePositionEditHud,
});
return {
...visible,
...invisibleOffset,
...invisibleLayout,
} as SubtitlePositionController & InvisibleOffsetController & MpvSubtitleLayoutController;
}

View File

@@ -0,0 +1,187 @@
import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context';
const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 5;
const INVISIBLE_MACOS_LINE_HEIGHT_SINGLE = '0.92';
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = '1.2';
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = '1.3';
export function applyContainerBaseLayout(
ctx: RendererContext,
params: {
horizontalAvailable: number;
leftInset: number;
marginX: number;
hAlign: 0 | 1 | 2;
},
): void {
const { horizontalAvailable, leftInset, marginX, hAlign } = params;
ctx.dom.subtitleContainer.style.position = 'absolute';
ctx.dom.subtitleContainer.style.maxWidth = `${horizontalAvailable}px`;
ctx.dom.subtitleContainer.style.width = `${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 = `${leftInset + marginX}px`;
ctx.dom.subtitleContainer.style.right = '';
ctx.dom.subtitleContainer.style.transform = '';
ctx.dom.subtitleContainer.style.textAlign = '';
if (hAlign === 0) {
ctx.dom.subtitleContainer.style.textAlign = 'left';
ctx.dom.subtitleRoot.style.textAlign = 'left';
} else if (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';
}
export function applyVerticalPosition(
ctx: RendererContext,
params: {
metrics: MpvSubtitleRenderMetrics;
renderAreaHeight: number;
topInset: number;
bottomInset: number;
marginY: number;
effectiveFontSize: number;
borderPx: number;
shadowPx: number;
vAlign: 0 | 1 | 2;
},
): void {
const usableHeight = Math.max(1, params.renderAreaHeight - params.topInset - params.bottomInset);
const baselineCompensationPx = Math.max(0, (params.borderPx + params.shadowPx) * 5);
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 anchorY =
params.topInset + (usableHeight * params.metrics.subPos) / 100 - params.marginY + baselineCompensationPx;
const bottomPx = Math.max(0, params.renderAreaHeight - anchorY);
ctx.dom.subtitleContainer.style.top = '';
ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`;
}
function resolveFontFamily(rawFont: string): string {
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();
return strippedFont !== rawFont
? `"${rawFont}", "${strippedFont}", sans-serif`
: `"${rawFont}", sans-serif`;
}
function resolveLineHeight(lineCount: number, isMacOSPlatform: boolean): string {
if (!isMacOSPlatform) return 'normal';
if (lineCount >= 3) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE;
if (lineCount >= 2) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI;
return INVISIBLE_MACOS_LINE_HEIGHT_SINGLE;
}
function resolveLetterSpacing(
spacing: number,
pxPerScaledPixel: number,
isMacOSPlatform: boolean,
): string {
if (Math.abs(spacing) > 0.0001) {
return `${spacing * pxPerScaledPixel * (isMacOSPlatform ? 0.7 : 1)}px`;
}
return isMacOSPlatform ? '-0.02em' : '0px';
}
function applyComputedLineHeightCompensation(
ctx: RendererContext,
effectiveFontSize: number,
): void {
const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight);
if (!Number.isFinite(computedLineHeight) || computedLineHeight <= effectiveFontSize) {
return;
}
const halfLeading = (computedLineHeight - effectiveFontSize) / 2;
if (halfLeading <= 0.5) return;
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
if (Number.isFinite(currentBottom)) {
ctx.dom.subtitleContainer.style.bottom = `${Math.max(0, currentBottom - halfLeading)}px`;
}
const currentTop = parseFloat(ctx.dom.subtitleContainer.style.top);
if (Number.isFinite(currentTop)) {
ctx.dom.subtitleContainer.style.top = `${Math.max(0, currentTop - halfLeading)}px`;
}
}
function applyMacOSAdjustments(ctx: RendererContext): void {
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
if (!isMacOSPlatform) return;
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
if (!Number.isFinite(currentBottom)) return;
ctx.dom.subtitleContainer.style.bottom = `${Math.max(
0,
currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX,
)}px`;
}
export function applyTypography(
ctx: RendererContext,
params: {
metrics: MpvSubtitleRenderMetrics;
pxPerScaledPixel: number;
effectiveFontSize: number;
},
): void {
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
ctx.dom.subtitleRoot.style.setProperty(
'line-height',
resolveLineHeight(lineCount, isMacOSPlatform),
isMacOSPlatform ? 'important' : '',
);
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont);
ctx.dom.subtitleRoot.style.setProperty(
'letter-spacing',
resolveLetterSpacing(params.metrics.subSpacing, params.pxPerScaledPixel, isMacOSPlatform),
isMacOSPlatform ? 'important' : '',
);
ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? 'auto' : 'none';
ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold ? '700' : '400';
ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic ? 'italic' : 'normal';
ctx.dom.subtitleRoot.style.transform = '';
ctx.dom.subtitleRoot.style.transformOrigin = '';
applyComputedLineHeightCompensation(ctx, params.effectiveFontSize);
applyMacOSAdjustments(ctx);
}

View File

@@ -0,0 +1,133 @@
import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context';
export type SubtitleAlignment = { hAlign: 0 | 1 | 2; vAlign: 0 | 1 | 2 };
export type SubtitleLayoutGeometry = {
renderAreaHeight: number;
renderAreaWidth: number;
leftInset: number;
rightInset: number;
topInset: number;
bottomInset: number;
horizontalAvailable: number;
marginY: number;
marginX: number;
pxPerScaledPixel: number;
effectiveFontSize: number;
};
export function calculateOsdScale(
metrics: MpvSubtitleRenderMetrics,
isMacOSPlatform: boolean,
viewportWidth: number,
viewportHeight: number,
devicePixelRatio: number,
): number {
const dims = metrics.osdDimensions;
if (!isMacOSPlatform || !dims) {
return devicePixelRatio;
}
const ratios = [dims.w / Math.max(1, viewportWidth), dims.h / Math.max(1, viewportHeight)].filter(
(value) => Number.isFinite(value) && value > 0,
);
const avgRatio =
ratios.length > 0
? ratios.reduce((sum, value) => sum + value, 0) / ratios.length
: devicePixelRatio;
return avgRatio > 1.25 ? avgRatio : 1;
}
export function calculateSubtitlePosition(
_metrics: MpvSubtitleRenderMetrics,
_scale: number,
alignment: number,
): SubtitleAlignment {
return {
hAlign: ((alignment - 1) % 3) as 0 | 1 | 2,
vAlign: Math.floor((alignment - 1) / 3) as 0 | 1 | 2,
};
}
function resolveLinePadding(
metrics: MpvSubtitleRenderMetrics,
pxPerScaledPixel: number,
): { marginY: number; marginX: number } {
return {
marginY: metrics.subMarginY * pxPerScaledPixel,
marginX: Math.max(0, metrics.subMarginX * pxPerScaledPixel),
};
}
export function applyPlatformFontCompensation(
fontSizePx: number,
isMacOSPlatform: boolean,
): number {
return isMacOSPlatform ? fontSizePx * 0.87 : fontSizePx;
}
function calculateGeometry(
metrics: MpvSubtitleRenderMetrics,
osdToCssScale: number,
): Omit<SubtitleLayoutGeometry, 'marginY' | 'marginX' | 'pxPerScaledPixel' | 'effectiveFontSize'> {
const dims = metrics.osdDimensions;
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 horizontalAvailable = Math.max(0, renderAreaWidth - leftInset - rightInset);
return {
renderAreaHeight,
renderAreaWidth,
leftInset,
rightInset,
topInset,
bottomInset,
horizontalAvailable,
};
}
export function calculateSubtitleMetrics(
ctx: RendererContext,
metrics: MpvSubtitleRenderMetrics,
): SubtitleLayoutGeometry {
const osdToCssScale = calculateOsdScale(
metrics,
ctx.platform.isMacOSPlatform,
window.innerWidth,
window.innerHeight,
window.devicePixelRatio || 1,
);
const geometry = calculateGeometry(metrics, osdToCssScale);
const videoHeight = geometry.renderAreaHeight - geometry.topInset - geometry.bottomInset;
const scaleRefHeight = metrics.subScaleByWindow ? geometry.renderAreaHeight : videoHeight;
const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720);
const computedFontSize =
metrics.subFontSize * metrics.subScale * (ctx.platform.isLinuxPlatform ? 1 : pxPerScaledPixel);
const effectiveFontSize = applyPlatformFontCompensation(
computedFontSize,
ctx.platform.isMacOSPlatform,
);
const spacing = resolveLinePadding(metrics, pxPerScaledPixel);
return {
...geometry,
marginY: spacing.marginY,
marginX: spacing.marginX,
pxPerScaledPixel,
effectiveFontSize,
};
}

View File

@@ -0,0 +1,85 @@
import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context';
import {
applyContainerBaseLayout,
applyTypography,
applyVerticalPosition,
} from './invisible-layout-helpers.js';
import { calculateSubtitleMetrics, calculateSubtitlePosition } from './invisible-layout-metrics.js';
export type MpvSubtitleLayoutController = {
applyInvisibleSubtitleLayoutFromMpvMetrics: (
metrics: MpvSubtitleRenderMetrics,
source: string,
) => void;
};
export function createMpvSubtitleLayoutController(
ctx: RendererContext,
applySubtitleFontSize: (fontSize: number) => void,
options: {
applyInvisibleSubtitleOffsetPosition: () => void;
updateInvisiblePositionEditHud: () => void;
},
): MpvSubtitleLayoutController {
function applyInvisibleSubtitleLayoutFromMpvMetrics(
metrics: MpvSubtitleRenderMetrics,
source: string,
): void {
ctx.state.mpvSubtitleRenderMetrics = metrics;
const geometry = calculateSubtitleMetrics(ctx, metrics);
const alignment = calculateSubtitlePosition(metrics, geometry.pxPerScaledPixel, 2);
applySubtitleFontSize(geometry.effectiveFontSize);
const effectiveBorderSize = metrics.subBorderSize * geometry.pxPerScaledPixel;
const effectiveShadowOffset = metrics.subShadowOffset * geometry.pxPerScaledPixel;
document.documentElement.style.setProperty('--sub-border-size', `${effectiveBorderSize}px`);
applyContainerBaseLayout(ctx, {
horizontalAvailable: Math.max(
0,
geometry.horizontalAvailable - Math.round(geometry.marginX * 2),
),
leftInset: geometry.leftInset,
marginX: geometry.marginX,
hAlign: alignment.hAlign,
});
applyVerticalPosition(ctx, {
metrics,
renderAreaHeight: geometry.renderAreaHeight,
topInset: geometry.topInset,
bottomInset: geometry.bottomInset,
marginY: geometry.marginY,
effectiveFontSize: geometry.effectiveFontSize,
borderPx: effectiveBorderSize,
shadowPx: effectiveShadowOffset,
vAlign: alignment.vAlign,
});
applyTypography(ctx, {
metrics,
pxPerScaledPixel: geometry.pxPerScaledPixel,
effectiveFontSize: geometry.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;
options.applyInvisibleSubtitleOffsetPosition();
options.updateInvisiblePositionEditHud();
console.log('[invisible-overlay] Applied mpv subtitle render metrics from', source);
}
return {
applyInvisibleSubtitleLayoutFromMpvMetrics,
};
}

View File

@@ -0,0 +1,161 @@
import type { SubtitlePosition } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
export type InvisibleOffsetController = {
applyInvisibleStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void;
applyInvisibleSubtitleOffsetPosition: () => void;
updateInvisiblePositionEditHud: () => void;
setInvisiblePositionEditMode: (enabled: boolean) => void;
saveInvisiblePositionEdit: () => void;
cancelInvisiblePositionEdit: () => void;
setupInvisiblePositionEditHud: () => void;
};
function formatEditHudText(offsetX: number, offsetY: number): string {
return `Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(offsetX)} y:${Math.round(offsetY)}`;
}
function createEditPositionText(ctx: RendererContext): string {
return formatEditHudText(
ctx.state.invisibleSubtitleOffsetXPx,
ctx.state.invisibleSubtitleOffsetYPx,
);
}
function applyOffsetByBasePosition(ctx: RendererContext): 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 = '';
}
}
export function createInvisibleOffsetController(
ctx: RendererContext,
modalStateReader: Pick<ModalStateReader, 'isAnySettingsModalOpen'>,
): InvisibleOffsetController {
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 && !modalStateReader.isAnySettingsModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
}
}
updateInvisiblePositionEditHud();
}
function updateInvisiblePositionEditHud(): void {
if (!ctx.state.invisiblePositionEditHud) return;
ctx.state.invisiblePositionEditHud.textContent = createEditPositionText(ctx);
}
function applyInvisibleSubtitleOffsetPosition(): void {
applyOffsetByBasePosition(ctx);
}
function applyInvisibleStoredSubtitlePosition(
position: SubtitlePosition | null,
source: string,
): void {
if (position && typeof position.yPercent === 'number' && Number.isFinite(position.yPercent)) {
ctx.state.persistedSubtitlePosition = {
...ctx.state.persistedSubtitlePosition,
yPercent: position.yPercent,
};
}
if (position) {
const nextX =
typeof position.invisibleOffsetXPx === 'number' &&
Number.isFinite(position.invisibleOffsetXPx)
? position.invisibleOffsetXPx
: 0;
const nextY =
typeof position.invisibleOffsetYPx === 'number' &&
Number.isFinite(position.invisibleOffsetYPx)
? position.invisibleOffsetYPx
: 0;
ctx.state.invisibleSubtitleOffsetXPx = nextX;
ctx.state.invisibleSubtitleOffsetYPx = nextY;
} else {
ctx.state.invisibleSubtitleOffsetXPx = 0;
ctx.state.invisibleSubtitleOffsetYPx = 0;
}
applyOffsetByBasePosition(ctx);
console.log(
'[invisible-overlay] Applied subtitle offset from',
source,
`${ctx.state.invisibleSubtitleOffsetXPx}px`,
`${ctx.state.invisibleSubtitleOffsetYPx}px`,
);
updateInvisiblePositionEditHud();
}
function saveInvisiblePositionEdit(): void {
const nextPosition = {
yPercent: ctx.state.persistedSubtitlePosition.yPercent,
invisibleOffsetXPx: ctx.state.invisibleSubtitleOffsetXPx,
invisibleOffsetYPx: ctx.state.invisibleSubtitleOffsetYPx,
};
window.electronAPI.saveSubtitlePosition(nextPosition);
setInvisiblePositionEditMode(false);
}
function cancelInvisiblePositionEdit(): void {
ctx.state.invisibleSubtitleOffsetXPx = ctx.state.invisiblePositionEditStartX;
ctx.state.invisibleSubtitleOffsetYPx = ctx.state.invisiblePositionEditStartY;
applyOffsetByBasePosition(ctx);
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,
applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud,
setInvisiblePositionEditMode,
saveInvisiblePositionEdit,
cancelInvisiblePositionEdit,
setupInvisiblePositionEditHud,
};
}

View File

@@ -0,0 +1,120 @@
import type { SubtitlePosition } from '../../types';
import type { RendererContext } from '../context';
const PREFERRED_Y_PERCENT_MIN = 2;
const PREFERRED_Y_PERCENT_MAX = 80;
export type SubtitlePositionController = {
applyStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void;
getCurrentYPercent: () => number;
applyYPercent: (yPercent: number) => void;
persistSubtitlePositionPatch: (patch: Partial<SubtitlePosition>) => void;
};
function clampYPercent(yPercent: number): number {
return Math.max(PREFERRED_Y_PERCENT_MIN, Math.min(PREFERRED_Y_PERCENT_MAX, yPercent));
}
function getPersistedYPercent(ctx: RendererContext, position: SubtitlePosition | null): number {
if (!position || typeof position.yPercent !== 'number' || !Number.isFinite(position.yPercent)) {
return ctx.state.persistedSubtitlePosition.yPercent;
}
return position.yPercent;
}
function getPersistedOffset(
position: SubtitlePosition | null,
key: 'invisibleOffsetXPx' | 'invisibleOffsetYPx',
): number {
if (position && typeof position[key] === 'number' && Number.isFinite(position[key])) {
return position[key];
}
return 0;
}
function updatePersistedSubtitlePosition(
ctx: RendererContext,
position: SubtitlePosition | null,
): void {
ctx.state.persistedSubtitlePosition = {
yPercent: getPersistedYPercent(ctx, position),
invisibleOffsetXPx: getPersistedOffset(position, 'invisibleOffsetXPx'),
invisibleOffsetYPx: getPersistedOffset(position, 'invisibleOffsetYPx'),
};
}
function getNextPersistedPosition(
ctx: RendererContext,
patch: Partial<SubtitlePosition>,
): SubtitlePosition {
return {
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),
};
}
export function createInMemorySubtitlePositionController(
ctx: RendererContext,
): SubtitlePositionController {
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 persistSubtitlePositionPatch(patch: Partial<SubtitlePosition>): void {
const nextPosition = getNextPersistedPosition(ctx, patch);
ctx.state.persistedSubtitlePosition = nextPosition;
window.electronAPI.saveSubtitlePosition(nextPosition);
}
function applyStoredSubtitlePosition(position: SubtitlePosition | null, source: string): void {
updatePersistedSubtitlePosition(ctx, 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);
}
return {
applyStoredSubtitlePosition,
getCurrentYPercent,
applyYPercent,
persistSubtitlePositionPatch,
};
}

472
src/renderer/renderer.ts Normal file
View File

@@ -0,0 +1,472 @@
/*
* SubMiner - All-in-one sentence mining overlay
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {
KikuDuplicateCardInfo,
MpvSubtitleRenderMetrics,
RuntimeOptionState,
SecondarySubMode,
SubtitleData,
SubtitlePosition,
SubsyncManualPayload,
ConfigHotReloadPayload,
} from '../types';
import { createKeyboardHandlers } from './handlers/keyboard.js';
import { createMouseHandlers } from './handlers/mouse.js';
import { createJimakuModal } from './modals/jimaku.js';
import { createKikuModal } from './modals/kiku.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubsyncModal } from './modals/subsync.js';
import { createPositioningController } from './positioning.js';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
import { createRendererState } from './state.js';
import { createSubtitleRenderer } from './subtitle-render.js';
import {
createRendererRecoveryController,
registerRendererGlobalErrorHandlers,
} from './error-recovery.js';
import { resolveRendererDom } from './utils/dom.js';
import { resolvePlatformInfo } from './utils/platform.js';
import {
buildMpvLoadfileCommands,
collectDroppedVideoPaths,
} from '../core/services/overlay-drop.js';
const ctx = {
dom: resolveRendererDom(),
platform: resolvePlatformInfo(),
state: createRendererState(),
};
function isAnySettingsModalOpen(): boolean {
return (
ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.jimakuModalOpen ||
ctx.state.sessionHelpModalOpen
);
}
function isAnyModalOpen(): boolean {
return (
ctx.state.jimakuModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.sessionHelpModalOpen
);
}
function syncSettingsModalSubtitleSuppression(): void {
const suppressSubtitles = isAnySettingsModalOpen();
document.body.classList.toggle('settings-modal-open', suppressSubtitles);
if (suppressSubtitles) {
ctx.state.isOverSubtitle = false;
}
}
const subtitleRenderer = createSubtitleRenderer(ctx);
const measurementReporter = createOverlayContentMeasurementReporter(ctx);
const positioning = createPositioningController(ctx, {
modalStateReader: { isAnySettingsModalOpen },
applySubtitleFontSize: subtitleRenderer.applySubtitleFontSize,
});
const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const kikuModal = createKikuModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const jimakuModal = createJimakuModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
saveInvisiblePositionEdit: positioning.saveInvisiblePositionEdit,
cancelInvisiblePositionEdit: positioning.cancelInvisiblePositionEdit,
setInvisiblePositionEditMode: positioning.setInvisiblePositionEditMode,
applyInvisibleSubtitleOffsetPosition: positioning.applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud: positioning.updateInvisiblePositionEditHud,
appendClipboardVideoToQueue: () => {
void window.electronAPI.appendClipboardVideoToQueue();
},
});
const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
applyInvisibleSubtitleLayoutFromMpvMetrics:
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics,
applyYPercent: positioning.applyYPercent,
getCurrentYPercent: positioning.getCurrentYPercent,
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
reportHoveredTokenIndex: (tokenIndex: number | null) => {
window.electronAPI.reportHoveredSubtitleToken(tokenIndex);
},
});
let lastSubtitlePreview = '';
let lastSecondarySubtitlePreview = '';
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
function truncateForErrorLog(text: string): string {
const normalized = text.replace(/\s+/g, ' ').trim();
if (normalized.length <= 180) {
return normalized;
}
return `${normalized.slice(0, 177)}...`;
}
function getActiveModal(): string | null {
if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
if (ctx.state.subsyncModalOpen) return 'subsync';
if (ctx.state.sessionHelpModalOpen) return 'session-help';
return null;
}
function dismissActiveUiAfterError(): void {
if (ctx.state.jimakuModalOpen) {
jimakuModal.closeJimakuModal();
}
if (ctx.state.runtimeOptionsModalOpen) {
runtimeOptionsModal.closeRuntimeOptionsModal();
}
if (ctx.state.subsyncModalOpen) {
subsyncModal.closeSubsyncModal();
}
if (ctx.state.kikuModalOpen) {
kikuModal.cancelKikuFieldGrouping();
}
if (ctx.state.sessionHelpModalOpen) {
sessionHelpModal.closeSessionHelpModal();
}
syncSettingsModalSubtitleSuppression();
}
function restoreOverlayInteractionAfterError(): void {
ctx.state.isOverSubtitle = false;
if (ctx.state.invisiblePositionEditMode) {
positioning.setInvisiblePositionEditMode(false);
}
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
}
function showOverlayErrorToast(message: string): void {
if (overlayErrorToastTimeout) {
clearTimeout(overlayErrorToastTimeout);
overlayErrorToastTimeout = null;
}
ctx.dom.overlayErrorToast.textContent = message;
ctx.dom.overlayErrorToast.classList.remove('hidden');
overlayErrorToastTimeout = setTimeout(() => {
ctx.dom.overlayErrorToast.classList.add('hidden');
ctx.dom.overlayErrorToast.textContent = '';
overlayErrorToastTimeout = null;
}, 3200);
}
const recovery = createRendererRecoveryController({
dismissActiveUi: dismissActiveUiAfterError,
restoreOverlayInteraction: restoreOverlayInteractionAfterError,
showToast: showOverlayErrorToast,
getSnapshot: () => ({
activeModal: getActiveModal(),
subtitlePreview: lastSubtitlePreview,
secondarySubtitlePreview: lastSecondarySubtitlePreview,
isOverlayInteractive: ctx.dom.overlay.classList.contains('interactive'),
isOverSubtitle: ctx.state.isOverSubtitle,
invisiblePositionEditMode: ctx.state.invisiblePositionEditMode,
overlayLayer: ctx.platform.overlayLayer,
}),
logError: (payload) => {
console.error('renderer overlay recovery', payload);
},
});
registerRendererGlobalErrorHandlers(window, recovery);
function runGuarded(action: string, fn: () => void): void {
try {
fn();
} catch (error) {
recovery.handleError(error, { source: 'callback', action });
}
}
function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
Promise.resolve()
.then(fn)
.catch((error) => {
recovery.handleError(error, { source: 'callback', action });
});
}
async function init(): Promise<void> {
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
if (typeof data === 'string') {
lastSubtitlePreview = truncateForErrorLog(data);
} else if (data && typeof data.text === 'string') {
lastSubtitlePreview = truncateForErrorLog(data.text);
}
subtitleRenderer.renderSubtitle(data);
measurementReporter.schedule();
});
});
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
runGuarded('subtitle-position:update', () => {
if (ctx.platform.isInvisibleLayer) {
positioning.applyInvisibleStoredSubtitlePosition(position, 'media-change');
} else {
positioning.applyStoredSubtitlePosition(position, 'media-change');
}
measurementReporter.schedule();
});
});
if (ctx.platform.isInvisibleLayer) {
window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => {
runGuarded('mpv-metrics:update', () => {
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, 'event');
measurementReporter.schedule();
});
});
window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => {
runGuarded('overlay-debug-visualization:update', () => {
document.body.classList.toggle('debug-invisible-visualization', enabled);
});
});
}
const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
lastSubtitlePreview = truncateForErrorLog(initialSubtitle);
subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();
window.electronAPI.onSecondarySub((text: string) => {
runGuarded('secondary-subtitle:update', () => {
lastSecondarySubtitlePreview = truncateForErrorLog(text);
subtitleRenderer.renderSecondarySub(text);
measurementReporter.schedule();
});
});
window.electronAPI.onSecondarySubMode((mode: SecondarySubMode) => {
runGuarded('secondary-subtitle-mode:update', () => {
subtitleRenderer.updateSecondarySubMode(mode);
measurementReporter.schedule();
});
});
subtitleRenderer.updateSecondarySubMode(await window.electronAPI.getSecondarySubMode());
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
measurementReporter.schedule();
const hoverTarget = ctx.platform.isInvisibleLayer
? ctx.dom.subtitleRoot
: ctx.dom.subtitleContainer;
hoverTarget.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
hoverTarget.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
mouseHandlers.setupInvisibleHoverSelection();
mouseHandlers.setupInvisibleTokenHoverReporter();
positioning.setupInvisiblePositionEditHud();
mouseHandlers.setupResizeHandler();
mouseHandlers.setupSelectionObserver();
mouseHandlers.setupYomitanObserver();
setupDragDropToMpvQueue();
window.addEventListener('resize', () => {
measurementReporter.schedule();
});
jimakuModal.wireDomEvents();
kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents();
subsyncModal.wireDomEvents();
sessionHelpModal.wireDomEvents();
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
runGuarded('runtime-options:changed', () => {
runtimeOptionsModal.updateRuntimeOptions(options);
});
});
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
runGuarded('config:hot-reload', () => {
keyboardHandlers.updateKeybindings(payload.keybindings);
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
measurementReporter.schedule();
});
});
window.electronAPI.onOpenRuntimeOptions(() => {
runGuardedAsync('runtime-options:open', async () => {
try {
await runtimeOptionsModal.openRuntimeOptionsModal();
} catch {
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
window.electronAPI.notifyOverlayModalClosed('runtime-options');
syncSettingsModalSubtitleSuppression();
}
});
});
window.electronAPI.onOpenJimaku(() => {
runGuarded('jimaku:open', () => {
jimakuModal.openJimakuModal();
});
});
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
runGuarded('subsync:manual-open', () => {
subsyncModal.openSubsyncModal(payload);
});
});
window.electronAPI.onKikuFieldGroupingRequest(
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
runGuarded('kiku:field-grouping-open', () => {
kikuModal.openKikuFieldGroupingModal(data);
});
},
);
if (!ctx.platform.isInvisibleLayer) {
mouseHandlers.setupDragging();
}
await keyboardHandlers.setupMpvInputForwarding();
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle());
if (ctx.platform.isInvisibleLayer) {
positioning.applyInvisibleStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
await window.electronAPI.getMpvSubtitleRenderMetrics(),
'startup',
);
} else {
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
measurementReporter.schedule();
}
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
measurementReporter.emitNow();
}
function setupDragDropToMpvQueue(): void {
let dragDepth = 0;
const setDropInteractive = (): void => {
ctx.dom.overlay.classList.add('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
};
const clearDropInteractive = (): void => {
dragDepth = 0;
if (isAnyModalOpen() || ctx.state.isOverSubtitle || ctx.state.invisiblePositionEditMode) {
return;
}
ctx.dom.overlay.classList.remove('interactive');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
}
};
document.addEventListener('dragenter', (event: DragEvent) => {
if (!event.dataTransfer) return;
dragDepth += 1;
setDropInteractive();
});
document.addEventListener('dragover', (event: DragEvent) => {
if (dragDepth <= 0 || !event.dataTransfer) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
});
document.addEventListener('dragleave', () => {
if (dragDepth <= 0) return;
dragDepth -= 1;
if (dragDepth === 0) {
clearDropInteractive();
}
});
document.addEventListener('drop', (event: DragEvent) => {
if (!event.dataTransfer) return;
event.preventDefault();
const droppedPaths = collectDroppedVideoPaths(event.dataTransfer);
const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey);
for (const command of loadCommands) {
window.electronAPI.sendMpvCommand(command);
}
if (loadCommands.length > 0) {
const action = event.shiftKey ? 'Queued' : 'Loaded';
window.electronAPI.sendMpvCommand([
'show-text',
`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`,
'1500',
]);
}
clearDropInteractive();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
runGuardedAsync('bootstrap:init', init);
});
} else {
runGuardedAsync('bootstrap:init', init);
}

176
src/renderer/state.ts Normal file
View File

@@ -0,0 +1,176 @@
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;
sessionHelpModalOpen: boolean;
sessionHelpSelectedIndex: number;
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;
lastHoveredTokenIndex: number | null;
knownWordColor: string;
nPlusOneColor: string;
jlptN1Color: string;
jlptN2Color: string;
jlptN3Color: string;
jlptN4Color: string;
jlptN5Color: string;
preserveSubtitleLineBreaks: boolean;
frequencyDictionaryEnabled: boolean;
frequencyDictionaryTopX: number;
frequencyDictionaryMode: 'single' | 'banded';
frequencyDictionarySingleColor: string;
frequencyDictionaryBand1Color: string;
frequencyDictionaryBand2Color: string;
frequencyDictionaryBand3Color: string;
frequencyDictionaryBand4Color: string;
frequencyDictionaryBand5Color: string;
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,
sessionHelpModalOpen: false,
sessionHelpSelectedIndex: 0,
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,
lastHoveredTokenIndex: null,
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f',
jlptN3Color: '#f9e2af',
jlptN4Color: '#a6e3a1',
jlptN5Color: '#8aadf4',
preserveSubtitleLineBreaks: false,
frequencyDictionaryEnabled: false,
frequencyDictionaryTopX: 1000,
frequencyDictionaryMode: 'single',
frequencyDictionarySingleColor: '#f5a97f',
frequencyDictionaryBand1Color: '#ed8796',
frequencyDictionaryBand2Color: '#f5a97f',
frequencyDictionaryBand3Color: '#f9e2af',
frequencyDictionaryBand4Color: '#a6e3a1',
frequencyDictionaryBand5Color: '#8aadf4',
keybindingsMap: new Map(),
chordPending: false,
chordTimeout: null,
};
}

1121
src/renderer/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,293 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import type { MergedToken } from '../types';
import { PartOfSpeech } from '../types.js';
import { alignTokensToSourceText, computeWordClass, normalizeSubtitle } from './subtitle-render.js';
function createToken(overrides: Partial<MergedToken>): MergedToken {
return {
surface: '',
reading: '',
headword: '',
startPos: 0,
endPos: 0,
partOfSpeech: PartOfSpeech.other,
isMerged: true,
isKnown: false,
isNPlusOneTarget: false,
...overrides,
};
}
function extractClassBlock(cssText: string, selector: string): string {
const ruleRegex = /([^{}]+)\{([^}]*)\}/g;
let match: RegExpExecArray | null = null;
let fallbackBlock = '';
while ((match = ruleRegex.exec(cssText)) !== null) {
const selectorsBlock = match[1]?.trim() ?? '';
const selectorBlock = match[2] ?? '';
const selectors = selectorsBlock
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (selectors.includes(selector)) {
if (selectors.length === 1) {
return selectorBlock;
}
if (!fallbackBlock) {
fallbackBlock = selectorBlock;
}
}
}
if (fallbackBlock) {
return fallbackBlock;
}
return '';
}
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
const knownJlpt = createToken({
isKnown: true,
jlptLevel: 'N1',
surface: '猫',
});
const nPlusOneJlpt = createToken({
isNPlusOneTarget: true,
jlptLevel: 'N2',
surface: '犬',
});
assert.equal(computeWordClass(knownJlpt), 'word word-known word-jlpt-n1');
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
});
test('computeWordClass does not add frequency class to known or N+1 terms', () => {
const known = createToken({
isKnown: true,
frequencyRank: 10,
surface: '既知',
});
const nPlusOne = createToken({
isNPlusOneTarget: true,
frequencyRank: 10,
surface: '目標',
});
const frequency = createToken({
frequencyRank: 10,
surface: '頻度',
});
assert.equal(
computeWordClass(known, {
enabled: true,
topX: 100,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
}),
'word word-known',
);
assert.equal(
computeWordClass(nPlusOne, {
enabled: true,
topX: 100,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
}),
'word word-n-plus-one',
);
assert.equal(
computeWordClass(frequency, {
enabled: true,
topX: 100,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
}),
'word word-frequency-single',
);
});
test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
const token = createToken({
surface: '猫',
frequencyRank: 50,
});
const actual = computeWordClass(token, {
enabled: true,
topX: 100,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
});
assert.equal(actual, 'word word-frequency-single');
});
test('computeWordClass adds frequency class when rank equals topX', () => {
const token = createToken({
surface: '水',
frequencyRank: 100,
});
const actual = computeWordClass(token, {
enabled: true,
topX: 100,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
});
assert.equal(actual, 'word word-frequency-single');
});
test('computeWordClass adds frequency class for banded mode', () => {
const token = createToken({
surface: '犬',
frequencyRank: 250,
});
const actual = computeWordClass(token, {
enabled: true,
topX: 1000,
mode: 'banded',
singleColor: '#000000',
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'] as const,
});
assert.equal(actual, 'word word-frequency-band-2');
});
test('computeWordClass uses configured band count for banded mode', () => {
const token = createToken({
surface: '犬',
frequencyRank: 2,
});
const actual = computeWordClass(token, {
enabled: true,
topX: 4,
mode: 'banded',
singleColor: '#000000',
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'],
} as any);
assert.equal(actual, 'word word-frequency-band-3');
});
test('computeWordClass skips frequency class when rank is out of topX', () => {
const token = createToken({
surface: '犬',
frequencyRank: 1200,
});
const actual = computeWordClass(token, {
enabled: true,
topX: 1000,
mode: 'single',
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
});
assert.equal(actual, 'word');
});
test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => {
const tokens = [
createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }),
createToken({ surface: 'かかってこい', reading: 'かかってこい', headword: 'かかってこい' }),
];
const segments = alignTokensToSourceText(tokens, 'キリキリと\nかかってこい');
assert.deepEqual(
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
['token', 'text:\n', 'token'],
);
});
test('alignTokensToSourceText treats whitespace-only token surfaces as plain text separators', () => {
const tokens = [
createToken({ surface: '常人が使えば' }),
createToken({ surface: ' ' }),
createToken({ surface: 'その圧倒的な力に' }),
createToken({ surface: '\n' }),
createToken({ surface: '体が耐えきれず死に至るが…' }),
];
const segments = alignTokensToSourceText(tokens, '常人が使えば その圧倒的な力に\n体が耐えきれず死に至るが…');
assert.deepEqual(
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
['token', 'text: ', 'token', 'text:\n', 'token'],
);
});
test('alignTokensToSourceText avoids duplicate tail when later token surface does not match source', () => {
const tokens = [
createToken({ surface: '君たちが潰した拠点に' }),
createToken({ surface: '教団の主力は1人もいない' }),
];
const segments = alignTokensToSourceText(
tokens,
'君たちが潰した拠点に\n教団の主力は人もいない',
);
assert.deepEqual(
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
['token', 'text:\n教団の主力は人もいない'],
);
});
test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks is enabled', () => {
assert.equal(
normalizeSubtitle('常人が使えば\\Nその圧倒的な力に\\n体が耐えきれず死に至るが…', true, true),
'常人が使えば その圧倒的な力に 体が耐えきれず死に至るが…',
);
});
test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
const cssPath = fs.existsSync(distCssPath) ? distCssPath : srcCssPath;
if (!fs.existsSync(cssPath)) {
assert.fail(
'JLPT CSS file missing. Run `bun run build` first, or ensure src/renderer/style.css exists.',
);
}
const cssText = fs.readFileSync(cssPath, 'utf-8');
for (let level = 1; level <= 5; level += 1) {
const block = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
assert.match(block, /text-decoration-line:\s*underline;/);
assert.match(block, /text-decoration-thickness:\s*2px;/);
assert.match(block, /text-underline-offset:\s*4px;/);
assert.match(block, /color:\s*inherit;/);
}
for (let band = 1; band <= 5; band += 1) {
const block = extractClassBlock(
cssText,
band === 1
? '#subtitleRoot .word.word-frequency-single'
: `#subtitleRoot .word.word-frequency-band-${band}`,
);
assert.ok(
block.length > 0,
`frequency class word-frequency-${band === 1 ? 'single' : `band-${band}`} should exist`,
);
assert.match(block, /color:\s*var\(/);
}
});

View File

@@ -0,0 +1,540 @@
import type { MergedToken, SecondarySubMode, SubtitleData, SubtitleStyleConfig } from '../types';
import type { RendererContext } from './context';
type FrequencyRenderSettings = {
enabled: boolean;
topX: number;
mode: 'single' | 'banded';
singleColor: string;
bandedColors: [string, string, string, string, string];
};
function isWhitespaceOnly(value: string): boolean {
return value.trim().length === 0;
}
export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks = false): string {
if (!text) return '';
let normalized = text.replace(/\\N/g, '\n').replace(/\\n/g, '\n');
normalized = normalized.replace(/\{[^}]*\}/g, '');
if (collapseLineBreaks) {
normalized = normalized.replace(/\n/g, ' ');
normalized = normalized.replace(/\s+/g, ' ');
}
return trim ? normalized.trim() : normalized;
}
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
function sanitizeHexColor(value: unknown, fallback: string): string {
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim())
? value.trim()
: fallback;
}
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
enabled: false,
topX: 1000,
mode: 'single',
singleColor: '#f5a97f',
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
};
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return fallback;
}
return Math.max(1, Math.floor(value));
}
function sanitizeFrequencyBandedColors(
value: unknown,
fallback: FrequencyRenderSettings['bandedColors'],
): FrequencyRenderSettings['bandedColors'] {
if (!Array.isArray(value) || value.length !== 5) {
return fallback;
}
return [
sanitizeHexColor(value[0], fallback[0]),
sanitizeHexColor(value[1], fallback[1]),
sanitizeHexColor(value[2], fallback[2]),
sanitizeHexColor(value[3], fallback[3]),
sanitizeHexColor(value[4], fallback[4]),
];
}
function getFrequencyDictionaryClass(
token: MergedToken,
settings: FrequencyRenderSettings,
): string {
if (!settings.enabled) {
return '';
}
if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) {
return '';
}
const rank = Math.max(1, Math.floor(token.frequencyRank));
const topX = sanitizeFrequencyTopX(settings.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX);
if (rank > topX) {
return '';
}
if (settings.mode === 'banded') {
const bandCount = settings.bandedColors.length;
const normalizedBand = Math.ceil((rank / topX) * bandCount);
const band = Math.min(bandCount, Math.max(1, normalizedBand));
return `word-frequency-band-${band}`;
}
return 'word-frequency-single';
}
function renderWithTokens(
root: HTMLElement,
tokens: MergedToken[],
frequencyRenderSettings?: Partial<FrequencyRenderSettings>,
sourceText?: string,
preserveLineBreaks = false,
): void {
const resolvedFrequencyRenderSettings = {
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
...frequencyRenderSettings,
bandedColors: sanitizeFrequencyBandedColors(
frequencyRenderSettings?.bandedColors,
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
),
topX: sanitizeFrequencyTopX(
frequencyRenderSettings?.topX,
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
),
singleColor: sanitizeHexColor(
frequencyRenderSettings?.singleColor,
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
),
};
const fragment = document.createDocumentFragment();
if (preserveLineBreaks && sourceText) {
const normalizedSource = normalizeSubtitle(sourceText, true, false);
const segments = alignTokensToSourceText(tokens, normalizedSource);
for (const segment of segments) {
if (segment.kind === 'text') {
renderPlainTextPreserveLineBreaks(fragment, segment.text);
continue;
}
const token = segment.token;
const span = document.createElement('span');
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
span.textContent = token.surface;
span.dataset.tokenIndex = String(segment.tokenIndex);
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
fragment.appendChild(span);
}
root.appendChild(fragment);
return;
}
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) {
continue;
}
const surface = token.surface.replace(/\n/g, ' ');
if (!surface) {
continue;
}
if (isWhitespaceOnly(surface)) {
fragment.appendChild(document.createTextNode(surface));
continue;
}
const span = document.createElement('span');
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
span.textContent = surface;
span.dataset.tokenIndex = String(index);
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
fragment.appendChild(span);
}
root.appendChild(fragment);
}
type SubtitleRenderSegment =
| { kind: 'text'; text: string }
| { kind: 'token'; token: MergedToken; tokenIndex: number };
export function alignTokensToSourceText(
tokens: MergedToken[],
sourceText: string,
): SubtitleRenderSegment[] {
if (tokens.length === 0) {
return sourceText ? [{ kind: 'text', text: sourceText }] : [];
}
const segments: SubtitleRenderSegment[] = [];
let cursor = 0;
for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) {
const token = tokens[tokenIndex];
if (!token) {
continue;
}
const surface = token.surface;
if (!surface || isWhitespaceOnly(surface)) {
continue;
}
const foundIndex = sourceText.indexOf(surface, cursor);
if (foundIndex < 0) {
// Token text can diverge from source normalization (e.g., half/full-width forms).
// Skip unmatched token to avoid duplicating visible tail text in preserve-line-break mode.
continue;
}
if (foundIndex > cursor) {
segments.push({ kind: 'text', text: sourceText.slice(cursor, foundIndex) });
}
segments.push({ kind: 'token', token, tokenIndex });
cursor = foundIndex + surface.length;
}
if (cursor < sourceText.length) {
segments.push({ kind: 'text', text: sourceText.slice(cursor) });
}
return segments;
}
export function computeWordClass(
token: MergedToken,
frequencySettings?: Partial<FrequencyRenderSettings>,
): string {
const resolvedFrequencySettings = {
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
...frequencySettings,
bandedColors: sanitizeFrequencyBandedColors(
frequencySettings?.bandedColors,
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
),
topX: sanitizeFrequencyTopX(frequencySettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX),
singleColor: sanitizeHexColor(
frequencySettings?.singleColor,
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
),
};
const classes = ['word'];
if (token.isNPlusOneTarget) {
classes.push('word-n-plus-one');
} else if (token.isKnown) {
classes.push('word-known');
}
if (token.jlptLevel) {
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
}
if (!token.isKnown && !token.isNPlusOneTarget) {
const frequencyClass = getFrequencyDictionaryClass(token, resolvedFrequencySettings);
if (frequencyClass) {
classes.push(frequencyClass);
}
}
return classes.join(' ');
}
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: ParentNode, 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;
ctx.state.lastHoveredTokenIndex = 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,
);
if (tokens && tokens.length > 0) {
renderWithTokens(
ctx.dom.subtitleRoot,
tokens,
getFrequencyRenderSettings(),
text,
true,
);
} else {
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
}
return;
}
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
if (tokens && tokens.length > 0) {
renderWithTokens(
ctx.dom.subtitleRoot,
tokens,
getFrequencyRenderSettings(),
text,
ctx.state.preserveSubtitleLineBreaks,
);
return;
}
renderCharacterLevel(ctx.dom.subtitleRoot, normalized);
}
function getFrequencyRenderSettings(): Partial<FrequencyRenderSettings> {
return {
enabled: ctx.state.frequencyDictionaryEnabled,
topX: ctx.state.frequencyDictionaryTopX,
mode: ctx.state.frequencyDictionaryMode,
singleColor: ctx.state.frequencyDictionarySingleColor,
bandedColors: [
ctx.state.frequencyDictionaryBand1Color,
ctx.state.frequencyDictionaryBand2Color,
ctx.state.frequencyDictionaryBand3Color,
ctx.state.frequencyDictionaryBand4Color,
ctx.state.frequencyDictionaryBand5Color,
] as [string, string, string, string, string],
};
}
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) {
const line = lines[i];
if (line) {
ctx.dom.secondarySubRoot.appendChild(document.createTextNode(line));
}
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 knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6';
const jlptColors = {
N1: ctx.state.jlptN1Color ?? '#ed8796',
N2: ctx.state.jlptN2Color ?? '#f5a97f',
N3: ctx.state.jlptN3Color ?? '#f9e2af',
N4: ctx.state.jlptN4Color ?? '#a6e3a1',
N5: ctx.state.jlptN5Color ?? '#8aadf4',
...(style.jlptColors
? {
N1: sanitizeHexColor(style.jlptColors?.N1, ctx.state.jlptN1Color),
N2: sanitizeHexColor(style.jlptColors?.N2, ctx.state.jlptN2Color),
N3: sanitizeHexColor(style.jlptColors?.N3, ctx.state.jlptN3Color),
N4: sanitizeHexColor(style.jlptColors?.N4, ctx.state.jlptN4Color),
N5: sanitizeHexColor(style.jlptColors?.N5, ctx.state.jlptN5Color),
}
: {}),
};
ctx.state.knownWordColor = knownWordColor;
ctx.state.nPlusOneColor = nPlusOneColor;
ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor);
ctx.state.jlptN1Color = jlptColors.N1;
ctx.state.jlptN2Color = jlptColors.N2;
ctx.state.jlptN3Color = jlptColors.N3;
ctx.state.jlptN4Color = jlptColors.N4;
ctx.state.jlptN5Color = jlptColors.N5;
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n4-color', jlptColors.N4);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n5-color', jlptColors.N5);
const frequencyDictionarySettings = style.frequencyDictionary ?? {};
const frequencyEnabled =
frequencyDictionarySettings.enabled ?? ctx.state.frequencyDictionaryEnabled;
const frequencyTopX = sanitizeFrequencyTopX(
frequencyDictionarySettings.topX,
ctx.state.frequencyDictionaryTopX,
);
const frequencyMode = frequencyDictionarySettings.mode
? frequencyDictionarySettings.mode
: ctx.state.frequencyDictionaryMode;
const frequencySingleColor = sanitizeHexColor(
frequencyDictionarySettings.singleColor,
ctx.state.frequencyDictionarySingleColor,
);
const frequencyBandedColors = sanitizeFrequencyBandedColors(
frequencyDictionarySettings.bandedColors,
[
ctx.state.frequencyDictionaryBand1Color,
ctx.state.frequencyDictionaryBand2Color,
ctx.state.frequencyDictionaryBand3Color,
ctx.state.frequencyDictionaryBand4Color,
ctx.state.frequencyDictionaryBand5Color,
] as [string, string, string, string, string],
);
ctx.state.frequencyDictionaryEnabled = frequencyEnabled;
ctx.state.frequencyDictionaryTopX = frequencyTopX;
ctx.state.frequencyDictionaryMode = frequencyMode;
ctx.state.frequencyDictionarySingleColor = frequencySingleColor;
[
ctx.state.frequencyDictionaryBand1Color,
ctx.state.frequencyDictionaryBand2Color,
ctx.state.frequencyDictionaryBand3Color,
ctx.state.frequencyDictionaryBand4Color,
ctx.state.frequencyDictionaryBand5Color,
] = frequencyBandedColors;
ctx.dom.subtitleRoot.style.setProperty(
'--subtitle-frequency-single-color',
frequencySingleColor,
);
ctx.dom.subtitleRoot.style.setProperty(
'--subtitle-frequency-band-1-color',
frequencyBandedColors[0],
);
ctx.dom.subtitleRoot.style.setProperty(
'--subtitle-frequency-band-2-color',
frequencyBandedColors[1],
);
ctx.dom.subtitleRoot.style.setProperty(
'--subtitle-frequency-band-3-color',
frequencyBandedColors[2],
);
ctx.dom.subtitleRoot.style.setProperty(
'--subtitle-frequency-band-4-color',
frequencyBandedColors[3],
);
ctx.dom.subtitleRoot.style.setProperty(
'--subtitle-frequency-band-5-color',
frequencyBandedColors[4],
);
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,
};
}

143
src/renderer/utils/dom.ts Normal file
View File

@@ -0,0 +1,143 @@
export type RendererDom = {
subtitleRoot: HTMLElement;
subtitleContainer: HTMLElement;
overlay: HTMLElement;
overlayErrorToast: HTMLDivElement;
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;
sessionHelpModal: HTMLDivElement;
sessionHelpClose: HTMLButtonElement;
sessionHelpShortcut: HTMLDivElement;
sessionHelpWarning: HTMLDivElement;
sessionHelpStatus: HTMLDivElement;
sessionHelpFilter: HTMLInputElement;
sessionHelpContent: 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'),
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
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'),
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
sessionHelpWarning: getRequiredElement<HTMLDivElement>('sessionHelpWarning'),
sessionHelpStatus: getRequiredElement<HTMLDivElement>('sessionHelpStatus'),
sessionHelpFilter: getRequiredElement<HTMLInputElement>('sessionHelpFilter'),
sessionHelpContent: getRequiredElement<HTMLDivElement>('sessionHelpContent'),
};
}

View File

@@ -0,0 +1,48 @@
export type OverlayLayer = 'visible' | 'invisible' | 'secondary';
export type PlatformInfo = {
overlayLayer: OverlayLayer;
isInvisibleLayer: boolean;
isSecondaryLayer: boolean;
isLinuxPlatform: boolean;
isMacOSPlatform: boolean;
shouldToggleMouseIgnore: boolean;
invisiblePositionEditToggleCode: string;
invisiblePositionStepPx: number;
invisiblePositionStepFastPx: number;
};
export function resolvePlatformInfo(): PlatformInfo {
const overlayLayerFromPreload = window.electronAPI.getOverlayLayer();
const queryLayer = new URLSearchParams(window.location.search).get('layer');
const overlayLayerFromQuery: OverlayLayer | null =
queryLayer === 'visible' || queryLayer === 'invisible' || queryLayer === 'secondary'
? queryLayer
: null;
const overlayLayer: OverlayLayer =
overlayLayerFromQuery ??
(overlayLayerFromPreload === 'visible' ||
overlayLayerFromPreload === 'invisible' ||
overlayLayerFromPreload === 'secondary'
? overlayLayerFromPreload
: 'visible');
const isInvisibleLayer = overlayLayer === 'invisible';
const isSecondaryLayer = overlayLayer === 'secondary';
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
const isMacOSPlatform =
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
return {
overlayLayer,
isInvisibleLayer,
isSecondaryLayer,
isLinuxPlatform,
isMacOSPlatform,
shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer,
invisiblePositionEditToggleCode: 'KeyP',
invisiblePositionStepPx: 1,
invisiblePositionStepFastPx: 4,
};
}