mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
glorp!
This commit is contained in:
@@ -68,8 +68,8 @@ export default function CardFrame({
|
||||
? { borderColor: '#008a35' }
|
||||
: accentRgb
|
||||
? {
|
||||
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
|
||||
// backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.1)} 100%)`,
|
||||
// backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
|
||||
backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.2)} 100%)`,
|
||||
// backgroundImage: `background-color: ${rgba(accentRgb, 0.2)}`
|
||||
}
|
||||
: undefined;
|
||||
@@ -100,7 +100,7 @@ export default function CardFrame({
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
{title ? (
|
||||
<p className={cx('m-0 text-[0.78rem] font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
|
||||
<p className={cx('m-0 font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
|
||||
{title}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Adaptive Control Hint
|
||||
// Purpose: Renders the binding for a logical action using the user's most recently used input type.
|
||||
// Scope: Keeps the render component separate from its hook so React fast refresh can safely
|
||||
// replace this module without treating a non-component export as component state.
|
||||
import { useControlHintLabel } from './useControlHintLabel.js';
|
||||
|
||||
export default function ControlHint({ actionId }) {
|
||||
return <>{useControlHintLabel(actionId)}</>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Adaptive Control Hint Label Hook
|
||||
// Purpose: Resolves a logical action to the keyboard or controller label appropriate for the
|
||||
// operator's most recently used input device.
|
||||
// Scope: Reads control/settings state only; it never captures input or dispatches rover commands.
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useControllerRuntime } from '../../controls/inputs/controllerRuntime.js';
|
||||
import { formatControllerBinding } from '../../controls/inputs/controllerLabels.js';
|
||||
import { resolveGamepadProfile } from '../../controls/inputs/gamepadBindings.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
|
||||
export function useControlHintLabel(actionId) {
|
||||
const keyValue = useControlSelector((control) => control.state.keymap?.[actionId]?.[0]);
|
||||
const runtime = useControllerRuntime();
|
||||
const { value: gamepadSettings } = useSettingsNamespace('gamepad', GAMEPAD_SETTINGS_DEFAULTS);
|
||||
|
||||
if (runtime.inputMethod !== 'controller' || !runtime.controller) {
|
||||
return formatKeyLabel(keyValue);
|
||||
}
|
||||
|
||||
/* Profiles remain keyed by reusable hardware signature, while runtime controller selection is
|
||||
instance-specific. This lets two identical connected pads share a mapping without losing the
|
||||
browser slot used to decide which one currently owns control. */
|
||||
const storedProfile =
|
||||
gamepadSettings?.profiles?.[runtime.controller.signature] ??
|
||||
gamepadSettings?.defaults?.profile ??
|
||||
GAMEPAD_PROFILE_DEFAULT;
|
||||
const profile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
||||
return formatControllerBinding(profile, actionId, runtime.controller);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import '../MobileControls/mobileControls.css';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
@@ -90,7 +90,6 @@ export default function DriveDockAction({
|
||||
}) {
|
||||
const isMobile = layout === 'mobile';
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const actions = useControlActions();
|
||||
const dockAssist = useManualDockAssist();
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
@@ -104,8 +103,8 @@ export default function DriveDockAction({
|
||||
const driveDisabled = !roverId || pending !== null;
|
||||
const dockDisabled = !roverId || pending !== null;
|
||||
|
||||
const driveKeyLabel = formatKeyLabel(keymap?.driveMacro?.[0]);
|
||||
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
|
||||
const driveKeyLabel = <ControlHint actionId="driveMacro" />;
|
||||
const dockKeyLabel = <ControlHint actionId="dockMacro" />;
|
||||
|
||||
const dockInstructions = {
|
||||
summary: 'Use assist mode to manually line up with the dock.',
|
||||
|
||||
@@ -7,13 +7,15 @@ import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settin
|
||||
import {
|
||||
computeGamepadOutputs,
|
||||
createProfileForPad,
|
||||
resolveGamepadProfile,
|
||||
} from '../../controls/inputs/gamepadBindings.js';
|
||||
import { useGamepadHubState } from '../../controls/inputs/gamepadHub.js';
|
||||
import { acquireControllerControlLock } from '../../controls/inputs/controllerRuntime.js';
|
||||
import { describeController, formatControllerBinding } from '../../controls/inputs/controllerLabels.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import SliderField from './SliderField.jsx';
|
||||
import { ACTIONS, NUMBER_FORMAT } from './constants.js';
|
||||
import {
|
||||
formatSource,
|
||||
groupActions,
|
||||
pickActivePad,
|
||||
snapshotBaseline,
|
||||
@@ -26,21 +28,45 @@ function SettingsGroupLabel({ children }) {
|
||||
return <p className="mx-auto w-full max-w-lg text-sm font-semibold text-white">{children}</p>;
|
||||
}
|
||||
|
||||
function CurveField({ label, value, onChange }) {
|
||||
return (
|
||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
|
||||
<span className="font-semibold">{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
<option value="linear">Linear</option>
|
||||
<option value="expo">Fine center control</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function MappingRow({
|
||||
action,
|
||||
source,
|
||||
sourceLabel,
|
||||
liveValue,
|
||||
isCapturing,
|
||||
onClear,
|
||||
onCapture,
|
||||
onInvert,
|
||||
disabled,
|
||||
}) {
|
||||
// Mapping rows are constrained to a readable width so the source text and buttons remain
|
||||
// visually connected. Buttons wrap on very narrow panes instead of forcing tiny text.
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[520px]:grid-cols-1">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold leading-snug text-white">{action.label}</p>
|
||||
<p className="mt-0.5 text-xs leading-snug text-white">{formatSource(source)}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-semibold leading-snug text-white">{action.label}</p>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${liveValue ? 'bg-emerald-400' : 'bg-neutral-600'}`} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-snug text-white">{sourceLabel}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1 max-[520px]:justify-start">
|
||||
{/* Axis-pair controls expose independent inversion because stick X/Y directions often
|
||||
@@ -50,7 +76,7 @@ function MappingRow({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
disabled={disabled || !source}
|
||||
onClick={() => onInvert(action, 'invertX')}
|
||||
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
@@ -58,7 +84,7 @@ function MappingRow({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
disabled={disabled || !source}
|
||||
onClick={() => onInvert(action, 'invertY')}
|
||||
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
@@ -71,7 +97,7 @@ function MappingRow({
|
||||
{action.kind === 'axis' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
disabled={disabled || !source}
|
||||
onClick={() => onInvert(action)}
|
||||
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
@@ -80,17 +106,18 @@ function MappingRow({
|
||||
)}
|
||||
{/* Clear and Capture are always present because they are the primary row actions. They
|
||||
wrap with the inversion controls on narrow panes instead of shrinking text. */}
|
||||
<button type="button" onClick={() => onClear(action)} className="button-dark px-1 py-0.5 text-xs">
|
||||
<button type="button" disabled={disabled} onClick={() => onClear(action)} className="button-dark px-1 py-0.5 text-xs disabled:opacity-50">
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onCapture(action)}
|
||||
className={`${
|
||||
isCapturing
|
||||
? 'rounded-md bg-emerald-500 px-1 py-0.5 text-emerald-950 hover:bg-emerald-400'
|
||||
: 'button-dark px-1 py-0.5'
|
||||
} text-xs font-medium`}
|
||||
} text-xs font-medium disabled:opacity-50`}
|
||||
>
|
||||
{isCapturing ? 'Waiting...' : 'Capture'}
|
||||
</button>
|
||||
@@ -106,24 +133,38 @@ export default function GamepadMappingSettings() {
|
||||
GAMEPAD_SETTINGS_DEFAULTS,
|
||||
);
|
||||
const [captureAction, setCaptureAction] = useState(null);
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
const baselineRef = useRef(null);
|
||||
const grouped = useMemo(() => groupActions(ACTIONS), []);
|
||||
const captureCandidateRef = useRef(null);
|
||||
const grouped = useMemo(() => {
|
||||
const query = actionFilter.trim().toLowerCase();
|
||||
const visibleActions = query
|
||||
? ACTIONS.filter((action) => `${action.label} ${action.section}`.toLowerCase().includes(query))
|
||||
: ACTIONS;
|
||||
return groupActions(visibleActions);
|
||||
}, [actionFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
/* The Controller tab is a diagnostic surface. Locking for its entire mounted lifetime makes
|
||||
calibration and casual input testing safe, not only the brief moment a binding is captured. */
|
||||
return acquireControllerControlLock('controller-settings');
|
||||
}, []);
|
||||
|
||||
const activePad = useMemo(
|
||||
() => pickActivePad(hubState.pads, gamepadSettings.activeSignature),
|
||||
[hubState.pads, gamepadSettings.activeSignature],
|
||||
() => pickActivePad(hubState.pads, gamepadSettings.activeInstanceKey),
|
||||
[hubState.pads, gamepadSettings.activeInstanceKey],
|
||||
);
|
||||
|
||||
const activeSignature = activePad?.signature ?? null;
|
||||
const activeProfile = useMemo(() => {
|
||||
if (!activeSignature) {
|
||||
return gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||
}
|
||||
return (
|
||||
const storedProfile = !activeSignature
|
||||
? gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT
|
||||
: (
|
||||
gamepadSettings?.profiles?.[activeSignature] ??
|
||||
gamepadSettings?.defaults?.profile ??
|
||||
GAMEPAD_PROFILE_DEFAULT
|
||||
);
|
||||
);
|
||||
return resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
||||
}, [activeSignature, gamepadSettings?.defaults?.profile, gamepadSettings?.profiles]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,7 +173,7 @@ export default function GamepadMappingSettings() {
|
||||
saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
if (current.profiles?.[activeSignature]) return current;
|
||||
const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||
const base = resolveGamepadProfile(current?.defaults?.profile, GAMEPAD_PROFILE_DEFAULT);
|
||||
const nextProfile = createProfileForPad(activePad, base);
|
||||
return {
|
||||
...current,
|
||||
@@ -146,6 +187,7 @@ export default function GamepadMappingSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
baselineRef.current = null;
|
||||
captureCandidateRef.current = null;
|
||||
}, [captureAction, activeSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,16 +196,45 @@ export default function GamepadMappingSettings() {
|
||||
baselineRef.current = snapshotBaseline(activePad);
|
||||
return;
|
||||
}
|
||||
const descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction);
|
||||
let descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction);
|
||||
if (captureAction.kind === 'button' && descriptor?.kind !== 'chord') {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
if (descriptor && !captureCandidateRef.current) {
|
||||
/* Give the user a short window to add a modifier after the first button. Immediate capture
|
||||
makes chords physically impossible because browser frames never report both presses at
|
||||
precisely the same instant. */
|
||||
captureCandidateRef.current = { descriptor, startedAt: now };
|
||||
return;
|
||||
}
|
||||
if (!captureCandidateRef.current) return;
|
||||
if (now - captureCandidateRef.current.startedAt < 220) return;
|
||||
/* A quick tap may already be released when the chord window expires. Preserve the original
|
||||
candidate so capture completes normally instead of waiting for an unrelated later press. */
|
||||
descriptor = captureCandidateRef.current.descriptor;
|
||||
}
|
||||
if (!descriptor) return;
|
||||
saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
const baseProfile =
|
||||
current.profiles?.[activeSignature] ?? current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT;
|
||||
const baseProfile = resolveGamepadProfile(
|
||||
current.profiles?.[activeSignature] ?? current?.defaults?.profile,
|
||||
GAMEPAD_PROFILE_DEFAULT,
|
||||
);
|
||||
const descriptorKey = JSON.stringify(descriptor);
|
||||
const bindingsWithoutConflict = Object.fromEntries(
|
||||
Object.entries(baseProfile.bindings ?? {}).map(([actionId, binding]) => {
|
||||
if (actionId === captureAction.id) return [actionId, binding];
|
||||
const sources = (binding?.sources ?? []).filter(
|
||||
(source) => JSON.stringify(source) !== descriptorKey,
|
||||
);
|
||||
return [actionId, { ...binding, sources }];
|
||||
}),
|
||||
);
|
||||
const nextProfile = {
|
||||
...baseProfile,
|
||||
bindings: {
|
||||
...(baseProfile.bindings ?? {}),
|
||||
/* A physical input has one owner by default. Removing an exact duplicate avoids two
|
||||
toggles firing from one press while still allowing deliberate multi-button chords. */
|
||||
...bindingsWithoutConflict,
|
||||
[captureAction.id]: {
|
||||
...(baseProfile.bindings?.[captureAction.id] ?? {}),
|
||||
kind: captureAction.kind,
|
||||
@@ -179,14 +250,20 @@ export default function GamepadMappingSettings() {
|
||||
},
|
||||
};
|
||||
});
|
||||
setCaptureAction(null);
|
||||
/* Hub snapshots drive this effect, but capture state is React-owned UI state. Deferring its
|
||||
reset to a microtask avoids a synchronous state cascade inside the effect while the id
|
||||
guard prevents an older completion from cancelling a newer capture request. */
|
||||
const completedActionId = captureAction.id;
|
||||
queueMicrotask(() => {
|
||||
setCaptureAction((current) => current?.id === completedActionId ? null : current);
|
||||
});
|
||||
}, [activePad, activeSignature, captureAction, saveGamepadSettings]);
|
||||
|
||||
const setActiveSignature = useCallback(
|
||||
(signature) => {
|
||||
const setActiveInstanceKey = useCallback(
|
||||
(instanceKey) => {
|
||||
saveGamepadSettings((prev) => ({
|
||||
...(prev ?? GAMEPAD_SETTINGS_DEFAULTS),
|
||||
activeSignature: signature || null,
|
||||
activeInstanceKey: instanceKey || null,
|
||||
}));
|
||||
},
|
||||
[saveGamepadSettings],
|
||||
@@ -196,10 +273,10 @@ export default function GamepadMappingSettings() {
|
||||
(patch) => {
|
||||
saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
const baseProfile =
|
||||
const storedProfile =
|
||||
(activeSignature && current.profiles?.[activeSignature]) ??
|
||||
current?.defaults?.profile ??
|
||||
GAMEPAD_PROFILE_DEFAULT;
|
||||
current?.defaults?.profile;
|
||||
const baseProfile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
||||
const nextProfile = {
|
||||
...baseProfile,
|
||||
calibration: {
|
||||
@@ -228,14 +305,48 @@ export default function GamepadMappingSettings() {
|
||||
[activeSignature, saveGamepadSettings],
|
||||
);
|
||||
|
||||
const updateProfile = useCallback(
|
||||
(patch) => {
|
||||
saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
const storedProfile =
|
||||
(activeSignature && current.profiles?.[activeSignature]) ??
|
||||
current?.defaults?.profile;
|
||||
const nextProfile = {
|
||||
...resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT),
|
||||
...patch,
|
||||
};
|
||||
if (!activeSignature) {
|
||||
return {
|
||||
...current,
|
||||
defaults: { ...(current.defaults ?? {}), profile: nextProfile },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
profiles: { ...(current.profiles ?? {}), [activeSignature]: nextProfile },
|
||||
};
|
||||
});
|
||||
},
|
||||
[activeSignature, saveGamepadSettings],
|
||||
);
|
||||
|
||||
const resetActiveProfile = useCallback(() => {
|
||||
/* Resetting only the selected hardware avoids erasing carefully tuned profiles for other
|
||||
controllers. Device metadata is rebuilt so the profile remains recognizable offline. */
|
||||
const nextProfile = createProfileForPad(activePad, GAMEPAD_PROFILE_DEFAULT);
|
||||
updateProfile(nextProfile);
|
||||
setCaptureAction(null);
|
||||
}, [activePad, updateProfile]);
|
||||
|
||||
const updateBinding = useCallback(
|
||||
(actionId, updater) => {
|
||||
saveGamepadSettings((prev) => {
|
||||
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
|
||||
const baseProfile =
|
||||
const storedProfile =
|
||||
(activeSignature && current.profiles?.[activeSignature]) ??
|
||||
current?.defaults?.profile ??
|
||||
GAMEPAD_PROFILE_DEFAULT;
|
||||
current?.defaults?.profile;
|
||||
const baseProfile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
|
||||
const nextBinding = updater(baseProfile.bindings?.[actionId] ?? {});
|
||||
const nextProfile = {
|
||||
...baseProfile,
|
||||
@@ -300,39 +411,69 @@ export default function GamepadMappingSettings() {
|
||||
const outputs = computeGamepadOutputs(activePad, activeProfile);
|
||||
return { outputs };
|
||||
}, [activePad, activeProfile]);
|
||||
const controllerDescription = useMemo(() => describeController(activePad), [activePad]);
|
||||
|
||||
const liveValueForAction = useCallback((actionId) => {
|
||||
const outputs = diagnostics?.outputs;
|
||||
if (!outputs) return false;
|
||||
if (actionId === 'drive') return Math.hypot(outputs.driveVector.x, outputs.driveVector.y) > 0.01;
|
||||
if (actionId === 'cameraTilt') return Math.abs(outputs.cameraAxis) > 0.01;
|
||||
if (actionId === 'mainBrush') return Math.abs(outputs.auxAxis.main) > 0.01;
|
||||
if (actionId === 'sideBrush') return Math.abs(outputs.auxAxis.side) > 0.01;
|
||||
return Boolean(outputs.buttons[actionId]);
|
||||
}, [diagnostics]);
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Controller"
|
||||
meta={activePad ? 'Move sticks or press buttons to bind' : 'Connect a controller to configure.'}
|
||||
actions={
|
||||
<button type="button" disabled={!activePad} onClick={resetActiveProfile} className="button-dark px-1 py-0.5 text-xs disabled:opacity-50">
|
||||
Reset profile
|
||||
</button>
|
||||
}
|
||||
bodyClassName="space-y-2 p-1 text-sm"
|
||||
>
|
||||
{captureAction && (
|
||||
<p className="mx-auto w-full max-w-lg rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
|
||||
Capturing {captureAction.label}...
|
||||
</p>
|
||||
<div className="mx-auto flex w-full max-w-lg items-center justify-between gap-1 rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
|
||||
<span>Release controls, then move or press the input for {captureAction.label}.</span>
|
||||
<button type="button" onClick={() => setCaptureAction(null)} className="button-dark px-1 py-0.5 text-xs">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<SettingsGroupLabel>Connected controller</SettingsGroupLabel>
|
||||
{hubState.pads.length === 0 ? (
|
||||
<p className="mx-auto w-full max-w-lg text-sm text-white">No controller detected.</p>
|
||||
{hubState.error ? (
|
||||
<p className="mx-auto w-full max-w-lg rounded border border-red-500/60 bg-red-950/40 px-1.5 py-1 text-sm text-white">
|
||||
Controller access failed: {hubState.error}
|
||||
</p>
|
||||
) : hubState.pads.length === 0 ? (
|
||||
<p className="mx-auto w-full max-w-lg text-sm text-white">
|
||||
{hubState.supported === false
|
||||
? 'This browser does not support controllers.'
|
||||
: 'No controller detected. Connect it, focus this page, then press a button.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
|
||||
<select
|
||||
value={activeSignature ?? ''}
|
||||
onChange={(event) => setActiveSignature(event.target.value)}
|
||||
value={activePad?.instanceKey ?? ''}
|
||||
onChange={(event) => setActiveInstanceKey(event.target.value)}
|
||||
className="min-w-0 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
{hubState.pads.map((pad) => (
|
||||
<option key={pad.signature} value={pad.signature}>
|
||||
{pad.id || 'Unknown controller'}
|
||||
<option key={pad.instanceKey} value={pad.instanceKey}>
|
||||
{pad.id || 'Unknown controller'} (slot {pad.index + 1})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
{activePad?.mapping ?? 'unknown'}
|
||||
</span>
|
||||
<p className="col-span-full truncate text-xs text-slate-300" title={activePad?.id}>
|
||||
{controllerDescription.description ?? activePad?.id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -342,6 +483,23 @@ export default function GamepadMappingSettings() {
|
||||
{/* Calibration controls stay in one stacked column because range inputs become harder to
|
||||
tune when squeezed into multiple narrow columns. */}
|
||||
<div className="grid gap-1">
|
||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
|
||||
<span className="font-semibold">Button prompts</span>
|
||||
<select
|
||||
value={activeProfile.promptStyle ?? 'auto'}
|
||||
onChange={(event) => updateProfile({ promptStyle: event.target.value })}
|
||||
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="xbox">Xbox</option>
|
||||
<option value="playstation">PlayStation</option>
|
||||
<option value="switch">Nintendo</option>
|
||||
<option value="standard">Generic</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-snug text-white">Override this only when the browser reports the controller incorrectly.</p>
|
||||
</label>
|
||||
<SliderField
|
||||
label="Drive deadzone"
|
||||
description="Ignore small drive stick drift"
|
||||
@@ -351,6 +509,29 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.driveDeadzone ?? 0.18}
|
||||
onChange={(value) => updateCalibration({ driveDeadzone: value })}
|
||||
/>
|
||||
<CurveField
|
||||
label="Drive response"
|
||||
value={activeProfile.calibration?.driveCurve ?? 'linear'}
|
||||
onChange={(value) => updateCalibration({ driveCurve: value })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Full-stick speed"
|
||||
description="Maximum wheel output at full stick"
|
||||
min={50}
|
||||
max={500}
|
||||
step={10}
|
||||
value={activeProfile.calibration?.baseSpeed ?? 500}
|
||||
onChange={(value) => updateCalibration({ baseSpeed: value })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Turbo drive speed"
|
||||
description="Maximum output while holding the turbo modifier"
|
||||
min={50}
|
||||
max={500}
|
||||
step={10}
|
||||
value={activeProfile.calibration?.turboSpeed ?? 500}
|
||||
onChange={(value) => updateCalibration({ turboSpeed: value })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Camera deadzone"
|
||||
description="Ignore small camera tilt drift"
|
||||
@@ -360,6 +541,11 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
|
||||
onChange={(value) => updateCalibration({ cameraDeadzone: value })}
|
||||
/>
|
||||
<CurveField
|
||||
label="Camera response"
|
||||
value={activeProfile.calibration?.cameraCurve ?? 'linear'}
|
||||
onChange={(value) => updateCalibration({ cameraCurve: value })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Aux deadzone"
|
||||
description="Ignore small trigger noise"
|
||||
@@ -369,6 +555,11 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.auxDeadzone ?? 0.05}
|
||||
onChange={(value) => updateCalibration({ auxDeadzone: value })}
|
||||
/>
|
||||
<CurveField
|
||||
label="Brush response"
|
||||
value={activeProfile.calibration?.auxCurve ?? 'linear'}
|
||||
onChange={(value) => updateCalibration({ auxCurve: value })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Side brush scale"
|
||||
description="Scale side brush output"
|
||||
@@ -378,13 +569,22 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.auxSideScale ?? 0.55}
|
||||
onChange={(value) => updateCalibration({ auxSideScale: value })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Precision speed"
|
||||
description="Maximum drive speed while holding the precision modifier"
|
||||
min={20}
|
||||
max={250}
|
||||
step={5}
|
||||
value={activeProfile.calibration?.precisionSpeed ?? 100}
|
||||
onChange={(value) => updateCalibration({ precisionSpeed: value })}
|
||||
/>
|
||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||
{/* Camera mode is styled like the sliders so calibration controls read as one group
|
||||
even though this specific setting is a select instead of a range input. */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
|
||||
<span className="min-w-0 font-semibold text-white">Camera mode</span>
|
||||
<select
|
||||
value={activeProfile.calibration?.cameraMode ?? 'absolute'}
|
||||
value={activeProfile.calibration?.cameraMode ?? 'velocity'}
|
||||
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
|
||||
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
@@ -407,6 +607,16 @@ export default function GamepadMappingSettings() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="mx-auto block w-full max-w-lg">
|
||||
<span className="sr-only">Filter controller actions</span>
|
||||
<input
|
||||
type="search"
|
||||
value={actionFilter}
|
||||
onChange={(event) => setActionFilter(event.target.value)}
|
||||
placeholder="Find a controller action"
|
||||
className="field-input w-full px-1.5 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
{Object.entries(grouped).map(([section, actions]) => (
|
||||
<div key={section} className="space-y-1">
|
||||
<SettingsGroupLabel>{section}</SettingsGroupLabel>
|
||||
@@ -421,10 +631,13 @@ export default function GamepadMappingSettings() {
|
||||
key={action.id}
|
||||
action={action}
|
||||
source={source}
|
||||
sourceLabel={formatControllerBinding(activeProfile, action.id, activePad)}
|
||||
liveValue={liveValueForAction(action.id)}
|
||||
isCapturing={captureAction?.id === action.id}
|
||||
onClear={handleClear}
|
||||
onCapture={setCaptureAction}
|
||||
onInvert={handleInvert}
|
||||
disabled={!activePad}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -434,11 +647,12 @@ export default function GamepadMappingSettings() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<SettingsGroupLabel>Diagnostics</SettingsGroupLabel>
|
||||
{!activePad ? (
|
||||
<p className="mx-auto w-full max-w-lg text-sm text-white">No controller detected.</p>
|
||||
null
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-lg space-y-1 rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
|
||||
<details className="mx-auto w-full max-w-lg rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-white">Advanced diagnostics</summary>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p className="text-white">Raw axes</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{activePad.axes.map((value, index) => (
|
||||
@@ -475,7 +689,8 @@ export default function GamepadMappingSettings() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</CardFrame>
|
||||
|
||||
@@ -40,6 +40,25 @@ export const ACTIONS = [
|
||||
{ id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' },
|
||||
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
|
||||
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
|
||||
{ id: 'boostModifier', label: 'Turbo modifier', kind: 'button', section: 'Driving' },
|
||||
{ id: 'slowModifier', label: 'Precision modifier', kind: 'button', section: 'Driving' },
|
||||
{ id: 'hornHonk', label: 'Horn (hold)', kind: 'button', section: 'Audio and chat' },
|
||||
{ id: 'micPtt', label: 'Microphone push to talk', kind: 'button', section: 'Audio and chat' },
|
||||
{ id: 'chatFocus', label: 'Focus chat', kind: 'button', section: 'Audio and chat' },
|
||||
{ id: 'videoFilterCycle', label: 'Cycle video filter', kind: 'button', section: 'Camera' },
|
||||
{ id: 'songNoteUp', label: 'Song note up', kind: 'button', section: 'Audio and chat' },
|
||||
{ id: 'songNoteDown', label: 'Song note down', kind: 'button', section: 'Audio and chat' },
|
||||
{ id: 'homeAssistantOn', label: 'Next room control on', kind: 'button', section: 'Room controls' },
|
||||
{ id: 'homeAssistantOff', label: 'Next room control off', kind: 'button', section: 'Room controls' },
|
||||
/* Digital aux actions provide exact parity with the keyboard help surface. They coexist with
|
||||
analog brush controls so each operator can choose proportional triggers or discrete buttons. */
|
||||
{ id: 'auxMainForward', label: 'Main brush forward', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxMainReverse', label: 'Main brush reverse', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxSideForward', label: 'Side brush forward', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxSideReverse', label: 'Side brush reverse', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxVacuumFast', label: 'Vacuum max', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxVacuumSlow', label: 'Vacuum low', kind: 'button', section: 'Aux buttons' },
|
||||
{ id: 'auxAllForward', label: 'All motors forward', kind: 'button', section: 'Aux buttons' },
|
||||
];
|
||||
|
||||
export const CAPTURE_AXIS_THRESHOLD = 0.45;
|
||||
|
||||
@@ -33,10 +33,10 @@ export function groupActions(actions) {
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function pickActivePad(pads, activeSignature) {
|
||||
export function pickActivePad(pads, activeInstanceKey) {
|
||||
if (!pads || pads.length === 0) return null;
|
||||
if (activeSignature) {
|
||||
const match = pads.find((pad) => pad.signature === activeSignature);
|
||||
if (activeInstanceKey) {
|
||||
const match = pads.find((pad) => pad.instanceKey === activeInstanceKey);
|
||||
if (match) return match;
|
||||
}
|
||||
return pads[0];
|
||||
@@ -62,10 +62,13 @@ function detectAxisCapture(pad, baseline, action) {
|
||||
if (action.kind === 'axisPair') {
|
||||
const top = deltas.filter((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD).slice(0, 2);
|
||||
if (top.length < 2) return null;
|
||||
const orderedIndices = top.map((entry) => entry.index).sort((a, b) => a - b);
|
||||
return {
|
||||
kind: 'axisPair',
|
||||
x: top[0].index,
|
||||
y: top[1].index,
|
||||
// Browsers expose two-dimensional controls as adjacent X/Y axes. Sorting the captured pair
|
||||
// prevents whichever direction moved first from randomly swapping steering and throttle.
|
||||
x: orderedIndices[0],
|
||||
y: orderedIndices[1],
|
||||
...(action.invertDefaults ?? {}),
|
||||
};
|
||||
}
|
||||
@@ -80,16 +83,25 @@ function detectAxisCapture(pad, baseline, action) {
|
||||
|
||||
function detectButtonCapture(pad, baseline, action) {
|
||||
const buttons = pad.buttons ?? [];
|
||||
const newlyPressed = [];
|
||||
for (let i = 0; i < buttons.length; i += 1) {
|
||||
const btn = buttons[i];
|
||||
const value = typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0;
|
||||
if (btn?.pressed || value > CAPTURE_BUTTON_THRESHOLD) {
|
||||
const baselineValue = baseline.buttons?.[i]?.value ?? 0;
|
||||
const baselinePressed = baseline.buttons?.[i]?.pressed ?? false;
|
||||
if (!baselinePressed && (btn?.pressed || value - baselineValue > CAPTURE_BUTTON_THRESHOLD)) {
|
||||
if (action.kind === 'axis') {
|
||||
return { kind: 'buttonAxis', index: i };
|
||||
}
|
||||
return { kind: 'button', index: i };
|
||||
newlyPressed.push({ kind: 'button', index: i });
|
||||
}
|
||||
}
|
||||
if (newlyPressed.length > 1) {
|
||||
// Capturing all buttons observed in the same frame makes intentional modifier chords possible
|
||||
// without a separate advanced editor, while a normal single press keeps the compact shape.
|
||||
return { kind: 'chord', inputs: newlyPressed };
|
||||
}
|
||||
if (newlyPressed.length === 1) return newlyPressed[0];
|
||||
const axes = pad.axes ?? [];
|
||||
for (let i = 0; i < axes.length; i += 1) {
|
||||
const value = axes[i] ?? 0;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Controller Capture Tests
|
||||
// Purpose: Verifies that binding capture cannot select held controls or swap stick axes randomly.
|
||||
// Scope: Covers the pure capture detector used by the Controller settings surface.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildDescriptorFromCapture, snapshotBaseline } from './helpers.js';
|
||||
|
||||
function button(pressed = false, value = pressed ? 1 : 0) {
|
||||
return { pressed, value };
|
||||
}
|
||||
|
||||
test('a button held before capture is ignored', () => {
|
||||
const baselinePad = { axes: [0, 0], buttons: [button(true), button(false)] };
|
||||
const currentPad = { axes: [0, 0], buttons: [button(true), button(false)] };
|
||||
const descriptor = buildDescriptorFromCapture(
|
||||
currentPad,
|
||||
snapshotBaseline(baselinePad),
|
||||
{ kind: 'button' },
|
||||
);
|
||||
assert.equal(descriptor, null);
|
||||
});
|
||||
|
||||
test('axis-pair capture assigns the lower adjacent axis to X regardless of movement order', () => {
|
||||
const baseline = snapshotBaseline({ axes: [0, 0, 0, 0], buttons: [] });
|
||||
const descriptor = buildDescriptorFromCapture(
|
||||
{ axes: [0, 0, -0.7, 0.9], buttons: [] },
|
||||
baseline,
|
||||
{ kind: 'axisPair', invertDefaults: { invertY: true } },
|
||||
);
|
||||
assert.deepEqual(descriptor, {
|
||||
kind: 'axisPair',
|
||||
x: 2,
|
||||
y: 3,
|
||||
invertY: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('simultaneous new buttons are represented as a chord', () => {
|
||||
const baseline = snapshotBaseline({ axes: [], buttons: [button(), button(), button()] });
|
||||
const descriptor = buildDescriptorFromCapture(
|
||||
{ axes: [], buttons: [button(true), button(), button(true)] },
|
||||
baseline,
|
||||
{ kind: 'button' },
|
||||
);
|
||||
assert.deepEqual(descriptor, {
|
||||
kind: 'chord',
|
||||
inputs: [{ kind: 'button', index: 0 }, { kind: 'button', index: 2 }],
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,14 @@
|
||||
// Purpose: Defines the Help Content View module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useMemo } from 'react';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import { useControllerRuntime } from '../../controls/inputs/controllerRuntime.js';
|
||||
import { getHelpContent } from '../../help/content.js';
|
||||
|
||||
function KeyPill({ actionId, keymap }) {
|
||||
const value = keymap?.[actionId]?.[0] ?? '';
|
||||
function KeyPill({ actionId }) {
|
||||
return (
|
||||
<span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
|
||||
{formatKeyLabel(value)}
|
||||
<ControlHint actionId={actionId} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -130,14 +130,20 @@ function KeyboardGroup({ group, keymap }) {
|
||||
}
|
||||
|
||||
function KeyboardBlock({ block, keymap }) {
|
||||
const runtime = useControllerRuntime();
|
||||
if (!block) return null;
|
||||
const usingController = runtime.inputMethod === 'controller';
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{/* Heading and footnote share a row when possible and wrap independently
|
||||
when the Help card is mounted in a narrow desktop column. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-0.5 text-xs text-slate-200">
|
||||
<span className="font-semibold">{block.title}</span>
|
||||
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
|
||||
<span className="font-semibold">{usingController ? 'Controller controls' : block.title}</span>
|
||||
<span className="text-[0.7rem] text-slate-400">
|
||||
{usingController
|
||||
? 'Per-controller; adjust bindings in Settings → Controller.'
|
||||
: block.footnote}
|
||||
</span>
|
||||
</div>
|
||||
{/* Two keyboard groups fit comfortably once the Help surface reaches 32rem.
|
||||
Using the real content threshold restores the established old-page layout
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useMemo } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
@@ -184,7 +183,6 @@ export default function HomeAssistantControls() {
|
||||
}
|
||||
|
||||
function HomeAssistantControlsContent() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
|
||||
useSessionActions();
|
||||
@@ -196,8 +194,8 @@ function HomeAssistantControlsContent() {
|
||||
const lightPolicyLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
|
||||
const controlsLocked = lightPolicyLocked && !adminCanControlLockedLights;
|
||||
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
|
||||
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
|
||||
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
|
||||
const onKeyLabel = <ControlHint actionId="homeAssistantOn" />;
|
||||
const offKeyLabel = <ControlHint actionId="homeAssistantOff" />;
|
||||
|
||||
if (!ha?.enabled) {
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef } from 'react';
|
||||
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
|
||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
@@ -45,7 +45,6 @@ export default function BottomLeftPod({ roverId }) {
|
||||
const headlightOn = useControlSelector((control) => Boolean(control.pipeline?.headlightState?.headlightOn));
|
||||
const laserOn = useControlSelector((control) => Boolean(control.pipeline?.laserState?.laserOn));
|
||||
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const { setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const hornPointerRef = useRef(null);
|
||||
@@ -80,13 +79,13 @@ export default function BottomLeftPod({ roverId }) {
|
||||
{/* Physical rover actions become visibly and behaviorally unavailable
|
||||
while another queued driver owns the turn. Pod/settings controls
|
||||
remain interactive because they do not mutate rover hardware. */}
|
||||
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={formatKeyLabel(keymap?.hornHonk?.[0])} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
|
||||
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={formatKeyLabel(keymap?.headlightToggle?.[0])} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
|
||||
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
|
||||
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
|
||||
{/* The room-light lock deliberately blocks laser activation because
|
||||
the laser is only intended for use while the room is dark. This
|
||||
mirrors the old desktop control's visible disabled state; turn
|
||||
ownership remains the other independent control restriction. */}
|
||||
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
|
||||
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
|
||||
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { FaVideo } from 'react-icons/fa';
|
||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
@@ -32,7 +32,6 @@ export default function BottomRightPod({ roverId }) {
|
||||
const [open, setOpen] = usePodVisibility('camera', true);
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const { setServoAngle } = useControlActions();
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const config = camera?.config;
|
||||
@@ -86,8 +85,8 @@ export default function BottomRightPod({ roverId }) {
|
||||
</button>
|
||||
{/* These positions continue around the same circle just beyond the two slider endpoints.
|
||||
Together they occupy the open third facing the corner without enlarging the pod. */}
|
||||
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraDown?.[0])} /></div>
|
||||
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraUp?.[0])} /></div>
|
||||
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={<ControlHint actionId="cameraDown" />} /></div>
|
||||
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={<ControlHint actionId="cameraUp" />} /></div>
|
||||
<CornerPodToggle corner="bottom-right" expanded label="Hide camera tilt" onClick={() => setOpen(false)} />
|
||||
</div>
|
||||
) : showCameraControls && enabled ? (
|
||||
|
||||
@@ -4,14 +4,12 @@ import { useCallback, useState } from 'react';
|
||||
import { FaComment } from 'react-icons/fa';
|
||||
import { useChatActions } from '../../../../context/ChatContext.jsx';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import HudChatInput from '../../HudChatInput/index.jsx';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
|
||||
export default function ChatExpansion({ podOpen }) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const chatKeyLabel = useControlSelector((control) => formatKeyLabel(control.state.keymap?.chatFocus?.[0]));
|
||||
const { blurChat, focusChat } = useChatActions();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -50,7 +48,7 @@ export default function ChatExpansion({ podOpen }) {
|
||||
<FaComment aria-hidden="true" />
|
||||
{/* The pill reflects the live keymap so remapping chat focus updates this
|
||||
compact HUD hint without duplicating or hardcoding the default key. */}
|
||||
{chatKeyLabel ? <KeyPill label={chatKeyLabel} /> : null}
|
||||
<KeyPill label={<ControlHint actionId="chatFocus" />} />
|
||||
</button>
|
||||
|
||||
<HudChatInput variant="newdrive" open={open} onOpenChange={setChatOpen} />
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// the archived desktop layout retains its previous DriveDockAction behavior.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FaChargingStation } from 'react-icons/fa';
|
||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
|
||||
import { useControlActions } from '../../../../controls/index.js';
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
|
||||
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
|
||||
@@ -252,7 +252,6 @@ function UndockTransitionGhost({ onFinish }) {
|
||||
export default function DockingHud({ roverId }) {
|
||||
const layout = useDriverLayout();
|
||||
const actions = useControlActions();
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
|
||||
// success sounds, camera positioning, speed cap, and automatic exit after charging begins.
|
||||
@@ -282,12 +281,8 @@ export default function DockingHud({ roverId }) {
|
||||
/* Mobile already presents its own touch-oriented driving controls. The docked
|
||||
action therefore keeps its plain-language instruction without advertising a
|
||||
keyboard shortcut that is irrelevant on that layout. */
|
||||
const driveKeyLabel = layout === 'desktop'
|
||||
? formatKeyLabel(keymap?.driveMacro?.[0])
|
||||
: '';
|
||||
const dockKeyLabel = layout === 'desktop'
|
||||
? formatKeyLabel(keymap?.dockMacro?.[0])
|
||||
: '';
|
||||
const driveKeyLabel = layout === 'desktop' ? <ControlHint actionId="driveMacro" /> : '';
|
||||
const dockKeyLabel = layout === 'desktop' ? <ControlHint actionId="dockMacro" /> : '';
|
||||
const batteryPodOpen = podSettings?.battery !== false;
|
||||
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
|
||||
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
|
||||
|
||||
@@ -16,8 +16,8 @@ import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
|
||||
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useControlActions } from '../../controls/index.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
@@ -283,12 +283,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
function keyLabelFor(keymap, actionId) {
|
||||
return formatKeyLabel(keymap?.[actionId]?.[0]);
|
||||
}
|
||||
|
||||
function PtzControlReference() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const rows = [
|
||||
['Tilt up', 'driveForward'],
|
||||
['Tilt down', 'driveBackward'],
|
||||
@@ -305,7 +300,7 @@ function PtzControlReference() {
|
||||
{rows.map(([label, actionId]) => (
|
||||
<div key={label} className="surface flex items-center justify-between gap-1">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<KeyPill label={keyLabelFor(keymap, actionId)} />
|
||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { getSocialById } from '../../lib/socials.js';
|
||||
|
||||
function ControlRow({ label, keyLabel }) {
|
||||
function ControlRow({ label, actionId }) {
|
||||
return (
|
||||
<div className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.35 text-[0.8rem] text-slate-200">
|
||||
<span>{label}</span>
|
||||
<KeyPill label={keyLabel} />
|
||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopQuickstart({ keymap }) {
|
||||
function DesktopQuickstart() {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-200">1. Click "Your rover is docked" to undock.</p>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-200">2. Drive with these keybindings:</p>
|
||||
<div className="space-y-0.5">
|
||||
<ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} />
|
||||
<ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} />
|
||||
<ControlRow label="Turn Left" keyLabel={formatKeyLabel(keymap?.driveLeft?.[0])} />
|
||||
<ControlRow label="Turn Right" keyLabel={formatKeyLabel(keymap?.driveRight?.[0])} />
|
||||
<ControlRow label="Move faster" keyLabel={formatKeyLabel(keymap?.boostModifier?.[0])} />
|
||||
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
|
||||
<ControlRow label="Forward" actionId="driveForward" />
|
||||
<ControlRow label="Backward" actionId="driveBackward" />
|
||||
<ControlRow label="Turn Left" actionId="driveLeft" />
|
||||
<ControlRow label="Turn Right" actionId="driveRight" />
|
||||
<ControlRow label="Move faster" actionId="boostModifier" />
|
||||
<ControlRow label="Move slower" actionId="slowModifier" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-200">3. Use the video HUD for rover controls and information.</p>
|
||||
@@ -75,9 +73,7 @@ export default function QuickstartOverlay({
|
||||
onToggleShowOnLoad,
|
||||
onClose,
|
||||
}) {
|
||||
const rawKeymap = useControlSelector((control) => control.state.keymap);
|
||||
const isDesktop = layout === 'desktop';
|
||||
const keymap = useMemo(() => rawKeymap || {}, [rawKeymap]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
@@ -97,7 +93,7 @@ export default function QuickstartOverlay({
|
||||
</div>
|
||||
<div className={`grid gap-0.5 p-0.5 ${isDesktop ? 'md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]' : 'grid-cols-1'}`}>
|
||||
<section className="space-y-0.5 border-b border-slate-700">
|
||||
{isDesktop ? <DesktopQuickstart keymap={keymap} /> : <MobileQuickstart />}
|
||||
{isDesktop ? <DesktopQuickstart /> : <MobileQuickstart />}
|
||||
</section>
|
||||
{/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
|
||||
<section className="space-y-0.5">
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import {
|
||||
DEFAULT_PAGE_THEME_KEY,
|
||||
PAGE_THEME_OPTIONS,
|
||||
@@ -129,7 +129,6 @@ function reconnectSocketWithTransport(socket, transport) {
|
||||
}
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const { sendOiCommand, setSensorStream } = useControlActions();
|
||||
const canControl = Boolean(roverId);
|
||||
@@ -170,7 +169,7 @@ export default function SettingsPanel() {
|
||||
? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount))
|
||||
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
|
||||
const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter);
|
||||
const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]);
|
||||
const videoFilterCycleKeyLabel = <ControlHint actionId="videoFilterCycle" />;
|
||||
|
||||
useEffect(() => {
|
||||
// Settings load after the provider mounts and can also be replaced by an incoming inter-instance
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fieldClass } from '../constants.js';
|
||||
import { useControlSelector } from '../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../controls/keymapUtils.js';
|
||||
import ControlHint from '../../ControlHint/index.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE, RTC_CONFIG } from './constants.js';
|
||||
import { bytesToBase64, buildAuthHeader } from './base64.js';
|
||||
@@ -28,7 +28,6 @@ export default function VipAudioUploadCard({
|
||||
readyMicWhip,
|
||||
stopMicWhip,
|
||||
}) {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
|
||||
const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', {
|
||||
openMicEnabled: false,
|
||||
@@ -81,7 +80,7 @@ export default function VipAudioUploadCard({
|
||||
const whipLinkActive = !clipMode && (micState === 'live' || micState === 'starting');
|
||||
const clipRecording = clipMode && clipState === 'recording';
|
||||
const clipSending = clipMode && clipState === 'sending';
|
||||
const pttKeyLabel = formatKeyLabel(keymap?.micPtt?.[0]) || 'M';
|
||||
const pttKeyLabel = <ControlHint actionId="micPtt" />;
|
||||
|
||||
const setPttMode = useCallback(
|
||||
(nextMode) => {
|
||||
|
||||
@@ -12,8 +12,8 @@ import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useControlActions } from '../../controls/index.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
@@ -307,12 +307,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
function keyLabelFor(keymap, actionId) {
|
||||
return formatKeyLabel(keymap?.[actionId]?.[0]);
|
||||
}
|
||||
|
||||
function PtzControlReference() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const rows = [
|
||||
['Tilt up', 'driveForward'],
|
||||
['Tilt down', 'driveBackward'],
|
||||
@@ -331,7 +326,7 @@ function PtzControlReference() {
|
||||
<span className="text-slate-400">{label}</span>
|
||||
{/* Use the same key display component as the rest of the UI so PTZ
|
||||
controls read as normal mapped controls instead of custom labels. */}
|
||||
<KeyPill label={keyLabelFor(keymap, actionId)} />
|
||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
|
||||
Reference in New Issue
Block a user