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

@@ -1106,6 +1106,135 @@ test('parses global shortcuts and startup settings', () => {
assert.equal(config.youtubeSubgen.fixWithAi, true);
});
test('parses controller settings with logical bindings and tuning knobs', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"enabled": true,
"preferredGamepadId": "Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)",
"preferredGamepadLabel": "Xbox Wireless Controller",
"smoothScroll": false,
"scrollPixelsPerSecond": 1440,
"horizontalJumpPixels": 180,
"stickDeadzone": 0.3,
"triggerInputMode": "analog",
"triggerDeadzone": 0.4,
"repeatDelayMs": 220,
"repeatIntervalMs": 70,
"buttonIndices": {
"select": 6,
"leftStickPress": 9,
"rightStickPress": 10
},
"bindings": {
"toggleLookup": "buttonWest",
"closeLookup": "buttonEast",
"toggleKeyboardOnlyMode": "buttonNorth",
"mineCard": "buttonSouth",
"quitMpv": "select",
"previousAudio": "leftShoulder",
"nextAudio": "rightShoulder",
"playCurrentAudio": "none",
"toggleMpvPause": "leftStickPress",
"leftStickHorizontal": "rightStickX",
"leftStickVertical": "rightStickY",
"rightStickHorizontal": "leftStickX",
"rightStickVertical": "leftStickY"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.controller.enabled, true);
assert.equal(
config.controller.preferredGamepadId,
'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)',
);
assert.equal(config.controller.preferredGamepadLabel, 'Xbox Wireless Controller');
assert.equal(config.controller.smoothScroll, false);
assert.equal(config.controller.scrollPixelsPerSecond, 1440);
assert.equal(config.controller.horizontalJumpPixels, 180);
assert.equal(config.controller.stickDeadzone, 0.3);
assert.equal(config.controller.triggerInputMode, 'analog');
assert.equal(config.controller.triggerDeadzone, 0.4);
assert.equal(config.controller.repeatDelayMs, 220);
assert.equal(config.controller.repeatIntervalMs, 70);
assert.equal(config.controller.buttonIndices.select, 6);
assert.equal(config.controller.buttonIndices.leftStickPress, 9);
assert.equal(config.controller.bindings.toggleLookup, 'buttonWest');
assert.equal(config.controller.bindings.quitMpv, 'select');
assert.equal(config.controller.bindings.playCurrentAudio, 'none');
assert.equal(config.controller.bindings.toggleMpvPause, 'leftStickPress');
assert.equal(config.controller.bindings.leftStickHorizontal, 'rightStickX');
assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY');
});
test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"scrollPixelsPerSecond": 0.5,
"horizontalJumpPixels": 0.2,
"repeatDelayMs": 0.9,
"repeatIntervalMs": 0.1
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond);
assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels);
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
});
test('controller button index config rejects fractional values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"buttonIndices": {
"select": 6.5,
"leftStickPress": 9.1
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
assert.equal(
config.controller.buttonIndices.leftStickPress,
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
);
assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true);
assert.equal(
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
true,
);
});
test('runtime options registry is centralized', () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, [
@@ -1638,6 +1767,7 @@ test('template generator includes known keys', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
assert.match(output, /"ai":/);
assert.match(output, /"ankiConnect":/);
assert.match(output, /"controller":/);
assert.match(output, /"logging":/);
assert.match(output, /"websocket":/);
assert.match(output, /"discordPresence":/);
@@ -1662,6 +1792,14 @@ test('template generator includes known keys', () => {
output,
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
);
assert.match(
output,
/"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./,
);
assert.match(
output,
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
);
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
assert.match(
output,

View File

@@ -25,6 +25,7 @@ const {
annotationWebsocket,
logging,
texthooker,
controller,
shortcuts,
secondarySub,
subsync,
@@ -43,6 +44,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
annotationWebsocket,
logging,
texthooker,
controller,
ankiConnect,
shortcuts,
secondarySub,

View File

@@ -8,6 +8,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'annotationWebsocket'
| 'logging'
| 'texthooker'
| 'controller'
| 'shortcuts'
| 'secondarySub'
| 'subsync'
@@ -31,6 +32,47 @@ export const CORE_DEFAULT_CONFIG: Pick<
launchAtStartup: true,
openBrowser: true,
},
controller: {
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',
},
},
shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
copySubtitle: 'CommandOrControl+C',

View File

@@ -19,6 +19,8 @@ test('config option registry includes critical paths and has unique entries', ()
for (const requiredPath of [
'logging.level',
'annotationWebsocket.enabled',
'controller.enabled',
'controller.scrollPixelsPerSecond',
'startupWarmups.lowPowerMode',
'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
@@ -38,6 +40,7 @@ test('config template sections include expected domains and unique keys', () =>
const requiredKeys: (typeof keys)[number][] = [
'websocket',
'annotationWebsocket',
'controller',
'startupWarmups',
'subtitleStyle',
'ankiConnect',

View File

@@ -4,6 +4,21 @@ import { ConfigOptionRegistryEntry } from './shared';
export function buildCoreConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
const controllerButtonEnumValues = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
];
return [
{
path: 'logging.level',
@@ -12,6 +27,230 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.logging.level,
description: 'Minimum log level for runtime logging.',
},
{
path: 'controller.enabled',
kind: 'boolean',
defaultValue: defaultConfig.controller.enabled,
description: 'Enable overlay controller support through the Chrome Gamepad API.',
},
{
path: 'controller.preferredGamepadId',
kind: 'string',
defaultValue: defaultConfig.controller.preferredGamepadId,
description: 'Preferred controller id saved from the controller selection modal.',
},
{
path: 'controller.preferredGamepadLabel',
kind: 'string',
defaultValue: defaultConfig.controller.preferredGamepadLabel,
description: 'Preferred controller display label saved for diagnostics.',
},
{
path: 'controller.smoothScroll',
kind: 'boolean',
defaultValue: defaultConfig.controller.smoothScroll,
description: 'Use smooth scrolling for controller-driven popup scroll input.',
},
{
path: 'controller.scrollPixelsPerSecond',
kind: 'number',
defaultValue: defaultConfig.controller.scrollPixelsPerSecond,
description: 'Base popup scroll speed for controller stick input.',
},
{
path: 'controller.horizontalJumpPixels',
kind: 'number',
defaultValue: defaultConfig.controller.horizontalJumpPixels,
description: 'Popup page-jump distance for controller jump input.',
},
{
path: 'controller.stickDeadzone',
kind: 'number',
defaultValue: defaultConfig.controller.stickDeadzone,
description: 'Deadzone applied to controller stick axes.',
},
{
path: 'controller.triggerInputMode',
kind: 'enum',
enumValues: ['auto', 'digital', 'analog'],
defaultValue: defaultConfig.controller.triggerInputMode,
description: 'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
},
{
path: 'controller.triggerDeadzone',
kind: 'number',
defaultValue: defaultConfig.controller.triggerDeadzone,
description: 'Minimum analog trigger value required when trigger input uses auto or analog mode.',
},
{
path: 'controller.repeatDelayMs',
kind: 'number',
defaultValue: defaultConfig.controller.repeatDelayMs,
description: 'Delay before repeating held controller actions.',
},
{
path: 'controller.repeatIntervalMs',
kind: 'number',
defaultValue: defaultConfig.controller.repeatIntervalMs,
description: 'Repeat interval for held controller actions.',
},
{
path: 'controller.buttonIndices.select',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.select,
description: 'Raw button index used for the controller select/minus/back button.',
},
{
path: 'controller.buttonIndices.buttonSouth',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.buttonSouth,
description: 'Raw button index used for controller south/A button input.',
},
{
path: 'controller.buttonIndices.buttonEast',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.buttonEast,
description: 'Raw button index used for controller east/B button input.',
},
{
path: 'controller.buttonIndices.buttonNorth',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.buttonNorth,
description: 'Raw button index used for controller north/Y button input.',
},
{
path: 'controller.buttonIndices.buttonWest',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.buttonWest,
description: 'Raw button index used for controller west/X button input.',
},
{
path: 'controller.buttonIndices.leftShoulder',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.leftShoulder,
description: 'Raw button index used for controller left shoulder input.',
},
{
path: 'controller.buttonIndices.rightShoulder',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.rightShoulder,
description: 'Raw button index used for controller right shoulder input.',
},
{
path: 'controller.buttonIndices.leftStickPress',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.leftStickPress,
description: 'Raw button index used for controller L3 input.',
},
{
path: 'controller.buttonIndices.rightStickPress',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.rightStickPress,
description: 'Raw button index used for controller R3 input.',
},
{
path: 'controller.buttonIndices.leftTrigger',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.leftTrigger,
description: 'Raw button index used for controller L2 input.',
},
{
path: 'controller.buttonIndices.rightTrigger',
kind: 'number',
defaultValue: defaultConfig.controller.buttonIndices.rightTrigger,
description: 'Raw button index used for controller R2 input.',
},
{
path: 'controller.bindings.toggleLookup',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.toggleLookup,
description: 'Controller binding for toggling lookup.',
},
{
path: 'controller.bindings.closeLookup',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.closeLookup,
description: 'Controller binding for closing lookup.',
},
{
path: 'controller.bindings.toggleKeyboardOnlyMode',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode,
description: 'Controller binding for toggling keyboard-only mode.',
},
{
path: 'controller.bindings.mineCard',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.mineCard,
description: 'Controller binding for mining the active card.',
},
{
path: 'controller.bindings.quitMpv',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.quitMpv,
description: 'Controller binding for quitting mpv.',
},
{
path: 'controller.bindings.previousAudio',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.previousAudio,
description: 'Controller binding for previous Yomitan audio.',
},
{
path: 'controller.bindings.nextAudio',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.nextAudio,
description: 'Controller binding for next Yomitan audio.',
},
{
path: 'controller.bindings.playCurrentAudio',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.playCurrentAudio,
description: 'Controller binding for playing the current Yomitan audio.',
},
{
path: 'controller.bindings.toggleMpvPause',
kind: 'enum',
enumValues: controllerButtonEnumValues,
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
description: 'Controller binding for toggling mpv play/pause.',
},
{
path: 'controller.bindings.leftStickHorizontal',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.leftStickHorizontal,
description: 'Axis binding used for left/right token selection.',
},
{
path: 'controller.bindings.leftStickVertical',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
description: 'Axis binding used for primary popup scrolling.',
},
{
path: 'controller.bindings.rightStickHorizontal',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
description: 'Axis binding reserved for alternate right-stick mappings.',
},
{
path: 'controller.bindings.rightStickVertical',
kind: 'enum',
enumValues: ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'],
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
description: 'Axis binding used for popup page jumps.',
},
{
path: 'texthooker.launchAtStartup',
kind: 'boolean',

View File

@@ -34,6 +34,16 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
key: 'logging',
},
{
title: 'Controller Support',
description: [
'Gamepad support for the visible overlay while keyboard-only mode is active.',
'Use the selection modal to save a preferred controller by id for future launches.',
'Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.',
'Override controller.buttonIndices when your pad reports non-standard raw button numbers.',
],
key: 'controller',
},
{
title: 'Startup Warmups',
description: [

View File

@@ -3,6 +3,21 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
const controllerButtonBindings = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const controllerAxisBindings = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
@@ -101,6 +116,170 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.controller)) {
const enabled = asBoolean(src.controller.enabled);
if (enabled !== undefined) {
resolved.controller.enabled = enabled;
} else if (src.controller.enabled !== undefined) {
warn(
'controller.enabled',
src.controller.enabled,
resolved.controller.enabled,
'Expected boolean.',
);
}
const preferredGamepadId = asString(src.controller.preferredGamepadId);
if (preferredGamepadId !== undefined) {
resolved.controller.preferredGamepadId = preferredGamepadId;
}
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
if (preferredGamepadLabel !== undefined) {
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
}
const smoothScroll = asBoolean(src.controller.smoothScroll);
if (smoothScroll !== undefined) {
resolved.controller.smoothScroll = smoothScroll;
} else if (src.controller.smoothScroll !== undefined) {
warn(
'controller.smoothScroll',
src.controller.smoothScroll,
resolved.controller.smoothScroll,
'Expected boolean.',
);
}
const triggerInputMode = asString(src.controller.triggerInputMode);
if (
triggerInputMode === 'auto' ||
triggerInputMode === 'digital' ||
triggerInputMode === 'analog'
) {
resolved.controller.triggerInputMode = triggerInputMode;
} else if (src.controller.triggerInputMode !== undefined) {
warn(
'controller.triggerInputMode',
src.controller.triggerInputMode,
resolved.controller.triggerInputMode,
"Expected 'auto', 'digital', or 'analog'.",
);
}
const boundedNumberKeys = [
'scrollPixelsPerSecond',
'horizontalJumpPixels',
'repeatDelayMs',
'repeatIntervalMs',
] as const;
for (const key of boundedNumberKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.');
}
}
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
for (const key of deadzoneKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && value >= 0 && value <= 1) {
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(`controller.${key}`, src.controller[key], resolved.controller[key], 'Expected number between 0 and 1.');
}
}
if (isObject(src.controller.buttonIndices)) {
const buttonIndexKeys = [
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
for (const key of buttonIndexKeys) {
const value = asNumber(src.controller.buttonIndices[key]);
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
resolved.controller.buttonIndices[key] = value;
} else if (src.controller.buttonIndices[key] !== undefined) {
warn(
`controller.buttonIndices.${key}`,
src.controller.buttonIndices[key],
resolved.controller.buttonIndices[key],
'Expected non-negative integer.',
);
}
}
}
if (isObject(src.controller.bindings)) {
const buttonBindingKeys = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
for (const key of buttonBindingKeys) {
const value = asString(src.controller.bindings[key]);
if (
value !== undefined &&
controllerButtonBindings.includes(value as (typeof controllerButtonBindings)[number])
) {
resolved.controller.bindings[key] =
value as (typeof resolved.controller.bindings)[typeof key];
} else if (src.controller.bindings[key] !== undefined) {
warn(
`controller.bindings.${key}`,
src.controller.bindings[key],
resolved.controller.bindings[key],
`Expected one of: ${controllerButtonBindings.join(', ')}.`,
);
}
}
const axisBindingKeys = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
for (const key of axisBindingKeys) {
const value = asString(src.controller.bindings[key]);
if (
value !== undefined &&
controllerAxisBindings.includes(value as (typeof controllerAxisBindings)[number])
) {
resolved.controller.bindings[key] =
value as (typeof resolved.controller.bindings)[typeof key];
} else if (src.controller.bindings[key] !== undefined) {
warn(
`controller.bindings.${key}`,
src.controller.bindings[key],
resolved.controller.bindings[key],
`Expected one of: ${controllerAxisBindings.join(', ')}.`,
);
}
}
}
}
if (Array.isArray(src.keybindings)) {
resolved.keybindings = src.keybindings.filter(
(entry): entry is { key: string; command: (string | number)[] | null } => {