mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
* fix(overlay): reload imported mpv keys after mpv connects The overlay fetched mpv's input-bindings before SubMiner connected to mpv, cached an empty list, and never refetched because the connect-time refresh only broadcast session bindings when their compiled signature changed. Keys from input.conf and mpv defaults (9/0 volume, m mute) then did nothing while the overlay had focus, most often in mpv.backend x11 mode. Main now emits mpv-input-bindings:changed whenever the discovered mpv key set or client changes, and the renderer refreshes its imported keys on it. * fix(overlay): subscribe to mpv key changes before initial discovery The renderer registered the mpv-input-bindings:changed listener only after awaiting setupMpvInputForwarding and several other startup calls. If mpv connected in that window, the event was dropped and the imported mpv keys stayed empty.
1016 lines
37 KiB
TypeScript
1016 lines
37 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 type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
|
import { createGamepadController } from './handlers/gamepad-controller.js';
|
|
import { createMouseHandlers } from './handlers/mouse.js';
|
|
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
|
import { createControllerDebugModal } from './modals/controller-debug.js';
|
|
import { createControllerSelectModal } from './modals/controller-select.js';
|
|
import { createJimakuModal } from './modals/jimaku.js';
|
|
import { createTsukihimeModal } from './modals/tsukihime.js';
|
|
import { createKikuModal } from './modals/kiku.js';
|
|
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
|
|
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
|
import { createSessionHelpModal } from './modals/session-help.js';
|
|
import { createChangelogModal } from './modals/changelog.js';
|
|
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
|
import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selection.js';
|
|
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
|
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
|
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
|
import { createSubtitleSelectionModal } from './modals/subtitle-selection';
|
|
import { createSubsyncModal } from './modals/subsync.js';
|
|
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
|
|
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
|
import { createMediaTimingReviewModal } from './modals/media-timing-review.js';
|
|
import { createPositioningController } from './positioning.js';
|
|
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
|
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
|
import {
|
|
createOverlayNotificationRenderer,
|
|
handleOverlayNotificationEvent,
|
|
overlayNotificationPositionClass,
|
|
} from './overlay-notifications.js';
|
|
import { createOverlayNotificationHistoryPanel } from './overlay-notification-history.js';
|
|
import { createRendererState } from './state.js';
|
|
import { createModalRegistry, type ModalDescriptor } from './modal-registry.js';
|
|
import { createSubtitleRenderer } from './subtitle-render.js';
|
|
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
|
|
import {
|
|
createRendererRecoveryController,
|
|
registerRendererGlobalErrorHandlers,
|
|
} from './error-recovery.js';
|
|
import { resolveRendererDom } from './utils/dom.js';
|
|
import { resolvePlatformInfo } from './utils/platform.js';
|
|
import {
|
|
buildMpvLoadfileCommands,
|
|
buildMpvSubtitleAddCommands,
|
|
collectDroppedSubtitlePaths,
|
|
collectDroppedVideoPaths,
|
|
} from '../core/services/overlay-drop.js';
|
|
|
|
const ctx = {
|
|
dom: resolveRendererDom(),
|
|
platform: resolvePlatformInfo(),
|
|
state: createRendererState(),
|
|
};
|
|
|
|
const modalDescriptors = [
|
|
{
|
|
id: 'subtitle-generation',
|
|
isOpen: () => ctx.state.subtitleGenerationModalOpen,
|
|
close: () => subtitleGenerationModal.close(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'controller-select',
|
|
isOpen: () => ctx.state.controllerSelectModalOpen,
|
|
close: () => controllerSelectModal.closeControllerSelectModal(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'controller-debug',
|
|
isOpen: () => ctx.state.controllerDebugModalOpen,
|
|
close: () => controllerDebugModal.closeControllerDebugModal(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'subtitle-sidebar',
|
|
isOpen: () => ctx.state.subtitleSidebarModalOpen,
|
|
close: () => subtitleSidebarModal.closeSubtitleSidebarModal(),
|
|
suppressesSubtitles: false,
|
|
},
|
|
{
|
|
id: 'jimaku',
|
|
isOpen: () => ctx.state.jimakuModalOpen,
|
|
close: () => jimakuModal.closeJimakuModal(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'tsukihime',
|
|
isOpen: () => ctx.state.tsukihimeModalOpen,
|
|
close: () => tsukihimeModal.closeTsukihimeModal(),
|
|
suppressesSubtitles: false,
|
|
},
|
|
{
|
|
id: 'youtube-track-picker',
|
|
isOpen: () => ctx.state.youtubePickerModalOpen,
|
|
close: () => youtubePickerModal.closeYoutubePickerModal(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'media-timing-review',
|
|
isOpen: () => ctx.state.mediaTimingReviewModalOpen,
|
|
close: () => mediaTimingReviewModal.requestCancel(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'playlist-browser',
|
|
isOpen: () => ctx.state.playlistBrowserModalOpen,
|
|
close: () => playlistBrowserModal.closePlaylistBrowserModal(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'kiku',
|
|
isOpen: () => ctx.state.kikuModalOpen,
|
|
close: () => kikuModal.cancelKikuFieldGrouping(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'runtime-options',
|
|
isOpen: () => ctx.state.runtimeOptionsModalOpen,
|
|
close: () => runtimeOptionsModal.closeRuntimeOptionsModal(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'character-dictionary',
|
|
isOpen: () => ctx.state.characterDictionaryModalOpen,
|
|
close: () => characterDictionaryModal.closeCharacterDictionaryModal(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'subtitle-selection',
|
|
isOpen: () => ctx.state.subtitleSelectionModalOpen,
|
|
close: () => subtitleSelectionModal.close(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'subsync',
|
|
isOpen: () => ctx.state.subsyncModalOpen,
|
|
close: () => subsyncModal.closeSubsyncModal(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'session-help',
|
|
isOpen: () => ctx.state.sessionHelpModalOpen,
|
|
close: () => sessionHelpModal.closeSessionHelpModal(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
{
|
|
id: 'changelog',
|
|
isOpen: () => ctx.state.changelogModalOpen,
|
|
close: () => changelogModal.closeChangelogModal(),
|
|
suppressesSubtitles: true,
|
|
},
|
|
] satisfies readonly ModalDescriptor<OverlayHostedModal>[];
|
|
|
|
const modalRegistry = createModalRegistry(modalDescriptors);
|
|
|
|
function isAnySettingsModalOpen(): boolean {
|
|
return modalRegistry.isAnySuppressingSubtitlesOpen();
|
|
}
|
|
|
|
function isAnyModalOpen(): boolean {
|
|
return modalRegistry.isAnyOpen();
|
|
}
|
|
|
|
function isControllerInputBlocked(): boolean {
|
|
return isControllerInteractionBlocked(ctx.state);
|
|
}
|
|
|
|
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 notificationHistory = createOverlayNotificationHistoryPanel(ctx, {
|
|
onChanged: () => measurementReporter.schedule(),
|
|
});
|
|
const overlayNotifications = createOverlayNotificationRenderer(ctx, {
|
|
onChanged: () => {
|
|
notificationHistory.syncSide();
|
|
measurementReporter.schedule();
|
|
},
|
|
onShow: (entry) => notificationHistory.record(entry),
|
|
});
|
|
const positioning = createPositioningController(ctx);
|
|
const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const subtitleSelectionModal = createSubtitleSelectionModal(ctx, {
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const subsyncModal = createSubsyncModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const subtitleGenerationModal = createSubtitleGenerationModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const controllerSelectModal = createControllerSelectModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
notifyControllerDisabled: showControllerDisabledNotice,
|
|
});
|
|
const controllerDebugModal = createControllerDebugModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
notifyControllerDisabled: showControllerDisabledNotice,
|
|
});
|
|
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
|
const sessionHelpModal = createSessionHelpModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const changelogModal = createChangelogModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
shouldRestoreOpenOnStartup: async () =>
|
|
ctx.platform.overlayLayer === 'visible' && (await window.electronAPI.getSubtitleSidebarOpen()),
|
|
onVisibilityChanged: () => {
|
|
measurementReporter.emitNow();
|
|
},
|
|
});
|
|
const disposeSubtitleSidebarSelection = wireSubtitleSidebarSelection(ctx);
|
|
window.addEventListener('beforeunload', disposeSubtitleSidebarSelection, { once: true });
|
|
const kikuModal = createKikuModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const jimakuModal = createJimakuModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const tsukihimeModal = createTsukihimeModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
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);
|
|
},
|
|
});
|
|
const youtubePickerModal = createYoutubeTrackPickerModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
restorePointerInteractionState: mouseHandlers.restorePointerInteractionState,
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
|
|
modalStateReader: { isAnyModalOpen },
|
|
syncSettingsModalSubtitleSuppression,
|
|
});
|
|
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
|
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
|
handleSubtitleSelectionKeydown: subtitleSelectionModal.handleKeydown,
|
|
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
|
handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown,
|
|
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
|
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
|
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
|
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
|
handleMediaTimingReviewKeydown: mediaTimingReviewModal.handleMediaTimingReviewKeydown,
|
|
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
|
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
|
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
|
handleChangelogKeydown: changelogModal.handleChangelogKeydown,
|
|
// Route through main so it can gate help commands on mpv playback state;
|
|
// onOpenSessionHelp re-resolves the chord hint when the open event arrives.
|
|
openSessionHelpModal: () => {
|
|
void window.electronAPI.dispatchSessionAction('openSessionHelp').catch((error: unknown) => {
|
|
console.error('Could not open session help', error);
|
|
});
|
|
},
|
|
openControllerSelectModal: () => {
|
|
if (controllerSelectModal.openControllerSelectModal()) {
|
|
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
|
}
|
|
},
|
|
openControllerDebugModal: () => {
|
|
if (controllerDebugModal.openControllerDebugModal()) {
|
|
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
|
}
|
|
},
|
|
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
|
toggleSubtitleSidebarModal: () => {
|
|
void subtitleSidebarModal.toggleSubtitleSidebarModal();
|
|
},
|
|
});
|
|
|
|
let lastSubtitlePreview = '';
|
|
let lastSecondarySubtitlePreview = '';
|
|
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let controllerAnimationFrameId: number | 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(): OverlayHostedModal | null {
|
|
return modalRegistry.getActive();
|
|
}
|
|
|
|
function dismissActiveUiAfterError(): void {
|
|
modalRegistry.dismissOpen();
|
|
syncSettingsModalSubtitleSuppression();
|
|
}
|
|
|
|
function applyControllerSnapshot(snapshot: {
|
|
connectedGamepads: Array<{ id: string; index: number; mapping: string; connected: boolean }>;
|
|
activeGamepadId: string | null;
|
|
rawAxes: number[];
|
|
rawButtons: Array<{ value: number; pressed: boolean; touched?: boolean }>;
|
|
}): void {
|
|
controllerStatusIndicator.update({
|
|
connectedGamepads: snapshot.connectedGamepads,
|
|
activeGamepadId: snapshot.activeGamepadId,
|
|
});
|
|
ctx.state.connectedGamepads = snapshot.connectedGamepads;
|
|
ctx.state.activeGamepadId = snapshot.activeGamepadId;
|
|
ctx.state.controllerRawAxes = snapshot.rawAxes;
|
|
ctx.state.controllerRawButtons = snapshot.rawButtons;
|
|
controllerSelectModal.updateDevices();
|
|
controllerDebugModal.updateSnapshot();
|
|
}
|
|
|
|
function showControllerDisabledNotice(): void {
|
|
controllerStatusIndicator.show(
|
|
'Controller support disabled. Set controller.enabled to true in config to use controller tools.',
|
|
);
|
|
}
|
|
|
|
function emitControllerPopupScroll(deltaPixels: number): void {
|
|
if (deltaPixels === 0) return;
|
|
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
|
}
|
|
|
|
function emitControllerPopupJump(deltaPixels: number): void {
|
|
if (deltaPixels === 0) return;
|
|
keyboardHandlers.scrollPopupByController(0, deltaPixels * 4);
|
|
}
|
|
|
|
function startControllerPolling(): void {
|
|
if (controllerAnimationFrameId !== null) {
|
|
cancelAnimationFrame(controllerAnimationFrameId);
|
|
controllerAnimationFrameId = null;
|
|
}
|
|
|
|
const gamepadController = createGamepadController({
|
|
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
|
getConfig: () =>
|
|
ctx.state.controllerConfig ?? {
|
|
enabled: false,
|
|
preferredGamepadId: '',
|
|
preferredGamepadLabel: '',
|
|
smoothScroll: true,
|
|
scrollPixelsPerSecond: 900,
|
|
horizontalJumpPixels: 160,
|
|
stickDeadzone: 0.2,
|
|
triggerInputMode: 'auto',
|
|
triggerDeadzone: 0.5,
|
|
repeatDelayMs: 320,
|
|
repeatIntervalMs: 120,
|
|
buttonIndices: {
|
|
select: 6,
|
|
buttonSouth: 0,
|
|
buttonEast: 1,
|
|
buttonWest: 2,
|
|
buttonNorth: 3,
|
|
leftShoulder: 4,
|
|
rightShoulder: 5,
|
|
leftStickPress: 9,
|
|
rightStickPress: 10,
|
|
leftTrigger: 6,
|
|
rightTrigger: 7,
|
|
},
|
|
bindings: {
|
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
previousAudio: { kind: 'none' },
|
|
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
},
|
|
profiles: {},
|
|
},
|
|
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
|
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
|
getInteractionBlocked: () => isControllerInputBlocked(),
|
|
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
|
|
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
|
|
closeLookup: () => {
|
|
keyboardHandlers.closeLookupWindow();
|
|
},
|
|
moveSelection: (delta) => {
|
|
keyboardHandlers.moveSelectionForController(delta);
|
|
},
|
|
mineCard: () => {
|
|
keyboardHandlers.mineSelectedFromController();
|
|
},
|
|
quitMpv: () => {
|
|
window.electronAPI.sendMpvCommand(['quit']);
|
|
},
|
|
previousAudio: () => {
|
|
keyboardHandlers.cyclePopupAudioSourceForController(-1);
|
|
},
|
|
nextAudio: () => {
|
|
keyboardHandlers.cyclePopupAudioSourceForController(1);
|
|
},
|
|
playCurrentAudio: () => {
|
|
keyboardHandlers.playCurrentAudioForController();
|
|
},
|
|
toggleMpvPause: () => {
|
|
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
|
|
},
|
|
scrollPopup: (deltaPixels) => {
|
|
emitControllerPopupScroll(deltaPixels);
|
|
},
|
|
jumpPopup: (deltaPixels) => {
|
|
emitControllerPopupJump(deltaPixels);
|
|
},
|
|
onState: (snapshot) => {
|
|
applyControllerSnapshot(snapshot);
|
|
},
|
|
});
|
|
|
|
const poll = (now: number): void => {
|
|
gamepadController.poll(now);
|
|
controllerAnimationFrameId = requestAnimationFrame(poll);
|
|
};
|
|
|
|
controllerAnimationFrameId = requestAnimationFrame(poll);
|
|
}
|
|
|
|
function restoreOverlayInteractionAfterError(): void {
|
|
ctx.state.isOverSubtitle = false;
|
|
ctx.dom.overlay.classList.remove('interactive');
|
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
|
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
|
}
|
|
}
|
|
|
|
const OVERLAY_TOAST_POSITION_CLASSES = [
|
|
'position-top-left',
|
|
'position-top',
|
|
'position-top-right',
|
|
] as const;
|
|
|
|
// Mirror the notification stack's current position onto a toast so error/status toasts honor the
|
|
// configured `notifications.overlayPosition` instead of always pinning to the top-right corner.
|
|
function applyConfiguredToastPosition(toast: HTMLElement): void {
|
|
const stackClasses = ctx.dom.overlayNotificationStack.classList;
|
|
const active =
|
|
OVERLAY_TOAST_POSITION_CLASSES.find((cls) => stackClasses.contains(cls)) ??
|
|
'position-top-right';
|
|
toast.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES);
|
|
toast.classList.add(active);
|
|
}
|
|
|
|
function showOverlayErrorToast(message: string): void {
|
|
if (overlayErrorToastTimeout) {
|
|
clearTimeout(overlayErrorToastTimeout);
|
|
overlayErrorToastTimeout = null;
|
|
}
|
|
ctx.dom.overlayErrorToast.textContent = message;
|
|
applyConfiguredToastPosition(ctx.dom.overlayErrorToast);
|
|
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.onSubtitleGenerationOpen(() => {
|
|
runGuarded('subtitle-generation:open', () => subtitleGenerationModal.open());
|
|
});
|
|
window.electronAPI.onOpenRuntimeOptions(() => {
|
|
runGuarded('runtime-options:open', () => {
|
|
runtimeOptionsModal.openRuntimeOptionsModal();
|
|
window.electronAPI.notifyOverlayModalOpened('runtime-options');
|
|
});
|
|
});
|
|
window.electronAPI.onOpenCharacterDictionaryManager(() => {
|
|
runGuardedAsync('character-dictionary-manager:open', async () => {
|
|
await characterDictionaryModal.openCharacterDictionaryManagerModal();
|
|
});
|
|
});
|
|
window.electronAPI.onOpenSessionHelp((payload) => {
|
|
runGuarded('session-help:open', () => {
|
|
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo(), payload);
|
|
window.electronAPI.notifyOverlayModalOpened('session-help');
|
|
});
|
|
});
|
|
window.electronAPI.onOpenChangelog(() => {
|
|
runGuarded('changelog:open', () => {
|
|
changelogModal.openChangelogModal();
|
|
window.electronAPI.notifyOverlayModalOpened('changelog');
|
|
});
|
|
});
|
|
window.electronAPI.onOpenControllerSelect(() => {
|
|
runGuarded('controller-select:open', () => {
|
|
if (controllerSelectModal.openControllerSelectModal()) {
|
|
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
|
}
|
|
});
|
|
});
|
|
window.electronAPI.onOpenControllerDebug(() => {
|
|
runGuarded('controller-debug:open', () => {
|
|
if (controllerDebugModal.openControllerDebugModal()) {
|
|
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
|
}
|
|
});
|
|
});
|
|
window.electronAPI.onOpenJimaku(() => {
|
|
runGuarded('jimaku:open', () => {
|
|
jimakuModal.openJimakuModal();
|
|
window.electronAPI.notifyOverlayModalOpened('jimaku');
|
|
});
|
|
});
|
|
window.electronAPI.onOpenTsukihime(() => {
|
|
runGuarded('tsukihime:open', () => {
|
|
tsukihimeModal.openTsukihimeModal();
|
|
window.electronAPI.notifyOverlayModalOpened('tsukihime');
|
|
});
|
|
});
|
|
window.electronAPI.onOpenYoutubeTrackPicker((payload) => {
|
|
runGuarded('youtube:picker-open', () => {
|
|
youtubePickerModal.openYoutubePickerModal(payload);
|
|
});
|
|
});
|
|
window.electronAPI.onOpenMediaTimingReview((payload) => {
|
|
runGuarded('media-timing-review:open', () => {
|
|
mediaTimingReviewModal.openMediaTimingReviewModal(payload);
|
|
});
|
|
});
|
|
window.electronAPI.onMediaTimingReviewPreviewEnded((reviewId) => {
|
|
runGuarded('media-timing-review:preview-ended', () => {
|
|
mediaTimingReviewModal.handlePreviewEnded(reviewId);
|
|
});
|
|
});
|
|
window.electronAPI.onMediaTimingReviewPreviewPosition((position) => {
|
|
mediaTimingReviewModal.handlePreviewPosition(position);
|
|
});
|
|
window.electronAPI.onOpenPlaylistBrowser(() => {
|
|
runGuardedAsync('playlist-browser:open', async () => {
|
|
await playlistBrowserModal.openPlaylistBrowserModal();
|
|
});
|
|
});
|
|
window.electronAPI.onCancelYoutubeTrackPicker(() => {
|
|
runGuarded('youtube:picker-cancel', () => {
|
|
youtubePickerModal.closeYoutubePickerModal();
|
|
});
|
|
});
|
|
window.electronAPI.onSubtitleSelectionOpen(() => {
|
|
runGuarded('subtitle-selection:open', () => subtitleSelectionModal.open());
|
|
});
|
|
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', () => {
|
|
prepareForKikuFieldGroupingOpen({
|
|
closeLookupWindow: () => keyboardHandlers.closeLookupWindow(),
|
|
pausePlayback: () => {
|
|
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
|
|
},
|
|
});
|
|
kikuModal.openKikuFieldGroupingModal(data);
|
|
window.electronAPI.notifyOverlayModalOpened('kiku');
|
|
});
|
|
},
|
|
);
|
|
window.electronAPI.onKikuFieldGroupingCancel(() => {
|
|
runGuarded('kiku:field-grouping-cancel', () => {
|
|
// Main already settled the choice (timeout/failure); just close the dialog. Using the
|
|
// plain close path avoids sending a second, redundant response back to main.
|
|
kikuModal.closeKikuFieldGroupingModal();
|
|
});
|
|
});
|
|
}
|
|
|
|
function registerKeyboardCommandHandlers(): void {
|
|
window.electronAPI.onSessionNumericSelectionStart((payload) => {
|
|
runGuarded('session:numeric-selection-start', () => {
|
|
keyboardHandlers.beginSessionNumericSelection(payload.actionId, payload.timeoutMs);
|
|
});
|
|
});
|
|
|
|
window.electronAPI.onKeyboardModeToggleRequested(() => {
|
|
runGuarded('keyboard-mode-toggle:requested', () => {
|
|
keyboardHandlers.handleKeyboardModeToggleRequested();
|
|
});
|
|
});
|
|
|
|
window.electronAPI.onLookupWindowToggleRequested(() => {
|
|
runGuarded('lookup-window-toggle:requested', () => {
|
|
keyboardHandlers.handleLookupWindowToggleRequested();
|
|
});
|
|
});
|
|
|
|
window.electronAPI.onSubtitleSidebarToggle(() => {
|
|
runGuardedAsync('subtitle-sidebar:toggle', async () => {
|
|
await subtitleSidebarModal.toggleSubtitleSidebarModal();
|
|
});
|
|
});
|
|
|
|
window.electronAPI.onPrimarySubtitleBarToggle(() => {
|
|
runGuarded('primary-subtitle-bar:toggle', () => {
|
|
keyboardHandlers.togglePrimarySubtitleBarVisibility();
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
registerYomitanLookupListener(window, () => {
|
|
runGuarded('yomitan:lookup', () => {
|
|
window.electronAPI.recordYomitanLookup(subtitleSidebarModal.getSubtitleSidebarMiningContext());
|
|
});
|
|
});
|
|
|
|
async function init(): Promise<void> {
|
|
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
|
|
if (ctx.platform.isMacOSPlatform) {
|
|
document.body.classList.add('platform-macos');
|
|
}
|
|
if (ctx.platform.isWindowsPlatform) {
|
|
document.body.classList.add('platform-windows');
|
|
}
|
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
|
syncOverlayMouseIgnoreState(ctx);
|
|
}
|
|
|
|
// Seed the notification stack position from config before subscribing to history toggles, so the
|
|
// closed history panel starts on the same side it will slide in from.
|
|
try {
|
|
const overlayNotificationPosition = await window.electronAPI.getOverlayNotificationPosition();
|
|
ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES);
|
|
ctx.dom.overlayNotificationStack.classList.add(
|
|
overlayNotificationPositionClass(overlayNotificationPosition),
|
|
);
|
|
notificationHistory.syncSide();
|
|
} catch {
|
|
// Non-fatal: keep the default position class from index.html.
|
|
}
|
|
|
|
window.electronAPI.onOverlayPointerRecoveryRequested(() => {
|
|
runGuarded('overlay:pointer-recovery', () => {
|
|
if (!ctx.platform.isMacOSPlatform || !ctx.platform.shouldToggleMouseIgnore) {
|
|
return;
|
|
}
|
|
if (isAnyModalOpen()) {
|
|
return;
|
|
}
|
|
mouseHandlers.restorePointerInteractionState();
|
|
});
|
|
});
|
|
window.electronAPI.onOverlayNotification((payload) => {
|
|
runGuarded('overlay:notification', () => {
|
|
handleOverlayNotificationEvent(overlayNotifications, payload);
|
|
});
|
|
});
|
|
window.electronAPI.onNotificationHistoryToggle(() => {
|
|
runGuarded('notification-history:toggle', () => {
|
|
notificationHistory.toggle();
|
|
});
|
|
});
|
|
|
|
// Subscribe before the initial discovery: mpv can connect while it is in flight,
|
|
// and a missed change event leaves the imported mpv keys empty.
|
|
window.electronAPI.onMpvInputBindingsChanged(keyboardHandlers.refreshMpvInputBindings);
|
|
await keyboardHandlers.setupMpvInputForwarding();
|
|
|
|
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
|
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
|
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
|
|
positioning.applyStoredSubtitlePosition(
|
|
await window.electronAPI.getSubtitlePosition(),
|
|
'startup',
|
|
);
|
|
measurementReporter.schedule();
|
|
|
|
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);
|
|
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave);
|
|
ctx.dom.secondarySubContainer.addEventListener(
|
|
'mouseenter',
|
|
mouseHandlers.handleSecondaryMouseEnter,
|
|
);
|
|
ctx.dom.secondarySubContainer.addEventListener(
|
|
'mouseleave',
|
|
mouseHandlers.handleSecondaryMouseLeave,
|
|
);
|
|
|
|
mouseHandlers.setupResizeHandler();
|
|
mouseHandlers.setupPointerTracking();
|
|
mouseHandlers.setupSelectionObserver();
|
|
mouseHandlers.setupYomitanObserver();
|
|
|
|
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
|
|
runGuarded('subtitle-position:update', () => {
|
|
positioning.applyStoredSubtitlePosition(position, 'media-change');
|
|
measurementReporter.schedule();
|
|
if (ctx.state.playlistBrowserModalOpen) {
|
|
runGuardedAsync('playlist-browser:refresh-on-media-change', async () => {
|
|
await playlistBrowserModal.refreshSnapshot();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
let initialSubtitle: SubtitleData | string = '';
|
|
try {
|
|
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
|
|
} catch {
|
|
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
|
}
|
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
|
keyboardHandlers.handleSubtitleContentUpdated();
|
|
subtitleRenderer.renderSubtitle(initialSubtitle);
|
|
positioning.applyYPercent(positioning.getCurrentYPercent());
|
|
measurementReporter.emitNow();
|
|
|
|
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
|
runGuarded('subtitle:update', () => {
|
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
|
keyboardHandlers.handleSubtitleContentUpdated();
|
|
subtitleRenderer.renderSubtitle(data);
|
|
positioning.applyYPercent(positioning.getCurrentYPercent());
|
|
measurementReporter.emitNow();
|
|
subtitleSidebarModal.handleSubtitleUpdated(data);
|
|
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();
|
|
|
|
setupDragDropToMpvQueue();
|
|
window.addEventListener('resize', () => {
|
|
measurementReporter.schedule();
|
|
});
|
|
|
|
jimakuModal.wireDomEvents();
|
|
tsukihimeModal.wireDomEvents();
|
|
youtubePickerModal.wireDomEvents();
|
|
mediaTimingReviewModal.wireDomEvents();
|
|
playlistBrowserModal.wireDomEvents();
|
|
kikuModal.wireDomEvents();
|
|
runtimeOptionsModal.wireDomEvents();
|
|
subtitleSelectionModal.wireDomEvents();
|
|
subsyncModal.wireDomEvents();
|
|
subtitleGenerationModal.wireDomEvents();
|
|
controllerSelectModal.wireDomEvents();
|
|
controllerDebugModal.wireDomEvents();
|
|
sessionHelpModal.wireDomEvents();
|
|
changelogModal.wireDomEvents();
|
|
subtitleSidebarModal.wireDomEvents();
|
|
characterDictionaryModal.wireDomEvents();
|
|
window.addEventListener('beforeunload', () => {
|
|
subtitleSelectionModal.dispose();
|
|
subtitleGenerationModal.dispose();
|
|
subtitleSidebarModal.disposeDomEvents();
|
|
});
|
|
|
|
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
|
runGuarded('runtime-options:changed', () => {
|
|
runtimeOptionsModal.updateRuntimeOptions(options);
|
|
});
|
|
});
|
|
window.electronAPI.onSessionBindingsChanged(keyboardHandlers.updateSessionBindings);
|
|
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
|
|
runGuarded('config:hot-reload', () => {
|
|
void window.electronAPI
|
|
.getSessionBindings()
|
|
.then(keyboardHandlers.updateSessionBindings)
|
|
.catch((error: unknown) => console.error('Could not refresh session bindings', error));
|
|
void keyboardHandlers.refreshConfiguredShortcuts();
|
|
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
|
subtitleRenderer.updatePrimarySubMode(payload.primarySubMode);
|
|
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
|
ctx.state.subtitleSidebarConfig = payload.subtitleSidebar;
|
|
ctx.state.subtitleSidebarToggleKey = payload.subtitleSidebar.toggleKey;
|
|
ctx.state.subtitleSidebarPauseVideoOnHover = payload.subtitleSidebar.pauseVideoOnHover;
|
|
ctx.state.subtitleSidebarAutoScroll = payload.subtitleSidebar.autoScroll;
|
|
void subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
|
|
measurementReporter.schedule();
|
|
});
|
|
});
|
|
mouseHandlers.setupDragging();
|
|
try {
|
|
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
|
|
} catch (error) {
|
|
console.error('Failed to load controller config.', error);
|
|
ctx.state.controllerConfig = null;
|
|
}
|
|
startControllerPolling();
|
|
|
|
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
|
|
await subtitleSidebarModal.autoOpenSubtitleSidebarOnStartup();
|
|
|
|
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 resolvedFilePaths = Array.from(event.dataTransfer.files, (file) =>
|
|
window.electronAPI.getPathForFile(file),
|
|
);
|
|
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer, resolvedFilePaths);
|
|
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer, resolvedFilePaths);
|
|
const appendDroppedVideos = event.shiftKey;
|
|
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, appendDroppedVideos);
|
|
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
|
|
for (const command of loadCommands) {
|
|
window.electronAPI.sendMpvCommand(command);
|
|
}
|
|
for (const command of subtitleCommands) {
|
|
window.electronAPI.sendMpvCommand(command);
|
|
}
|
|
const osdParts: string[] = [];
|
|
if (loadCommands.length > 0) {
|
|
const action = appendDroppedVideos ? 'Queued' : 'Loaded';
|
|
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
|
|
}
|
|
if (subtitleCommands.length > 0) {
|
|
osdParts.push(
|
|
`Loaded ${subtitleCommands.length} subtitle file${subtitleCommands.length === 1 ? '' : 's'}`,
|
|
);
|
|
}
|
|
if (osdParts.length > 0) {
|
|
window.electronAPI.sendMpvCommand(['show-text', osdParts.join(' | '), '1500']);
|
|
}
|
|
|
|
clearDropInteractive();
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
runGuardedAsync('bootstrap:init', init);
|
|
});
|
|
} else {
|
|
runGuardedAsync('bootstrap:init', init);
|
|
}
|