Skip to content

Commit 9747e2d

Browse files
committed
cockpit: 1..N digit hotkeys + cycle for the stream switcher
Bare digits 1..N select the Nth video stream and backtick cycles, at the cockpit level, but only on a multi-stream node (the key otherwise passes through). The gamepad D-pad left/right cycles too. Digits 1..9 are reserved from skill binding so the switcher never collides with a bound slot.
1 parent 5db65dc commit 9747e2d

3 files changed

Lines changed: 111 additions & 0 deletions

File tree

src/components/fly/CockpitView.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import { useUiStore } from "@/stores/ui-store";
6464
import { useInputStore } from "@/stores/input-store";
6565
import { useSkillConfirmStore } from "@/stores/skill-confirm-store";
6666
import { useFlyModeStore } from "@/stores/fly-mode-store";
67+
import { useVideoStreamsStore } from "@/stores/video-streams-store";
6768
import { useSettingsStore } from "@/stores/settings-store";
6869
import {
6970
DEFAULT_LOADOUT_ID,
@@ -329,6 +330,76 @@ export function CockpitView({ droneId }: CockpitViewProps) {
329330
return () => unsubscribe();
330331
}, [flyEnabled, toggleQuick]);
331332

333+
// Stream switcher hotkeys: bare digits 1..N select the Nth video stream and
334+
// backtick cycles — but only on a multi-stream node (otherwise the key passes
335+
// straight through). Handled at the cockpit level like the other reserved
336+
// keys; digits are reserved from skill binding (chord.ts) so they never
337+
// collide with a bound slot, and this works whether or not the skill layer is
338+
// on (the switcher is a video feature).
339+
useEffect(() => {
340+
if (!droneId) return;
341+
const handler = (e: KeyboardEvent) => {
342+
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
343+
const target = e.target;
344+
if (
345+
target instanceof HTMLInputElement ||
346+
target instanceof HTMLTextAreaElement ||
347+
target instanceof HTMLSelectElement ||
348+
(target instanceof HTMLElement && target.isContentEditable)
349+
) {
350+
return;
351+
}
352+
if (useSkillConfirmStore.getState().pending !== null) return;
353+
if (editing || paletteOpen) return;
354+
if (useFlyQuickSettingsStore.getState().isOpen) return;
355+
const streams =
356+
useVideoStreamsStore.getState().streamsByDrone[droneId] ?? [];
357+
if (streams.length <= 1) return; // pass through on a single-stream node
358+
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(e.code);
359+
if (digit) {
360+
const index = Number(digit[1]);
361+
if (index > streams.length) return; // no such stream → pass through
362+
e.preventDefault();
363+
useVideoStreamsStore.getState().selectStream(droneId, index);
364+
return;
365+
}
366+
if (e.code === "Backquote") {
367+
e.preventDefault();
368+
useVideoStreamsStore.getState().cycleStream(droneId, 1);
369+
}
370+
};
371+
window.addEventListener("keydown", handler);
372+
return () => window.removeEventListener("keydown", handler);
373+
}, [droneId, editing, paletteOpen]);
374+
375+
// Gamepad D-pad left/right cycles the video stream on a multi-stream node.
376+
useEffect(() => {
377+
if (!droneId) return;
378+
const DPAD_LEFT = 14;
379+
const DPAD_RIGHT = 15;
380+
let prevL = useInputStore.getState().buttons[DPAD_LEFT] ?? false;
381+
let prevR = useInputStore.getState().buttons[DPAD_RIGHT] ?? false;
382+
const unsubscribe = useInputStore.subscribe((state) => {
383+
const nowL = state.buttons[DPAD_LEFT] ?? false;
384+
const nowR = state.buttons[DPAD_RIGHT] ?? false;
385+
const streams =
386+
useVideoStreamsStore.getState().streamsByDrone[droneId] ?? [];
387+
if (
388+
streams.length > 1 &&
389+
useSkillConfirmStore.getState().pending === null
390+
) {
391+
if (nowR && !prevR) {
392+
useVideoStreamsStore.getState().cycleStream(droneId, 1);
393+
} else if (nowL && !prevL) {
394+
useVideoStreamsStore.getState().cycleStream(droneId, -1);
395+
}
396+
}
397+
prevL = nowL;
398+
prevR = nowR;
399+
});
400+
return () => unsubscribe();
401+
}, [droneId]);
402+
332403
// Reserved gamepad exit chord (Start): leaves immersive mode so a stick-only
333404
// operator always has a way back to the embedded tab.
334405
useEffect(() => {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { canonicalChord, isReservedChord } from "@/lib/skills/chord";
4+
5+
describe("chord digits — the stream-switcher hotkeys", () => {
6+
it("canonicalizes a bare digit row key to '1'..'9'", () => {
7+
for (let d = 1; d <= 9; d++) {
8+
const e = new KeyboardEvent("keydown", { code: `Digit${d}`, key: String(d) });
9+
expect(canonicalChord(e)).toBe(String(d));
10+
}
11+
});
12+
13+
it("canonicalizes numpad digits the same way", () => {
14+
const e = new KeyboardEvent("keydown", { code: "Numpad5", key: "5" });
15+
expect(canonicalChord(e)).toBe("5");
16+
});
17+
18+
it("reserves bare digits 1..9 so no skill can bind them", () => {
19+
for (let d = 1; d <= 9; d++) {
20+
expect(isReservedChord(String(d))).toBe(true);
21+
}
22+
});
23+
24+
it("leaves modified digits and 0 bindable (only bare 1..9 are the switcher)", () => {
25+
expect(isReservedChord("ctrl+1")).toBe(false);
26+
expect(isReservedChord("shift+2")).toBe(false);
27+
expect(isReservedChord("0")).toBe(false);
28+
});
29+
});

src/lib/skills/chord.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export function canonicalChord(e: KeyboardEvent): string | null {
5656
* - the FC param shortcuts (ctrl+s save, ctrl+shift+s flash, ctrl+r refresh;
5757
* their meta equivalents are reserved as well since those handlers treat
5858
* meta as ctrl)
59+
* - the bare digits 1..9, owned globally by the cockpit stream switcher (a
60+
* digit selects the Nth video stream), so a skill can never be bound to one
5961
*/
6062
const RESERVED_CHORDS: ReadonlySet<string> = new Set([
6163
"escape",
@@ -67,6 +69,15 @@ const RESERVED_CHORDS: ReadonlySet<string> = new Set([
6769
"meta+shift+s",
6870
"ctrl+r",
6971
"meta+r",
72+
"1",
73+
"2",
74+
"3",
75+
"4",
76+
"5",
77+
"6",
78+
"7",
79+
"8",
80+
"9",
7081
]);
7182

7283
/** True when a chord is globally reserved and must not be bound to a slot. */

0 commit comments

Comments
 (0)