Add overlay gamepad support for keyboard-only mode (#17)

This commit is contained in:
2026-03-11 20:34:46 -07:00
committed by GitHub
parent 2f17859b7b
commit 4d7c80f2e4
49 changed files with 5677 additions and 42 deletions

View File

@@ -26,7 +26,11 @@ import type {
ConfigHotReloadPayload,
} from '../types';
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 { createKikuModal } from './modals/kiku.js';
import { createSessionHelpModal } from './modals/session-help.js';
@@ -36,6 +40,7 @@ import { createPositioningController } from './positioning.js';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
import { createRendererState } from './state.js';
import { createSubtitleRenderer } from './subtitle-render.js';
import { isYomitanPopupVisible } from './yomitan-popup.js';
import {
createRendererRecoveryController,
registerRendererGlobalErrorHandlers,
@@ -55,6 +60,8 @@ const ctx = {
function isAnySettingsModalOpen(): boolean {
return (
ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.kikuModalOpen ||
@@ -65,6 +72,8 @@ function isAnySettingsModalOpen(): boolean {
function isAnyModalOpen(): boolean {
return (
ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen ||
ctx.state.jimakuModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
@@ -92,6 +101,15 @@ const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const controllerSelectModal = createControllerSelectModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const controllerDebugModal = createControllerDebugModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -109,12 +127,22 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
appendClipboardVideoToQueue: () => {
void window.electronAPI.appendClipboardVideoToQueue();
},
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
openControllerSelectModal: () => {
controllerSelectModal.openControllerSelectModal();
window.electronAPI.notifyOverlayModalOpened('controller-select');
},
openControllerDebugModal: () => {
controllerDebugModal.openControllerDebugModal();
window.electronAPI.notifyOverlayModalOpened('controller-debug');
},
});
const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
@@ -132,6 +160,7 @@ const mouseHandlers = createMouseHandlers(ctx, {
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();
@@ -152,6 +181,8 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
}
function getActiveModal(): string | null {
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
@@ -161,6 +192,12 @@ function getActiveModal(): string | null {
}
function dismissActiveUiAfterError(): void {
if (ctx.state.controllerSelectModalOpen) {
controllerSelectModal.closeControllerSelectModal();
}
if (ctx.state.controllerDebugModalOpen) {
controllerDebugModal.closeControllerDebugModal();
}
if (ctx.state.jimakuModalOpen) {
jimakuModal.closeJimakuModal();
}
@@ -180,6 +217,132 @@ function dismissActiveUiAfterError(): void {
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 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: true,
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: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
},
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
getInteractionBlocked: () => isAnyModalOpen(),
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');
@@ -298,6 +461,7 @@ async function init(): Promise<void> {
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data);
measurementReporter.schedule();
});
@@ -317,6 +481,7 @@ async function init(): Promise<void> {
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
}
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();
@@ -355,6 +520,8 @@ async function init(): Promise<void> {
kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents();
subsyncModal.wireDomEvents();
controllerSelectModal.wireDomEvents();
controllerDebugModal.wireDomEvents();
sessionHelpModal.wireDomEvents();
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
@@ -373,6 +540,13 @@ async function init(): Promise<void> {
mouseHandlers.setupDragging();
await keyboardHandlers.setupMpvInputForwarding();
try {
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
} catch (error) {
console.error('Failed to load controller config.', error);
ctx.state.controllerConfig = null;
}
startControllerPolling();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);