import { SPECIAL_COMMANDS } from '../../config/definitions'; import type { Keybinding, ShortcutsConfig } from '../../types'; import type { RendererContext } from '../context'; import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT, YOMITAN_POPUP_COMMAND_EVENT, isYomitanPopupVisible, isYomitanPopupIframe, } from '../yomitan-popup.js'; export function createKeyboardHandlers( ctx: RendererContext, options: { handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean; handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean; handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean; handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean; handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; openSessionHelpModal: (opening: { bindingKey: 'KeyH' | 'KeyK'; fallbackUsed: boolean; fallbackUnavailable: boolean; }) => void; appendClipboardVideoToQueue: () => void; getPlaybackPaused: () => Promise; openControllerSelectModal: () => void; openControllerDebugModal: () => void; toggleSubtitleSidebarModal?: () => void; }, ) { // Timeout for the modal chord capture window (e.g. Y followed by H/K). const CHORD_TIMEOUT_MS = 1000; const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected'; const linuxOverlayShortcutCommands = new Map(); let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null; let pendingLookupRefreshAfterSubtitleSeek = false; let resetSelectionToStartOnNextSubtitleSync = false; let lookupScanFallbackTimer: ReturnType | null = null; 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'] }], ['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 (isYomitanPopupIframe(target)) return true; if (target.closest && target.closest('iframe.yomitan-popup, 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 acceleratorToKeyToken(token: string): string | null { const normalized = token.trim(); if (!normalized) return null; if (/^[a-z]$/i.test(normalized)) { return `Key${normalized.toUpperCase()}`; } if (/^[0-9]$/.test(normalized)) { return `Digit${normalized}`; } const exactMap: Record = { space: 'Space', tab: 'Tab', enter: 'Enter', return: 'Enter', esc: 'Escape', escape: 'Escape', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight', backspace: 'Backspace', delete: 'Delete', slash: 'Slash', backslash: 'Backslash', minus: 'Minus', plus: 'Equal', equal: 'Equal', comma: 'Comma', period: 'Period', quote: 'Quote', semicolon: 'Semicolon', bracketleft: 'BracketLeft', bracketright: 'BracketRight', backquote: 'Backquote', }; const lower = normalized.toLowerCase(); if (exactMap[lower]) return exactMap[lower]; if (/^key[a-z]$/i.test(normalized) || /^digit[0-9]$/i.test(normalized)) { return normalized[0]!.toUpperCase() + normalized.slice(1); } if (/^arrow(?:up|down|left|right)$/i.test(normalized)) { return normalized[0]!.toUpperCase() + normalized.slice(1); } if (/^f\d{1,2}$/i.test(normalized)) { return normalized.toUpperCase(); } return null; } function acceleratorToKeyString(accelerator: string): string | null { const normalized = accelerator .replace(/\s+/g, '') .replace(/cmdorctrl/gi, 'CommandOrControl'); if (!normalized) return null; const parts = normalized.split('+').filter(Boolean); const keyToken = parts.pop(); if (!keyToken) return null; const eventParts: string[] = []; for (const modifier of parts) { const lower = modifier.toLowerCase(); if (lower === 'ctrl' || lower === 'control') { eventParts.push('Ctrl'); continue; } if (lower === 'alt' || lower === 'option') { eventParts.push('Alt'); continue; } if (lower === 'shift') { eventParts.push('Shift'); continue; } if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') { eventParts.push('Meta'); continue; } if (lower === 'commandorcontrol') { eventParts.push(ctx.platform.isMacOSPlatform ? 'Meta' : 'Ctrl'); continue; } return null; } const normalizedKey = acceleratorToKeyToken(keyToken); if (!normalizedKey) return null; eventParts.push(normalizedKey); return eventParts.join('+'); } function updateConfiguredShortcuts(shortcuts: Required): void { linuxOverlayShortcutCommands.clear(); const bindings: Array<[string | null, (string | number)[]]> = [ [shortcuts.triggerSubsync, [SPECIAL_COMMANDS.SUBSYNC_TRIGGER]], [shortcuts.openRuntimeOptions, [SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN]], [shortcuts.openJimaku, [SPECIAL_COMMANDS.JIMAKU_OPEN]], ]; for (const [accelerator, command] of bindings) { if (!accelerator) continue; const keyString = acceleratorToKeyString(accelerator); if (keyString) { linuxOverlayShortcutCommands.set(keyString, command); } } } async function refreshConfiguredShortcuts(): Promise { updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts()); } function dispatchYomitanPopupKeydown( key: string, code: string, modifiers: Array<'alt' | 'ctrl' | 'shift' | 'meta'>, repeat: boolean, ) { window.dispatchEvent( new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { detail: { type: 'forwardKeyDown', key, code, modifiers, repeat, }, }), ); } function dispatchYomitanPopupVisibility(visible: boolean) { window.dispatchEvent( new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { detail: { type: 'setVisible', visible, }, }), ); } function dispatchYomitanPopupMineSelected() { window.dispatchEvent( new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { detail: { type: 'mineSelected', }, }), ); } function dispatchYomitanPopupCycleAudioSource(direction: -1 | 1) { window.dispatchEvent( new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { detail: { type: 'cycleAudioSource', direction, }, }), ); } function dispatchYomitanPopupPlayCurrentAudio() { window.dispatchEvent( new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { detail: { type: 'playCurrentAudio', }, }), ); } function dispatchYomitanPopupScrollBy(deltaX: number, deltaY: number) { window.dispatchEvent( new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { detail: { type: 'scrollBy', deltaX, deltaY, }, }), ); } function dispatchYomitanFrontendScanSelectedText() { window.dispatchEvent( new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { detail: { type: 'scanSelectedText', }, }), ); } function dispatchYomitanFrontendClearActiveTextSource() { window.dispatchEvent( new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { detail: { type: 'clearActiveTextSource', }, }), ); } function isPrimaryModifierPressed(e: KeyboardEvent): boolean { return e.ctrlKey || e.metaKey; } function isKeyboardDrivenModeToggle(e: KeyboardEvent): boolean { const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y'; return isPrimaryModifierPressed(e) && !e.altKey && e.shiftKey && isYKey && !e.repeat; } function isLookupWindowToggle(e: KeyboardEvent): boolean { const isYKey = e.code === 'KeyY' || e.key.toLowerCase() === 'y'; return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat; } function isControllerModalShortcut(e: KeyboardEvent): boolean { return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC'; } function isSubtitleSidebarToggle(e: KeyboardEvent): boolean { const toggleKey = ctx.state.subtitleSidebarToggleKey; if (!toggleKey) return false; const isBackslashConfigured = toggleKey === 'Backslash' || toggleKey === '\\'; const isBackslashLikeCode = ['Backslash', 'IntlBackslash', 'IntlYen'].includes(e.code); const keyMatches = toggleKey === e.code || (isBackslashConfigured && isBackslashLikeCode) || (isBackslashConfigured && e.key === '\\') || (toggleKey.length === 1 && e.key === toggleKey); return keyMatches && !e.ctrlKey && !e.altKey && !e.metaKey && !e.repeat; } function isStatsOverlayToggle(e: KeyboardEvent): boolean { return ( e.code === ctx.state.statsToggleKey && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && !e.repeat ); } function isMarkWatchedKey(e: KeyboardEvent): boolean { return ( e.code === ctx.state.markWatchedKey && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && !e.repeat ); } async function handleMarkWatched(): Promise { const marked = await window.electronAPI.markActiveVideoWatched(); if (marked) { window.electronAPI.sendMpvCommand(['show-text', 'Marked as watched', '1500']); window.electronAPI.sendMpvCommand(['playlist-next', 'force']); } } function getSubtitleWordNodes(): HTMLElement[] { return Array.from( ctx.dom.subtitleRoot.querySelectorAll('.word[data-token-index]'), ); } function clearKeyboardSelectedWordClasses( wordNodes: HTMLElement[] = getSubtitleWordNodes(), ): void { for (const wordNode of wordNodes) { wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS); } } function clearNativeSubtitleSelection(): void { window.getSelection()?.removeAllRanges(); ctx.dom.subtitleRoot.classList.remove('has-selection'); } function syncKeyboardTokenSelection(): void { const wordNodes = getSubtitleWordNodes(); clearKeyboardSelectedWordClasses(wordNodes); if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) { ctx.state.keyboardSelectedWordIndex = null; ctx.state.keyboardSelectionVisible = false; if (!ctx.state.keyboardDrivenModeEnabled) { pendingSelectionAnchorAfterSubtitleSeek = null; pendingLookupRefreshAfterSubtitleSeek = false; resetSelectionToStartOnNextSubtitleSync = false; clearNativeSubtitleSelection(); } return; } if (pendingSelectionAnchorAfterSubtitleSeek) { ctx.state.keyboardSelectedWordIndex = pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1; ctx.state.keyboardSelectionVisible = true; pendingSelectionAnchorAfterSubtitleSeek = null; resetSelectionToStartOnNextSubtitleSync = false; const shouldRefreshLookup = pendingLookupRefreshAfterSubtitleSeek && (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)); pendingLookupRefreshAfterSubtitleSeek = false; if (shouldRefreshLookup) { queueMicrotask(() => { triggerLookupForSelectedWord(); }); } } if (resetSelectionToStartOnNextSubtitleSync) { ctx.state.keyboardSelectedWordIndex = 0; ctx.state.keyboardSelectionVisible = true; resetSelectionToStartOnNextSubtitleSync = false; } const selectedIndex = Math.min( Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0), wordNodes.length - 1, ); ctx.state.keyboardSelectedWordIndex = selectedIndex; const selectedWordNode = wordNodes[selectedIndex]; if (selectedWordNode && ctx.state.keyboardSelectionVisible) { selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS); } } function setKeyboardDrivenModeEnabled(enabled: boolean): void { ctx.state.keyboardDrivenModeEnabled = enabled; ctx.state.keyboardSelectionVisible = enabled; if (!enabled) { ctx.state.keyboardSelectedWordIndex = null; pendingSelectionAnchorAfterSubtitleSeek = null; pendingLookupRefreshAfterSubtitleSeek = false; resetSelectionToStartOnNextSubtitleSync = false; clearNativeSubtitleSelection(); } syncKeyboardTokenSelection(); } function toggleKeyboardDrivenMode(): void { setKeyboardDrivenModeEnabled(!ctx.state.keyboardDrivenModeEnabled); } function moveKeyboardSelection( delta: -1 | 1, ): 'moved' | 'start-boundary' | 'end-boundary' | 'no-words' { const wordNodes = getSubtitleWordNodes(); if (wordNodes.length === 0) { ctx.state.keyboardSelectedWordIndex = null; syncKeyboardTokenSelection(); return 'no-words'; } const currentIndex = Math.min( Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0), wordNodes.length - 1, ); if (delta < 0 && currentIndex <= 0) { return 'start-boundary'; } if (delta > 0 && currentIndex >= wordNodes.length - 1) { return 'end-boundary'; } const nextIndex = currentIndex + delta; ctx.state.keyboardSelectedWordIndex = nextIndex; ctx.state.keyboardSelectionVisible = true; syncKeyboardTokenSelection(); return 'moved'; } function seekAdjacentSubtitleAndQueueSelection(delta: -1 | 1, popupVisible: boolean): void { pendingSelectionAnchorAfterSubtitleSeek = delta > 0 ? 'start' : 'end'; pendingLookupRefreshAfterSubtitleSeek = popupVisible; void options .getPlaybackPaused() .then((paused) => { window.electronAPI.sendMpvCommand(['sub-seek', delta]); if (paused !== false) { window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']); } }) .catch(() => { window.electronAPI.sendMpvCommand(['sub-seek', delta]); }); } function isSubtitleSeekCommand( command: (string | number)[] | undefined, ): command is [string, number] { return Array.isArray(command) && command[0] === 'sub-seek' && typeof command[1] === 'number'; } function dispatchConfiguredMpvCommand(command: (string | number)[]): void { if (!isSubtitleSeekCommand(command)) { window.electronAPI.sendMpvCommand(command); return; } void options .getPlaybackPaused() .then((paused) => { window.electronAPI.sendMpvCommand(command); if (paused !== false) { window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']); } }) .catch(() => { window.electronAPI.sendMpvCommand(command); }); } type ScanModifierState = { shiftKey?: boolean; ctrlKey?: boolean; altKey?: boolean; metaKey?: boolean; }; function emitSyntheticScanEvents( target: Element, clientX: number, clientY: number, modifiers: ScanModifierState = {}, ): void { if (typeof PointerEvent !== 'undefined') { const pointerEventInit = { bubbles: true, cancelable: true, composed: true, clientX, clientY, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 0, shiftKey: modifiers.shiftKey ?? false, ctrlKey: modifiers.ctrlKey ?? false, altKey: modifiers.altKey ?? false, metaKey: modifiers.metaKey ?? false, } satisfies PointerEventInit; target.dispatchEvent(new PointerEvent('pointerover', pointerEventInit)); target.dispatchEvent(new PointerEvent('pointermove', pointerEventInit)); target.dispatchEvent(new PointerEvent('pointerdown', { ...pointerEventInit, buttons: 1 })); target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit)); } const mouseEventInit = { bubbles: true, cancelable: true, composed: true, clientX, clientY, button: 0, buttons: 0, shiftKey: modifiers.shiftKey ?? false, ctrlKey: modifiers.ctrlKey ?? false, altKey: modifiers.altKey ?? false, metaKey: modifiers.metaKey ?? false, } satisfies MouseEventInit; target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit)); target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 })); target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit)); target.dispatchEvent(new MouseEvent('click', mouseEventInit)); } function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void { emitSyntheticScanEvents(target, clientX, clientY, {}); } function selectWordNodeText(wordNode: HTMLElement): void { const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); range.selectNodeContents(wordNode); selection.removeAllRanges(); selection.addRange(range); ctx.dom.subtitleRoot.classList.add('has-selection'); } function triggerLookupForSelectedWord(): boolean { const wordNodes = getSubtitleWordNodes(); if (wordNodes.length === 0) { return false; } const selectedIndex = Math.min( Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0), wordNodes.length - 1, ); ctx.state.keyboardSelectedWordIndex = selectedIndex; const selectedWordNode = wordNodes[selectedIndex]; if (!selectedWordNode) return false; ctx.state.keyboardSelectionVisible = true; syncKeyboardTokenSelection(); selectWordNodeText(selectedWordNode); const rect = selectedWordNode.getBoundingClientRect(); const clientX = rect.left + rect.width / 2; const clientY = rect.top + rect.height / 2; dispatchYomitanFrontendScanSelectedText(); if (ctx.state.keyboardDrivenModeEnabled) { // Keep overlay as the keyboard focus owner so token navigation can continue // while the popup is visible. queueMicrotask(() => { scheduleOverlayFocusReclaim(8); }); } // Fallback only if the explicit scan path did not open popup quickly. if (lookupScanFallbackTimer !== null) clearTimeout(lookupScanFallbackTimer); lookupScanFallbackTimer = setTimeout(() => { lookupScanFallbackTimer = null; if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { return; } // Dispatch directly on the selected token span; when overlay pointer-events are disabled, // elementFromPoint may resolve to the underlying video surface instead. emitLookupScanFallback(selectedWordNode, clientX, clientY); }, 60); return true; } function handleKeyboardModeToggleRequested(): void { toggleKeyboardDrivenMode(); } function handleSubtitleContentUpdated(): void { if (!ctx.state.keyboardDrivenModeEnabled) { dispatchYomitanFrontendClearActiveTextSource(); clearNativeSubtitleSelection(); return; } if (pendingSelectionAnchorAfterSubtitleSeek) { return; } resetSelectionToStartOnNextSubtitleSync = true; } function handleLookupWindowToggleRequested(): void { if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { closeLookupWindow(); return; } triggerLookupForSelectedWord(); } function closeLookupWindow(): boolean { if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { return false; } if (lookupScanFallbackTimer !== null) { clearTimeout(lookupScanFallbackTimer); lookupScanFallbackTimer = null; } dispatchYomitanPopupVisibility(false); dispatchYomitanFrontendClearActiveTextSource(); clearNativeSubtitleSelection(); if (ctx.state.keyboardDrivenModeEnabled) { queueMicrotask(() => { restoreOverlayKeyboardFocus(); }); } return true; } function moveSelectionForController(delta: -1 | 1): boolean { if (!ctx.state.keyboardDrivenModeEnabled) { return false; } const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document); const result = moveKeyboardSelection(delta); if (result === 'no-words') { seekAdjacentSubtitleAndQueueSelection(delta, popupVisible); return true; } if (result === 'start-boundary' || result === 'end-boundary') { seekAdjacentSubtitleAndQueueSelection(delta, popupVisible); } else if (popupVisible && result === 'moved') { triggerLookupForSelectedWord(); } return true; } function forwardPopupKeydownForController( key: string, code: string, repeat: boolean = true, ): boolean { if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { return false; } dispatchYomitanPopupKeydown(key, code, [], repeat); return true; } function mineSelectedFromController(): boolean { if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { return false; } dispatchYomitanPopupMineSelected(); return true; } function cyclePopupAudioSourceForController(direction: -1 | 1): boolean { if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { return false; } dispatchYomitanPopupCycleAudioSource(direction); return true; } function playCurrentAudioForController(): boolean { if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { return false; } dispatchYomitanPopupPlayCurrentAudio(); return true; } function scrollPopupByController(deltaX: number, deltaY: number): boolean { if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { return false; } dispatchYomitanPopupScrollBy(deltaX, deltaY); return true; } function restoreOverlayKeyboardFocus(): void { void window.electronAPI.focusMainWindow(); window.focus(); ctx.dom.overlay.focus({ preventScroll: true }); } function scheduleOverlayFocusReclaim(attempts: number = 0): void { if (!ctx.state.keyboardDrivenModeEnabled) { return; } restoreOverlayKeyboardFocus(); if (attempts <= 0) { return; } let remaining = attempts; const reclaim = () => { if (!ctx.state.keyboardDrivenModeEnabled) { return; } if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { return; } restoreOverlayKeyboardFocus(); remaining -= 1; if (remaining > 0) { setTimeout(reclaim, 25); } }; setTimeout(reclaim, 25); } function handleKeyboardDrivenModeNavigation(e: KeyboardEvent): boolean { if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { return false; } const key = e.code; if (key === 'ArrowLeft') { const result = moveKeyboardSelection(-1); if (result === 'start-boundary' || result === 'no-words') { seekAdjacentSubtitleAndQueueSelection(-1, false); } return true; } if (key === 'ArrowRight' || key === 'KeyL') { const result = moveKeyboardSelection(1); if (result === 'end-boundary' || result === 'no-words') { seekAdjacentSubtitleAndQueueSelection(1, false); } return true; } return false; } function handleKeyboardDrivenModeLookupControls(e: KeyboardEvent): boolean { if (!ctx.state.keyboardDrivenModeEnabled) { return false; } if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { return false; } const key = e.code; const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document); if (key === 'ArrowLeft' || key === 'KeyH') { const result = moveKeyboardSelection(-1); if (result === 'start-boundary' || result === 'no-words') { seekAdjacentSubtitleAndQueueSelection(-1, popupVisible); } else if (popupVisible && result === 'moved') { triggerLookupForSelectedWord(); } return true; } if (key === 'ArrowRight' || key === 'KeyL') { const result = moveKeyboardSelection(1); if (result === 'end-boundary' || result === 'no-words') { seekAdjacentSubtitleAndQueueSelection(1, popupVisible); } else if (popupVisible && result === 'moved') { triggerLookupForSelectedWord(); } return true; } return false; } function handleYomitanPopupKeybind(e: KeyboardEvent): boolean { const modifierOnlyCodes = new Set([ 'ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight', ]); if (modifierOnlyCodes.has(e.code)) return false; const keyString = keyEventToString(e); if (ctx.state.keybindingsMap.has(keyString)) { return false; } if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code === 'KeyM') { if (e.repeat) return false; dispatchYomitanPopupMineSelected(); return true; } const modifiers: Array<'alt' | 'ctrl' | 'shift' | 'meta'> = []; if (e.altKey) modifiers.push('alt'); if (e.ctrlKey) modifiers.push('ctrl'); if (e.shiftKey) modifiers.push('shift'); if (e.metaKey) modifiers.push('meta'); dispatchYomitanPopupKeydown(e.key, e.code, modifiers, e.repeat); return true; } 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 resetChord(): void { ctx.state.chordPending = false; if (ctx.state.chordTimeout !== null) { clearTimeout(ctx.state.chordTimeout); ctx.state.chordTimeout = null; } } async function setupMpvInputForwarding(): Promise { const [keybindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([ window.electronAPI.getKeybindings(), window.electronAPI.getConfiguredShortcuts(), window.electronAPI.getStatsToggleKey(), window.electronAPI.getMarkWatchedKey(), ]); updateKeybindings(keybindings); updateConfiguredShortcuts(shortcuts); ctx.state.statsToggleKey = statsToggleKey; ctx.state.markWatchedKey = markWatchedKey; syncKeyboardTokenSelection(); const subtitleMutationObserver = new MutationObserver(() => { syncKeyboardTokenSelection(); }); subtitleMutationObserver.observe(ctx.dom.subtitleRoot, { childList: true, subtree: true, }); window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { clearNativeSubtitleSelection(); if (!ctx.state.keyboardDrivenModeEnabled) { syncKeyboardTokenSelection(); return; } restoreOverlayKeyboardFocus(); }); window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { if (!ctx.state.keyboardDrivenModeEnabled) { return; } queueMicrotask(() => { scheduleOverlayFocusReclaim(8); }); }); document.addEventListener( 'focusin', (e: FocusEvent) => { if (!ctx.state.keyboardDrivenModeEnabled) { return; } const target = e.target; if ( target && typeof target === 'object' && 'tagName' in target && isYomitanPopupIframe(target as Element) ) { queueMicrotask(() => { scheduleOverlayFocusReclaim(8); }); } }, true, ); document.addEventListener('keydown', (e: KeyboardEvent) => { if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) { e.preventDefault(); handleKeyboardModeToggleRequested(); return; } if (isLookupWindowToggle(e)) { e.preventDefault(); handleLookupWindowToggleRequested(); return; } if (ctx.state.playlistBrowserModalOpen) { if (options.handlePlaylistBrowserKeydown(e)) { return; } } if (handleKeyboardDrivenModeLookupControls(e)) { e.preventDefault(); 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.youtubePickerModalOpen) { if (options.handleYoutubePickerKeydown(e)) { return; } } if (ctx.state.controllerSelectModalOpen) { options.handleControllerSelectKeydown(e); return; } if (ctx.state.controllerDebugModalOpen) { options.handleControllerDebugKeydown(e); return; } if (ctx.state.sessionHelpModalOpen) { options.handleSessionHelpKeydown(e); return; } if (isStatsOverlayToggle(e)) { e.preventDefault(); window.electronAPI.toggleStatsOverlay(); return; } if (isMarkWatchedKey(e)) { e.preventDefault(); void handleMarkWatched(); return; } if (isSubtitleSidebarToggle(e)) { e.preventDefault(); options.toggleSubtitleSidebarModal?.(); return; } if ( (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) && !isControllerModalShortcut(e) ) { if (handleYomitanPopupKeybind(e)) { e.preventDefault(); return; } } if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) { e.preventDefault(); 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; } if (isControllerModalShortcut(e)) { e.preventDefault(); if (e.shiftKey) { options.openControllerDebugModal(); } else { options.openControllerSelectModal(); } return; } const keyString = keyEventToString(e); const linuxOverlayCommand = ctx.platform.isLinuxPlatform ? linuxOverlayShortcutCommands.get(keyString) : undefined; if (linuxOverlayCommand) { e.preventDefault(); dispatchConfiguredMpvCommand(linuxOverlayCommand); return; } const command = ctx.state.keybindingsMap.get(keyString); if (command) { e.preventDefault(); dispatchConfiguredMpvCommand(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, refreshConfiguredShortcuts, updateKeybindings, syncKeyboardTokenSelection, handleSubtitleContentUpdated, handleKeyboardModeToggleRequested, handleLookupWindowToggleRequested, closeLookupWindow, moveSelectionForController, forwardPopupKeydownForController, mineSelectedFromController, cyclePopupAudioSourceForController, playCurrentAudioForController, scrollPopupByController, }; }