@@ -64,6 +64,7 @@ import { useUiStore } from "@/stores/ui-store";
6464import { useInputStore } from "@/stores/input-store" ;
6565import { useSkillConfirmStore } from "@/stores/skill-confirm-store" ;
6666import { useFlyModeStore } from "@/stores/fly-mode-store" ;
67+ import { useVideoStreamsStore } from "@/stores/video-streams-store" ;
6768import { useSettingsStore } from "@/stores/settings-store" ;
6869import {
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 = / ^ (?: D i g i t | N u m p a d ) ( [ 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 ( ( ) => {
0 commit comments