mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
set default tab to help. add beep keys.
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -28,6 +28,7 @@ type inboundMessage struct {
|
||||
Servo *servoPayload `json:"servo,omitempty"`
|
||||
TTS *ttsPayload `json:"tts,omitempty"`
|
||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||
Song *songPayload `json:"song,omitempty"`
|
||||
}
|
||||
|
||||
type driveDirectPayload struct {
|
||||
@@ -67,6 +68,17 @@ type nightVisionPayload struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type songPayload struct {
|
||||
Slot *int `json:"slot,omitempty"`
|
||||
Notes []songNote `json:"notes"`
|
||||
Loop bool `json:"loop,omitempty"`
|
||||
}
|
||||
|
||||
type songNote struct {
|
||||
Note int `json:"note"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
type ackMessage struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -95,3 +95,26 @@ func (s *SerialAdapter) SendRaw(raw []byte) error {
|
||||
func (s *SerialAdapter) SeekDock() error {
|
||||
return s.write([]byte{143})
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||
if len(notes) == 0 {
|
||||
return fmt.Errorf("song requires at least one note")
|
||||
}
|
||||
if len(notes) > 16 {
|
||||
return fmt.Errorf("song supports up to 16 notes, got %d", len(notes))
|
||||
}
|
||||
if slot < 0 || slot > 4 {
|
||||
return fmt.Errorf("song slot must be 0-4")
|
||||
}
|
||||
|
||||
payload := []byte{140, byte(slot), byte(len(notes))}
|
||||
for _, n := range notes {
|
||||
note := clampInt(n.Note, 31, 127)
|
||||
duration := clampInt(n.Duration, 1, 255)
|
||||
payload = append(payload, byte(note), byte(duration))
|
||||
}
|
||||
if err := s.write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.write([]byte{141, byte(slot)})
|
||||
}
|
||||
|
||||
@@ -56,3 +56,8 @@ func (s *SerialAdapter) SeekDock() error {
|
||||
s.log.Printf("[dummy] seek dock")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
||||
s.log.Printf("[dummy] play song slot=%d notes=%v", slot, notes)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -161,6 +161,12 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
||||
return fmt.Errorf("night vision disabled")
|
||||
}
|
||||
return c.nightVision.HandleAction(msg.NightVision.Action)
|
||||
case msg.Song != nil:
|
||||
slot := 0
|
||||
if msg.Song.Slot != nil {
|
||||
slot = clampInt(*msg.Song.Slot, 0, 4)
|
||||
}
|
||||
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
||||
default:
|
||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-6QuSGuDC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DusXB4R9.css">
|
||||
<script type="module" crossorigin src="/assets/index-CjsBr3wX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BrXzdi-k.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import VideoTile from './VideoTile.jsx';
|
||||
|
||||
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
const { session } = useSession();
|
||||
const {
|
||||
state: { song },
|
||||
} = useControlSystem();
|
||||
const roverId = session?.assignment?.roverId;
|
||||
const rosterEntry =
|
||||
roverId && session?.roster ? session.roster.find((item) => String(item.id) === String(roverId)) : null;
|
||||
@@ -37,6 +41,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
telemetryFrame={frame}
|
||||
batteryConfig={batteryConfig}
|
||||
layoutFormat={layoutFormat}
|
||||
songNote={song?.note}
|
||||
/>
|
||||
) : (
|
||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video">
|
||||
|
||||
@@ -25,6 +25,8 @@ const KEY_ACTIONS = [
|
||||
{ id: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
|
||||
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
|
||||
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
|
||||
{ id: 'songNoteUp', label: 'Song Note Up', group: 'Audio' },
|
||||
{ id: 'songNoteDown', label: 'Song Note Down', group: 'Audio' },
|
||||
];
|
||||
|
||||
function groupActions(actions) {
|
||||
|
||||
@@ -4,6 +4,15 @@ import { WhepPlayer } from '../lib/whepPlayer.js';
|
||||
const RESTART_DELAY_MS = 2000;
|
||||
const UNMUTE_RETRY_MS = 3000;
|
||||
|
||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
function formatNoteLabel(note) {
|
||||
if (typeof note !== 'number' || !Number.isFinite(note)) return '--';
|
||||
const name = NOTE_NAMES[note % 12] || '?';
|
||||
const octave = Math.floor(note / 12) - 1;
|
||||
return `${name}${octave}`;
|
||||
}
|
||||
|
||||
function buildBatteryVisual(charge, config) {
|
||||
const full = config?.Full;
|
||||
const warn = config?.Warn;
|
||||
@@ -40,6 +49,7 @@ export default function VideoTile({
|
||||
layoutFormat = 'desktop',
|
||||
hudVariant = 'default',
|
||||
driverLabel = null,
|
||||
songNote = null,
|
||||
}) {
|
||||
const videoRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
@@ -265,6 +275,7 @@ export default function VideoTile({
|
||||
variant={hudVariant}
|
||||
driverLabel={driverLabel}
|
||||
battery={batteryVisual}
|
||||
songNote={songNote}
|
||||
/>
|
||||
<OvercurrentOverlay motors={overcurrentMotors} />
|
||||
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} />
|
||||
@@ -325,6 +336,7 @@ function HudOverlay({
|
||||
variant = 'default',
|
||||
driverLabel = null,
|
||||
battery,
|
||||
songNote = null,
|
||||
}) {
|
||||
const sensors = frame?.sensors;
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
@@ -350,6 +362,11 @@ function HudOverlay({
|
||||
<span>Status: {status}</span>
|
||||
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
|
||||
</div>
|
||||
{songNote != null ? (
|
||||
<div className="absolute right-1 top-1 rounded bg-black/70 px-1 py-0.25 text-[0.65rem] font-semibold text-emerald-200">
|
||||
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute left-1 top-1/2 flex -translate-y-1/2 flex-col gap-0.25 bg-black/70 px-1 py-0.75 text-[0.65rem] text-slate-100">
|
||||
<span className="text-[0.6rem] uppercase tracking-wide text-slate-400">Telemetry</span>
|
||||
@@ -382,6 +399,11 @@ function HudOverlay({
|
||||
<span>Status: {status}</span>
|
||||
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
|
||||
</div>
|
||||
{songNote != null ? (
|
||||
<div className="absolute right-1 top-1 rounded bg-black/70 px-1 py-0.25 text-[0.65rem] font-semibold text-emerald-200">
|
||||
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-slate-100">
|
||||
<span>Rover: "{label || 'Unnamed Rover'}"</span>
|
||||
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useReducer,
|
||||
import { controlReducer, initialControlState } from './controlReducer.js';
|
||||
import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
||||
import { useCommandPipeline } from './commandPipeline.js';
|
||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS } from './constants.js';
|
||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.js';
|
||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
@@ -258,6 +258,22 @@ export function ControlSystemProvider({ children }) {
|
||||
pipeline.sendNightVision('toggle');
|
||||
}, [pipeline]);
|
||||
|
||||
const setSongNote = useCallback(
|
||||
(note) => {
|
||||
const next = typeof note === 'number' ? note : SONG_DEFAULT_NOTE;
|
||||
dispatch({ type: 'control/set-song-note', payload: next });
|
||||
return next;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const sendSong = useCallback(
|
||||
(notes, options) => {
|
||||
return pipeline.sendSong(notes, options);
|
||||
},
|
||||
[pipeline],
|
||||
);
|
||||
|
||||
const registerInputState = useCallback((source, data) => {
|
||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||
}, []);
|
||||
@@ -282,6 +298,8 @@ export function ControlSystemProvider({ children }) {
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
},
|
||||
}),
|
||||
[
|
||||
@@ -301,6 +319,8 @@ export function ControlSystemProvider({ children }) {
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { AUX_LIMITS, COMMAND_DELAY_MS, OI_COMMANDS } from './constants.js';
|
||||
import {
|
||||
AUX_LIMITS,
|
||||
COMMAND_DELAY_MS,
|
||||
OI_COMMANDS,
|
||||
SONG_DEFAULT_DURATION,
|
||||
SONG_DEFAULT_NOTE,
|
||||
SONG_NOTE_RANGE,
|
||||
} from './constants.js';
|
||||
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
|
||||
|
||||
export function useCommandPipeline() {
|
||||
@@ -151,6 +158,32 @@ export function useCommandPipeline() {
|
||||
[emitCommand, nightVision, roverId],
|
||||
);
|
||||
|
||||
const sendSong = useCallback(
|
||||
(notes = [], options = {}) => {
|
||||
if (!roverId) return null;
|
||||
const prepared =
|
||||
Array.isArray(notes) && notes.length > 0
|
||||
? notes
|
||||
: [{ note: SONG_DEFAULT_NOTE, duration: SONG_DEFAULT_DURATION }];
|
||||
const payloadNotes = prepared.slice(0, 16).map((entry) => ({
|
||||
note: clampRange(Math.round(entry?.note ?? SONG_DEFAULT_NOTE), SONG_NOTE_RANGE),
|
||||
duration: clampRange(Math.round(entry?.duration ?? SONG_DEFAULT_DURATION), [1, 255]),
|
||||
}));
|
||||
emitCommand({
|
||||
type: 'song',
|
||||
data: {
|
||||
song: {
|
||||
notes: payloadNotes,
|
||||
slot: options.slot,
|
||||
loop: options.loop,
|
||||
},
|
||||
},
|
||||
});
|
||||
return payloadNotes;
|
||||
},
|
||||
[emitCommand, roverId],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
roverId,
|
||||
@@ -164,6 +197,7 @@ export function useCommandPipeline() {
|
||||
sendServoAngle,
|
||||
sendOiCommand,
|
||||
sendNightVision,
|
||||
sendSong,
|
||||
runMacroSteps,
|
||||
}),
|
||||
[
|
||||
|
||||
@@ -10,7 +10,12 @@ export const DRIVE_LIMITS = {
|
||||
boostSpeed: 400,
|
||||
};
|
||||
|
||||
export const COMMAND_DELAY_MS = 200;
|
||||
export const COMMAND_DELAY_MS = 100;
|
||||
|
||||
export const SONG_NOTE_RANGE = [31, 127];
|
||||
export const SONG_DEFAULT_NOTE = 60;
|
||||
export const SONG_DEFAULT_DURATION = 32;
|
||||
export const SONG_REPEAT_MS = 150;
|
||||
|
||||
export const OI_COMMANDS = {
|
||||
start: [128],
|
||||
@@ -40,6 +45,8 @@ export const DEFAULT_KEYMAP = {
|
||||
driveMacro: ['f'],
|
||||
dockMacro: ['g'],
|
||||
chatFocus: ['enter'],
|
||||
songNoteUp: ['ArrowUp'],
|
||||
songNoteDown: ['ArrowDown'],
|
||||
};
|
||||
|
||||
export const DEFAULT_MACROS = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS } from './constants.js';
|
||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.js';
|
||||
|
||||
function createDriveState() {
|
||||
return {
|
||||
@@ -21,12 +21,19 @@ function createCameraState() {
|
||||
};
|
||||
}
|
||||
|
||||
function createSongState() {
|
||||
return {
|
||||
note: SONG_DEFAULT_NOTE,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialControlState = {
|
||||
roverId: null,
|
||||
mode: 'drive',
|
||||
drive: createDriveState(),
|
||||
aux: createAuxState(),
|
||||
camera: createCameraState(),
|
||||
song: createSongState(),
|
||||
macros: DEFAULT_MACROS,
|
||||
keymap: DEFAULT_KEYMAP,
|
||||
inputs: {},
|
||||
@@ -40,6 +47,7 @@ export function controlReducer(state, action) {
|
||||
roverId: action.payload ?? null,
|
||||
drive: action.payload ? state.drive : createDriveState(),
|
||||
aux: action.payload ? state.aux : createAuxState(),
|
||||
song: action.payload ? state.song : createSongState(),
|
||||
};
|
||||
case 'control/set-mode':
|
||||
return state.mode === action.payload
|
||||
@@ -114,6 +122,15 @@ export function controlReducer(state, action) {
|
||||
...state,
|
||||
drive: createDriveState(),
|
||||
aux: createAuxState(),
|
||||
song: createSongState(),
|
||||
};
|
||||
case 'control/set-song-note':
|
||||
return {
|
||||
...state,
|
||||
song: {
|
||||
...(state.song || createSongState()),
|
||||
note: action.payload ?? SONG_DEFAULT_NOTE,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
||||
@@ -4,11 +4,19 @@ import { useChat } from '../../context/ChatContext.jsx';
|
||||
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import {
|
||||
SONG_DEFAULT_DURATION,
|
||||
SONG_DEFAULT_NOTE,
|
||||
SONG_NOTE_RANGE,
|
||||
SONG_REPEAT_MS,
|
||||
} from '../constants.js';
|
||||
|
||||
const SOURCE = 'keyboard';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
|
||||
const SERVO_REPEAT_MS = 110;
|
||||
const NOTE_MIN = SONG_NOTE_RANGE[0];
|
||||
const NOTE_MAX = SONG_NOTE_RANGE[1];
|
||||
|
||||
function clampSpeed(value, fallback) {
|
||||
const num = Number(value);
|
||||
@@ -95,6 +103,8 @@ export default function KeyboardInputManager() {
|
||||
stopAllMotion,
|
||||
registerInputState,
|
||||
toggleNightVision,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
},
|
||||
} = useControlSystem();
|
||||
const { focusChat, blurChat, isChatFocused } = useChat();
|
||||
@@ -126,6 +136,7 @@ export default function KeyboardInputManager() {
|
||||
const lastVectorRef = useRef(ZERO_VECTOR);
|
||||
const lastAuxRef = useRef(ZERO_AUX);
|
||||
const servoIntervalRef = useRef(null);
|
||||
const songIntervalRef = useRef(null);
|
||||
|
||||
const driveFromKeys = useCallback(() => {
|
||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||
@@ -194,14 +205,66 @@ export default function KeyboardInputManager() {
|
||||
servoIntervalRef.current = setTimeout(tick, 0);
|
||||
}, [computeServoDirection, nudgeServo, servoStep, stopServoLoop]);
|
||||
|
||||
const stopSongLoop = useCallback(() => {
|
||||
if (songIntervalRef.current) {
|
||||
clearTimeout(songIntervalRef.current);
|
||||
songIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const computeSongDirection = useCallback(() => {
|
||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||
const up = bindingActive(keymap.songNoteUp, tokensSnapshot);
|
||||
const down = bindingActive(keymap.songNoteDown, tokensSnapshot);
|
||||
return (up ? 1 : 0) - (down ? 1 : 0);
|
||||
}, [keymap]);
|
||||
|
||||
const triggerSongChange = useCallback(
|
||||
(direction) => {
|
||||
if (direction === 0) return;
|
||||
const current = typeof state.song?.note === 'number' ? state.song.note : SONG_DEFAULT_NOTE;
|
||||
let next = current + direction;
|
||||
if (next > NOTE_MAX) {
|
||||
next = NOTE_MIN;
|
||||
} else if (next < NOTE_MIN) {
|
||||
next = NOTE_MAX;
|
||||
}
|
||||
const finalNote = setSongNote(next);
|
||||
sendSong([{ note: finalNote, duration: SONG_DEFAULT_DURATION }], { slot: 0 });
|
||||
},
|
||||
[sendSong, setSongNote, state.song?.note],
|
||||
);
|
||||
|
||||
const ensureSongLoop = useCallback(() => {
|
||||
const direction = computeSongDirection();
|
||||
if (direction === 0) {
|
||||
stopSongLoop();
|
||||
return;
|
||||
}
|
||||
if (songIntervalRef.current) {
|
||||
return;
|
||||
}
|
||||
const tick = () => {
|
||||
const nextDirection = computeSongDirection();
|
||||
if (nextDirection === 0) {
|
||||
stopSongLoop();
|
||||
return;
|
||||
}
|
||||
triggerSongChange(nextDirection);
|
||||
songIntervalRef.current = setTimeout(tick, SONG_REPEAT_MS);
|
||||
};
|
||||
songIntervalRef.current = setTimeout(tick, 0);
|
||||
}, [computeSongDirection, stopSongLoop, triggerSongChange]);
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
activeTokensRef.current.clear();
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
lastAuxRef.current = ZERO_AUX;
|
||||
stopServoLoop();
|
||||
stopSongLoop();
|
||||
stopAllMotion();
|
||||
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
|
||||
}, [registerInputState, stopAllMotion, stopServoLoop]);
|
||||
}, [registerInputState, stopAllMotion, stopServoLoop, stopSongLoop]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) {
|
||||
@@ -238,6 +301,7 @@ export default function KeyboardInputManager() {
|
||||
}
|
||||
|
||||
ensureServoLoop();
|
||||
ensureSongLoop();
|
||||
driveFromKeys();
|
||||
}
|
||||
|
||||
@@ -245,6 +309,7 @@ export default function KeyboardInputManager() {
|
||||
const tokens = tokensForEvent(event);
|
||||
tokens.forEach((token) => activeTokensRef.current.delete(token));
|
||||
ensureServoLoop();
|
||||
ensureSongLoop();
|
||||
driveFromKeys();
|
||||
}
|
||||
|
||||
@@ -265,6 +330,7 @@ export default function KeyboardInputManager() {
|
||||
blurChat,
|
||||
driveFromKeys,
|
||||
ensureServoLoop,
|
||||
ensureSongLoop,
|
||||
focusChat,
|
||||
isChatFocused,
|
||||
keymap.chatFocus,
|
||||
@@ -274,6 +340,7 @@ export default function KeyboardInputManager() {
|
||||
runMacro,
|
||||
setMode,
|
||||
stopAllMotion,
|
||||
stopSongLoop,
|
||||
]);
|
||||
|
||||
const latestResetAllRef = useRef(resetAll);
|
||||
@@ -288,8 +355,9 @@ export default function KeyboardInputManager() {
|
||||
useEffect(
|
||||
() => () => {
|
||||
stopServoLoop();
|
||||
stopSongLoop();
|
||||
},
|
||||
[stopServoLoop],
|
||||
[stopServoLoop, stopSongLoop],
|
||||
);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -92,5 +92,9 @@ export function formatKeyLabel(value) {
|
||||
if (canonical === '/') return '/ or ?';
|
||||
if (canonical === '-') return '- or _';
|
||||
if (canonical === '=') return '= or +';
|
||||
if (canonical === 'arrowup') return 'Arrow Up';
|
||||
if (canonical === 'arrowdown') return 'Arrow Down';
|
||||
if (canonical === 'arrowleft') return 'Arrow Left';
|
||||
if (canonical === 'arrowright') return 'Arrow Right';
|
||||
return canonical.length === 1 ? canonical.toUpperCase() : canonical;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,14 @@ export const HELP_CONTENT = {
|
||||
{ action: 'auxAllForward', label: 'All motors forward' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'audio',
|
||||
title: 'Audio / Song',
|
||||
items: [
|
||||
{ action: 'songNoteUp', label: 'Song note up' },
|
||||
{ action: 'songNoteDown', label: 'Song note down' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user