Files
SubMiner/src/renderer/renderer.ts

462 lines
15 KiB
TypeScript

/*
* SubMiner - All-in-one sentence mining overlay
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {
KikuDuplicateCardInfo,
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);
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,
appendClipboardVideoToQueue: () => {
void window.electronAPI.appendClipboardVideoToQueue();
},
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
});
const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
applyYPercent: positioning.applyYPercent,
getCurrentYPercent: positioning.getCurrentYPercent,
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover,
getYomitanPopupAutoPauseEnabled: () => ctx.state.autoPauseVideoOnYomitanPopup,
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
sendMpvCommand: (command) => {
window.electronAPI.sendMpvCommand(command);
},
});
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 getSubtitleTextForPreview(data: SubtitleData | string): string {
if (typeof data === 'string') {
return data;
}
if (data && typeof data.text === 'string') {
return data.text;
}
return '';
}
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;
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,
overlayLayer: ctx.platform.overlayLayer,
}),
logError: (payload) => {
console.error('renderer overlay recovery', payload);
},
});
registerRendererGlobalErrorHandlers(window, recovery);
function registerModalOpenHandlers(): void {
window.electronAPI.onOpenRuntimeOptions(() => {
runGuardedAsync('runtime-options:open', async () => {
try {
await runtimeOptionsModal.openRuntimeOptionsModal();
window.electronAPI.notifyOverlayModalOpened('runtime-options');
} 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.notifyOverlayModalOpened('jimaku');
});
});
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
runGuarded('subsync:manual-open', () => {
subsyncModal.openSubsyncModal(payload);
window.electronAPI.notifyOverlayModalOpened('subsync');
});
});
window.electronAPI.onKikuFieldGroupingRequest(
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
runGuarded('kiku:field-grouping-open', () => {
kikuModal.openKikuFieldGroupingModal(data);
window.electronAPI.notifyOverlayModalOpened('kiku');
});
},
);
}
function registerKeyboardCommandHandlers(): void {
window.electronAPI.onKeyboardModeToggleRequested(() => {
runGuarded('keyboard-mode-toggle:requested', () => {
keyboardHandlers.handleKeyboardModeToggleRequested();
});
});
window.electronAPI.onLookupWindowToggleRequested(() => {
runGuarded('lookup-window-toggle:requested', () => {
keyboardHandlers.handleLookupWindowToggleRequested();
});
});
}
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 });
});
}
registerModalOpenHandlers();
registerKeyboardCommandHandlers();
async function init(): Promise<void> {
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
if (ctx.platform.isMacOSPlatform) {
document.body.classList.add('platform-macos');
}
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
subtitleRenderer.renderSubtitle(data);
measurementReporter.schedule();
});
});
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
runGuarded('subtitle-position:update', () => {
positioning.applyStoredSubtitlePosition(position, 'media-change');
measurementReporter.schedule();
});
});
let initialSubtitle: SubtitleData | string = '';
try {
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
} catch {
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
}
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(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();
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
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();
});
});
mouseHandlers.setupDragging();
await keyboardHandlers.setupMpvInputForwarding();
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle());
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) {
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);
}