Controller: subtitle line actions, Button Style, keyboard-only mode pause, Alt+C fixes (#316)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
YashaNoosandClaude Opus 5.5 authored and GitHub committed 2026-11-02 21:41:46 -08:00
1 parent 6d0f37ac21
commit b99409bbcd
60 files changed
+1900 -219

No files matched your search

+28
View File
@@ -2216,6 +2216,34 @@ test('parses controller face button labels and warns on unknown styles', () => {
);
});
test('controller profiles inherit the button style unless they set their own', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
JSON.stringify({
controller: {
faceButtonLabels: 'nintendo',
profiles: {
'pad-ps': { faceButtonLabels: 'playstation' },
'pad-plain': { label: 'Plain' },
'pad-bad': { faceButtonLabels: 'sega' },
},
},
}),
'utf-8',
);
const service = new ConfigService(dir);
const { profiles } = service.getConfig().controller;
assert.equal(profiles['pad-ps']!.faceButtonLabels, 'playstation');
assert.equal(profiles['pad-plain']!.faceButtonLabels, 'nintendo');
assert.equal(profiles['pad-bad']!.faceButtonLabels, 'nintendo');
assert.deepEqual(
service.getWarnings().map((warning) => warning.path),
['controller.profiles.pad-bad.faceButtonLabels'],
);
});
test('parses controller review bindings globally and per profile, and warns on bad entries', () => {
const dir = makeTempDir();
fs.writeFileSync(
+4
View File
@@ -85,6 +85,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
previousSubtitle: { kind: 'none' },
nextSubtitle: { kind: 'none' },
replaySubtitle: { kind: 'none' },
playNextSubtitle: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -8,6 +8,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
preserveLineBreaks: false,
autoPauseVideoOnHover: true,
autoPauseVideoOnYomitanPopup: true,
autoPauseVideoOnKeyboardMode: false,
primaryVisibleOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'transparent',
+28 -3
View File
@@ -61,7 +61,8 @@ export function buildCoreConfigOptionRegistry(
{
id: 'toggleMpvPause',
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
description: 'Controller binding descriptor for toggling mpv play/pause.',
description:
'Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode.',
},
{
id: 'holdSecondarySub',
@@ -81,6 +82,30 @@ export function buildCoreConfigOptionRegistry(
description:
'Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry.',
},
{
id: 'previousSubtitle',
defaultValue: defaultConfig.controller.bindings.previousSubtitle,
description:
'Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode.',
},
{
id: 'nextSubtitle',
defaultValue: defaultConfig.controller.bindings.nextSubtitle,
description:
'Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode.',
},
{
id: 'replaySubtitle',
defaultValue: defaultConfig.controller.bindings.replaySubtitle,
description:
'Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode.',
},
{
id: 'playNextSubtitle',
defaultValue: defaultConfig.controller.bindings.playNextSubtitle,
description:
'Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode.',
},
] as const;
const axisBindings = [
@@ -235,10 +260,10 @@ export function buildCoreConfigOptionRegistry(
{
path: 'controller.faceButtonLabels',
kind: 'enum',
enumValues: ['xbox', 'nintendo'],
enumValues: ['xbox', 'nintendo', 'playstation'],
defaultValue: defaultConfig.controller.faceButtonLabels,
description:
"Letters printed on your controller's face buttons: xbox (A at the bottom; also PlayStation) or nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads). Used by fixed controller layouts and button names in Alt+C.",
'Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others.',
},
{
path: 'controller.triggerDeadzone',
@@ -100,6 +100,13 @@ export function buildSubtitleConfigOptionRegistry(
description:
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
},
{
path: 'subtitleStyle.autoPauseVideoOnKeyboardMode',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnKeyboardMode,
description:
'Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is.',
},
{
path: 'subtitleStyle.primaryVisibleOnYomitanPopup',
kind: 'boolean',
+24 -5
View File
@@ -9,6 +9,7 @@ import type {
ResolvedControllerDiscreteBinding,
} from '../../types/runtime';
import {
isControllerFaceButtonLabels,
isControllerScreenAction,
parseControllerReviewBinding,
type ControllerScreenBindings,
@@ -18,6 +19,8 @@ import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
const FACE_BUTTON_LABELS_EXPECTED = "Expected 'xbox', 'nintendo', or 'playstation'.";
const CONTROLLER_BUTTON_BINDINGS = [
'none',
'select',
@@ -98,6 +101,10 @@ const CONTROLLER_DISCRETE_BINDING_KEYS = [
'holdSecondarySub',
'previousEntry',
'nextEntry',
'previousSubtitle',
'nextSubtitle',
'replaySubtitle',
'playNextSubtitle',
] as const;
const CONTROLLER_AXIS_BINDING_KEYS = [
@@ -360,15 +367,15 @@ export function applyControllerConfig(context: ResolveContext): void {
);
}
const faceButtonLabels = asString(src.controller.faceButtonLabels);
if (faceButtonLabels === 'xbox' || faceButtonLabels === 'nintendo') {
const faceButtonLabels = src.controller.faceButtonLabels;
if (isControllerFaceButtonLabels(faceButtonLabels)) {
resolved.controller.faceButtonLabels = faceButtonLabels;
} else if (src.controller.faceButtonLabels !== undefined) {
} else if (faceButtonLabels !== undefined) {
warn(
'controller.faceButtonLabels',
src.controller.faceButtonLabels,
faceButtonLabels,
resolved.controller.faceButtonLabels,
"Expected 'xbox' or 'nintendo'.",
FACE_BUTTON_LABELS_EXPECTED,
);
}
@@ -465,9 +472,21 @@ export function applyControllerConfig(context: ResolveContext): void {
label: label ?? profileId,
buttonIndices: structuredClone(resolved.controller.buttonIndices),
bindings: structuredClone(resolved.controller.bindings),
faceButtonLabels: resolved.controller.faceButtonLabels,
reviewBindings: structuredClone(resolved.controller.reviewBindings),
fieldGroupingBindings: structuredClone(resolved.controller.fieldGroupingBindings),
};
// Set per controller from Alt+C; falls back to controller.faceButtonLabels.
if (isControllerFaceButtonLabels(rawProfile.faceButtonLabels)) {
profile.faceButtonLabels = rawProfile.faceButtonLabels;
} else if (rawProfile.faceButtonLabels !== undefined) {
warn(
`controller.profiles.${profileId}.faceButtonLabels`,
rawProfile.faceButtonLabels,
profile.faceButtonLabels,
FACE_BUTTON_LABELS_EXPECTED,
);
}
applyControllerButtonIndices(
rawProfile.buttonIndices,
profile.buttonIndices,
+19
View File
@@ -125,6 +125,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
const fallbackSubtitleStyleAutoPauseVideoOnKeyboardMode =
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode;
const fallbackSubtitleStylePrimaryVisibleOnYomitanPopup =
resolved.subtitleStyle.primaryVisibleOnYomitanPopup;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
@@ -274,6 +276,23 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const rawAutoPauseVideoOnKeyboardMode = (
src.subtitleStyle as { autoPauseVideoOnKeyboardMode?: unknown }
).autoPauseVideoOnKeyboardMode;
const autoPauseVideoOnKeyboardMode = asBoolean(rawAutoPauseVideoOnKeyboardMode);
if (autoPauseVideoOnKeyboardMode !== undefined) {
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode = autoPauseVideoOnKeyboardMode;
} else if (rawAutoPauseVideoOnKeyboardMode !== undefined) {
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode =
fallbackSubtitleStyleAutoPauseVideoOnKeyboardMode;
warn(
'subtitleStyle.autoPauseVideoOnKeyboardMode',
rawAutoPauseVideoOnKeyboardMode,
resolved.subtitleStyle.autoPauseVideoOnKeyboardMode,
'Expected boolean.',
);
}
const primaryVisibleOnYomitanPopup = asBoolean(
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
.primaryVisibleOnYomitanPopup,
+25
View File
@@ -346,3 +346,28 @@ test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns
),
);
});
test('subtitleStyle autoPauseVideoOnKeyboardMode is off by default and falls back on invalid value', () => {
const defaults = createResolveContext({});
applySubtitleDomainConfig(defaults.context);
assert.equal(defaults.context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, false);
const enabled = createResolveContext({ subtitleStyle: { autoPauseVideoOnKeyboardMode: true } });
applySubtitleDomainConfig(enabled.context);
assert.equal(enabled.context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, true);
const { context, warnings } = createResolveContext({
subtitleStyle: {
autoPauseVideoOnKeyboardMode: 'yes' as unknown as boolean,
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnKeyboardMode, false);
assert.ok(
warnings.some(
(warning) =>
warning.path === 'subtitleStyle.autoPauseVideoOnKeyboardMode' &&
warning.message === 'Expected boolean.',
),
);
});
+2
View File
@@ -233,6 +233,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
'subtitleStyle.autoPauseVideoOnKeyboardMode': 'Pause Video In Keyboard-Only Mode',
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
@@ -344,6 +345,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (
path === 'subtitleStyle.autoPauseVideoOnHover' ||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleStyle.autoPauseVideoOnKeyboardMode' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'behavior', section: 'Playback Behavior' };
+35
View File
@@ -76,6 +76,10 @@ function createControllerConfigFixture() {
holdSecondarySub: { kind: 'none' as const },
previousEntry: { kind: 'none' as const },
nextEntry: { kind: 'none' as const },
previousSubtitle: { kind: 'none' as const },
nextSubtitle: { kind: 'none' as const },
replaySubtitle: { kind: 'none' as const },
playNextSubtitle: { kind: 'none' as const },
leftStickHorizontal: {
kind: 'axis' as const,
axisIndex: 0,
@@ -140,6 +144,8 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
reportActiveController: () => {},
getActiveControllerId: () => null,
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
@@ -226,6 +232,31 @@ test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction activ
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
});
test('registerIpcHandlers stores the controller the overlay reports for Alt+C to ask', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
let active: string | null = null;
registerIpcHandlers(
createRegisterIpcDeps({
reportActiveController: (gamepadId) => {
active = gamepadId;
},
getActiveControllerId: () => active,
}),
registrar,
);
const report = handlers.on.get(IPC_CHANNELS.command.reportActiveController);
report?.({}, 'pad-1');
report?.({}, 42);
assert.equal(
await handlers.handle.get(IPC_CHANNELS.request.getActiveControllerId)?.({}),
'pad-1',
);
report?.({}, null);
assert.equal(await handlers.handle.get(IPC_CHANNELS.request.getActiveControllerId)?.({}), null);
});
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -328,6 +359,8 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
reportActiveController: () => {},
getActiveControllerId: () => null,
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
@@ -609,6 +642,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
reportActiveController: () => {},
getActiveControllerId: () => null,
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
+17
View File
@@ -89,6 +89,9 @@ export interface IpcServiceDeps {
getOverlayNotificationPosition: () => string;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
/** The overlay reports the controller it is reading (null when none). */
reportActiveController: (gamepadId: string | null) => void;
getActiveControllerId: () => string | null;
getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string;
focusMainWindow: () => void;
@@ -356,6 +359,9 @@ export interface IpcDepsRuntimeOptions {
getOverlayNotificationPosition: () => string;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
/** The overlay reports the controller it is reading (null when none). */
reportActiveController: (gamepadId: string | null) => void;
getActiveControllerId: () => string | null;
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void;
@@ -432,6 +438,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getOverlayNotificationPosition: options.getOverlayNotificationPosition,
getControllerConfig: options.getControllerConfig,
saveControllerConfig: options.saveControllerConfig,
reportActiveController: options.reportActiveController,
getActiveControllerId: options.getActiveControllerId,
getSecondarySubMode: options.getSecondarySubMode,
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
focusMainWindow: () => {
@@ -760,6 +768,15 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getControllerConfig();
});
ipc.on(IPC_CHANNELS.command.reportActiveController, (_event: unknown, gamepadId: unknown) => {
if (gamepadId !== null && typeof gamepadId !== 'string') return;
deps.reportActiveController(gamepadId);
});
ipc.handle(IPC_CHANNELS.request.getActiveControllerId, () => {
return deps.getActiveControllerId();
});
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
return deps.getSecondarySubMode();
});
+11
View File
@@ -341,6 +341,8 @@ const texthookerService = new Texthooker(() => {
},
};
});
/** The controller the overlay is reading, as it last reported; null before it sees one. */
let activeControllerIdFromOverlay: string | null = null;
const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler({
platform: process.platform,
});
@@ -2083,11 +2085,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getMarkWatchedKey: () => configService.getConfig().stats.markWatchedKey,
getOverlayNotificationPosition: () => configService.getConfig().notifications.overlayPosition,
getControllerConfig: () => configService.getConfig().controller,
// Alt+C opens in its own window, which cannot see a pad until a button is pressed in
// it; the overlay has usually seen it already, so Alt+C asks which one is in use.
reportActiveController: (gamepadId) => {
activeControllerIdFromOverlay = gamepadId;
},
getActiveControllerId: () => activeControllerIdFromOverlay,
saveControllerConfig: (update) => {
const currentRawConfig = configService.getRawConfig();
configService.patchRawConfig({
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
});
// The overlay reads controller settings once at startup, and Alt+C runs in its own
// window, so remaps would otherwise only apply after a restart.
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.controllerConfigChanged);
},
getSecondarySubMode: () => appState.secondarySubMode,
getRuntimeOptions: () => getRuntimeOptionsStateHandler(),
+4
View File
@@ -78,6 +78,8 @@ export interface MainIpcRuntimeServiceDepsParams {
getOverlayNotificationPosition: IpcDepsRuntimeOptions['getOverlayNotificationPosition'];
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
reportActiveController: IpcDepsRuntimeOptions['reportActiveController'];
getActiveControllerId: IpcDepsRuntimeOptions['getActiveControllerId'];
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
@@ -256,6 +258,8 @@ export function createMainIpcRuntimeServiceDeps(
getOverlayNotificationPosition: params.getOverlayNotificationPosition,
getControllerConfig: params.getControllerConfig,
saveControllerConfig: params.saveControllerConfig,
reportActiveController: params.reportActiveController,
getActiveControllerId: params.getActiveControllerId,
focusMainWindow: params.focusMainWindow ?? (() => {}),
activatePlaybackWindowForOverlayInteraction: params.activatePlaybackWindowForOverlayInteraction,
getSecondarySubMode: params.getSecondarySubMode,
@@ -373,6 +373,8 @@ export function createMpvMainEventBindingDeps(deps: {
: undefined,
recordPauseState: (paused: boolean) => {
deps.appState.playbackPaused = paused;
// Lets keyboard-only mode's auto-pause see the user playing or pausing themselves.
deps.broadcastToOverlayWindows('mpv:pause-changed', paused);
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPauseState?.(paused);
},
+14
View File
@@ -190,6 +190,13 @@ const onControllerSuspendedChangedEvent = createQueuedIpcListenerWithPayload<boo
IPC_CHANNELS.event.controllerSuspendedChanged,
(payload) => payload === true,
);
const onPlaybackPauseChangedEvent = createQueuedIpcListenerWithPayload<boolean>(
IPC_CHANNELS.event.playbackPauseChanged,
(payload) => payload === true,
);
const onControllerConfigChangedEvent = createQueuedIpcListener(
IPC_CHANNELS.event.controllerConfigChanged,
);
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
IPC_CHANNELS.event.keyboardModeToggleRequested,
);
@@ -346,6 +353,11 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
saveControllerConfig: (update: ControllerConfigUpdate): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerConfig, update),
reportActiveController: (gamepadId: string | null): void => {
ipcRenderer.send(IPC_CHANNELS.command.reportActiveController, gamepadId);
},
getActiveControllerId: (): Promise<string | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getActiveControllerId),
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
@@ -496,6 +508,8 @@ const electronAPI: ElectronAPI = {
onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onControllerSuspendedChanged: onControllerSuspendedChangedEvent,
onControllerConfigChanged: onControllerConfigChangedEvent,
onPlaybackPauseChanged: onPlaybackPauseChangedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
getPlaylistBrowserSnapshot: (): Promise<PlaylistBrowserSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot),
@@ -16,6 +16,7 @@ export function resolveControllerConfigForGamepad(
if (!profile) return config;
return {
...config,
faceButtonLabels: profile.faceButtonLabels ?? config.faceButtonLabels,
buttonIndices: profile.buttonIndices,
bindings: profile.bindings,
reviewBindings: profile.reviewBindings ?? config.reviewBindings,
@@ -15,6 +15,7 @@ import {
getControllerReviewInputContext,
} from './controller-screen-layout.js';
import type { GamepadLike } from './gamepad-controller.js';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true };
const { repeatDelayMs, repeatIntervalMs } = CONFIG;
@@ -155,3 +156,33 @@ test('remapped duplicate card bindings replace the defaults', () => {
assert.deepEqual(actions, ['cancelMerge']);
});
test('dialog hints follow the button style of the controller in use', () => {
const config: ResolvedControllerConfig = {
...CONFIG,
profiles: {
'pad-ps': {
label: 'DualSense',
faceButtonLabels: 'playstation',
buttonIndices: CONFIG.buttonIndices,
bindings: CONFIG.bindings,
reviewBindings: CONFIG.reviewBindings,
fieldGroupingBindings: CONFIG.fieldGroupingBindings,
},
},
};
const hintsFor = (gamepadId: string) => {
const padConfig = resolveControllerConfigForGamepad(config, gamepadId);
return formatControllerScreenHints(
CONTROLLER_FIELD_GROUPING_SCREEN,
padConfig.fieldGroupingBindings,
padConfig,
'standard',
);
};
assert.equal(hintsFor('pad-ps').continue, '╳');
assert.equal(hintsFor('pad-ps').toggleView, '△');
// Controllers without their own style use controller.faceButtonLabels.
assert.equal(hintsFor('other-pad').continue, 'A');
});
@@ -70,6 +70,10 @@ const VOID_ACTIONS = [
'previousEntry',
'nextEntry',
'toggleMpvPause',
'previousSubtitle',
'nextSubtitle',
'replaySubtitle',
'playNextSubtitle',
] as const;
const DELTA_ACTIONS = ['moveSelection', 'scrollPopup', 'jumpPopup'] as const;
@@ -114,6 +118,10 @@ function createHarness(
previousEntry: () => {},
nextEntry: () => {},
toggleMpvPause: () => {},
previousSubtitle: () => {},
nextSubtitle: () => {},
replaySubtitle: () => {},
playNextSubtitle: () => {},
setSecondarySubHeld: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
@@ -288,6 +296,7 @@ test('gamepad controller uses active controller profile bindings before global b
profiles: {
'pad-profile': {
label: 'Profile Pad',
faceButtonLabels: globalConfig.faceButtonLabels,
buttonIndices: globalConfig.buttonIndices,
bindings: { ...globalConfig.bindings, toggleLookup: { kind: 'button', buttonIndex: 11 } },
reviewBindings: globalConfig.reviewBindings,
@@ -427,7 +436,8 @@ for (const c of [
harness.poll(0);
assert.deepEqual(harness.calls, c.fired ? ['playCurrentAudio', 'toggleMpvPause'] : []);
// Pause works outside keyboard-only mode, so it is handled before the popup actions.
assert.deepEqual(harness.calls, c.fired ? ['toggleMpvPause', 'playCurrentAudio'] : []);
});
}
@@ -784,3 +794,99 @@ test('gamepad controller still ignores a button held when the screen opened', ()
assert.deepEqual(events, ['activate:pad-1:latch']);
});
const SUBTITLE_NAVIGATION_BINDINGS = {
previousSubtitle: { kind: 'button', buttonIndex: 10 },
nextSubtitle: { kind: 'button', buttonIndex: 11 },
replaySubtitle: { kind: 'button', buttonIndex: 8 },
playNextSubtitle: { kind: 'button', buttonIndex: 16 },
} as const;
test('gamepad controller leaves subtitle navigation unbound by default', () => {
for (const action of [
'previousSubtitle',
'nextSubtitle',
'replaySubtitle',
'playNextSubtitle',
] as const) {
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings[action], { kind: 'none' }, action);
}
});
test('gamepad controller navigates subtitles once per press, even outside keyboard-only mode', () => {
let pressed: number[] = [];
const harness = createHarness({
gamepads: () => [
createGamepad('pad-1', {
buttons: Object.fromEntries(pressed.map((index) => [index, PRESSED])),
}),
],
config: createControllerConfig({ bindings: SUBTITLE_NAVIGATION_BINDINGS }),
deps: { getKeyboardModeEnabled: () => false },
});
harness.poll(0);
for (const index of [11, 10, 8, 16]) {
pressed = [index];
// Holding the button does not repeat.
harness.poll(16, 500, 1_000);
pressed = [];
harness.poll(1_016);
}
assert.deepEqual(harness.calls, [
'nextSubtitle',
'previousSubtitle',
'replaySubtitle',
'playNextSubtitle',
]);
});
test('gamepad controller pauses subtitle navigation while a blocking modal is open', () => {
let blocked = true;
let pressed = true;
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons: pressed ? { 11: PRESSED } : {} })],
config: createControllerConfig({ bindings: SUBTITLE_NAVIGATION_BINDINGS }),
deps: { getInteractionBlocked: () => blocked },
});
harness.poll(0, 16);
// Still held when the modal closes: not a fresh press.
blocked = false;
harness.poll(32);
assert.deepEqual(harness.calls, []);
pressed = false;
harness.poll(48);
pressed = true;
harness.poll(64);
assert.deepEqual(harness.calls, ['nextSubtitle']);
});
test('gamepad controller pauses mpv outside keyboard-only mode, but not under a blocking modal', () => {
let blocked = false;
let pressed = false;
const harness = createHarness({
gamepads: () => [createGamepad('pad-1', { buttons: pressed ? { 9: PRESSED } : {} })],
config: createControllerConfig({
bindings: { toggleMpvPause: { kind: 'button', buttonIndex: 9 } },
}),
deps: {
getKeyboardModeEnabled: () => false,
getInteractionBlocked: () => blocked,
},
});
harness.poll(0);
pressed = true;
// Held: fires once.
harness.poll(16, 500);
pressed = false;
harness.poll(516);
blocked = true;
pressed = true;
harness.poll(532);
assert.deepEqual(harness.calls, ['toggleMpvPause']);
});
+34 -11
View File
@@ -18,6 +18,8 @@ export type GamepadLike = {
index: number;
connected: boolean;
mapping: string;
/** When the pad last reported new input (Gamepad API). */
timestamp?: number;
axes: readonly number[];
buttons: readonly ControllerButtonState[];
};
@@ -56,6 +58,10 @@ type GamepadControllerOptions = {
previousEntry: () => void;
nextEntry: () => void;
toggleMpvPause: () => void;
previousSubtitle: () => void;
nextSubtitle: () => void;
replaySubtitle: () => void;
playNextSubtitle: () => void;
setSecondarySubHeld: (held: boolean) => void;
scrollPopup: (deltaPixels: number) => void;
jumpPopup: (deltaPixels: number) => void;
@@ -73,14 +79,16 @@ export type HoldState = {
initialFired: boolean;
};
const DPAD_BUTTON_INDEX = {
/** Buttons read as the D-pad on every pad, whatever its reported mapping. */
export const DPAD_BUTTON_INDEX = {
up: 12,
down: 13,
left: 14,
right: 15,
} as const;
const DPAD_AXIS_INDEX = {
/** Axes some raw (non-standard) pads use for the D-pad. */
export const DPAD_AXIS_INDEX = {
horizontal: 6,
vertical: 7,
} as const;
@@ -276,6 +284,15 @@ const POPUP_ONLY_ACTIONS = [
'nextEntry',
] as const;
/** Playback and subtitle actions; like the secondary hold, they do not need keyboard-only mode. */
const WATCHING_ACTIONS = [
'toggleMpvPause',
'previousSubtitle',
'nextSubtitle',
'replaySubtitle',
'playNextSubtitle',
] as const;
export function createGamepadController(options: GamepadControllerOptions) {
let previousActions = new Map<string, boolean>();
let selectionHold = createHoldState();
@@ -312,6 +329,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
index: gamepad.index,
mapping: gamepad.mapping,
connected: gamepad.connected,
timestamp: gamepad.timestamp,
})) satisfies ControllerDeviceInfo[],
activeGamepadId,
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
@@ -401,7 +419,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
['playCurrentAudio', config.bindings.playCurrentAudio],
['previousEntry', config.bindings.previousEntry],
['nextEntry', config.bindings.nextEntry],
['toggleMpvPause', config.bindings.toggleMpvPause],
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
] as const;
for (const [actionKey, binding] of discreteActions) {
@@ -503,6 +521,19 @@ export function createGamepadController(options: GamepadControllerOptions) {
),
);
// Pause and subtitle navigation also work while simply watching; a blocking modal pauses them.
if (activeConfig.enabled && !options.getInteractionBlocked()) {
for (const actionKey of WATCHING_ACTIONS) {
handleActionEdge(
actionKey,
activeConfig.bindings[actionKey],
activeGamepad,
activeConfig,
options[actionKey],
);
}
}
if (activeConfig.enabled) {
handleActionEdge(
'toggleKeyboardOnlyMode',
@@ -628,14 +659,6 @@ export function createGamepadController(options: GamepadControllerOptions) {
}
}
handleActionEdge(
'toggleMpvPause',
activeConfig.bindings.toggleMpvPause,
activeGamepad,
activeConfig,
options.toggleMpvPause,
);
handleSelectionAxis(
resolveAxisBindingValue(
activeGamepad,
+61 -1
View File
@@ -538,10 +538,15 @@ function createKeyboardHandlerHarness() {
let changelogKeydownCount = 0;
let jimakuKeydownCount = 0;
// Synthetic pointer/mouse events dispatched on words: the lookup's fallback scan.
let wordEventCount = 0;
const createWordNode = (left: number) => ({
classList: createClassList(),
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
dispatchEvent: () => true,
dispatchEvent: () => {
wordEventCount += 1;
return true;
},
});
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
@@ -618,6 +623,7 @@ function createKeyboardHandlerHarness() {
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
changelogKeydownCount: () => changelogKeydownCount,
jimakuKeydownCount: () => jimakuKeydownCount,
wordEventCount: () => wordEventCount,
setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
},
@@ -2554,3 +2560,57 @@ test('controller popup entry navigation forwards Alt+Up / Alt+Down only while th
testGlobals.restore();
}
});
test('keyboard mode: leaving the mode cancels a lookup whose popup has not appeared yet', async () => {
const { ctx, handlers, testGlobals, wordEventCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
// Without a popup, the lookup falls back to a synthetic scan shortly after.
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
await wait(100);
const afterFallback = wordEventCount();
assert.ok(afterFallback > 0, 'the fallback scan runs');
// Leaving the mode before that fallback runs must cancel it.
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
handlers.handleKeyboardModeToggleRequested();
await wait(100);
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(wordEventCount(), afterFallback, 'no scan after the mode is off');
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: leaving the mode closes an open popup', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
const closeEvents = () =>
testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' && event.visible === false,
);
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.handleKeyboardModeToggleRequested();
await wait(0);
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(closeEvents().length, 1);
// Turning the mode on again leaves a popup alone.
handlers.handleKeyboardModeToggleRequested();
await wait(0);
assert.equal(closeEvents().length, 1);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
+13 -4
View File
@@ -41,6 +41,8 @@ export function createKeyboardHandlers(
openControllerSelectModal?: () => void;
openControllerDebugModal?: () => void;
getPlaybackPaused: () => Promise<boolean | null>;
/** Keyboard-only mode turned on or off (keyboard shortcut or controller). */
onKeyboardDrivenModeChanged?: (enabled: boolean) => void;
toggleSubtitleSidebarModal?: () => void;
},
) {
@@ -550,6 +552,7 @@ export function createKeyboardHandlers(
}
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
const changed = ctx.state.keyboardDrivenModeEnabled !== enabled;
ctx.state.keyboardDrivenModeEnabled = enabled;
ctx.state.keyboardSelectionVisible = enabled;
if (!enabled) {
@@ -560,6 +563,10 @@ export function createKeyboardHandlers(
clearNativeSubtitleSelection();
}
syncKeyboardTokenSelection();
// Leaving the mode means going back to watching: close the popup it was used for.
// Done after the mode is off, so focus is not pulled back to the overlay.
if (changed && !enabled) closeLookupWindow();
if (changed) options.onKeyboardDrivenModeChanged?.(enabled);
}
function toggleKeyboardDrivenMode(): void {
@@ -756,6 +763,12 @@ export function createKeyboardHandlers(
// released) and attention without a pane, so callers like the Kiku modal
// opener leave nothing behind.
function closeLookupWindow(): boolean {
// Also when no popup shows yet: a lookup that has not opened one must not open it
// later through its fallback scan (for example after leaving keyboard-only mode).
if (lookupScanFallbackTimer !== null) {
clearTimeout(lookupScanFallbackTimer);
lookupScanFallbackTimer = null;
}
if (
!ctx.state.yomitanPopupVisible &&
!isYomitanPopupVisible(document) &&
@@ -764,10 +777,6 @@ export function createKeyboardHandlers(
return false;
}
if (lookupScanFallbackTimer !== null) {
clearTimeout(lookupScanFallbackTimer);
lookupScanFallbackTimer = null;
}
dispatchYomitanPopupVisibility(false);
dispatchYomitanFrontendClearActiveTextSource();
clearNativeSubtitleSelection();
+15
View File
@@ -509,6 +509,21 @@ test('popup hidden on macOS releases click-away capture back to mpv', (t) => {
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: true, forward: true });
});
test('a pointer leaving a popup that just closed does not mark it open again', (t) => {
const h = createMouseHarness(t, { platform: 'macos', popupHostVisible: true });
h.handlers.setupYomitanObserver();
h.fire('window', YOMITAN_POPUP_MOUSE_ENTER_EVENT);
// The popup hides under the pointer: hidden, then the leave it causes.
h.popupHostVisible = false;
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
h.fire('window', YOMITAN_POPUP_MOUSE_LEAVE_EVENT);
assert.equal(h.ctx.state.yomitanPopupVisible, false);
assert.equal(h.ctx.state.isOverYomitanPopup, false);
assert.equal(h.isInteractive(), false);
});
test('yomitan popup visibility marks primary subtitle hover hold while enabled', (t) => {
const h = createMouseHarness(t);
h.ctx.state.primaryVisibleOnYomitanPopup = true;
+4 -1
View File
@@ -505,7 +505,10 @@ export function createMouseHandlers(
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
ctx.state.isOverYomitanPopup = false;
reconcilePopupInteraction({ assumeVisible: true });
// A popup that hides under a resting pointer also reports a leave, right after its
// hidden event. Only keep a popup marked open if it still was; otherwise the next
// keyboard or controller word move would look it up again and reopen it.
reconcilePopupInteraction({ assumeVisible: yomitanPopupVisible });
});
window.addEventListener('focus', () => {
+4
View File
@@ -913,6 +913,10 @@
<span>Preferred Controller</span>
<select id="controllerSelectPicker"></select>
</label>
<label class="controller-select-field">
<span>Button Style</span>
<select id="controllerSelectFaceButtonStyle"></select>
</label>
<div id="controllerSelectSummary" class="controller-select-summary"></div>
<div id="controllerConfigList" class="controller-config-list"></div>
<div id="controllerSelectStatus" class="runtime-options-status"></div>
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createKeyboardModeAutoPause } from './keyboard-mode-auto-pause';
function setup(options: { enabled?: boolean; paused?: boolean | null } = {}) {
const commands: string[] = [];
const state = {
enabled: options.enabled ?? true,
paused: options.paused === undefined ? false : options.paused,
};
const autoPause = createKeyboardModeAutoPause({
isEnabled: () => state.enabled,
getPlaybackPaused: async () => state.paused,
sendMpvCommand: (command) => {
commands.push(command.join(' '));
if (command[1] === 'pause') {
state.paused = command[2] === 'yes';
// mpv reports the new pause state back.
autoPause.handlePlaybackPauseChanged(state.paused);
}
},
});
/** The user pauses or plays from mpv, the controller or the keyboard. */
const userSetsPause = (paused: boolean) => {
state.paused = paused;
autoPause.handlePlaybackPauseChanged(paused);
};
return { autoPause, commands, state, userSetsPause };
}
test('entering keyboard-only mode pauses a playing video and leaving resumes it', async () => {
const { autoPause, commands } = setup();
await autoPause.handleModeChanged(true);
assert.equal(autoPause.isPausedByMode(), true);
await autoPause.handleModeChanged(false);
assert.deepEqual(commands, ['set_property pause yes', 'set_property pause no']);
});
test('the setting is off: keyboard-only mode leaves playback alone', async () => {
const { autoPause, commands } = setup({ enabled: false });
await autoPause.handleModeChanged(true);
await autoPause.handleModeChanged(false);
assert.deepEqual(commands, []);
});
test('an already paused video stays paused when leaving the mode', async () => {
const { autoPause, commands } = setup({ paused: true });
await autoPause.handleModeChanged(true);
await autoPause.handleModeChanged(false);
assert.deepEqual(commands, []);
});
test('playing the video yourself in the mode means leaving does not touch it', async () => {
const { autoPause, commands, userSetsPause } = setup();
await autoPause.handleModeChanged(true);
userSetsPause(false);
await autoPause.handleModeChanged(false);
assert.deepEqual(commands, ['set_property pause yes']);
});
test('playing and pausing again yourself also keeps the video paused on leaving', async () => {
const { autoPause, commands, state, userSetsPause } = setup();
await autoPause.handleModeChanged(true);
userSetsPause(false);
userSetsPause(true);
await autoPause.handleModeChanged(false);
assert.deepEqual(commands, ['set_property pause yes']);
assert.equal(state.paused, true);
});
test('leaving before the pause lookup returns does not pause afterwards', async () => {
const commands: string[] = [];
let answer: (paused: boolean) => void = () => {};
const autoPause = createKeyboardModeAutoPause({
isEnabled: () => true,
getPlaybackPaused: () => new Promise((resolve) => (answer = resolve)),
sendMpvCommand: (command) => commands.push(command.join(' ')),
});
const entering = autoPause.handleModeChanged(true);
await autoPause.handleModeChanged(false);
answer(false);
await entering;
assert.deepEqual(commands, []);
assert.equal(autoPause.isPausedByMode(), false);
});
test("a pause made while the pause lookup is pending stays the user's", async () => {
const commands: string[] = [];
let answer: (paused: boolean) => void = () => {};
const autoPause = createKeyboardModeAutoPause({
isEnabled: () => true,
getPlaybackPaused: () => new Promise((resolve) => (answer = resolve)),
sendMpvCommand: (command) => commands.push(command.join(' ')),
});
const entering = autoPause.handleModeChanged(true);
// The user pauses before the lookup answers; the answer still says "playing".
autoPause.handlePlaybackPauseChanged(true);
answer(false);
await entering;
await autoPause.handleModeChanged(false);
assert.deepEqual(commands, [], "leaving does not resume the user's pause");
assert.equal(autoPause.isPausedByMode(), false);
});
test('an unknown pause state is left alone', async () => {
const { autoPause, commands } = setup({ paused: null });
await autoPause.handleModeChanged(true);
await autoPause.handleModeChanged(false);
assert.deepEqual(commands, []);
});
+71
View File
@@ -0,0 +1,71 @@
type MpvCommand = Array<string | number>;
/**
* Optional pause while keyboard-only mode is on (`subtitleStyle.autoPauseVideoOnKeyboardMode`).
*
* Entering the mode pauses a playing video. Leaving it resumes only a pause the mode made
* itself: if the video was already paused, or the user played or paused it while in the
* mode, leaving does nothing. The user taking over is seen through mpv's pause changes.
*/
export function createKeyboardModeAutoPause(deps: {
isEnabled: () => boolean;
getPlaybackPaused: () => Promise<boolean | null>;
sendMpvCommand: (command: MpvCommand) => void;
}) {
let pausedByMode = false;
/** Our own pause command is still to be reported back by mpv. */
let awaitingOwnPause = false;
/** Bumped on every mode change so a slow pause lookup cannot act on a stale mode. */
let generation = 0;
/** Bumped on every pause change, so a lookup answered after one is known to be stale. */
let playbackChanges = 0;
async function enterMode(current: number): Promise<void> {
if (!deps.isEnabled()) return;
const changesBefore = playbackChanges;
let paused: boolean | null = null;
try {
paused = await deps.getPlaybackPaused();
} catch {
return;
}
// Left the mode again (or re-entered) while waiting: this request is stale. So is the
// answer if the user paused or played meanwhile: that playback state is theirs.
if (current !== generation || playbackChanges !== changesBefore || paused !== false) return;
pausedByMode = true;
awaitingOwnPause = true;
deps.sendMpvCommand(['set_property', 'pause', 'yes']);
}
function leaveMode(): void {
if (!pausedByMode) return;
pausedByMode = false;
awaitingOwnPause = false;
deps.sendMpvCommand(['set_property', 'pause', 'no']);
}
return {
/** Keyboard-only mode turned on or off, from the keyboard or the controller. */
handleModeChanged(enabled: boolean): Promise<void> {
generation += 1;
if (enabled) return enterMode(generation);
leaveMode();
return Promise.resolve();
},
/** mpv's pause property changed, from any source. */
handlePlaybackPauseChanged(paused: boolean): void {
playbackChanges += 1;
if (!pausedByMode) return;
if (paused && awaitingOwnPause) {
awaitingOwnPause = false;
return;
}
// Anything else (a resume, or a pause after one) is the user's own playback choice.
pausedByMode = false;
awaitingOwnPause = false;
},
isPausedByMode: (): boolean => pausedByMode,
};
}
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
import type { ResolvedControllerConfig } from '../../types';
import { findControllerBindingIssues } from './controller-binding-issues.js';
import { CONTROLLER_BINDING_DEFINITIONS } from './controller-config-form.js';
type Bindings = ResolvedControllerConfig['bindings'];
const DEFAULTS = CORE_DEFAULT_CONFIG.controller.bindings as Bindings;
const issuesWith = (changes: Partial<Bindings>) =>
findControllerBindingIssues(CONTROLLER_BINDING_DEFINITIONS, { ...DEFAULTS, ...changes });
test('the default bindings share no input', () => {
assert.deepEqual(issuesWith({}), {});
});
test('two actions on the same button both warn', () => {
// Next Audio is on R1 (button 5) by default.
assert.deepEqual(issuesWith({ nextSubtitle: { kind: 'button', buttonIndex: 5 } }), {
nextSubtitle: 'Same as Next Audio',
nextAudio: 'Same as Next Subtitle',
});
});
test('a button on the D-pad clashes with a D-pad row that is still on', () => {
assert.deepEqual(issuesWith({ nextSubtitle: { kind: 'button', buttonIndex: 15 } }), {
nextSubtitle: 'Same as Token Move (D-pad)',
'leftStickHorizontal:dpad': 'Same as Next Subtitle',
});
// Clearing the D-pad row (as the docs advise) removes the warning.
assert.deepEqual(
issuesWith({
nextSubtitle: { kind: 'button', buttonIndex: 15 },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'none' },
}),
{},
);
});
test('a D-pad reported as a hat axis also counts as the D-pad', () => {
assert.deepEqual(
issuesWith({ previousEntry: { kind: 'axis', axisIndex: 7, direction: 'negative' } }),
{
previousEntry: 'Same as Popup Scroll (D-pad)',
'leftStickVertical:dpad': 'Same as Previous Entry',
},
);
});
test('a stick direction clashes with a stick action on that axis', () => {
const bindings = {
rightStickVertical: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
holdSecondarySub: { kind: 'axis', axisIndex: 3, direction: 'negative' },
} as const;
assert.deepEqual(issuesWith(bindings), {
holdSecondarySub: 'Same as Popup Jump (Stick)',
rightStickVertical: 'Same as Show Secondary Subtitle (Hold)',
});
});
test('opposite directions of one stick are different inputs', () => {
assert.deepEqual(
issuesWith({
previousSubtitle: { kind: 'axis', axisIndex: 2, direction: 'negative' },
nextSubtitle: { kind: 'axis', axisIndex: 2, direction: 'positive' },
}),
{},
);
});
test('two stick actions on the same stick both warn', () => {
assert.deepEqual(
issuesWith({ leftStickVertical: { kind: 'axis', axisIndex: 0, dpadFallback: 'vertical' } }),
{
leftStickHorizontal: 'Same as Popup Scroll (Stick)',
leftStickVertical: 'Same as Token Move (Stick)',
},
);
});
test('Alt Horizontal does nothing, so it never clashes', () => {
// On standard pads the right stick's vertical axis is 3, the default of Alt Horizontal.
assert.deepEqual(
issuesWith({ rightStickVertical: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' } }),
{},
);
});
test('a row shared by several actions lists them all', () => {
assert.equal(
issuesWith({
mineCard: { kind: 'button', buttonIndex: 5 },
nextSubtitle: { kind: 'button', buttonIndex: 5 },
}).nextAudio,
'Same as Mine Card, Next Subtitle',
);
});
@@ -0,0 +1,112 @@
import { DPAD_AXIS_INDEX, DPAD_BUTTON_INDEX } from '../handlers/gamepad-controller.js';
import type {
ControllerDpadFallback,
ResolvedControllerAxisBinding,
ResolvedControllerConfig,
ResolvedControllerDiscreteBinding,
} from '../../types';
type ControllerBindings = ResolvedControllerConfig['bindings'];
type ControllerBindingActionId = keyof ControllerBindings;
/** What the checker needs from an Alt+C row definition. */
export type ControllerBindingIssueDefinition = {
id: ControllerBindingActionId;
label: string;
bindingType: 'discrete' | 'axis';
};
/**
* Rows that do nothing in SubMiner: no action reads `rightStickHorizontal`, so it cannot
* clash with anything.
*/
const INACTIVE_BINDINGS: ReadonlySet<ControllerBindingActionId> = new Set(['rightStickHorizontal']);
/** Row key of a stick binding's D-pad row in Alt+C. */
export function getControllerDpadRowKey(actionId: ControllerBindingActionId): string {
return `${actionId}:dpad`;
}
const buttonKey = (buttonIndex: number) => `button:${buttonIndex}`;
const axisKey = (axisIndex: number, direction: 'negative' | 'positive') =>
`axis:${axisIndex}:${direction}`;
function discreteInputs(binding: ResolvedControllerDiscreteBinding): string[] {
if (binding.kind === 'none') return [];
if ('buttonIndex' in binding) return [buttonKey(binding.buttonIndex)];
return [axisKey(binding.axisIndex, binding.direction)];
}
function stickInputs(binding: ResolvedControllerAxisBinding): string[] {
if (binding.kind === 'none') return [];
return [axisKey(binding.axisIndex, 'negative'), axisKey(binding.axisIndex, 'positive')];
}
/** The D-pad is read from buttons 12-15 and, on some raw pads, from a hat axis. */
function dpadInputs(fallback: ControllerDpadFallback): string[] {
if (fallback === 'horizontal') {
return [
buttonKey(DPAD_BUTTON_INDEX.left),
buttonKey(DPAD_BUTTON_INDEX.right),
axisKey(DPAD_AXIS_INDEX.horizontal, 'negative'),
axisKey(DPAD_AXIS_INDEX.horizontal, 'positive'),
];
}
if (fallback === 'vertical') {
return [
buttonKey(DPAD_BUTTON_INDEX.up),
buttonKey(DPAD_BUTTON_INDEX.down),
axisKey(DPAD_AXIS_INDEX.vertical, 'negative'),
axisKey(DPAD_AXIS_INDEX.vertical, 'positive'),
];
}
return [];
}
/**
* Warnings for the main Alt+C list, keyed by row (the action id, or its D-pad row key):
* a row warns when another row uses one of its inputs. Every such pair fires together at
* some point, since the situations nest (always, then keyboard-only mode, then popup open),
* so a shared input is always worth pointing out. Dialog layouts are checked separately:
* they pause these actions while open.
*/
export function findControllerBindingIssues(
definitions: readonly ControllerBindingIssueDefinition[],
bindings: ControllerBindings,
): Partial<Record<string, string>> {
const rows: { key: string; label: string; inputs: string[] }[] = [];
for (const definition of definitions) {
if (INACTIVE_BINDINGS.has(definition.id)) continue;
const binding = bindings[definition.id];
if (!binding) continue;
if (definition.bindingType === 'axis') {
const axisBinding = binding as ResolvedControllerAxisBinding;
rows.push({
key: definition.id,
label: `${definition.label} (Stick)`,
inputs: stickInputs(axisBinding),
});
rows.push({
key: getControllerDpadRowKey(definition.id),
label: `${definition.label} (D-pad)`,
inputs: dpadInputs(axisBinding.kind === 'none' ? 'none' : axisBinding.dpadFallback),
});
} else {
rows.push({
key: definition.id,
label: definition.label,
inputs: discreteInputs(binding as ResolvedControllerDiscreteBinding),
});
}
}
const issues: Partial<Record<string, string>> = {};
for (const row of rows) {
const inputs = new Set(row.inputs);
const clashes = rows
.filter((other) => other !== row && other.inputs.some((input) => inputs.has(input)))
.map((other) => other.label);
if (clashes.length > 0) issues[row.key] = `Same as ${clashes.join(', ')}`;
}
return issues;
}
@@ -5,7 +5,7 @@ import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
import type { ResolvedControllerConfig } from '../../types';
import type { ControllerFaceButtonLabels, ResolvedControllerConfig } from '../../types';
import type { ControllerScreenLearning } from './controller-config-form';
import { createControllerConfigForm } from './controller-config-form.js';
@@ -24,6 +24,10 @@ const BINDINGS: ResolvedControllerConfig['bindings'] = {
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
previousSubtitle: { kind: 'none' },
nextSubtitle: { kind: 'none' },
replaySubtitle: { kind: 'none' },
playNextSubtitle: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -45,6 +49,12 @@ class FakeElement {
this.className = [...new Set([...this.classes(), ...tokens])].join(' ');
},
contains: (token: string) => this.classes().includes(token),
toggle: (token: string, force?: boolean) => {
const on = force ?? !this.classes().includes(token);
const rest = this.classes().filter((existing) => existing !== token);
this.className = (on ? [...rest, token] : rest).join(' ');
return on;
},
};
set innerHTML(value: string) {
@@ -99,7 +109,7 @@ function renderForm(
options: {
bindings?: typeof BINDINGS;
mapping?: string;
faceButtonLabels?: 'xbox' | 'nintendo';
faceButtonLabels?: ControllerFaceButtonLabels;
} = {},
) {
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
@@ -148,7 +158,7 @@ test('controller config form renders grouped rows with friendly binding labels',
assert.deepEqual(
findAll(container, 'controller-config-group').map((el) => el.textContent),
['Lookup', 'Playback', 'Popup Audio', 'Navigation'],
['Lookup', 'Playback', 'Subtitles', 'Popup Audio', 'Navigation'],
);
assert.deepEqual(
findAll(container, 'controller-config-row').map((row) => [
@@ -165,6 +175,10 @@ test('controller config form renders grouped rows with friendly binding labels',
['Toggle MPV Pause', 'R3 / RS'],
['Show Secondary Subtitle (Hold)', 'None'],
['Quit MPV', 'Back / Select'],
['Previous Subtitle', 'None'],
['Next Subtitle', 'None'],
['Replay Subtitle', 'None'],
['Play Next Subtitle', 'None'],
['Previous Audio', 'None'],
['Next Audio', 'RB / R1'],
['Play Current Audio', 'LB / L1'],
@@ -180,7 +194,7 @@ test('controller config form renders grouped rows with friendly binding labels',
);
assert.deepEqual(
findAll(container, 'disabled').map((badge) => badge.textContent),
['None', 'None', 'None', 'None', 'None', 'None'],
['None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None', 'None'],
);
assert.deepEqual(findAll(container, 'expanded'), []);
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
@@ -297,6 +311,7 @@ type ReviewFormOptions = {
reviewBindings?: ResolvedControllerConfig['reviewBindings'];
fieldGroupingBindings?: ResolvedControllerConfig['fieldGroupingBindings'];
learning?: ControllerScreenLearning | null;
faceButtonLabels?: ControllerFaceButtonLabels;
};
/** Renders the form with the dialog groups; `act` runs while `document` is still stubbed. */
@@ -340,7 +355,7 @@ function withReviewForm(
},
getScreenInputContext: () => ({
mapping: 'standard',
faceButtonLabels: 'xbox',
faceButtonLabels: options.faceButtonLabels ?? 'xbox',
buttonIndices: CORE_DEFAULT_CONFIG.controller.buttonIndices,
}),
getScreenLearning: () => options.learning ?? null,
@@ -430,6 +445,43 @@ test('controller config form warns about review bindings that clash', () => {
});
});
test('controller config form warns when main actions share an input', () => {
const { container, row } = renderForm(null, {
bindings: { ...BINDINGS, nextSubtitle: { kind: 'button', buttonIndex: 15 } },
mapping: 'standard',
});
assert.deepEqual(
findAll(container, 'controller-config-warning').map((el) => el.textContent),
['\u26a0 Same as Token Move (D-pad)', '\u26a0 Same as Next Subtitle'],
);
assert.equal(row('Next Subtitle').classList.contains('has-warning'), true);
assert.equal(row('Token Move (D-pad)').classList.contains('has-warning'), true);
assert.equal(row('Next Audio').classList.contains('has-warning'), false);
});
test('an edit panel slides in when it opens, not each time the list is redrawn', () => {
withReviewForm({}, ({ container, reviewRow }) => {
const panel = () => findOne(container, 'controller-config-edit-panel');
const kept = () => panel().classList.contains('controller-config-edit-panel-kept');
reviewRow('Toggle Lookup').click();
assert.equal(kept(), false, 'opening animates');
// Any other click redraws the whole list; the open panel is recreated as it was.
findOne(container, 'controller-config-group-toggle').click();
assert.equal(kept(), true);
reviewRow('Toggle Lookup').click();
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
reviewRow('Toggle Lookup').click();
assert.equal(kept(), false, 'reopening animates again');
reviewRow('Close Lookup').click();
assert.equal(kept(), false, 'another row opening animates');
});
});
/** The dialog group headers, in order. */
const groupHeaders = (container: FakeElement) =>
findAll(container, 'controller-config-group-toggle');
@@ -483,3 +535,30 @@ test('learning a duplicate card action opens only that group', () => {
},
);
});
test('controller config form names face buttons with PlayStation symbols when configured', () => {
const badge = (row: FakeElement) => findOne(row, 'controller-config-badge').textContent;
const bindings: typeof BINDINGS = {
...BINDINGS,
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
mineCard: { kind: 'button', buttonIndex: 2 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
};
const form = renderForm(null, { bindings, mapping: 'standard', faceButtonLabels: 'playstation' });
assert.equal(badge(form.row('Toggle Lookup')), '╳');
assert.equal(badge(form.row('Close Lookup')), '○');
assert.equal(badge(form.row('Mine Card')), '□');
assert.equal(badge(form.row('Toggle Keyboard-Only Mode')), '△');
assert.equal(badge(form.row('Next Audio')), 'RB / R1');
// The dialog groups print the same symbols.
withReviewForm({ faceButtonLabels: 'playstation' }, ({ container, reviewRow }) => {
findOne(container, 'controller-config-group-toggle').click();
assert.equal(badgeOf(reviewRow('Use this timing')), '╳');
assert.equal(badgeOf(reviewRow('Keep editing')), '○');
assert.equal(badgeOf(reviewRow('Play selection')), '□');
assert.equal(badgeOf(reviewRow('Reset timing')), '△');
});
});
+86 -14
View File
@@ -1,5 +1,6 @@
import {
findControllerScreenBindingIssues,
getControllerFaceButtonName,
formatControllerReviewBinding,
type ControllerReviewInputContext,
type ControllerScreenActionDefinition,
@@ -7,6 +8,10 @@ import {
type ControllerScreenDefinition,
} from '../../shared/controller-review';
import type { ControllerScreenId } from '../../shared/controller-screens';
import {
findControllerBindingIssues,
getControllerDpadRowKey,
} from './controller-binding-issues.js';
import type { ResolvedControllerReviewBinding } from '../../types';
/** A dialog layout shown as a collapsible group in Alt+C. */
@@ -102,6 +107,34 @@ export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 6 },
},
{
id: 'previousSubtitle',
label: 'Previous Subtitle',
group: 'Subtitles',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{
id: 'nextSubtitle',
label: 'Next Subtitle',
group: 'Subtitles',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{
id: 'replaySubtitle',
label: 'Replay Subtitle',
group: 'Subtitles',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{
id: 'playNextSubtitle',
label: 'Play Next Subtitle',
group: 'Subtitles',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{
id: 'previousAudio',
label: 'Previous Audio',
@@ -240,9 +273,6 @@ const W3C_STANDARD_AXIS_NAMES: Record<number, string> = {
type ControllerMapping = string | null | undefined;
type FaceButtonLabels = ResolvedControllerConfig['faceButtonLabels'] | undefined;
/** Nintendo-style pads print B at the bottom and A on the right; positions 0-3 are the same. */
const NINTENDO_FACE_BUTTON_NAMES: Record<number, string> = { 0: 'B', 1: 'A', 2: 'Y', 3: 'X' };
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
none: 'None',
horizontal: 'D-pad \u2194',
@@ -254,9 +284,13 @@ export function getFriendlyButtonName(
mapping?: ControllerMapping,
faceButtonLabels?: FaceButtonLabels,
): string {
const nintendoName =
faceButtonLabels === 'nintendo' ? NINTENDO_FACE_BUTTON_NAMES[buttonIndex] : undefined;
if (nintendoName) return nintendoName;
// The default Xbox style keeps the shared "A / Cross" names; the others print their own.
// Face positions 0-3 are the same on raw and standard layouts.
const styledName =
faceButtonLabels && faceButtonLabels !== 'xbox'
? getControllerFaceButtonName(buttonIndex, faceButtonLabels)
: null;
if (styledName) return styledName;
const names = mapping === 'standard' ? W3C_STANDARD_BUTTON_NAMES : RAW_LAYOUT_BUTTON_NAMES;
return names[buttonIndex] ?? `Button ${buttonIndex}`;
}
@@ -326,6 +360,8 @@ export function createControllerConfigForm(options: {
getControllerMapping?: () => ControllerMapping;
/** `controller.faceButtonLabels`; names the face buttons as printed on the pad. */
getFaceButtonLabels?: () => FaceButtonLabels;
/** True until the window sees a controller: rows are a preview and actions are dimmed. */
isEditingLocked?: () => boolean;
/** Dialog layouts (review, field grouping) of the controller being configured. */
getScreenGroups?: () => ControllerScreenGroup[];
getScreenInputContext?: () => ControllerReviewInputContext;
@@ -336,14 +372,35 @@ export function createControllerConfigForm(options: {
onScreenResetAll?: (screenId: ControllerScreenId) => void;
}) {
let expandedRowKey: ExpandedRowKey | null = null;
/**
* Row whose edit panel the previous render showed. Every change rebuilds the list, so a
* panel that stays open is recreated; it only slides in when it was not open before.
*/
let previousPanelRowKey: ExpandedRowKey | null = null;
let panelRowKey: ExpandedRowKey | null = null;
/** Dialog groups start collapsed; this holds the opened ones. */
const expandedScreens = new Set<ControllerScreenId>();
function render(): void {
panelRowKey = null;
try {
renderRows();
} finally {
previousPanelRowKey = panelRowKey;
}
}
function renderRows(): void {
options.container.innerHTML = '';
options.container.classList?.toggle(
'controller-config-locked',
options.isEditingLocked?.() ?? false,
);
let lastGroup = '';
const learningActionId = options.getLearningActionId();
const dpadLearningActionId = options.getDpadLearningActionId();
const bindings = options.getBindings();
const issues = findControllerBindingIssues(CONTROLLER_BINDING_DEFINITIONS, bindings);
// Auto-expand when learning starts
if (learningActionId) {
@@ -361,17 +418,23 @@ export function createControllerConfigForm(options: {
lastGroup = definition.group;
}
const binding = options.getBindings()[definition.id];
const binding = bindings[definition.id];
if (definition.bindingType === 'axis') {
renderAxisStickRow(definition, binding as ResolvedControllerAxisBinding, learningActionId);
renderAxisStickRow(
definition,
binding as ResolvedControllerAxisBinding,
learningActionId,
issues[definition.id],
);
renderAxisDpadRow(
definition,
binding as ResolvedControllerAxisBinding,
dpadLearningActionId,
issues[getControllerDpadRowKey(definition.id)],
);
} else {
renderDiscreteRow(definition, binding, learningActionId);
renderDiscreteRow(definition, binding, learningActionId, issues[definition.id]);
}
}
@@ -485,7 +548,7 @@ export function createControllerConfigForm(options: {
: holdLabel
? `Holding ${holdLabel}: press another button for a combo, or let go to use ${holdLabel} alone\u2026`
: 'Press a button, or hold one and press another for a combo\u2026';
const panel = createEditPanel(hint, isLearning, {
const panel = createEditPanel(rowKey, hint, isLearning, {
onLearn: learn,
onClear: (e) => {
e.stopPropagation();
@@ -501,6 +564,7 @@ export function createControllerConfigForm(options: {
definition: ControllerBindingDefinition,
binding: ResolvedControllerConfig['bindings'][ControllerBindingActionId],
learningActionId: ControllerBindingActionId | null,
issue: string | undefined,
): void {
const rowKey = definition.id as string;
const isExpanded = expandedRowKey === rowKey;
@@ -526,6 +590,7 @@ export function createControllerConfigForm(options: {
e.stopPropagation();
options.onReset(definition.id);
},
issue,
);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
@@ -537,7 +602,7 @@ export function createControllerConfigForm(options: {
const hint = isLearning
? 'Press a button, trigger, or move a stick\u2026'
: `Currently: ${formatControllerBindingSummary(binding)}`;
const panel = createEditPanel(hint, isLearning, {
const panel = createEditPanel(rowKey, hint, isLearning, {
onLearn: (e) => {
e.stopPropagation();
options.onLearn(definition.id, definition.bindingType);
@@ -559,6 +624,7 @@ export function createControllerConfigForm(options: {
definition: ControllerBindingDefinition,
binding: ResolvedControllerAxisBinding,
learningActionId: ControllerBindingActionId | null,
issue: string | undefined,
): void {
const rowKey = definition.id as string;
const isExpanded = expandedRowKey === rowKey;
@@ -580,6 +646,7 @@ export function createControllerConfigForm(options: {
e.stopPropagation();
options.onReset(definition.id);
},
issue,
);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
@@ -590,7 +657,7 @@ export function createControllerConfigForm(options: {
if (isExpanded) {
const summary = binding.kind === 'none' ? 'Disabled' : `Axis ${binding.axisIndex}`;
const hint = isLearning ? 'Move a stick or trigger\u2026' : `Currently: ${summary}`;
const panel = createEditPanel(hint, isLearning, {
const panel = createEditPanel(rowKey, hint, isLearning, {
onLearn: (e) => {
e.stopPropagation();
options.onLearn(definition.id, 'axis');
@@ -612,8 +679,9 @@ export function createControllerConfigForm(options: {
definition: ControllerBindingDefinition,
binding: ResolvedControllerAxisBinding,
dpadLearningActionId: ControllerBindingActionId | null,
issue: string | undefined,
): void {
const rowKey = `${definition.id as string}:dpad`;
const rowKey = getControllerDpadRowKey(definition.id);
const isExpanded = expandedRowKey === rowKey;
const isLearning = dpadLearningActionId === definition.id;
@@ -636,6 +704,7 @@ export function createControllerConfigForm(options: {
e.stopPropagation();
options.onDpadReset(definition.id);
},
issue,
);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
@@ -647,7 +716,7 @@ export function createControllerConfigForm(options: {
const hint = isLearning
? 'Press a D-pad direction\u2026'
: `Currently: ${DPAD_FALLBACK_LABELS[dpadFallback]}`;
const panel = createEditPanel(hint, isLearning, {
const panel = createEditPanel(rowKey, hint, isLearning, {
onLearn: (e) => {
e.stopPropagation();
options.onDpadLearn(definition.id);
@@ -729,6 +798,7 @@ export function createControllerConfigForm(options: {
}
function createEditPanel(
rowKey: ExpandedRowKey,
hintText: string,
isLearning: boolean,
callbacks: {
@@ -739,6 +809,8 @@ export function createControllerConfigForm(options: {
): HTMLDivElement {
const panel = document.createElement('div');
panel.className = 'controller-config-edit-panel';
if (rowKey === previousPanelRowKey) panel.classList.add('controller-config-edit-panel-kept');
panelRowKey = rowKey;
const inner = document.createElement('div');
inner.className = 'controller-config-edit-inner';
@@ -77,6 +77,10 @@ test('controller debug modal renders active controller axes, buttons, and config
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
previousSubtitle: { kind: 'none' },
nextSubtitle: { kind: 'none' },
replaySubtitle: { kind: 'none' },
playNextSubtitle: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -198,6 +202,10 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
previousSubtitle: { kind: 'none' },
nextSubtitle: { kind: 'none' },
replaySubtitle: { kind: 'none' },
playNextSubtitle: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -318,6 +326,10 @@ test('controller debug modal stays closed and notifies when controller support i
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
previousSubtitle: { kind: 'none' },
nextSubtitle: { kind: 'none' },
replaySubtitle: { kind: 'none' },
playNextSubtitle: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
+236 -41
View File
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createRendererState } from '../state.js';
import { createControllerSelectModal } from './controller-select.js';
import {
createControllerSelectModal,
findMostRecentlyUsedIndex,
shortControllerName,
} from './controller-select.js';
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
@@ -159,6 +163,10 @@ function buildContext() {
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
previousSubtitle: { kind: 'none' },
nextSubtitle: { kind: 'none' },
replaySubtitle: { kind: 'none' },
playNextSubtitle: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -179,6 +187,7 @@ function buildContext() {
controllerSelectModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
controllerSelectClose: createFakeElement(),
controllerSelectPicker: createFakeElement(),
controllerSelectFaceButtonStyle: createFakeElement(),
controllerSelectSummary: createFakeElement(),
controllerConfigList: createFakeElement(),
controllerSelectStatus: { textContent: '', classList: createClassList() },
@@ -328,6 +337,7 @@ test('controller select modal reset control stores the default binding in the se
state.controllerConfig.profiles = {
'pad-1': {
label: 'pad-1',
faceButtonLabels: state.controllerConfig.faceButtonLabels,
buttonIndices: state.controllerConfig.buttonIndices,
bindings: {
...state.controllerConfig.bindings,
@@ -429,7 +439,7 @@ test('controller select modal binding badge starts learn mode and persists bindi
}
});
test('controller select modal learn mode falls back to global bindings without a controller', async () => {
test('controller select modal locks editing until this window sees a controller', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
@@ -448,44 +458,45 @@ test('controller select modal learn mode falls back to global bindings without a
try {
const { state, dom } = buildContext();
// A freshly opened window sees no pad until a button is pressed in it.
state.connectedGamepads = [];
// No controller visible and none saved: edits go to the global bindings.
state.controllerConfig!.preferredGamepadId = '';
state.activeGamepadId = null;
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const firstRow = dom.controllerConfigList.children[1];
firstRow.dispatch('click');
const editPanel = dom.controllerConfigList.children[2];
const learnButton = editPanel.children[0].children[1].children[0];
learnButton.dispatch('click');
const press = 'Press any button on your controller to edit its bindings.';
assert.equal(dom.controllerSelectStatus.textContent, press);
assert.equal(dom.controllerConfigList.classList.contains('controller-config-locked'), true);
assert.equal(dom.controllerSelectFaceButtonStyle.disabled, true);
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
value: 0,
pressed: false,
touched: false,
// Learn and Clear do nothing yet.
const row = findReviewRow(dom.controllerConfigList, 'Toggle Lookup');
row.children[1].children[0].dispatch('click');
state.controllerRawButtons = Array.from({ length: 12 }, (_, index) => ({
value: index === 11 ? 1 : 0,
pressed: index === 11,
touched: index === 11,
}));
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
modal.updateDevices();
await Promise.resolve();
assert.deepEqual(saved, []);
assert.equal(dom.controllerSelectStatus.textContent, press, 'kept across frames');
assert.deepEqual(saved.at(-1), {
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
},
});
assert.deepEqual(state.controllerConfig?.bindings.toggleLookup, {
kind: 'button',
buttonIndex: 11,
});
// The press makes the pad visible: editing unlocks for it.
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
modal.updateDevices();
assert.equal(dom.controllerConfigList.classList.contains('controller-config-locked'), false);
assert.equal(dom.controllerSelectFaceButtonStyle.disabled, false);
assert.equal(
dom.controllerSelectStatus.textContent,
'Choose a controller or click Learn to remap an action.',
);
} finally {
domHandle.restore();
}
@@ -799,7 +810,7 @@ test('controller select modal learns a duplicate card button and saves it to the
}
});
test('controller select modal shows and edits the saved controller before this window sees it', async () => {
test('controller select modal previews the saved controller, then edits it once seen', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
@@ -825,6 +836,7 @@ test('controller select modal shows and edits the saved controller before this w
config.profiles = {
'pad-1': {
label: 'pad-1',
faceButtonLabels: config.faceButtonLabels,
buttonIndices: config.buttonIndices,
bindings: config.bindings,
fieldGroupingBindings: config.fieldGroupingBindings,
@@ -846,18 +858,31 @@ test('controller select modal shows and edits the saved controller before this w
modal.wireDomEvents();
modal.openControllerSelectModal();
// The preview is the saved controller's layout, labelled as such.
assert.equal(dom.controllerSelectSummary.textContent, 'Showing pad-1 (saved).');
findByClass(dom.controllerConfigList, 'controller-config-group-toggle').dispatch('click');
const row = findReviewRow(dom.controllerConfigList, 'Start \u22120.1s');
assert.equal(row.children[1].children[0].textContent, 'L2 + D-pad \u25b6');
const waiting = 'Press any button on your controller so this window can see it.';
assert.equal(dom.controllerSelectStatus.textContent, waiting);
const startBack = () => findReviewRow(dom.controllerConfigList, 'Start \u22120.1s');
assert.equal(startBack().children[1].children[0].textContent, 'L2 + D-pad \u25b6');
const press = 'Press any button on your controller to edit its bindings.';
assert.equal(dom.controllerSelectStatus.textContent, press);
// Controller polling keeps publishing snapshots while no pad is visible.
modal.updateDevices();
modal.updateDevices();
assert.equal(dom.controllerSelectStatus.textContent, waiting, 'the prompt stays');
assert.equal(dom.controllerSelectStatus.textContent, press, 'the prompt stays');
// Reset goes to the saved controller's profile, not the global layout.
row.children[1].children[1].dispatch('click');
// Reset is locked until the window sees the pad.
startBack().children[1].children[1].dispatch('click');
await Promise.resolve();
assert.deepEqual(saved, []);
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
modal.updateDevices();
assert.equal(
dom.controllerSelectStatus.textContent,
'Choose a controller or click Learn to remap an action.',
);
startBack().children[1].children[1].dispatch('click');
await Promise.resolve();
assert.deepEqual(saved.at(-1), {
profiles: {
@@ -873,19 +898,19 @@ test('controller select modal shows and edits the saved controller before this w
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
const afterReset = dom.controllerSelectStatus.textContent;
assert.equal(afterReset, 'Start \u22120.1s reset.');
assert.equal(dom.controllerSelectStatus.textContent, 'Start \u22120.1s reset.');
modal.updateDevices();
assert.equal(dom.controllerSelectStatus.textContent, afterReset, 'the reset message stays');
assert.equal(
dom.controllerSelectStatus.textContent,
'Start \u22120.1s reset.',
'the reset message stays',
);
// The pad appears, then disappears again.
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
modal.updateDevices();
// The pad disappears, then comes back.
state.connectedGamepads = [];
state.activeGamepadId = null;
modal.updateDevices();
assert.equal(dom.controllerSelectStatus.textContent, waiting);
assert.equal(dom.controllerSelectStatus.textContent, press);
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
modal.updateDevices();
@@ -897,3 +922,173 @@ test('controller select modal shows and edits the saved controller before this w
domHandle.restore();
}
});
test('controller select modal previews the controller the overlay reports in use', async () => {
const domHandle = installFakeDom();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async () => {},
notifyOverlayModalClosed: () => {},
getActiveControllerId: async () => 'DualSense Wireless Controller (STANDARD GAMEPAD)',
},
},
});
try {
const { state, dom } = buildContext();
state.connectedGamepads = [];
state.activeGamepadId = null;
const config = state.controllerConfig!;
// The saved controller is pad-1, but the overlay is reading the DualSense.
config.profiles = {
'DualSense Wireless Controller (STANDARD GAMEPAD)': {
label: 'DualSense',
faceButtonLabels: 'playstation',
buttonIndices: config.buttonIndices,
bindings: { ...config.bindings, mineCard: { kind: 'button', buttonIndex: 3 } },
reviewBindings: config.reviewBindings,
fieldGroupingBindings: config.fieldGroupingBindings,
},
};
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
dom.controllerSelectSummary.textContent,
'Showing DualSense Wireless Controller (in use).',
);
assert.equal(
findReviewRow(dom.controllerConfigList, 'Mine Card').children[1].children[0].textContent,
'\u25b3',
);
assert.equal(dom.controllerSelectFaceButtonStyle.value, 'playstation');
} finally {
domHandle.restore();
}
});
test('controller select modal selects the controller you pressed when several appear', () => {
const domHandle = installFakeDom();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: { saveControllerConfig: async () => {}, notifyOverlayModalClosed: () => {} },
},
});
try {
const { state, dom } = buildContext();
state.connectedGamepads = [];
state.activeGamepadId = null;
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
// One press reveals every pad; pad-2 has the newest input, pad-1 is the saved one.
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true, timestamp: 100 },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true, timestamp: 500 },
];
state.activeGamepadId = 'pad-1';
modal.updateDevices();
assert.equal(state.controllerDeviceSelectedIndex, 1);
assert.equal(state.controllerSelectGamepadId, 'pad-2', 'this window then reads pad-2');
modal.closeControllerSelectModal();
assert.equal(state.controllerSelectGamepadId, null);
} finally {
domHandle.restore();
}
});
test('controller select modal shows and saves the button style per controller', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
notifyControllerDisabled: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const stylePicker = dom.controllerSelectFaceButtonStyle;
assert.deepEqual(
stylePicker.children.map((option: any) => option.value),
['xbox', 'nintendo', 'playstation'],
);
assert.equal(stylePicker.value, 'xbox');
stylePicker.value = 'playstation';
stylePicker.dispatch('change');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(saved.at(-1), {
profiles: { 'pad-1': { faceButtonLabels: 'playstation' } },
});
assert.equal(state.controllerConfig?.profiles['pad-1']?.faceButtonLabels, 'playstation');
assert.equal(state.controllerConfig?.faceButtonLabels, 'xbox', 'other controllers keep theirs');
assert.equal(dom.controllerSelectStatus.textContent, 'Button style: PlayStation.');
// Toggle Lookup is on the bottom face button.
assert.equal(
findReviewRow(dom.controllerConfigList, 'Toggle Lookup').children[1].children[0].textContent,
'╳',
);
// The other controller still uses the default style.
dom.controllerSelectPicker.value = 'pad-2:1';
dom.controllerSelectPicker.dispatch('change');
assert.equal(stylePicker.value, 'xbox');
} finally {
domHandle.restore();
}
});
test('shortControllerName drops the driver details in brackets', () => {
assert.equal(
shortControllerName('Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)'),
'Xbox Wireless Controller',
);
assert.equal(shortControllerName('pad-1'), 'pad-1');
assert.equal(shortControllerName('(only details)'), '(only details)');
});
test('findMostRecentlyUsedIndex picks the newest input and ignores missing timestamps', () => {
assert.equal(findMostRecentlyUsedIndex([{ timestamp: 100 }, { timestamp: 500 }, {}]), 1);
assert.equal(findMostRecentlyUsedIndex([{ timestamp: 900 }, { timestamp: Number.NaN }]), 0);
assert.equal(findMostRecentlyUsedIndex([{}, { timestamp: 0 }]), -1);
assert.equal(findMostRecentlyUsedIndex([]), -1);
});
+160 -26
View File
@@ -3,6 +3,7 @@ import { resolveControllerConfigForGamepad } from '../controller-profile-config.
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
import { createControllerComboCapture } from '../handlers/controller-combo-capture.js';
import {
CONTROLLER_FACE_BUTTON_STYLES,
formatControllerReviewInput,
getControllerScreenAction,
getDefaultControllerScreenBindings,
@@ -22,6 +23,27 @@ import {
getDefaultDpadFallback,
} from './controller-config-form.js';
const PRESS_TO_EDIT_STATUS = 'Press any button on your controller to edit its bindings.';
/** `8BitDo Ultimate Wireless Controller (STANDARD GAMEPAD Vendor: …)` → the name part. */
export function shortControllerName(gamepadId: string): string {
return gamepadId.replace(/\s*\(.*\)\s*$/, '').trim() || gamepadId;
}
/** Index of the pad with the newest input (Gamepad API timestamp), or -1 if none report one. */
export function findMostRecentlyUsedIndex(devices: ReadonlyArray<{ timestamp?: number }>): number {
let best = -1;
let bestTimestamp = 0;
devices.forEach((device, index) => {
const timestamp = device.timestamp ?? 0;
if (Number.isFinite(timestamp) && timestamp > bestTimestamp) {
best = index;
bestTimestamp = timestamp;
}
});
return best;
}
function clampSelectedIndex(ctx: RendererContext): void {
if (ctx.state.connectedGamepads.length === 0) {
ctx.state.controllerDeviceSelectedIndex = 0;
@@ -34,11 +56,6 @@ function clampSelectedIndex(ctx: RendererContext): void {
);
}
const NO_CONTROLLER_STATUS = 'No controllers detected.';
const WAITING_FOR_SAVED_CONTROLLER_STATUS =
'Press any button on your controller so this window can see it.';
const NO_CONTROLLER_STATUSES = new Set([NO_CONTROLLER_STATUS, WAITING_FOR_SAVED_CONTROLLER_STATUS]);
export function createControllerSelectModal(
ctx: RendererContext,
options: {
@@ -65,17 +82,52 @@ export function createControllerSelectModal(
holdLabel: string | null;
} | null = null;
/** The controller the overlay reports in use, fetched when the window opens. */
let overlayActiveControllerId: string | null = null;
async function loadOverlayActiveController(): Promise<void> {
let gamepadId: string | null = null;
try {
gamepadId = (await window.electronAPI.getActiveControllerId?.()) ?? null;
} catch {
return;
}
if (!ctx.state.controllerSelectModalOpen || gamepadId === overlayActiveControllerId) return;
overlayActiveControllerId = gamepadId;
renderPicker();
controllerConfigForm.render();
}
function getSelectedController() {
return ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex] ?? null;
}
/**
* The controller whose profile Alt+C shows and edits. A freshly opened window cannot see
* any pad until a button is pressed in it, so until then use the saved (preferred)
* controller rather than the global defaults it does not actually use.
* any pad until a button is pressed in it; until then it previews the controller the
* overlay reports in use, else the saved (preferred) one, and editing stays locked.
*/
function getSelectedControllerId(): string | null {
return getSelectedController()?.id ?? (ctx.state.controllerConfig?.preferredGamepadId || null);
return (
getSelectedController()?.id ??
overlayActiveControllerId ??
(ctx.state.controllerConfig?.preferredGamepadId || null)
);
}
/**
* Nothing can be changed until this window sees a controller: before that it could only
* guess which controller you mean, and a change could land on the wrong one.
*/
function isEditingLocked(): boolean {
return ctx.state.connectedGamepads.length === 0;
}
/** True when editing may go ahead; otherwise says why not. */
function canEdit(): boolean {
if (!isEditingLocked()) return true;
setStatus(PRESS_TO_EDIT_STATUS);
return false;
}
function getSelectedControllerConfig() {
@@ -115,8 +167,9 @@ export function createControllerSelectModal(
const controllerConfigForm = createControllerConfigForm({
container: ctx.dom.controllerConfigList,
isEditingLocked,
getControllerMapping: () => getSelectedController()?.mapping ?? null,
getFaceButtonLabels: () => ctx.state.controllerConfig?.faceButtonLabels,
getFaceButtonLabels: () => getSelectedControllerConfig()?.faceButtonLabels,
getBindings: () =>
getSelectedControllerConfig()?.bindings ?? {
toggleLookup: { kind: 'button', buttonIndex: 0 },
@@ -131,6 +184,10 @@ export function createControllerSelectModal(
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
previousSubtitle: { kind: 'none' },
nextSubtitle: { kind: 'none' },
replaySubtitle: { kind: 'none' },
playNextSubtitle: { kind: 'none' },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
@@ -154,6 +211,7 @@ export function createControllerSelectModal(
}
: null,
onScreenLearn: (screenId, actionId) => {
if (!canEdit()) return;
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
@@ -171,6 +229,7 @@ export function createControllerSelectModal(
);
},
onScreenClear: (screenId, actionId) => {
if (!canEdit()) return;
const screen = getControllerScreen(screenId);
void saveScreenBindings(
screen,
@@ -179,6 +238,7 @@ export function createControllerSelectModal(
);
},
onScreenReset: (screenId, actionId) => {
if (!canEdit()) return;
const screen = getControllerScreen(screenId);
void saveScreenBindings(
screen,
@@ -187,6 +247,7 @@ export function createControllerSelectModal(
);
},
onScreenResetAll: (screenId) => {
if (!canEdit()) return;
const screen = getControllerScreen(screenId);
void saveScreenBindings(
screen,
@@ -197,6 +258,7 @@ export function createControllerSelectModal(
getLearningActionId: () => learningActionId,
getDpadLearningActionId: () => dpadLearningActionId,
onLearn: (actionId, bindingType) => {
if (!canEdit()) return;
const definition = getControllerBindingDefinition(actionId);
if (!definition) return;
dpadLearningActionId = null;
@@ -232,12 +294,15 @@ export function createControllerSelectModal(
setStatus(`Waiting for input for ${definition.label}.`);
},
onClear: (actionId) => {
if (!canEdit()) return;
void saveBinding(actionId, { kind: 'none' });
},
onReset: (actionId) => {
if (!canEdit()) return;
void saveBinding(actionId, getDefaultControllerBinding(actionId));
},
onDpadLearn: (actionId) => {
if (!canEdit()) return;
const definition = getControllerBindingDefinition(actionId);
if (!definition) return;
learningActionId = null;
@@ -259,9 +324,11 @@ export function createControllerSelectModal(
setStatus(`Press a D-pad direction for ${definition.label}.`);
},
onDpadClear: (actionId) => {
if (!canEdit()) return;
void saveDpadFallback(actionId, 'none');
},
onDpadReset: (actionId) => {
if (!canEdit()) return;
void saveDpadFallback(actionId, getDefaultDpadFallback(actionId));
},
});
@@ -279,9 +346,18 @@ export function createControllerSelectModal(
function syncSelectedControllerId(): void {
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
selectedControllerKey = selected ? getDeviceSelectionKey(selected) : null;
// This window then reads that controller, so Learn hears the pad being configured.
ctx.state.controllerSelectGamepadId = selected?.id ?? null;
}
function syncSelectedIndexToCurrentController(): void {
// The pad you just pressed (newest input) is the one in your hands.
const recentIndex = findMostRecentlyUsedIndex(ctx.state.connectedGamepads);
if (recentIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = recentIndex;
syncSelectedControllerId();
return;
}
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const activeIndex = ctx.state.connectedGamepads.findIndex(
(device) => device.id === ctx.state.activeGamepadId,
@@ -331,12 +407,58 @@ export function createControllerSelectModal(
ctx.dom.controllerSelectPicker.disabled = ctx.state.connectedGamepads.length === 0;
ctx.dom.controllerSelectSummary.textContent =
ctx.state.connectedGamepads.length === 0
? 'No controller detected.'
? describePreview()
: `Active: ${ctx.state.activeGamepadId ?? 'none'} · Preferred: ${preferredId || 'none'}`;
lastRenderedDevicesKey = getDevicesKey();
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
lastRenderedPreferredId = preferredId;
renderFaceButtonStylePicker();
}
/** Which controller the locked preview shows, before this window sees one. */
function describePreview(): string {
const previewId = getSelectedControllerId();
if (!previewId) return 'No controller detected.';
const source = previewId === overlayActiveControllerId ? 'in use' : 'saved';
return `Showing ${shortControllerName(previewId)} (${source}).`;
}
/** Shows the button style of the controller being configured (its own, else the default). */
function renderFaceButtonStylePicker(): void {
const picker = ctx.dom.controllerSelectFaceButtonStyle;
picker.disabled = isEditingLocked();
const current = getSelectedControllerConfig()?.faceButtonLabels ?? 'xbox';
picker.innerHTML = '';
for (const style of CONTROLLER_FACE_BUTTON_STYLES) {
const option = document.createElement('option');
option.value = style.id;
option.textContent = `${style.name} (${style.hint})`;
option.selected = style.id === current;
picker.appendChild(option);
}
picker.value = current;
}
/** Saved for the controller being configured, like its remaps. */
async function saveFaceButtonStyle(
style: (typeof CONTROLLER_FACE_BUTTON_STYLES)[number],
): Promise<void> {
const profileId = getSelectedControllerId();
try {
await saveControllerConfig(
profileId
? { profiles: { [profileId]: { faceButtonLabels: style.id } } }
: { faceButtonLabels: style.id },
);
renderFaceButtonStylePicker();
controllerConfigForm.render();
setStatus(`Button style: ${style.name}.`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Failed to save button style: ${message}`, true);
renderFaceButtonStylePicker();
}
}
async function saveControllerConfig(
@@ -350,6 +472,9 @@ export function createControllerSelectModal(
if (update.preferredGamepadLabel !== undefined) {
ctx.state.controllerConfig.preferredGamepadLabel = update.preferredGamepadLabel;
}
if (update.faceButtonLabels) {
ctx.state.controllerConfig.faceButtonLabels = update.faceButtonLabels;
}
if (update.bindings) {
ctx.state.controllerConfig.bindings = {
...ctx.state.controllerConfig.bindings,
@@ -371,6 +496,7 @@ export function createControllerSelectModal(
const currentProfile = ctx.state.controllerConfig.profiles[profileId];
const baseProfile = currentProfile ?? {
label: profileUpdate.label ?? profileId,
faceButtonLabels: ctx.state.controllerConfig.faceButtonLabels,
buttonIndices: ctx.state.controllerConfig.buttonIndices,
bindings: ctx.state.controllerConfig.bindings,
reviewBindings: ctx.state.controllerConfig.reviewBindings,
@@ -378,6 +504,10 @@ export function createControllerSelectModal(
};
ctx.state.controllerConfig.profiles[profileId] = {
label: profileUpdate.label ?? baseProfile.label,
faceButtonLabels:
profileUpdate.faceButtonLabels ??
baseProfile.faceButtonLabels ??
ctx.state.controllerConfig.faceButtonLabels,
buttonIndices: {
...baseProfile.buttonIndices,
...(profileUpdate.buttonIndices ?? {}),
@@ -568,33 +698,27 @@ export function createControllerSelectModal(
controllerConfigForm.render();
}
// Polling publishes a snapshot every frame, pad or not: only a change in the devices
// sets the no-pad status, so it neither replaces the prompt nor a message like "Reset".
// Polling publishes a snapshot every frame, pad or not. Until the window sees a pad,
// editing is locked, so the only status is the prompt; it is set once, not every frame.
const idle = !learningActionId && !dpadLearningActionId && !screenLearning;
if (ctx.state.connectedGamepads.length === 0) {
if (idle && shouldRender) setStatus(getNoControllerStatus());
} else if (
idle &&
NO_CONTROLLER_STATUSES.has(ctx.dom.controllerSelectStatus.textContent ?? '')
) {
if (ctx.state.connectedGamepads.length === 0 && idle) {
if (ctx.dom.controllerSelectStatus.textContent !== PRESS_TO_EDIT_STATUS) {
setStatus(PRESS_TO_EDIT_STATUS);
}
} else if (idle && ctx.dom.controllerSelectStatus.textContent === PRESS_TO_EDIT_STATUS) {
setStatus('Choose a controller or click Learn to remap an action.');
}
}
/** A saved controller is only waiting for a press; without one there is nothing to show. */
function getNoControllerStatus(): string {
return ctx.state.controllerConfig?.preferredGamepadId
? WAITING_FOR_SAVED_CONTROLLER_STATUS
: NO_CONTROLLER_STATUS;
}
function openControllerSelectModal(): boolean {
if (ctx.state.controllerConfig?.enabled !== true) {
options.notifyControllerDisabled();
return false;
}
ctx.state.controllerSelectModalOpen = true;
overlayActiveControllerId = null;
syncSelectedIndexToCurrentController();
void loadOverlayActiveController();
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.controllerSelectModal.classList.remove('hidden');
@@ -604,7 +728,7 @@ export function createControllerSelectModal(
renderPicker();
controllerConfigForm.render();
if (ctx.state.connectedGamepads.length === 0) {
setStatus(getNoControllerStatus());
setStatus(PRESS_TO_EDIT_STATUS);
} else {
setStatus('Choose a controller or click Learn to remap an action.');
}
@@ -618,6 +742,7 @@ export function createControllerSelectModal(
bindingCapture = null;
cancelScreenLearning();
ctx.state.controllerSelectModalOpen = false;
ctx.state.controllerSelectGamepadId = null;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.controllerSelectModal.classList.add('hidden');
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'true');
@@ -687,6 +812,15 @@ export function createControllerSelectModal(
ctx.dom.controllerSelectSave.addEventListener('click', () => {
void saveSelectedController();
});
ctx.dom.controllerSelectFaceButtonStyle.addEventListener('change', () => {
if (!canEdit()) {
renderFaceButtonStylePicker();
return;
}
const value = ctx.dom.controllerSelectFaceButtonStyle.value;
const style = CONTROLLER_FACE_BUTTON_STYLES.find((candidate) => candidate.id === value);
if (style) void saveFaceButtonStyle(style);
});
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
const selectedKey = ctx.dom.controllerSelectPicker.value;
const selectedIndex = ctx.state.connectedGamepads.findIndex(
+118 -47
View File
@@ -24,6 +24,7 @@ import type {
SubtitlePosition,
SubsyncManualPayload,
ConfigHotReloadPayload,
ResolvedControllerConfig,
} from '../types';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import { createKeyboardHandlers } from './handlers/keyboard.js';
@@ -55,6 +56,8 @@ import {
formatMediaTimingReviewControllerHints,
} from './handlers/media-timing-review-controller.js';
import { resolveControllerConfigForGamepad } from './controller-profile-config.js';
import { dispatchConfiguredMpvCommand } from './utils/mpv-command-dispatch.js';
import { createKeyboardModeAutoPause } from './keyboard-mode-auto-pause.js';
import { getDefaultControllerReviewBindings } from '../shared/controller-review';
import {
CONTROLLER_FIELD_GROUPING_SCREEN,
@@ -339,7 +342,15 @@ const fieldGroupingControllerLayout = createControllerComboScreenLayout({
kikuModal.handleControllerAction(action);
},
});
const keyboardModeAutoPause = createKeyboardModeAutoPause({
isEnabled: () => ctx.state.autoPauseVideoOnKeyboardMode,
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command),
});
const keyboardHandlers = createKeyboardHandlers(ctx, {
onKeyboardDrivenModeChanged: (enabled) => {
void keyboardModeAutoPause.handleModeChanged(enabled);
},
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
handleSubtitleSelectionKeydown: subtitleSelectionModal.handleKeydown,
@@ -427,6 +438,12 @@ function applyControllerSnapshot(snapshot: {
}
ctx.state.connectedGamepads = snapshot.connectedGamepads;
ctx.state.activeGamepadId = snapshot.activeGamepadId;
// Alt+C opens in its own window that cannot see a pad until a press there; it asks main
// which controller the overlay is reading so it can preview the right one.
if (!ctx.platform.isModalLayer && snapshot.activeGamepadId !== reportedActiveControllerId) {
reportedActiveControllerId = snapshot.activeGamepadId;
window.electronAPI.reportActiveController?.(snapshot.activeGamepadId);
}
ctx.state.controllerRawAxes = snapshot.rawAxes;
ctx.state.controllerRawButtons = snapshot.rawButtons;
controllerSelectModal.updateDevices();
@@ -467,12 +484,33 @@ function applyControllerSnapshot(snapshot: {
}
}
/**
* While Alt+C is open, its window reads the controller selected there instead of the saved
* one, so Learn hears the pad being configured when several are connected.
*/
function withAltCSelectedController(config: ResolvedControllerConfig): ResolvedControllerConfig {
const selectedId = ctx.state.controllerSelectModalOpen
? ctx.state.controllerSelectGamepadId
: null;
return selectedId ? { ...config, preferredGamepadId: selectedId } : config;
}
let reportedActiveControllerId: string | null | undefined;
function showControllerDisabledNotice(): void {
controllerStatusIndicator.show(
'Controller support disabled. Set controller.enabled to true in config to use controller tools.',
);
}
/** A paused video stays paused after the jump, as with the keyboard shortcut. */
function seekSubtitleForController(delta: -1 | 1): void {
void dispatchConfiguredMpvCommand(['sub-seek', delta], {
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command),
});
}
function emitControllerPopupScroll(deltaPixels: number): void {
if (deltaPixels === 0) return;
keyboardHandlers.scrollPopupByController(0, deltaPixels);
@@ -492,54 +530,60 @@ function startControllerPolling(): void {
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,
faceButtonLabels: 'xbox',
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,
withAltCSelectedController(
ctx.state.controllerConfig ?? {
enabled: false,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 900,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
faceButtonLabels: 'xbox',
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 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
previousSubtitle: { kind: 'none' },
nextSubtitle: { kind: 'none' },
replaySubtitle: { kind: 'none' },
playNextSubtitle: { kind: 'none' },
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' },
},
reviewBindings: getDefaultControllerReviewBindings(),
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
profiles: {},
},
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 },
holdSecondarySub: { kind: 'none' },
previousEntry: { kind: 'none' },
nextEntry: { kind: 'none' },
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' },
},
reviewBindings: getDefaultControllerReviewBindings(),
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
profiles: {},
},
),
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
getLookupWindowOpen: () => isLookupPopupOpen(ctx.state, document),
getInteractionBlocked: () => isControllerInputBlocked(),
@@ -557,6 +601,19 @@ function startControllerPolling(): void {
quitMpv: () => {
window.electronAPI.sendMpvCommand(['quit']);
},
// Same as Shift+H / Shift+L and Ctrl+Shift+H / Ctrl+Shift+L.
previousSubtitle: () => {
seekSubtitleForController(-1);
},
nextSubtitle: () => {
seekSubtitleForController(1);
},
replaySubtitle: () => {
void window.electronAPI.dispatchSessionAction('replayCurrentSubtitle');
},
playNextSubtitle: () => {
void window.electronAPI.dispatchSessionAction('playNextSubtitle');
},
previousAudio: () => {
keyboardHandlers.cyclePopupAudioSourceForController(-1);
},
@@ -801,6 +858,20 @@ function registerKeyboardCommandHandlers(): void {
ctx.state.controllerSuspendedByModalWindow = suspended;
});
window.electronAPI.onPlaybackPauseChanged((paused: boolean) => {
keyboardModeAutoPause.handlePlaybackPauseChanged(paused);
});
// Alt+C saves in its own window; pick up remaps and the button style without a restart.
window.electronAPI.onControllerConfigChanged(() => {
window.electronAPI
.getControllerConfig()
.then((config) => {
ctx.state.controllerConfig = config;
})
.catch((error: unknown) => console.error('Could not reload controller config', error));
});
window.electronAPI.onKeyboardModeToggleRequested(() => {
runGuarded('keyboard-mode-toggle:requested', () => {
keyboardHandlers.handleKeyboardModeToggleRequested();
+5
View File
@@ -100,6 +100,8 @@ export type RendererState = {
controllerDebugModalOpen: boolean;
subtitleSidebarModalOpen: boolean;
controllerDeviceSelectedIndex: number;
/** While Alt+C is open, the controller selected in it; that window then reads this pad. */
controllerSelectGamepadId: string | null;
controllerConfig: ResolvedControllerConfig | null;
connectedGamepads: ControllerDeviceInfo[];
activeGamepadId: string | null;
@@ -141,6 +143,7 @@ export type RendererState = {
jlptN5Color: string;
preserveSubtitleLineBreaks: boolean;
autoPauseVideoOnSubtitleHover: boolean;
autoPauseVideoOnKeyboardMode: boolean;
autoPauseVideoOnYomitanPopup: boolean;
primaryVisibleOnYomitanPopup: boolean;
frequencyDictionaryEnabled: boolean;
@@ -241,6 +244,7 @@ export function createRendererState(): RendererState {
controllerDebugModalOpen: false,
subtitleSidebarModalOpen: false,
controllerDeviceSelectedIndex: 0,
controllerSelectGamepadId: null,
controllerConfig: null,
connectedGamepads: [],
activeGamepadId: null,
@@ -282,6 +286,7 @@ export function createRendererState(): RendererState {
jlptN5Color: '#8aadf4',
preserveSubtitleLineBreaks: false,
autoPauseVideoOnSubtitleHover: false,
autoPauseVideoOnKeyboardMode: false,
autoPauseVideoOnYomitanPopup: false,
primaryVisibleOnYomitanPopup: true,
frequencyDictionaryEnabled: false,
+24 -1
View File
@@ -2077,6 +2077,10 @@ body:focus-visible,
#kikuFieldGroupingModal.controller-hints-visible [data-controller-hint]::after {
content: attr(data-controller-hint);
text-transform: none;
/* Buttons use the system UI font, where ○ □ △ are small; Japanese fonts draw them (and
the ╳ used for the PlayStation cross) at full size, and SubMiner already expects one. */
font-family:
'M PLUS 1', 'Noto Sans CJK JP', 'Hiragino Sans', 'Yu Gothic', 'DejaVu Sans', sans-serif;
display: inline-block;
margin-left: 6px;
padding: 1px 5px;
@@ -2085,7 +2089,6 @@ body:focus-visible,
border-radius: 5px;
background: var(--ctp-mantle);
color: var(--ctp-subtext0);
font-family: inherit;
font-size: 9px;
font-weight: 750;
letter-spacing: 0.02em;
@@ -3485,6 +3488,8 @@ iframe[id^='yomitan-popup'],
border-radius: 8px;
background: rgba(24, 25, 38, 0.9);
color: var(--ctp-text);
/* Selects default to the system UI font, where the PlayStation ○ is small. */
font-family: inherit;
}
.controller-select-summary {
@@ -3595,6 +3600,7 @@ iframe[id^='yomitan-popup'],
padding: 2px 8px;
border: 0;
border-radius: 4px;
font-family: inherit; /* the overlay's Japanese-first fonts draw ○ □ △ at full size */
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
@@ -3630,13 +3636,30 @@ iframe[id^='yomitan-popup'],
color: var(--ctp-overlay2);
}
/* Before Alt+C sees a controller the rows are a preview: actions look unavailable. */
.controller-config-locked .controller-config-reset-icon,
.controller-config-locked .controller-config-edit-icon,
.controller-config-locked .controller-config-group-action,
.controller-config-locked .controller-config-edit-panel button {
opacity: 0.35;
cursor: not-allowed;
}
.controller-config-edit-panel {
/* The list is a height-capped flex column: without this the panel (overflow hidden, so no
minimum height) is squeezed to its border once the rows overflow. */
flex-shrink: 0;
overflow: hidden;
animation: configEditSlideIn 180ms ease-out;
border-bottom: 1px solid rgba(138, 173, 244, 0.15);
background: rgba(138, 173, 244, 0.04);
}
/* Redrawn while it stays open (after Learn, Reset, …): no slide-in again. */
.controller-config-edit-panel-kept {
animation: none;
}
@keyframes configEditSlideIn {
from {
max-height: 0;
+1
View File
@@ -964,6 +964,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false;
ctx.state.autoPauseVideoOnKeyboardMode = style.autoPauseVideoOnKeyboardMode ?? false;
ctx.state.primaryVisibleOnYomitanPopup = style.primaryVisibleOnYomitanPopup ?? true;
syncPrimaryVisibleOnYomitanPopupClass(ctx);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
+4
View File
@@ -155,6 +155,7 @@ export type RendererDom = {
controllerSelectModal: HTMLDivElement;
controllerSelectClose: HTMLButtonElement;
controllerSelectPicker: HTMLSelectElement;
controllerSelectFaceButtonStyle: HTMLSelectElement;
controllerSelectSummary: HTMLDivElement;
controllerSelectStatus: HTMLDivElement;
controllerConfigList: HTMLDivElement;
@@ -432,6 +433,9 @@ export function resolveRendererDom(): RendererDom {
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
controllerSelectPicker: getRequiredElement<HTMLSelectElement>('controllerSelectPicker'),
controllerSelectFaceButtonStyle: getRequiredElement<HTMLSelectElement>(
'controllerSelectFaceButtonStyle',
),
controllerSelectSummary: getRequiredElement<HTMLDivElement>('controllerSelectSummary'),
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
controllerConfigList: getRequiredElement<HTMLDivElement>('controllerConfigList'),
+54
View File
@@ -6,6 +6,8 @@ import {
controllerReviewInputSourceKey,
formatControllerReviewBinding,
formatControllerReviewInput,
getControllerFaceButtonName,
isControllerFaceButtonLabels,
parseControllerReviewBinding,
resolveControllerReviewInput,
type ControllerReviewInputContext,
@@ -148,3 +150,55 @@ test('Alt+C updates accept field grouping bindings, checked against that dialog'
);
assert.equal(parseControllerConfigUpdate({ reviewBindings: { back: { kind: 'none' } } }), null);
});
test('PlayStation pads print symbols at the Xbox positions', () => {
const playstation = { ...standardXbox, faceButtonLabels: 'playstation' as const };
const name = (input: Parameters<typeof formatControllerReviewInput>[0]) =>
formatControllerReviewInput(input, playstation);
// Same physical buttons as Xbox: A is the bottom button, shown as ╳.
assert.equal(
controllerReviewInputSourceKey(
resolveControllerReviewInput({ kind: 'face', letter: 'a' }, playstation),
),
'button:0',
);
assert.deepEqual(
(['a', 'b', 'x', 'y'] as const).map((letter) => name({ kind: 'face', letter })),
['╳', '○', '□', '△'],
);
assert.equal(name({ kind: 'button', buttonIndex: 3 }), '△');
assert.equal(
formatControllerReviewBinding(
{
kind: 'combo',
press: { kind: 'face', letter: 'a' },
hold: { kind: 'button', buttonIndex: 4 },
},
playstation,
),
'L1 + ╳',
);
assert.equal(getControllerFaceButtonName(1, 'nintendo'), 'A');
assert.equal(getControllerFaceButtonName(4, 'playstation'), null);
assert.equal(isControllerFaceButtonLabels('playstation'), true);
assert.equal(isControllerFaceButtonLabels('sega'), false);
});
test('Alt+C updates accept a button style globally and per controller', () => {
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
assert.deepEqual(
plain(
parseControllerConfigUpdate({
faceButtonLabels: 'xbox',
profiles: { 'pad-1': { faceButtonLabels: 'playstation' } },
}),
),
{ faceButtonLabels: 'xbox', profiles: { 'pad-1': { faceButtonLabels: 'playstation' } } },
);
assert.equal(parseControllerConfigUpdate({ faceButtonLabels: 'sega' }), null);
assert.equal(
parseControllerConfigUpdate({ profiles: { 'pad-1': { faceButtonLabels: 1 } } }),
null,
);
});
+50 -11
View File
@@ -246,15 +246,35 @@ export type ControllerReviewInputSource =
type FacePosition = 'south' | 'east' | 'west' | 'north';
/** Xbox (and PlayStation) pads put A at the bottom; Nintendo-style pads put it on the right. */
/**
* Where each layout letter sits. Xbox pads put A at the bottom, Nintendo-style pads on the
* right. PlayStation pads follow the Xbox layout, so A is ╳, B ○, X □ and Y △.
*/
const FACE_POSITIONS: Record<
ControllerFaceButtonLabels,
Record<ControllerFaceLetter, FacePosition>
> = {
xbox: { a: 'south', b: 'east', x: 'west', y: 'north' },
nintendo: { a: 'east', b: 'south', x: 'north', y: 'west' },
playstation: { a: 'south', b: 'east', x: 'west', y: 'north' },
};
/** Every face button style, in the order Alt+C offers them. */
export const CONTROLLER_FACE_BUTTON_STYLES: readonly {
id: ControllerFaceButtonLabels;
name: string;
/** Tells the layouts apart, since 8BitDo and other pads come in more than one. */
hint: string;
}[] = [
{ id: 'xbox', name: 'Xbox', hint: 'A at the bottom, B on the right' },
{ id: 'nintendo', name: 'Nintendo', hint: 'B at the bottom, A on the right' },
{ id: 'playstation', name: 'PlayStation', hint: '\u2573 at the bottom, \u25cb on the right' },
];
export function isControllerFaceButtonLabels(value: unknown): value is ControllerFaceButtonLabels {
return CONTROLLER_FACE_BUTTON_STYLES.some((style) => style.id === value);
}
const POSITION_INDEX_KEYS = {
south: 'buttonSouth',
east: 'buttonEast',
@@ -359,11 +379,6 @@ const DPAD_GLYPHS: Record<ControllerDpadDirection, string> = {
down: '▼',
};
const LETTER_AT_POSITION: Record<ControllerFaceButtonLabels, Record<FacePosition, string>> = {
xbox: { south: 'A', east: 'B', west: 'X', north: 'Y' },
nintendo: { south: 'B', east: 'A', west: 'Y', north: 'X' },
};
const FACE_POSITION_BY_INDEX: Record<number, FacePosition> = {
0: 'south',
1: 'east',
@@ -371,6 +386,32 @@ const FACE_POSITION_BY_INDEX: Record<number, FacePosition> = {
3: 'north',
};
/** What is printed at each position, per style. */
const LETTER_AT_POSITION: Record<ControllerFaceButtonLabels, Record<FacePosition, string>> = {
xbox: { south: 'A', east: 'B', west: 'X', north: 'Y' },
nintendo: { south: 'B', east: 'A', west: 'Y', north: 'X' },
playstation: { south: '\u2573', east: '\u25cb', west: '\u25a1', north: '\u25b3' },
};
/** The printed name of the face button at index 0-3 (south, east, west, north), if any. */
export function getControllerFaceButtonName(
buttonIndex: number,
faceButtonLabels: ControllerFaceButtonLabels,
): string | null {
const position = FACE_POSITION_BY_INDEX[buttonIndex];
if (!position) return null;
return (LETTER_AT_POSITION[faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[position];
}
/** How a layout letter is printed on this style, e.g. `a` is `A` on Xbox and `╳` on PlayStation. */
function formatFaceLetter(
letter: ControllerFaceLetter,
faceButtonLabels: ControllerFaceButtonLabels,
): string {
const positions = FACE_POSITIONS[faceButtonLabels] ?? FACE_POSITIONS.xbox;
return (LETTER_AT_POSITION[faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[positions[letter]];
}
const STANDARD_BUTTON_NAMES: Record<number, string> = {
4: 'L1',
5: 'R1',
@@ -420,10 +461,8 @@ function formatSource(
}
const standard = isStandardMapping(context.mapping);
if (source.kind === 'button') {
const position = FACE_POSITION_BY_INDEX[source.buttonIndex];
if (position) {
return (LETTER_AT_POSITION[context.faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[position];
}
const faceName = getControllerFaceButtonName(source.buttonIndex, context.faceButtonLabels);
if (faceName) return faceName;
const names = standard ? STANDARD_BUTTON_NAMES : RAW_BUTTON_NAMES;
return names[source.buttonIndex] ?? `Button ${source.buttonIndex}`;
}
@@ -448,7 +487,7 @@ export function formatControllerReviewInput(
options: { dpadPrefix?: boolean } = {},
): string {
// Letters are shown as printed, whatever position they resolve to.
if (input.kind === 'face') return input.letter.toUpperCase();
if (input.kind === 'face') return formatFaceLetter(input.letter, context.faceButtonLabels);
return formatSource(
resolveControllerReviewInput(input, context),
context,
+4
View File
@@ -34,6 +34,7 @@ export const IPC_CHANNELS = {
statsNativeDialogOpened: 'stats:native-dialog-opened',
statsNativeDialogClosed: 'stats:native-dialog-closed',
saveControllerConfig: 'save-controller-config',
reportActiveController: 'controller:report-active',
mpvCommand: 'mpv-command',
refreshKnownWords: 'anki:refresh-known-words',
kikuFieldGroupingRespond: 'kiku:field-grouping-respond',
@@ -72,6 +73,7 @@ export const IPC_CHANNELS = {
getMarkWatchedKey: 'get-mark-watched-key',
getOverlayNotificationPosition: 'get-overlay-notification-position',
getControllerConfig: 'get-controller-config',
getActiveControllerId: 'controller:get-active',
getSecondarySubMode: 'get-secondary-sub-mode',
getCurrentSecondarySub: 'get-current-secondary-sub',
youtubePickerResolve: 'youtube:picker-resolve',
@@ -159,6 +161,8 @@ export const IPC_CHANNELS = {
sessionNumericSelectionStart: 'session:numeric-selection-start',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
controllerSuspendedChanged: 'controller:suspended-changed',
controllerConfigChanged: 'controller:config-changed',
playbackPauseChanged: 'mpv:pause-changed',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
changelogOpen: 'changelog:open',
+13
View File
@@ -17,6 +17,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
import type { SessionActionId, SessionActionPayload } from '../../types/session-bindings';
import type { SubtitlePosition } from '../../types/subtitle';
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
import { isControllerFaceButtonLabels } from '../controller-review';
import { CONTROLLER_SCREENS, parseControllerScreenBindingsPatch } from '../controller-screens';
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -221,6 +222,10 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
'holdSecondarySub',
'previousEntry',
'nextEntry',
'previousSubtitle',
'nextSubtitle',
'replaySubtitle',
'playNextSubtitle',
] as const;
for (const key of discreteKeys) {
if (value[key] === undefined) continue;
@@ -273,6 +278,10 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
if (typeof value.preferredGamepadLabel !== 'string') return null;
update.preferredGamepadLabel = value.preferredGamepadLabel;
}
if (value.faceButtonLabels !== undefined) {
if (!isControllerFaceButtonLabels(value.faceButtonLabels)) return null;
update.faceButtonLabels = value.faceButtonLabels;
}
if (value.buttonIndices !== undefined) {
const parsed = parseControllerButtonIndices(value.buttonIndices);
if (!parsed) return null;
@@ -297,6 +306,10 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
if (typeof rawProfile.label !== 'string') return null;
profile.label = rawProfile.label;
}
if (rawProfile.faceButtonLabels !== undefined) {
if (!isControllerFaceButtonLabels(rawProfile.faceButtonLabels)) return null;
profile.faceButtonLabels = rawProfile.faceButtonLabels;
}
if (rawProfile.buttonIndices !== undefined) {
const parsed = parseControllerButtonIndices(rawProfile.buttonIndices);
if (!parsed) return null;
+26 -2
View File
@@ -176,8 +176,11 @@ export type ControllerButtonBinding =
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
/** Letters printed on the face buttons: Xbox-style (A at the bottom) or Nintendo-style (A on the right). */
export type ControllerFaceButtonLabels = 'xbox' | 'nintendo';
/**
* Face button style: Xbox (A at the bottom), Nintendo (A on the right) or PlayStation
* (╳ at the bottom, laid out like Xbox). Decides button names and where letters land.
*/
export type ControllerFaceButtonLabels = 'xbox' | 'nintendo' | 'playstation';
export type ControllerAxisDirection = 'negative' | 'positive';
export type ControllerDpadFallback = 'none' | 'horizontal' | 'vertical';
@@ -239,6 +242,10 @@ export interface ControllerBindingsConfig {
holdSecondarySub?: ControllerDiscreteBindingConfig;
previousEntry?: ControllerDiscreteBindingConfig;
nextEntry?: ControllerDiscreteBindingConfig;
previousSubtitle?: ControllerDiscreteBindingConfig;
nextSubtitle?: ControllerDiscreteBindingConfig;
replaySubtitle?: ControllerDiscreteBindingConfig;
playNextSubtitle?: ControllerDiscreteBindingConfig;
leftStickHorizontal?: ControllerAxisBindingConfig;
leftStickVertical?: ControllerAxisBindingConfig;
rightStickHorizontal?: ControllerAxisBindingConfig;
@@ -258,6 +265,10 @@ export interface ResolvedControllerBindingsConfig {
holdSecondarySub?: ResolvedControllerDiscreteBinding;
previousEntry?: ResolvedControllerDiscreteBinding;
nextEntry?: ResolvedControllerDiscreteBinding;
previousSubtitle?: ResolvedControllerDiscreteBinding;
nextSubtitle?: ResolvedControllerDiscreteBinding;
replaySubtitle?: ResolvedControllerDiscreteBinding;
playNextSubtitle?: ResolvedControllerDiscreteBinding;
leftStickHorizontal?: ResolvedControllerAxisBinding;
leftStickVertical?: ResolvedControllerAxisBinding;
rightStickHorizontal?: ResolvedControllerAxisBinding;
@@ -357,6 +368,8 @@ export type ResolvedControllerFieldGroupingBindings = Record<
export interface ControllerProfileConfig {
label?: string;
/** Overrides `controller.faceButtonLabels` for this controller; set from Alt+C. */
faceButtonLabels?: ControllerFaceButtonLabels;
buttonIndices?: ControllerButtonIndicesConfig;
bindings?: ControllerBindingsConfig;
reviewBindings?: ControllerReviewBindingsConfig;
@@ -365,6 +378,7 @@ export interface ControllerProfileConfig {
export interface ResolvedControllerProfileConfig {
label: string;
faceButtonLabels: ControllerFaceButtonLabels;
buttonIndices: Required<ControllerButtonIndicesConfig>;
bindings: Required<ResolvedControllerBindingsConfig>;
reviewBindings: ResolvedControllerReviewBindings;
@@ -398,6 +412,8 @@ export interface ControllerDeviceInfo {
index: number;
mapping: string;
connected: boolean;
/** Gamepad API timestamp of the pad's last input; the newest is the one being used. */
timestamp?: number;
}
export interface ControllerButtonSnapshot {
@@ -593,6 +609,10 @@ export interface ElectronAPI {
markActiveVideoWatched: () => Promise<boolean>;
getControllerConfig: () => Promise<ResolvedControllerConfig>;
saveControllerConfig: (update: ControllerConfigUpdate) => Promise<void>;
/** The overlay reports which controller it is reading (null when none). */
reportActiveController: (gamepadId: string | null) => void;
/** The controller the overlay last reported, for Alt+C before it sees a pad itself. */
getActiveControllerId: () => Promise<string | null>;
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
@@ -673,6 +693,10 @@ export interface ElectronAPI {
) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void;
onControllerSuspendedChanged: (callback: (suspended: boolean) => void) => void;
/** Alt+C saved controller settings; reload them with `getControllerConfig`. */
onControllerConfigChanged: (callback: () => void) => void;
/** mpv's pause property changed, from any source. */
onPlaybackPauseChanged: (callback: (paused: boolean) => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void;
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (path: string) => Promise<PlaylistBrowserMutationResult>;
+1
View File
@@ -94,6 +94,7 @@ export interface SubtitleStyleConfig {
preserveLineBreaks?: boolean;
autoPauseVideoOnHover?: boolean;
autoPauseVideoOnYomitanPopup?: boolean;
autoPauseVideoOnKeyboardMode?: boolean;
primaryVisibleOnYomitanPopup?: boolean;
hoverTokenColor?: string;
hoverTokenBackgroundColor?: string;