Controller: layouts for the media timing review and duplicate card dialogs (#313)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YashaNoosandClaude Opus 5.5 authored and GitHub committed 2026-11-02 01:38:26 -08:00
1 parent 696462a82e
commit 6d0f37ac21
50 files changed
+4555 -46

No files matched your search

+62 -9
View File
@@ -13,7 +13,7 @@ type ControllerButtonState = {
touched?: boolean;
};
type GamepadLike = {
export type GamepadLike = {
id: string;
index: number;
connected: boolean;
@@ -22,6 +22,22 @@ type GamepadLike = {
buttons: readonly ControllerButtonState[];
};
/**
* A screen that takes over every controller input while it is open (for example the
* media timing review). `activate` runs on the first poll of a new session; with
* `latchHeld` it ignores buttons already held (so the press that opened the screen does
* not fire), otherwise it treats them as fresh presses. `poll` runs on every later frame.
*/
export type ControllerScreenLayout = {
activate: (
gamepad: GamepadLike,
config: ResolvedControllerConfig,
now: number,
options: { latchHeld: boolean },
) => void;
poll: (gamepad: GamepadLike, config: ResolvedControllerConfig, now: number) => void;
};
type GamepadControllerOptions = {
getGamepads: () => Array<GamepadLike | null>;
getConfig: () => ResolvedControllerConfig;
@@ -44,9 +60,13 @@ type GamepadControllerOptions = {
scrollPopup: (deltaPixels: number) => void;
jumpPopup: (deltaPixels: number) => void;
onState: (state: ControllerRuntimeSnapshot) => void;
/** Screen layout that currently owns the controller in this window, if any. */
getScreenLayout?: () => ControllerScreenLayout | null;
/** True while a modal in another window owns the controller; this window stands down. */
getSuspended?: () => boolean;
};
type HoldState = {
export type HoldState = {
repeatStarted: boolean;
direction: -1 | 1 | null;
lastFireAt: number;
@@ -65,7 +85,7 @@ const DPAD_AXIS_INDEX = {
vertical: 7,
} as const;
function normalizeRawButtonState(
export function normalizeRawButtonState(
button: ControllerButtonState | undefined,
triggerDeadzone: number,
): boolean {
@@ -114,7 +134,7 @@ function resolveDpadAxisValue(gamepad: GamepadLike, axisIndex: number): number {
return Math.sign(value);
}
function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
export function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
if (axisValue !== 0) {
return axisValue;
@@ -127,7 +147,7 @@ function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: numbe
);
}
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
export function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
if (axisValue !== 0) {
return axisValue;
@@ -141,7 +161,7 @@ function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadL
.sort((left, right) => left.index - right.index);
}
function createHoldState(): HoldState {
export function createHoldState(): HoldState {
return {
repeatStarted: false,
direction: null,
@@ -150,7 +170,7 @@ function createHoldState(): HoldState {
};
}
function shouldFireHeldAction(
export function shouldFireHeldAction(
state: HoldState,
now: number,
repeatDelayMs: number,
@@ -173,7 +193,7 @@ function shouldFireHeldAction(
return true;
}
function resetHeldAction(state: HoldState): void {
export function resetHeldAction(state: HoldState): void {
state.repeatStarted = false;
state.direction = null;
state.lastFireAt = 0;
@@ -198,7 +218,7 @@ function syncHeldActionBlocked(
state.initialFired = true;
}
function resolveDiscreteBindingPressed(
export function resolveDiscreteBindingPressed(
gamepad: GamepadLike,
binding: ResolvedControllerDiscreteBinding,
config: ResolvedControllerConfig,
@@ -263,6 +283,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
let activeGamepadId: string | null = null;
let lastPollAt: number | null = null;
let secondarySubHeld = false;
let activeScreenLayout: ControllerScreenLayout | null = null;
let screenLayoutWaitingForPad = false;
function getConnectedGamepads(): GamepadLike[] {
return resolveConnectedGamepads(options.getGamepads());
@@ -423,6 +445,11 @@ export function createGamepadController(options: GamepadControllerOptions) {
if (!activeGamepad) {
syncSecondarySubHold(false);
activeScreenLayout = null;
// Browsers hide a pad from a page until a button is pressed while it is open, and
// each review opens in a fresh window. When a screen is already waiting for the pad,
// the press that reveals it started after the screen opened, so it should count.
screenLayoutWaitingForPad = config.enabled && (options.getScreenLayout?.() ?? null) !== null;
previousActions = new Map();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
@@ -431,6 +458,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
}
if (activeGamepad.id !== previousActiveGamepadId) {
activeScreenLayout = null;
previousActions = new Map();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
@@ -438,6 +466,31 @@ export function createGamepadController(options: GamepadControllerOptions) {
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
// A screen layout or another window owning the controller pauses every normal action,
// including the keyboard-mode toggle and the secondary subtitle hold. Their edge state
// keeps tracking the pad, so a button still held when control returns does not fire.
const screenLayout = activeConfig.enabled ? (options.getScreenLayout?.() ?? null) : null;
const suspended = screenLayout === null && (options.getSuspended?.() ?? false);
if (screenLayout !== null || suspended) {
syncSecondarySubHold(false);
syncBlockedInteractionState(activeGamepad, activeConfig, now);
if (screenLayout === null) {
activeScreenLayout = null;
screenLayoutWaitingForPad = false;
} else if (screenLayout !== activeScreenLayout) {
activeScreenLayout = screenLayout;
screenLayout.activate(activeGamepad, activeConfig, now, {
latchHeld: !screenLayoutWaitingForPad,
});
screenLayoutWaitingForPad = false;
} else {
screenLayout.poll(activeGamepad, activeConfig, now);
}
return;
}
activeScreenLayout = null;
screenLayoutWaitingForPad = false;
// Unlike most actions, this one does not require keyboard-only mode, so it can be
// used while simply watching.
syncSecondarySubHold(