mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
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:
1 parent
6d0f37ac21
commit
b99409bbcd
60 files changed
+1900
-219
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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' };
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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')), '△');
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface SubtitleStyleConfig {
|
||||
preserveLineBreaks?: boolean;
|
||||
autoPauseVideoOnHover?: boolean;
|
||||
autoPauseVideoOnYomitanPopup?: boolean;
|
||||
autoPauseVideoOnKeyboardMode?: boolean;
|
||||
primaryVisibleOnYomitanPopup?: boolean;
|
||||
hoverTokenColor?: string;
|
||||
hoverTokenBackgroundColor?: string;
|
||||
|
||||
Reference in new issue
Block a user