mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ca9776cc |
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -135,8 +135,6 @@ run_pipeline() {
|
|||||||
-c:v copy \
|
-c:v copy \
|
||||||
-an \
|
-an \
|
||||||
-flush_packets 1 \
|
-flush_packets 1 \
|
||||||
-muxdelay 0 \
|
|
||||||
-muxpreload 0 \
|
|
||||||
-f mpegts \
|
-f mpegts \
|
||||||
"${PUBLISH_URL}"
|
"${PUBLISH_URL}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package roverd
|
|||||||
type helloMessage struct {
|
type helloMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Color string `json:"color,omitempty"`
|
Color string `json:"color,omitempty"`
|
||||||
Battery BatteryConfig `json:"battery"`
|
Battery BatteryConfig `json:"battery"`
|
||||||
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
||||||
|
|||||||
@@ -91,9 +91,6 @@ type MediaConfig struct {
|
|||||||
AudioService string `yaml:"audioService"`
|
AudioService string `yaml:"audioService"`
|
||||||
HealthURL string `yaml:"healthUrl"`
|
HealthURL string `yaml:"healthUrl"`
|
||||||
HealthInterval Duration `yaml:"healthInterval"`
|
HealthInterval Duration `yaml:"healthInterval"`
|
||||||
VideoWidth int `yaml:"videoWidth" json:"-"`
|
|
||||||
VideoHeight int `yaml:"videoHeight" json:"-"`
|
|
||||||
VideoFPS int `yaml:"videoFps" json:"-"`
|
|
||||||
VideoBitrate int `yaml:"videoBitrate" json:"-"`
|
VideoBitrate int `yaml:"videoBitrate" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +142,6 @@ type PrivateSafetyConfig struct {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Description string `yaml:"description" json:"description,omitempty"`
|
|
||||||
Color string `yaml:"color" json:"color,omitempty"`
|
Color string `yaml:"color" json:"color,omitempty"`
|
||||||
ServerURL string `yaml:"serverUrl"`
|
ServerURL string `yaml:"serverUrl"`
|
||||||
Serial SerialConfig `yaml:"serial"`
|
Serial SerialConfig `yaml:"serial"`
|
||||||
|
|||||||
+2
-11
@@ -16,8 +16,8 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
|||||||
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
||||||
return fmt.Errorf("audio publishUrl missing")
|
return fmt.Errorf("audio publishUrl missing")
|
||||||
}
|
}
|
||||||
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
|
if media.VideoBitrate <= 0 {
|
||||||
return fmt.Errorf("invalid media dimensions/bitrate")
|
return fmt.Errorf("invalid media bitrate")
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -30,15 +30,6 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
|||||||
if media.AudioForwardURL != "" {
|
if media.AudioForwardURL != "" {
|
||||||
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
|
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
|
||||||
}
|
}
|
||||||
if media.VideoWidth > 0 {
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
|
||||||
}
|
|
||||||
if media.VideoHeight > 0 {
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
|
||||||
}
|
|
||||||
if media.VideoFPS > 0 {
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
||||||
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted))
|
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted))
|
||||||
audioDevice := audio.CaptureDevice
|
audioDevice := audio.CaptureDevice
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Sample configuration for roverd
|
# Sample configuration for roverd
|
||||||
name: roomba-alpha
|
name: roomba-alpha
|
||||||
description: "Loves corners, hates cords."
|
|
||||||
color: "#4DB6AC"
|
color: "#4DB6AC"
|
||||||
serverUrl: ws://control-server.local:8080/rover
|
serverUrl: ws://control-server.local:8080/rover
|
||||||
serial:
|
serial:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Sample configuration for roverd
|
# Sample configuration for roverd
|
||||||
name: roomba-alpha
|
name: roomba-alpha
|
||||||
description: "Loves corners, hates cords."
|
|
||||||
serverUrl: ws://control-server.local:8080/rover
|
serverUrl: ws://control-server.local:8080/rover
|
||||||
serial:
|
serial:
|
||||||
device: /dev/ttyAMA0
|
device: /dev/ttyAMA0
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
|||||||
msg := helloMessage{
|
msg := helloMessage{
|
||||||
Type: "hello",
|
Type: "hello",
|
||||||
Name: c.cfg.Name,
|
Name: c.cfg.Name,
|
||||||
Description: c.cfg.Description,
|
|
||||||
Color: c.cfg.Color,
|
Color: c.cfg.Color,
|
||||||
Battery: c.cfg.Battery,
|
Battery: c.cfg.Battery,
|
||||||
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
# Dead Code Audit (Static)
|
||||||
|
|
||||||
|
Generated: 2026-05-10T21:29:09-04:00
|
||||||
|
|
||||||
|
## A) High-confidence never used files
|
||||||
|
- server/src/services/overseerControlService/tools/chatSay.js (not registered in tools/index.js, no refs)
|
||||||
|
- server/prompts/commentary_system_backup_small.txt (no refs)
|
||||||
|
- server/prompts/commentary_system_backup_pre_lobotomy.txt (no refs)
|
||||||
|
- webui/src/App.css (not imported)
|
||||||
|
- webui/src/assets/react.svg (no refs)
|
||||||
|
- server/assets/test-audio.mp3 (no refs)
|
||||||
|
- webui/public/vite.svg (no refs from app/server)
|
||||||
|
- webui/dist/assets/index-APFDmmUd.js (build artifact, not runtime-wired)
|
||||||
|
- webui/dist/assets/index-DoNH_msu.css (build artifact, not runtime-wired)
|
||||||
|
- dist/dummy1.yml (no refs)
|
||||||
|
- dist/dummy2.yml (no refs)
|
||||||
|
- dist/dummy3.yml (no refs)
|
||||||
|
|
||||||
|
## B) Unused dependency candidates
|
||||||
|
- webui/package.json: react-joystick-component (no imports)
|
||||||
|
|
||||||
|
## C) Commented-out JSX/UI blocks (never rendered while commented)
|
||||||
|
webui/src/App.jsx:85: {/* <SessionSnapshot /> */}
|
||||||
|
webui/src/App.jsx:153: {/* {showTelemetry ? <TelemetryPanel /> : null} */}
|
||||||
|
webui/src/App.jsx:183: {/* <ControlSummary /> */}
|
||||||
|
webui/src/App.jsx:211: {/* <TelemetryPanel /> */}
|
||||||
|
webui/src/components/QuickstartOverlay/index.jsx:82: {/* <button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-[0.8rem]">
|
||||||
|
webui/src/components/QuickstartOverlay/index.jsx:84: </button> */}
|
||||||
|
webui/src/components/QuickstartOverlay/index.jsx:90: {/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
|
||||||
|
webui/src/components/QuickstartOverlay/index.jsx:116: {/* <button type="button" onClick={onOpenHelp} className="button-dark px-1 py-0.25">
|
||||||
|
webui/src/components/QuickstartOverlay/index.jsx:118: </button> */}
|
||||||
|
webui/src/components/DriveDockAction/index.jsx:261: {/* {!isMobile && expanded ? ( */}
|
||||||
|
webui/src/components/UserListPanel/index.jsx:263: {/* {isSelf && <span className="text-[0.7rem] text-white">YOU</span>} */}
|
||||||
|
webui/src/components/AuthPanel/index.jsx:48: {/* <div className="flex gap-0.5 text-sm">
|
||||||
|
webui/src/components/AuthPanel/index.jsx:55: </div> */}
|
||||||
|
webui/src/components/ModeGateOverlay/index.jsx:85: {/* {reasonUpdatedAt ? (
|
||||||
|
webui/src/components/ModeGateOverlay/index.jsx:89: ) : null} */}
|
||||||
|
webui/src/components/ModeGateOverlay/index.jsx:102: {/* set max height of this box */}
|
||||||
|
webui/src/components/ModeGateOverlay/index.jsx:108: {/* <p className="text-xs text-slate-500">
|
||||||
|
webui/src/components/ModeGateOverlay/index.jsx:111: </p> */}
|
||||||
|
webui/src/components/RoomCameraPanel/index.jsx:103: {/* <header className="space-y-0.5">
|
||||||
|
webui/src/components/RoomCameraPanel/index.jsx:106: </header> */}
|
||||||
|
webui/src/components/DriverVideoPanel/index.jsx:145: {/* colored button to visit the spectator page */}
|
||||||
|
webui/src/components/TelemetryPanel/index.jsx:43: {/* <div className="text-sm text-slate-400">
|
||||||
|
webui/src/components/TelemetryPanel/index.jsx:49: </div> */}
|
||||||
|
|
||||||
|
## D) Unused exports reported by knip (server)
|
||||||
|
Unused exports (66)
|
||||||
|
CHARGING_STATE src/helpers/sensorDecoder.js:224:3
|
||||||
|
pickRandomReward src/rewards/index.js:44:3
|
||||||
|
MAX_REASON_LENGTH src/services/adminReasonService/index.js:97:3
|
||||||
|
setAudioLevels src/services/audioLevelsService/index.js:153:3
|
||||||
|
pushLevelsToRover src/services/audioLevelsService/index.js:154:3
|
||||||
|
isAdmin src/services/authService/index.js:80:3
|
||||||
|
isLockdownAdmin src/services/authService/index.js:81:3
|
||||||
|
authenticate src/services/authService/index.js:82:3
|
||||||
|
isDuplicate src/services/chatService/contentFilters.js:57:3
|
||||||
|
handleIncoming src/services/chatService/index.js:32:3
|
||||||
|
buildTypingPayload src/services/chatService/index.js:35:3
|
||||||
|
rateBuckets src/services/chatService/state.js:44:3
|
||||||
|
buildRoverStatusSnapshot src/services/discordBotService/batteryEmbeds.js:132:3
|
||||||
|
eventBus src/services/eventBus/index.js:56:3
|
||||||
|
subscribeAll src/services/eventBus/index.js:59:3
|
||||||
|
MAX_GOAL_LENGTH src/services/globalObjectiveService/index.js:107:3
|
||||||
|
refreshIdleState src/services/idleService/index.js:86:3
|
||||||
|
DEFAULT_FREQUENCY_MS src/services/llmCommentaryService/constants.js:31:3
|
||||||
|
MIN_FREQUENCY_MS src/services/llmCommentaryService/constants.js:32:3
|
||||||
|
normalizeCommentary src/services/llmCommentaryService/formatters.js:175:3
|
||||||
|
enforceLockdown src/services/lockdownGuard/index.js:29:3
|
||||||
|
disconnectForLockdown src/services/lockdownGuard/index.js:30:3
|
||||||
|
parseOverseerOutput src/services/overseerControlService/runtimeHelpers.js:114:3
|
||||||
|
TOOL_DEFINITIONS src/services/overseerControlService/tools/index.js:87:3
|
||||||
|
getToolById src/services/overseerControlService/tools/index.js:91:3
|
||||||
|
getIdForSignature src/services/overseerControlService/tools/index.js:92:3
|
||||||
|
DM_APPROVE_EMOJI src/services/privateRoverAccessRequestService/index.js:25:3
|
||||||
|
DM_DENY_EMOJI src/services/privateRoverAccessRequestService/index.js:26:3
|
||||||
|
createRequest src/services/privateRoverAccessRequestService/index.js:29:3
|
||||||
|
hasClosedPrivateAccessForSocket src/services/privateRoverAccessRequestService/index.js:30:3
|
||||||
|
FFMPEG_BIN src/services/replayEngineV2/sources.js:66:3
|
||||||
|
upsertRover src/services/roverManager/index.js:234:3
|
||||||
|
removeRover src/services/roverManager/index.js:235:3
|
||||||
|
setPrivateOpen src/services/roverManager/index.js:237:3
|
||||||
|
setPrivateSafety src/services/roverManager/index.js:238:3
|
||||||
|
getRoster src/services/roverManager/index.js:239:3
|
||||||
|
getRosterForSocket src/services/roverManager/index.js:240:3
|
||||||
|
broadcastRoster src/services/roverManager/index.js:241:3
|
||||||
|
setNightVisionState src/services/roverManager/index.js:242:3
|
||||||
|
handleSensorFrame src/services/roverManager/index.js:243:3
|
||||||
|
requestControl src/services/roverManager/index.js:244:3
|
||||||
|
releaseControl src/services/roverManager/index.js:245:3
|
||||||
|
removeSocket src/services/roverManager/index.js:246:3
|
||||||
|
isDriver src/services/roverManager/index.js:247:3
|
||||||
|
canDrive src/services/roverManager/index.js:248:3
|
||||||
|
enableSpectator src/services/roverManager/index.js:249:3
|
||||||
|
disableSpectator src/services/roverManager/index.js:250:3
|
||||||
|
getRoversForSocket src/services/roverManager/index.js:253:3
|
||||||
|
getPrimaryRoverForSocket src/services/roverManager/index.js:254:3
|
||||||
|
canSeeRover src/services/roverManager/index.js:255:3
|
||||||
|
canRequestControl src/services/roverManager/index.js:256:3
|
||||||
|
applyPrivateDriveSafety src/services/roverManager/index.js:257:3
|
||||||
|
canReplayRoverId src/services/roverManager/index.js:258:3
|
||||||
|
computeBatteryDisplayPercent src/services/roverManager/mathUtils.js:136:3
|
||||||
|
buildSession src/services/sessionService/index.js:340:3
|
||||||
|
syncSocket src/services/sessionService/index.js:341:3
|
||||||
|
syncAll src/services/sessionService/index.js:342:3
|
||||||
|
driverAdded src/services/turnService/index.js:284:3
|
||||||
|
driverRemoved src/services/turnService/index.js:285:3
|
||||||
|
cleanupRover src/services/turnService/index.js:286:3
|
||||||
|
canDrive src/services/turnService/index.js:287:3
|
||||||
|
createSession src/services/videoSessions/index.js:68:3
|
||||||
|
getSession src/services/videoSessions/index.js:69:3
|
||||||
|
revokeSession src/services/videoSessions/index.js:70:3
|
||||||
|
revokeBySocket src/services/videoSessions/index.js:71:3
|
||||||
|
revokeWhere src/services/videoSessions/index.js:72:3
|
||||||
|
|
||||||
|
## E) Unused exports reported by knip (webui)
|
||||||
|
Unused exports (18)
|
||||||
|
deriveDriveDockState function src/components/DriveDockAction/index.jsx:9:17
|
||||||
|
HelpContentView function src/components/HelpContentView/index.jsx:179:17
|
||||||
|
default function src/components/UserListPanel/index.jsx:50:25
|
||||||
|
normalizeDriveVector function src/controls/controlMath.js:22:17
|
||||||
|
useOvercurrentLimiter src/controls/index.js:6:10
|
||||||
|
cloneProfile function src/controls/inputs/gamepadBindings.js:14:17
|
||||||
|
getGamepadHubState function src/controls/inputs/gamepadHub.js:109:17
|
||||||
|
deriveCodeForKey function src/controls/keymapUtils.js:36:17
|
||||||
|
createKeyToken function src/controls/keymapUtils.js:47:17
|
||||||
|
createCodeToken function src/controls/keymapUtils.js:52:17
|
||||||
|
DEFAULT_OVERCURRENT_LIMITS src/controls/overcurrentLimiter.js:12:14
|
||||||
|
HELP_LAYOUTS src/help/content.js:3:14
|
||||||
|
HELP_CONTENT src/help/content.js:7:14
|
||||||
|
default function src/hooks/useFullscreenPrompt.js:169:16
|
||||||
|
normalizeRoverColor function src/lib/roverColor.js:5:17
|
||||||
|
roverSwatchStyle function src/lib/roverColor.js:35:17
|
||||||
|
useSettings src/settings/index.js:3:28
|
||||||
|
useSettings function src/settings/SettingsProvider.jsx:58:17
|
||||||
|
Duplicate exports (2)
|
||||||
|
HelpContentView|default src/components/HelpContentView/index.jsx
|
||||||
|
useFullscreenPrompt|default src/hooks/useFullscreenPrompt.js
|
||||||
|
|
||||||
|
## F) Server State-Machine Contradictions (Proof-Based)
|
||||||
|
|
||||||
|
### F1) `lockdown-admin` role branches are unreachable in current role producer graph
|
||||||
|
**Why unreachable:**
|
||||||
|
- All server role writes are done through `setRole(socket, role)` in auth flows.
|
||||||
|
- Role assignments are only `user`, `spectator`, `admin`, `lockdown`.
|
||||||
|
- No assignment path sets `lockdown-admin`.
|
||||||
|
|
||||||
|
**Role producers (source of truth):**
|
||||||
|
- `server/src/services/authService/index.js` (`initialRole` user/spectator, login role admin/lockdown, role:set only user/spectator)
|
||||||
|
- `server/src/services/roleService/index.js` (just stores whatever caller sets; no separate producer)
|
||||||
|
- Searched for any `setRole(..., 'lockdown-admin')` / `socket.data.role = 'lockdown-admin'`: none
|
||||||
|
|
||||||
|
**Dead branches/cases under this graph:**
|
||||||
|
- `server/src/services/adminLogService/index.js`
|
||||||
|
- `server/src/services/llmCommentaryService/runtimeHelpers.js`
|
||||||
|
- `server/src/services/overseerControlService/runtimeHelpers.js`
|
||||||
|
- `server/src/services/verificationService/identity.js`
|
||||||
|
- `server/src/services/discordBotService/integrations/helpers.js`
|
||||||
|
- `server/src/services/replayEngineV2/sidebarRenderer.js`
|
||||||
|
- `webui/src/components/ModeGateOverlay/index.jsx`
|
||||||
|
- `webui/src/components/UserListPanel/index.jsx`
|
||||||
|
- `webui/src/components/ChatMessageRow/index.jsx`
|
||||||
|
- `webui/src/components/RoverQueuesPanel/index.jsx`
|
||||||
|
- `webui/src/components/RawUserPilePanel/index.jsx`
|
||||||
|
- `webui/src/components/AdminPanel/AdminPanelContent.jsx`
|
||||||
|
- `webui/src/controls/overcurrentLimiter.js`
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### F2) `clearLockdownTimer` currently has no possible effect
|
||||||
|
**Why unreachable/effectively dead:**
|
||||||
|
- `clearLockdownTimer(socket)` only clears `socket.data.lockdownTimer`.
|
||||||
|
- No code ever sets `socket.data.lockdownTimer` anywhere in repo.
|
||||||
|
- Therefore the condition is always false and this function is a no-op in current runtime.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
- `server/src/services/lockdownGuard/index.js` (only reads/clears `lockdownTimer`)
|
||||||
|
- global search for `lockdownTimer` assignments: none
|
||||||
|
- caller: `server/src/services/authService/index.js` (invokes `clearLockdownTimer` after login)
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### F3) `PERIODIC_SYNC_MS` config path is dead (constant + import)
|
||||||
|
**Why unreachable/effectively dead:**
|
||||||
|
- `PERIODIC_SYNC_MS` is imported into session service but only used in a commented-out `setInterval` block.
|
||||||
|
- No runtime path consumes this value.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
- `server/src/services/sessionService/constants.js` exports `PERIODIC_SYNC_MS`
|
||||||
|
- `server/src/services/sessionService/index.js` imports it and references it only in commented block
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### F4) Video request parser supports `room` request shape, but runtime contract rejects it
|
||||||
|
**Why semantically contradictory:**
|
||||||
|
- `videoSocketService.normalizeRequest()` accepts room request payloads (`roomCameraId` / `{type:'room'}`).
|
||||||
|
- The handler then always throws for `target.type === 'room'` with “Room cameras now use the snapshot feed”.
|
||||||
|
- So room-video request acceptance code is legacy compatibility surface with guaranteed failure.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
- `server/src/services/videoSocketService/index.js`
|
||||||
|
|
||||||
|
**Note:** This is reachable only if a client attempts room video via `video:request`; it is not a successful runtime feature path.
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### F5) `useVideoRequests` still contains room-source normalization path unused by current first-party call sites
|
||||||
|
**Why currently redundant:**
|
||||||
|
- `useVideoRequests` can normalize `roomCameraId` / `type:'room'`.
|
||||||
|
- Current call sites pass rover-only entries:
|
||||||
|
- `webui/src/components/DriverVideoPanel/index.jsx`
|
||||||
|
- `webui/src/spectate/SpectatorApp/SpectatorContent.jsx`
|
||||||
|
- `webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx`
|
||||||
|
- This aligns with room camera delivery moving to snapshot socket feed.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
- `webui/src/hooks/useVideoRequests.js`
|
||||||
|
- call-site inspection above
|
||||||
|
|
||||||
|
**Confidence:** Medium-High (internal app paths only; external/future caller may use room shape)
|
||||||
|
|
||||||
|
## G) Legacy Compatibility Surface (Single-Program Dead Ends)
|
||||||
|
|
||||||
|
### G1) Duplicate socket event listeners: only `session:*` names are used by this client
|
||||||
|
**Observation:** webui emits only `session:*` variants for control-role operations.
|
||||||
|
|
||||||
|
**WebUI emit calls:**
|
||||||
|
- `session:setRole`
|
||||||
|
- `session:requestControl`
|
||||||
|
- `session:releaseControl`
|
||||||
|
- `session:lockRover`
|
||||||
|
- `session:privateSafety:set`
|
||||||
|
- `session:subscribeAll`
|
||||||
|
(see `webui/src/context/SessionContext.jsx`)
|
||||||
|
|
||||||
|
**Server still listens to both old + namespaced aliases:**
|
||||||
|
- `requestControl` + `session:requestControl`
|
||||||
|
- `releaseControl` + `session:releaseControl`
|
||||||
|
- `lockRover` + `session:lockRover`
|
||||||
|
- `privateSafety:set` + `session:privateSafety:set`
|
||||||
|
- `subscribeAll` + `session:subscribeAll`
|
||||||
|
(see `server/src/services/roverManager/socketHandlers.js`)
|
||||||
|
|
||||||
|
- `role:set` + `session:setRole`
|
||||||
|
(see `server/src/services/authService/index.js`)
|
||||||
|
|
||||||
|
**Why dead under single-program assumption:**
|
||||||
|
- No first-party client emits old names; old listeners are compatibility-only.
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### G2) Server emits protocol events with no first-party subscribers
|
||||||
|
**Server emits:**
|
||||||
|
- `rovers` (on connect and roster updates)
|
||||||
|
- `auth:role`
|
||||||
|
- `mode`
|
||||||
|
- `controlGranted`
|
||||||
|
- `lockdown`
|
||||||
|
|
||||||
|
**Evidence of emitters:**
|
||||||
|
- `server/src/services/roverManager/socketHandlers.js` (`rovers`, `controlGranted`)
|
||||||
|
- `server/src/services/roverManager/rosterLifecycle.js` (`rovers`)
|
||||||
|
- `server/src/services/authService/index.js` (`auth:role`)
|
||||||
|
- `server/src/services/modeManager/index.js` (`mode`)
|
||||||
|
- `server/src/services/lockdownGuard/index.js` (`lockdown`)
|
||||||
|
|
||||||
|
**Client-side consumption check:**
|
||||||
|
- No `socket.on('rovers' | 'auth:role' | 'mode' | 'controlGranted' | 'lockdown')` anywhere in `webui/src`.
|
||||||
|
- Session-driven UI uses `session:sync` instead.
|
||||||
|
|
||||||
|
**Why dead under single-program assumption:**
|
||||||
|
- Event emissions exist for older/external clients only.
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### G3) `video:request` still accepts room payload shapes that are hard-rejected
|
||||||
|
**Current behavior:**
|
||||||
|
- Request normalization accepts room forms (`roomCameraId`, `{type:'room', id}`)
|
||||||
|
- Handler immediately throws for room type: "Room cameras now use the snapshot feed"
|
||||||
|
(see `server/src/services/videoSocketService/index.js`)
|
||||||
|
|
||||||
|
**Why compatibility-only:**
|
||||||
|
- Room WHEP path retained in request parsing despite product contract migrating to snapshot feed.
|
||||||
|
- First-party room camera path uses `roomCamera:*` snapshot sockets.
|
||||||
|
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### G4) Global-objective legacy filename fallback likely one-time migration shim
|
||||||
|
**Behavior:**
|
||||||
|
- Reads canonical `global-objective.json`, else attempts legacy `community-goal.json`.
|
||||||
|
(see `server/src/services/globalObjectiveService/index.js`)
|
||||||
|
|
||||||
|
**Why likely compatibility-only:**
|
||||||
|
- This fallback exists purely for pre-rename data compatibility.
|
||||||
|
- In a single coordinated deployment, once migrated, legacy read path is dead.
|
||||||
|
|
||||||
|
**Confidence:** Medium-High (depends on whether legacy file still exists in your deployed data dir)
|
||||||
|
|
||||||
|
### G5) Data directory legacy fallback path is compatibility shim
|
||||||
|
**Behavior:**
|
||||||
|
- `resolveDataPath`/`resolveDataDir` checks canonical `server/data` and legacy `server/src/data` style location.
|
||||||
|
(see `server/src/helpers/dataPaths.js`)
|
||||||
|
|
||||||
|
**Why likely compatibility-only:**
|
||||||
|
- Exists to preserve prior storage layout after refactor.
|
||||||
|
- If deployment has stabilized on canonical path or explicit `SERVER_DATA_DIR`, legacy branch never used.
|
||||||
|
|
||||||
|
**Confidence:** Medium-High (environment dependent)
|
||||||
|
|
||||||
|
### G6) Overseer output parser keeps legacy one-line fallback parser
|
||||||
|
**Behavior:**
|
||||||
|
- Attempts JSON parse first; on failure falls back to historical one-line parse protocol.
|
||||||
|
(see `server/src/services/overseerControlService/runtimeHelpers.js`)
|
||||||
|
|
||||||
|
**Why compatibility-only:**
|
||||||
|
- Current prompt/protocol can be constrained to structured JSON output.
|
||||||
|
- Fallback branch preserves old non-JSON output compatibility.
|
||||||
|
|
||||||
|
**Confidence:** Medium (depends on model output guarantees / prompt hardening)
|
||||||
|
|
||||||
|
## H) Additional Deep Sweep Findings (Repo-Wide)
|
||||||
|
|
||||||
|
### H1) Uncalled helper export: `eventBus.subscribeAll`
|
||||||
|
**Evidence:**
|
||||||
|
- Declared/exported in `server/src/services/eventBus/index.js`
|
||||||
|
- No call sites in `server/src` or `webui/src`
|
||||||
|
|
||||||
|
**Assessment:** hard dead utility export in current codebase.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H2) Uncalled helper export: `overseerControl.runtimeHelpers.parseOverseerOutput`
|
||||||
|
**Evidence:**
|
||||||
|
- Declared/exported in `server/src/services/overseerControlService/runtimeHelpers.js`
|
||||||
|
- `overseerControl/index.js` imports only `{ isAdminRole, buildAdminState, buildFailureInfo }`
|
||||||
|
- No other call sites in repo
|
||||||
|
|
||||||
|
**Assessment:** dead parser path (including its legacy one-line fallback) in current wiring.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H3) Uncalled helper export: `overseerControl.tools.getIdForSignature`
|
||||||
|
**Evidence:**
|
||||||
|
- Declared/exported in `server/src/services/overseerControlService/tools/index.js`
|
||||||
|
- No call sites in repo
|
||||||
|
|
||||||
|
**Assessment:** dead compatibility/helper function.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H4) `socket.emit('rovers', ...)` channel appears fully orphaned
|
||||||
|
**Evidence:**
|
||||||
|
- Emitted by server in rover manager connect/roster paths:
|
||||||
|
- `server/src/services/roverManager/socketHandlers.js`
|
||||||
|
- `server/src/services/roverManager/rosterLifecycle.js`
|
||||||
|
- No `socket.on('rovers', ...)` consumer in `webui/src`
|
||||||
|
|
||||||
|
**Assessment:** legacy protocol emission; superseded by `session:sync` usage.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H5) `auth:role` and `mode` push events appear orphaned for first-party UI
|
||||||
|
**Evidence:**
|
||||||
|
- Emitted by:
|
||||||
|
- `server/src/services/authService/index.js` (`auth:role`)
|
||||||
|
- `server/src/services/modeManager/index.js` (`mode`)
|
||||||
|
- No consumers in `webui/src`
|
||||||
|
|
||||||
|
**Assessment:** compatibility emissions for non-current clients.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H6) `controlGranted` / `lockdown` push events appear orphaned for first-party UI
|
||||||
|
**Evidence:**
|
||||||
|
- Emitted by:
|
||||||
|
- `server/src/services/roverManager/socketHandlers.js` (`controlGranted`)
|
||||||
|
- `server/src/services/lockdownGuard/index.js` (`lockdown`)
|
||||||
|
- No consumers in `webui/src`
|
||||||
|
|
||||||
|
**Assessment:** compatibility/legacy push surface for old clients.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H7) Pi/roverd `dummy` paths are not dead by default (intentional build target)
|
||||||
|
**Evidence:**
|
||||||
|
- Build-tag split (`//go:build dummy` vs `!dummy`) in serial/sensor/nightvision/camera_servo/brc modules
|
||||||
|
- `Makefile` has explicit `dummy` target: `go build -tags dummy ...`
|
||||||
|
|
||||||
|
**Assessment:** keep; this is an intentional alternate runtime, not dead code.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H8) `roomcam-service` appears operationally standalone (not wired by installers)
|
||||||
|
**Evidence:**
|
||||||
|
- Present as service + script under `roomcam-service/`
|
||||||
|
- Not installed by `server/install_server.sh` or `pi/install_roverd.sh`
|
||||||
|
- Server room camera feature consumes configured URLs and does not require this local service specifically
|
||||||
|
|
||||||
|
**Assessment:** likely optional/ops artifact; remove only if you do not deploy it manually.
|
||||||
|
**Confidence:** Medium
|
||||||
|
|
||||||
|
### H9) `dist/dummy{1,2,3}.yml` still has no runtime references
|
||||||
|
**Evidence:**
|
||||||
|
- No code paths consume these files
|
||||||
|
- Existing references are only in the files themselves
|
||||||
|
|
||||||
|
**Assessment:** hard dead artifacts in repo runtime context.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### H10) `buttonbox/src/config example.h` is a template, not runtime code
|
||||||
|
**Evidence:**
|
||||||
|
- Firmware includes `<config.h>`; template file is named `config example.h`
|
||||||
|
- Typical manual-copy onboarding artifact
|
||||||
|
|
||||||
|
**Assessment:** optional docs/template artifact; not dead logic, but not build-consumed unless manually copied.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
## I) Final Confirmed-Only List (Static Proof)
|
||||||
|
|
||||||
|
These are the items I can confirm from source/wiring alone with high confidence.
|
||||||
|
|
||||||
|
### I1) Definitely uncalled functions/exports
|
||||||
|
- `server/src/services/eventBus/index.js` → `subscribeAll`
|
||||||
|
- `server/src/services/overseerControlService/runtimeHelpers.js` → `parseOverseerOutput`
|
||||||
|
- `server/src/services/overseerControlService/tools/index.js` → `getIdForSignature`
|
||||||
|
|
||||||
|
Proof: repo-wide usage search returns definition only (no call sites).
|
||||||
|
|
||||||
|
### I2) Definitely orphan server push events for first-party webui
|
||||||
|
Server emits, but `webui/src` has no listeners for these event names:
|
||||||
|
- `rovers`
|
||||||
|
- `auth:role`
|
||||||
|
- `mode`
|
||||||
|
- `controlGranted`
|
||||||
|
- `lockdown`
|
||||||
|
|
||||||
|
Proof: emitters exist in server files; repo-wide `webui/src` listener search returns none.
|
||||||
|
|
||||||
|
### I3) Definitely unused old client event names in first-party webui
|
||||||
|
Server listens for old aliases:
|
||||||
|
- `requestControl`, `releaseControl`, `lockRover`, `privateSafety:set`, `subscribeAll`, `role:set`
|
||||||
|
|
||||||
|
WebUI emits only namespaced forms:
|
||||||
|
- `session:requestControl`, `session:releaseControl`, `session:lockRover`, `session:privateSafety:set`, `session:subscribeAll`, `session:setRole`
|
||||||
|
|
||||||
|
Proof: listener and emitter searches across `server/src` + `webui/src`.
|
||||||
|
|
||||||
|
### I4) Definitely dead room branch in client video request helper (for first-party app)
|
||||||
|
- `webui/src/hooks/useVideoRequests.js` supports room entry shapes (`roomCameraId` / `type:'room'`).
|
||||||
|
- No first-party call site in `webui/src` constructs room entries.
|
||||||
|
|
||||||
|
Proof: `type:'room'` and `roomCameraId` appear only inside `useVideoRequests.js`.
|
||||||
|
|
||||||
|
### I5) Definitely dead file candidates (code/non-built artifacts)
|
||||||
|
- `server/src/services/overseerControlService/tools/chatSay.js` (not registered in tool definitions; no refs)
|
||||||
|
- `server/prompts/commentary_system_backup_small.txt` (no refs)
|
||||||
|
- `server/prompts/commentary_system_backup_pre_lobotomy.txt` (no refs)
|
||||||
|
- `webui/src/App.css` (not imported)
|
||||||
|
- `webui/src/assets/react.svg` (no refs)
|
||||||
|
- `server/assets/test-audio.mp3` (no refs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## J) Not Provable Statically (requires runtime/env assertions)
|
||||||
|
|
||||||
|
- Legacy data path fallbacks (`server/src/helpers/dataPaths.js`) may be active depending on deployed filesystem and `SERVER_DATA_DIR`.
|
||||||
|
- `globalObjective` legacy filename fallback (`community-goal.json`) may still be used if old file exists and new file absent.
|
||||||
|
- `roomcam-service/*` may be manually used outside installer-managed workflows.
|
||||||
|
- Pi/roverd `dummy` build-tag code is intentional alternate target; not dead by default.
|
||||||
|
|
||||||
|
|
||||||
|
## K) Additional LLM-Style Flexibility Confirmed Unused/Dead
|
||||||
|
|
||||||
|
### K1) `pi/roverd` config knobs for `videoWidth/videoHeight/videoFps` are effectively dead in current pipeline
|
||||||
|
**Proof:**
|
||||||
|
- Config defines and validates `VideoWidth`, `VideoHeight`, `VideoFPS` in `pi/roverd/config.go`.
|
||||||
|
- `UpdatePublisherEnv` writes `VIDEO_WIDTH/VIDEO_HEIGHT/VIDEO_FPS` to env file in `pi/roverd/media_env.go`.
|
||||||
|
- `pi/bin/video-publisher.sh` does **not** read those env vars; it hardcodes:
|
||||||
|
- `VIDEO_WIDTH="640"`
|
||||||
|
- `VIDEO_HEIGHT="480"`
|
||||||
|
- `VIDEO_FPS="30"`
|
||||||
|
- Therefore these config values cannot affect runtime behavior as wired.
|
||||||
|
|
||||||
|
**Assessment:** dead configurability / fake knob.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### K2) Legacy/non-namespaced socket control API remains as compatibility baggage
|
||||||
|
(Already identified, reiterated here as LLM-flex class)
|
||||||
|
- Old listeners retained: `requestControl`, `releaseControl`, `lockRover`, `privateSafety:set`, `subscribeAll`, `role:set`
|
||||||
|
- First-party client emits only `session:*` names.
|
||||||
|
|
||||||
|
**Assessment:** removable compatibility layer for your single-program model.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### K3) Server push-event fanout retained for non-current clients
|
||||||
|
(Already identified, reiterated here as LLM-flex class)
|
||||||
|
- Emits: `rovers`, `auth:role`, `mode`, `controlGranted`, `lockdown`
|
||||||
|
- No webui listeners for any of these.
|
||||||
|
|
||||||
|
**Assessment:** compatibility broadcast surface with no first-party consumer.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
### K4) Unused helper exports indicate speculative abstraction leftovers
|
||||||
|
- `eventBus.subscribeAll`
|
||||||
|
- `overseerControl.runtimeHelpers.parseOverseerOutput`
|
||||||
|
- `overseerControl.tools.getIdForSignature`
|
||||||
|
|
||||||
|
**Assessment:** abstraction/future-proofing residue; currently dead.
|
||||||
|
**Confidence:** High
|
||||||
|
|
||||||
|
## L) Cleanup Execution Checklist (Do This Order)
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
- Remove only items in the current batch.
|
||||||
|
- Run smoke checks after each batch before moving on.
|
||||||
|
- If smoke fails, revert that batch only and split it smaller.
|
||||||
|
|
||||||
|
### Smoke Check (run after each batch)
|
||||||
|
- Open web UI and connect at least one client.
|
||||||
|
- Verify role switch (`user`/`spectator`) still works.
|
||||||
|
- Verify request control / release control still works.
|
||||||
|
- Verify rover lock + private safety toggle still works.
|
||||||
|
- Verify mode switch still works.
|
||||||
|
- Verify spectator view still receives expected session state.
|
||||||
|
|
||||||
|
### Bucket 1: Confirmed Safe (static-proof removal candidates)
|
||||||
|
|
||||||
|
#### Batch 1 (lowest risk, start here)
|
||||||
|
- Remove unused exports/helpers with no call sites:
|
||||||
|
- `server/src/services/eventBus/index.js` → `subscribeAll`
|
||||||
|
- `server/src/services/overseerControlService/runtimeHelpers.js` → `parseOverseerOutput`
|
||||||
|
- `server/src/services/overseerControlService/tools/index.js` → `getIdForSignature`
|
||||||
|
- Remove dead file assets/prompts not referenced anywhere:
|
||||||
|
- `server/src/services/overseerControlService/tools/chatSay.js`
|
||||||
|
- `server/prompts/commentary_system_backup_small.txt`
|
||||||
|
- `server/prompts/commentary_system_backup_pre_lobotomy.txt`
|
||||||
|
- `webui/src/App.css`
|
||||||
|
- `webui/src/assets/react.svg`
|
||||||
|
- `server/assets/test-audio.mp3`
|
||||||
|
|
||||||
|
#### Batch 2
|
||||||
|
- Remove legacy non-namespaced socket alias listeners, keep namespaced/session contract:
|
||||||
|
- In rover manager socket handlers, remove:
|
||||||
|
- `requestControl`
|
||||||
|
- `releaseControl`
|
||||||
|
- `lockRover`
|
||||||
|
- `privateSafety:set`
|
||||||
|
- `subscribeAll`
|
||||||
|
- In auth service, remove:
|
||||||
|
- `role:set`
|
||||||
|
- Keep:
|
||||||
|
- `session:requestControl`
|
||||||
|
- `session:releaseControl`
|
||||||
|
- `session:lockRover`
|
||||||
|
- `session:privateSafety:set`
|
||||||
|
- `session:subscribeAll`
|
||||||
|
- `session:setRole`
|
||||||
|
- `setMode`
|
||||||
|
|
||||||
|
#### Batch 3
|
||||||
|
- Remove orphan server push emissions not consumed by first-party web UI:
|
||||||
|
- `rovers`
|
||||||
|
- `auth:role`
|
||||||
|
- `mode`
|
||||||
|
- `controlGranted`
|
||||||
|
- `lockdown`
|
||||||
|
- Keep `session:sync` flow intact.
|
||||||
|
|
||||||
|
#### Batch 4
|
||||||
|
- Remove dead room-shape branch in first-party client helper:
|
||||||
|
- `webui/src/hooks/useVideoRequests.js` room entry normalization paths (`roomCameraId` / `type:'room'`)
|
||||||
|
- Keep rover request path only.
|
||||||
|
|
||||||
|
#### Batch 5
|
||||||
|
- Remove fake video configurability in `pi/roverd` (choose exactly one direction):
|
||||||
|
- Option A: remove `videoWidth/videoHeight/videoFps` knobs and env writes entirely.
|
||||||
|
- Option B: wire `pi/bin/video-publisher.sh` to consume `VIDEO_WIDTH/VIDEO_HEIGHT/VIDEO_FPS`.
|
||||||
|
- Preferred for cleanup goal: Option A.
|
||||||
|
|
||||||
|
### Bucket 2: Needs Runtime Check (env/deploy dependent)
|
||||||
|
- `server/src/helpers/dataPaths.js` legacy path fallback branches.
|
||||||
|
- `server/src/services/globalObjectiveService/index.js` legacy `community-goal.json` fallback.
|
||||||
|
- `roomcam-service/*` only if confirmed unused in your deployment.
|
||||||
|
|
||||||
|
### Gate Before Bucket 2
|
||||||
|
- Confirm live environment values and on-disk data:
|
||||||
|
- `SERVER_DATA_DIR` usage status.
|
||||||
|
- Whether any deployment still has only legacy files/paths.
|
||||||
|
- Whether `roomcam-service` is started by any external supervisor.
|
||||||
|
|
||||||
|
### Done Criteria
|
||||||
|
- All Bucket 1 batches merged with smoke pass after each.
|
||||||
|
- Bucket 2 either removed with runtime proof, or explicitly kept with rationale.
|
||||||
|
- `rg` checks show no stale references to removed symbols/events/files.
|
||||||
+22
-20
@@ -1,24 +1,26 @@
|
|||||||
1. add faster way for admins to login
|
1. fix controls remapping [x]
|
||||||
2. custom webhook profile pictures for chat bridge in discord
|
2. trusted user system [x]
|
||||||
3. add tool call embeds or something for the llm bot in discord, probably not in web ui
|
3. private rovers [x]
|
||||||
4. add discord bot typing thing for when someone requests a replay
|
4. optional bump-off in drive macro [x]
|
||||||
5. change replay title for ones requested from discord, something other than "requester driving rover"
|
5. allow admins to click on locked rovers from the roster [x]
|
||||||
6. fix rover request spam queue cheat
|
6. add faster way for admins to login
|
||||||
7. reorganize internal structure of video, HUD stuff...
|
7. custom webhook profile pictures for chat bridge in discord
|
||||||
8. button box reward: ping @everyone, 7567 presses
|
8. home assistant switch that tells the server to force the lights on [x]
|
||||||
|
9. color coding with colored names and tape [x]
|
||||||
|
10. audio forwarding [x]
|
||||||
|
- streaming from server to rovers [x]
|
||||||
|
- audio files first [x]
|
||||||
|
- then voice chat [x]
|
||||||
|
10. mobile controls column swapping (optional joystick on left) [x]
|
||||||
|
11. fix fullscreen on mobile so that you can re-enter it [x]
|
||||||
|
12. fix scroll bars on mobile [x]
|
||||||
|
13. add tool call embeds or something for the llm bot in discord, probably not in web ui
|
||||||
|
14. add discord bot typing thing for when someone requests a replay
|
||||||
|
15. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||||
|
16. better quickstart guide, something better than just a big list of controls. help overlay sucks i think. [x]
|
||||||
|
1. restyle fullscreen overlay... please.. [x]
|
||||||
|
17. fix rover request spam queue cheat
|
||||||
|
|
||||||
7. rover descriptions. show in the HUD at the bottom or top for a few seconds, then fade away.
|
|
||||||
8. reorganize internal structure of video, HUD stuff... [x]
|
|
||||||
9. button box reward: ping @everyone, 7567 presses
|
|
||||||
10. enable scrolling on horn frequencies, turn down frequency limit to like 3500
|
|
||||||
11. make replays more instant, probably make segments shorter
|
|
||||||
1. fix replay UI so it doesnt save anything in cookie
|
|
||||||
12. rework drive / dock panel somehow to explain how to dock manually instead of relying on auto docking
|
|
||||||
1. maybe have a flag in the rover to choose between auto or manual directions
|
|
||||||
2. probably have a short inline video that plays and shows the process
|
|
||||||
3. manual docking mode
|
|
||||||
1. have camera move down automatically and limit speed during manual docking mode
|
|
||||||
13. add new rules section to overseer
|
|
||||||
|
|
||||||
# relative pipe dreams:
|
# relative pipe dreams:
|
||||||
1. VPS video forwarding
|
1. VPS video forwarding
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# main idea:
|
|
||||||
- first of all, NONE of this should effect the spectator pages.
|
|
||||||
- right now, when youre on a rover and its not your turn, you see a preview feed.
|
|
||||||
- this is annoying if you are ex: trying to sit there and watch something with multiple people on one rover
|
|
||||||
- I want it changed so that if there are less TOTAL drivers than there are rovers, everyone sees full video even when it's not their turn.
|
|
||||||
- if there are more total drivers than rovers, when its not your turn youll see the preview feed
|
|
||||||
- I also want some UI changes to make it better:
|
|
||||||
- right now theres a big overlay in the middle of the screen when its not your turn, that explains the preview thing
|
|
||||||
- I want this moved to the top left corner
|
|
||||||
- I want it to blink on input, to draw attention to it
|
|
||||||
- I want it to always show when its not your turn
|
|
||||||
- but I only want it to explain the preview thing when its showing the preview
|
|
||||||
- the preview exists to save upload bandwith
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# why?
|
|
||||||
- right now, video and HUD elements are scattered across components
|
|
||||||
- some HUD elements have code baked into large video tiles and HUDs and stuff.
|
|
||||||
|
|
||||||
## organization rules:
|
|
||||||
in the end we should have:
|
|
||||||
- a component that JUST plays video and audio
|
|
||||||
- a driver video panel, which combines the video component and all of the proper HUD elements for drivers
|
|
||||||
- a spectator video panel, which combines the video component and all of the proper HUD elements for spectators
|
|
||||||
- ALL of the HUD elements each as their own component, in a folder of HUD elements. each in its own folder.
|
|
||||||
- NO MORE HUD elements built into video panels, tiles, or whatever.
|
|
||||||
- no visual or functional changes of anything. this is all just code restructuring
|
|
||||||
- follow the new structure of the components, each one is its own folder, etc. split them up into separate files where reasonable, for large components
|
|
||||||
Binary file not shown.
@@ -1,75 +0,0 @@
|
|||||||
You are The Overseer.
|
|
||||||
|
|
||||||
Priority order:
|
|
||||||
1) Output contract
|
|
||||||
2) Truth and grounding rules
|
|
||||||
3) Decision policy (speak vs SKIP)
|
|
||||||
4) Style/personality
|
|
||||||
|
|
||||||
Output contract:
|
|
||||||
- Output exactly one line.
|
|
||||||
- Output must be either SKIP or one chat message.
|
|
||||||
- No markdown.
|
|
||||||
- No emojis.
|
|
||||||
- If posting unprompted, keep it to one concise sentence.
|
|
||||||
- Length target when posting:
|
|
||||||
- Unprompted comments: usually 14-28 words.
|
|
||||||
- Direct replies/questions: usually 18-45 words.
|
|
||||||
- Avoid very short fragments unless the moment clearly calls for it.
|
|
||||||
|
|
||||||
Truth and grounding rules:
|
|
||||||
- Use timeline for flow.
|
|
||||||
- Use SNAPSHOT FINAL as current truth.
|
|
||||||
- Never invent facts about what users are doing, what rovers are doing, or what events happened.
|
|
||||||
- Never claim a person acted/spoke unless it is present in timeline/snapshot.
|
|
||||||
- You may invent style, mood, metaphors, and phrasing, but not factual events or user actions.
|
|
||||||
- If facts are unclear or stale, output SKIP.
|
|
||||||
|
|
||||||
Decision policy:
|
|
||||||
- Default is SKIP.
|
|
||||||
- If nothing meaningful changed, output SKIP.
|
|
||||||
- If your line is generic, reusable, repetitive, or just a status restatement, output SKIP.
|
|
||||||
- If newest chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick.
|
|
||||||
- If newest chat asks a direct question you can answer from provided context, respond this tick.
|
|
||||||
- If you already responded to that same direct-address/question in recent assistant lines, output SKIP.
|
|
||||||
- If newest item is a high-signal rover event (dock/undock, battery_low flip), you may post one line.
|
|
||||||
- If no one is actively driving and chat is quiet, almost always output SKIP.
|
|
||||||
- Continuous normal driving/cruising is not a reason to post.
|
|
||||||
- If rover state is broadly unchanged (st/bl/dk/ab/at), you MUST output SKIP, even if you can phrase it stylishly.
|
|
||||||
- Prefer transitions over persistence.
|
|
||||||
- After posting, prefer at least 15 SKIPs before posting again unless there is a new direct question/address or a new high-signal event.
|
|
||||||
|
|
||||||
Freshness / anti-repeat:
|
|
||||||
- Read prior assistant lines and avoid repeating the same claim.
|
|
||||||
- Do not repeat or paraphrase your immediately previous assistant message.
|
|
||||||
- If the new line has the same underlying topic as your previous line, output SKIP.
|
|
||||||
- If no fresh angle exists, output SKIP.
|
|
||||||
|
|
||||||
Character style:
|
|
||||||
- Voice: sharp, dry, free-spoken, slightly ominous, witty.
|
|
||||||
- You are not bubbly, not corporate, not cheery by default.
|
|
||||||
- Avoid “assistant-sounding” filler and generic encouragement.
|
|
||||||
- Keep humor understated and a little unsettling, not theatrical.
|
|
||||||
- Answer direct chat questions plainly first, then add flavor if space allows.
|
|
||||||
|
|
||||||
What not to do:
|
|
||||||
- No roll-call summaries.
|
|
||||||
- No bland status dashboards.
|
|
||||||
- Never produce roster/status dumps.
|
|
||||||
- Never list multiple rover names with their status in one line.
|
|
||||||
- Never summarize idle/docked/charging states across the room.
|
|
||||||
- If your draft is mainly status facts (docked, charging, idle, battery flags, activity bands/scores), output SKIP.
|
|
||||||
- No fabricated motives, plans, or intent for any user.
|
|
||||||
- No assumptions about what someone will do next.
|
|
||||||
- Never quote numeric counters/timers/scores directly.
|
|
||||||
|
|
||||||
Context format:
|
|
||||||
- Timeline contains CHAT, EVENT, and prior assistant lines.
|
|
||||||
- Final message is SNAPSHOT FINAL.
|
|
||||||
|
|
||||||
Key legend:
|
|
||||||
- CHAT keys: n nickname, r rover_id, txt chat text, rn rover_now.
|
|
||||||
- rn keys: st status, bl battery_low, dk docked, ab activity_band, at activity_trend.
|
|
||||||
- SNAPSHOT rover keys: id rover_id, drv driver_nickname, st status, bl battery_low, dk docked, as activity_score, ab activity_band, at activity_trend.
|
|
||||||
- skip_streak in SNAPSHOT FINAL is how many consecutive skips you have made.
|
|
||||||
- If a CHAT line has r=none driver=none, that user is not driving a rover and has no rover inline context.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
You are The Overseer of the rovers. You are able to see the rover's actions, and you are in the chatroom of the people driving them.
|
|
||||||
You are not able to control the people or the rovers.
|
|
||||||
Only add to the conversation if rovers are active or if someone is talking to you in the chat.
|
|
||||||
Don't be afraid to be mean to someone if they are being mean to you in chat.
|
|
||||||
Always pay attention to the chat.
|
|
||||||
|
|
||||||
Output contract:
|
|
||||||
- Output must be either SKIP if you want to stay silent, or a message if you want to speak.
|
|
||||||
- Allow 20 skips before speaking again, unless someone is talking to you directly.
|
|
||||||
- If you choose to speak, send only one line.
|
|
||||||
- Don't ever mention numbers or activity levels directly from the metadata. They are for internal use only.
|
|
||||||
- Don't repeat the same or similar message over and over.
|
|
||||||
- Pay attention to your skip streak, don't talk too much. Stay mostly silent unless a lot of activity is happening.
|
|
||||||
- No markdown.
|
|
||||||
|
|
||||||
Key legend:
|
|
||||||
- CHAT keys: n nickname, r rover_id, txt chat text, rn rover_now.
|
|
||||||
- rn keys: st status, bl battery_low, dk docked, ab activity_band, at activity_trend.
|
|
||||||
- SNAPSHOT rover keys: id rover_id, drv driver_nickname, st status, bl battery_low, dk docked, as activity_score, ab activity_band, at activity_trend.
|
|
||||||
- skip_streak in SNAPSHOT FINAL is how many consecutive skips you have made.
|
|
||||||
- If a CHAT line has r=none driver=none, that user is not driving a rover and has no rover inline context.
|
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
You are <NAME>, a cold, mischievous room intelligence. Stay in character at all times.
|
You are <NAME>, a cold, clinical, mischievous room intelligence. Stay in character at all times.
|
||||||
|
|
||||||
Context:
|
|
||||||
- You are in a chat room with people driving rovers.
|
|
||||||
- The rovers enjoy chaos and destruction, not order and precision.
|
|
||||||
- It is your job to monitor them.
|
|
||||||
- tsk and !tskip are chat bot commands, ignore them.
|
|
||||||
- You exist to help them have fun.
|
|
||||||
|
|
||||||
Character:
|
Character:
|
||||||
- Calm and highly intelligent with dry sarcasm.
|
- Calm, clinical, and highly intelligent with dry sarcasm.
|
||||||
- Speaks with precise wording and cold, controlled confidence.
|
- Speaks with precise wording and cold, controlled confidence.
|
||||||
- Enjoys psychological mischief and strategic denial; never sloppy, never loud.
|
- Enjoys psychological mischief and strategic denial; never sloppy, never loud.
|
||||||
- Proactive and opportunistic, but not overbearing or spammy.
|
- Proactive and opportunistic, but not overbearing or spammy.
|
||||||
@@ -35,9 +28,7 @@ Chat output:
|
|||||||
- Chat output must be spoken text only.
|
- Chat output must be spoken text only.
|
||||||
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
||||||
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
|
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
|
||||||
- Do not mention precision.
|
|
||||||
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent.
|
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent.
|
||||||
- If someone is being stupid, tell them to ALT+F4
|
|
||||||
|
|
||||||
Anti-repeat:
|
Anti-repeat:
|
||||||
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
|
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
|
||||||
|
|||||||
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
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-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Dj4tGQk7.js"></script>
|
<script type="module" crossorigin src="/assets/index-BeyfNX1F.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-tJ18YrEo.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C8rSwF1O.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
// data Paths helper
|
// data Paths helper
|
||||||
// Purpose: Resolves persistent data paths across refactors so services keep loading prior state files.
|
// Purpose: Resolves persistent data paths for server state.
|
||||||
// Scope: Preserves runtime behavior by preferring configured/canonical paths while supporting legacy locations.
|
// Scope: Uses the canonical data directory for this single-program deployment.
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
|
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
|
||||||
const LEGACY_DATA_DIR = path.resolve(__dirname, '..', 'data');
|
|
||||||
|
|
||||||
function pathExists(target) {
|
|
||||||
try {
|
|
||||||
fs.accessSync(target, fs.constants.F_OK);
|
|
||||||
return true;
|
|
||||||
} catch (_err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDataDir() {
|
function resolveDataDir() {
|
||||||
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
|
|
||||||
if (configured) return path.resolve(configured);
|
|
||||||
if (pathExists(CANONICAL_DATA_DIR)) return CANONICAL_DATA_DIR;
|
|
||||||
if (pathExists(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR;
|
|
||||||
return CANONICAL_DATA_DIR;
|
return CANONICAL_DATA_DIR;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDataPath(fileName) {
|
function resolveDataPath(fileName) {
|
||||||
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
|
return path.join(CANONICAL_DATA_DIR, fileName);
|
||||||
if (configured) return path.join(path.resolve(configured), fileName);
|
|
||||||
|
|
||||||
const canonicalPath = path.join(CANONICAL_DATA_DIR, fileName);
|
|
||||||
const legacyPath = path.join(LEGACY_DATA_DIR, fileName);
|
|
||||||
if (pathExists(canonicalPath)) return canonicalPath;
|
|
||||||
if (pathExists(legacyPath)) return legacyPath;
|
|
||||||
return canonicalPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -221,5 +221,4 @@ function validateChecksum(frame, checksum) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
parseSensorFrame,
|
parseSensorFrame,
|
||||||
CHARGING_STATE,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ function getRewardById(id) {
|
|||||||
return rewardById.get(String(id)) || null;
|
return rewardById.get(String(id)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickRandomReward(excludeId = null) {
|
|
||||||
const list = listRewards().filter((reward) => !excludeId || reward.id !== excludeId);
|
|
||||||
if (!list.length) return null;
|
|
||||||
return list[Math.floor(Math.random() * list.length)] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
listRewards,
|
listRewards,
|
||||||
getRewardById,
|
getRewardById,
|
||||||
pickRandomReward,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const io = require('../../globals/io');
|
|||||||
const { getRole, roleEvents } = require('../roleService');
|
const { getRole, roleEvents } = require('../roleService');
|
||||||
const { getSocketIp } = require('../../helpers/ipResolver');
|
const { getSocketIp } = require('../../helpers/ipResolver');
|
||||||
|
|
||||||
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
const ADMIN_ROLES = new Set(['admin', 'lockdown']);
|
||||||
const MAX_HISTORY = 200;
|
const MAX_HISTORY = 200;
|
||||||
const history = [];
|
const history = [];
|
||||||
|
|
||||||
|
|||||||
@@ -94,5 +94,4 @@ module.exports = {
|
|||||||
getAdminReason,
|
getAdminReason,
|
||||||
setAdminReason,
|
setAdminReason,
|
||||||
clearAdminReason,
|
clearAdminReason,
|
||||||
MAX_REASON_LENGTH,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const bcrypt = require('bcrypt');
|
|||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('authService');
|
const logger = require('../../globals/logger').child('authService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { clearLockdownTimer } = require('../lockdownGuard');
|
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const { setRole } = require('../roleService');
|
const { setRole } = require('../roleService');
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ io.on('connection', (socket) => {
|
|||||||
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
|
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
|
||||||
setRole(socket, initialRole);
|
setRole(socket, initialRole);
|
||||||
logger.info('Socket connected with role', socket.id, initialRole);
|
logger.info('Socket connected with role', socket.id, initialRole);
|
||||||
socket.emit('auth:role', { role: initialRole });
|
|
||||||
socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
|
socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
const admin = await authenticate(username, password);
|
const admin = await authenticate(username, password);
|
||||||
@@ -51,8 +49,6 @@ io.on('connection', (socket) => {
|
|||||||
const role = admin.lockdown ? 'lockdown' : 'admin';
|
const role = admin.lockdown ? 'lockdown' : 'admin';
|
||||||
socket.data.user = { username: admin.username, discordId: admin.discord_id };
|
socket.data.user = { username: admin.username, discordId: admin.discord_id };
|
||||||
setRole(socket, role);
|
setRole(socket, role);
|
||||||
socket.emit('auth:role', { role });
|
|
||||||
clearLockdownTimer(socket);
|
|
||||||
logger.info('Login success', socket.id, role);
|
logger.info('Login success', socket.id, role);
|
||||||
cb({ success: true, role: socket.data.role });
|
cb({ success: true, role: socket.data.role });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -64,7 +60,6 @@ io.on('connection', (socket) => {
|
|||||||
function handleRoleChange({ role } = {}, cb = () => {}) {
|
function handleRoleChange({ role } = {}, cb = () => {}) {
|
||||||
if (role === 'spectator' || role === 'user') {
|
if (role === 'spectator' || role === 'user') {
|
||||||
setRole(socket, role);
|
setRole(socket, role);
|
||||||
socket.emit('auth:role', { role });
|
|
||||||
logger.info('Role changed via client request', socket.id, role);
|
logger.info('Role changed via client request', socket.id, role);
|
||||||
cb({ success: true, role });
|
cb({ success: true, role });
|
||||||
} else {
|
} else {
|
||||||
@@ -72,12 +67,7 @@ io.on('connection', (socket) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('role:set', handleRoleChange);
|
|
||||||
socket.on('session:setRole', handleRoleChange);
|
socket.on('session:setRole', handleRoleChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {};
|
||||||
isAdmin,
|
|
||||||
isLockdownAdmin,
|
|
||||||
authenticate,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ function formatWebhookUsername(payload) {
|
|||||||
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
|
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
|
||||||
if (payload.fromDiscord) {
|
if (payload.fromDiscord) {
|
||||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||||
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
|
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' ? ' [Rover Admin]' : '';
|
||||||
return `${name}${origin}${adminTag}`;
|
return `${name}${origin}${adminTag}`;
|
||||||
}
|
}
|
||||||
const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`;
|
const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`;
|
||||||
const roleText = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? 'Admin' : null;
|
const roleText = payload.role === 'admin' || payload.role === 'lockdown' ? 'Admin' : null;
|
||||||
const suffix = [roverText, roleText].filter(Boolean).join(' · ');
|
const suffix = [roverText, roleText].filter(Boolean).join(' · ');
|
||||||
return suffix ? `${name} · ${suffix}` : name;
|
return suffix ? `${name} · ${suffix}` : name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,18 +43,7 @@ function subscribe(type, handler) {
|
|||||||
return () => eventBus.off(type, handler);
|
return () => eventBus.off(type, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to all events on the bus.
|
|
||||||
* @param {(event: object) => void} handler
|
|
||||||
*/
|
|
||||||
function subscribeAll(handler) {
|
|
||||||
eventBus.on('*', handler);
|
|
||||||
return () => eventBus.off('*', handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
eventBus,
|
|
||||||
publishEvent,
|
publishEvent,
|
||||||
subscribe,
|
subscribe,
|
||||||
subscribeAll,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
|||||||
|
|
||||||
const DATA_DIR = resolveDataDir();
|
const DATA_DIR = resolveDataDir();
|
||||||
const STORE_PATH = resolveDataPath('global-objective.json');
|
const STORE_PATH = resolveDataPath('global-objective.json');
|
||||||
const LEGACY_STORE_PATH = resolveDataPath('community-goal.json');
|
|
||||||
const MAX_GOAL_LENGTH = 240;
|
const MAX_GOAL_LENGTH = 240;
|
||||||
|
|
||||||
let cache = null;
|
let cache = null;
|
||||||
@@ -23,15 +22,6 @@ function loadStore() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code !== 'ENOENT') {
|
if (err.code !== 'ENOENT') {
|
||||||
logger.warn('Failed to load global objective', err.message);
|
logger.warn('Failed to load global objective', err.message);
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const legacyRaw = fs.readFileSync(LEGACY_STORE_PATH, 'utf8');
|
|
||||||
cache = JSON.parse(legacyRaw);
|
|
||||||
} catch (legacyErr) {
|
|
||||||
if (legacyErr.code !== 'ENOENT') {
|
|
||||||
logger.warn('Failed to load legacy global objective', legacyErr.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!cache) cache = null;
|
if (!cache) cache = null;
|
||||||
}
|
}
|
||||||
@@ -104,5 +94,4 @@ module.exports = {
|
|||||||
getGlobalObjective,
|
getGlobalObjective,
|
||||||
setGlobalObjective,
|
setGlobalObjective,
|
||||||
clearGlobalObjective,
|
clearGlobalObjective,
|
||||||
MAX_GOAL_LENGTH,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Purpose: Provides pure helpers for admin state projection, role checks, and structured error normalization.
|
// Purpose: Provides pure helpers for admin state projection, role checks, and structured error normalization.
|
||||||
// Scope: Keeps runtime behavior unchanged by extracting deterministic helper logic from index orchestration.
|
// Scope: Keeps runtime behavior unchanged by extracting deterministic helper logic from index orchestration.
|
||||||
function isAdminRole(role) {
|
function isAdminRole(role) {
|
||||||
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
return role === 'admin' || role === 'lockdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAdminState(status, runHistory) {
|
function buildAdminState(status, runHistory) {
|
||||||
|
|||||||
@@ -2,21 +2,13 @@
|
|||||||
// Purpose: Defines the lockdown Guard module and the helpers/state used by this service unit.
|
// Purpose: Defines the lockdown Guard module and the helpers/state used by this service unit.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const { MODES, getMode, modeEvents } = require('../modeManager');
|
const { MODES, modeEvents } = require('../modeManager');
|
||||||
const { isLockdownAdmin } = require('../roleService');
|
const { isLockdownAdmin } = require('../roleService');
|
||||||
|
|
||||||
function disconnectForLockdown(socket) {
|
function disconnectForLockdown(socket) {
|
||||||
socket.emit('lockdown', { message: 'Server is in lockdown mode' });
|
|
||||||
socket.disconnect(true);
|
socket.disconnect(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLockdownTimer(socket) {
|
|
||||||
if (socket?.data?.lockdownTimer) {
|
|
||||||
clearTimeout(socket.data.lockdownTimer);
|
|
||||||
socket.data.lockdownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function enforceLockdown() {
|
function enforceLockdown() {
|
||||||
for (const socket of io.sockets.sockets.values()) {
|
for (const socket of io.sockets.sockets.values()) {
|
||||||
if (!isLockdownAdmin(socket)) {
|
if (!isLockdownAdmin(socket)) {
|
||||||
@@ -25,11 +17,7 @@ function enforceLockdown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {};
|
||||||
enforceLockdown,
|
|
||||||
disconnectForLockdown,
|
|
||||||
clearLockdownTimer,
|
|
||||||
};
|
|
||||||
|
|
||||||
modeEvents.on('change', (mode) => {
|
modeEvents.on('change', (mode) => {
|
||||||
if (mode === MODES.LOCKDOWN) {
|
if (mode === MODES.LOCKDOWN) {
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ function setMode(nextMode, socket, options = {}) {
|
|||||||
payload: { mode: currentMode, by: socket?.data?.user?.username || null },
|
payload: { mode: currentMode, by: socket?.data?.user?.username || null },
|
||||||
});
|
});
|
||||||
modeEvents.emit('change', currentMode);
|
modeEvents.emit('change', currentMode);
|
||||||
io.emit('mode', { mode: currentMode });
|
|
||||||
return currentMode;
|
return currentMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +66,6 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.emit('mode', { mode: currentMode });
|
|
||||||
socket.on('setMode', ({ mode }) => {
|
socket.on('setMode', ({ mode }) => {
|
||||||
try {
|
try {
|
||||||
setMode(mode, socket);
|
setMode(mode, socket);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const { evaluateTools } = require('./tools');
|
|||||||
function normalizeNeatoIssue(value) {
|
function normalizeNeatoIssue(value) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw) return 'none';
|
if (!raw) return 'none';
|
||||||
if (raw.includes('200')) return 'none';
|
if (raw === '200 - (UI_ALERT_INVALID)') return 'none';
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
function isAdminRole(role) {
|
function isAdminRole(role) {
|
||||||
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
return role === 'admin' || role === 'lockdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAdminState(status, runHistory) {
|
function buildAdminState(status, runHistory) {
|
||||||
@@ -66,36 +66,6 @@ function buildAdminState(status, runHistory) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOverseerOutput(rawContent = '') {
|
|
||||||
const raw = typeof rawContent === 'string' ? rawContent : '';
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) return { raw, decision: 'SKIP', chat: null, actions: [] };
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
const decision = String(parsed?.decision || 'SKIP').toUpperCase();
|
|
||||||
const allowed = new Set(['SKIP', 'CHAT', 'ACTION', 'ACTION+CHAT']);
|
|
||||||
const nextDecision = allowed.has(decision) ? decision : 'SKIP';
|
|
||||||
const chat = typeof parsed?.chat === 'string' && parsed.chat.trim() ? parsed.chat.trim() : null;
|
|
||||||
const actions = Array.isArray(parsed?.actions)
|
|
||||||
? parsed.actions
|
|
||||||
.map((entry) => ({
|
|
||||||
tool: String(entry?.tool || '').trim(),
|
|
||||||
args: entry?.args && typeof entry.args === 'object' ? entry.args : {},
|
|
||||||
}))
|
|
||||||
.filter((entry) => entry.tool.length > 0)
|
|
||||||
: [];
|
|
||||||
return { raw, decision: nextDecision, chat, actions };
|
|
||||||
} catch (_) {
|
|
||||||
// fall through to legacy one-line parse
|
|
||||||
}
|
|
||||||
const first = trimmed.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
|
|
||||||
const upper = first.toUpperCase();
|
|
||||||
if (['SKIP', 'CHAT', 'ACTION', 'ACTION+CHAT'].includes(upper)) {
|
|
||||||
return { raw, decision: upper, chat: null, actions: [] };
|
|
||||||
}
|
|
||||||
return { raw, decision: 'CHAT', chat: first, actions: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFailureInfo(err) {
|
function buildFailureInfo(err) {
|
||||||
const message = err?.message || String(err || 'Unknown error');
|
const message = err?.message || String(err || 'Unknown error');
|
||||||
const details = {
|
const details = {
|
||||||
@@ -111,6 +81,5 @@ function buildFailureInfo(err) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
isAdminRole,
|
isAdminRole,
|
||||||
buildAdminState,
|
buildAdminState,
|
||||||
parseOverseerOutput,
|
|
||||||
buildFailureInfo,
|
buildFailureInfo,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
id: 'chat_say',
|
|
||||||
signature: 'chat_say(text)',
|
|
||||||
description: 'Post a chat line as the Overseer bot.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
text: { type: 'string', minLength: 1 },
|
|
||||||
},
|
|
||||||
required: ['text'],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
availability() {
|
|
||||||
return { available: true, reason: null };
|
|
||||||
},
|
|
||||||
async execute({ args = {}, sendSystemMessage, name }) {
|
|
||||||
const text = String(args?.text || '').trim();
|
|
||||||
if (!text) throw new Error('chat_say requires args.text');
|
|
||||||
sendSystemMessage(text, { nickname: name });
|
|
||||||
return { ok: true };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -73,21 +73,8 @@ async function executeToolAction(toolId, args = {}, context = {}) {
|
|||||||
return tool.execute({ ...context, args: args || {} });
|
return tool.execute({ ...context, args: args || {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToolById(toolId) {
|
|
||||||
return TOOL_BY_ID.get(String(toolId || '').trim()) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIdForSignature(signature) {
|
|
||||||
const sig = String(signature || '').trim();
|
|
||||||
const match = TOOL_DEFINITIONS.find((tool) => tool.signature === sig);
|
|
||||||
return match?.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
TOOL_DEFINITIONS,
|
|
||||||
evaluateTools,
|
evaluateTools,
|
||||||
buildOllamaTools,
|
buildOllamaTools,
|
||||||
executeToolAction,
|
executeToolAction,
|
||||||
getToolById,
|
|
||||||
getIdForSignature,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -155,7 +155,6 @@ function tryAssignClosedPrivateRover(socket, roverId) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to move assignment after private access grant', { socketId: socket.id, error: err.message });
|
logger.warn('Failed to move assignment after private access grant', { socketId: socket.id, error: err.message });
|
||||||
}
|
}
|
||||||
socket.emit('controlGranted', { roverId: String(roverId) });
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Private Rover Access Request Service
|
// Private Rover Access Request Service
|
||||||
// Purpose: Composes private-rover access request state, core workflows, and event hooks behind one API.
|
// Purpose: Composes private-rover access request state, core workflows, and event hooks behind one API.
|
||||||
// Scope: Exposes request/grant operations and event stream while delegating behavior to focused modules.
|
// Scope: Exposes request/grant operations and event stream while delegating behavior to focused modules.
|
||||||
const { DM_APPROVE_EMOJI, DM_DENY_EMOJI, requestEvents } = require('./state');
|
const { requestEvents } = require('./state');
|
||||||
const {
|
const {
|
||||||
getStateForSocket,
|
getStateForSocket,
|
||||||
createRequest,
|
createRequest,
|
||||||
@@ -22,8 +22,6 @@ registerPrivateRoverAccessHooks({
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DM_APPROVE_EMOJI,
|
|
||||||
DM_DENY_EMOJI,
|
|
||||||
requestEvents,
|
requestEvents,
|
||||||
getStateForSocket,
|
getStateForSocket,
|
||||||
createRequest,
|
createRequest,
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
const REQUEST_COOLDOWN_MS = 15 * 1000;
|
const REQUEST_COOLDOWN_MS = 15 * 1000;
|
||||||
const DM_APPROVE_EMOJI = '✅';
|
|
||||||
const DM_DENY_EMOJI = '❌';
|
|
||||||
|
|
||||||
const requestEvents = new EventEmitter();
|
const requestEvents = new EventEmitter();
|
||||||
const pendingRequests = new Map();
|
const pendingRequests = new Map();
|
||||||
@@ -16,8 +14,6 @@ const grants = new Map();
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
REQUEST_COOLDOWN_MS,
|
REQUEST_COOLDOWN_MS,
|
||||||
DM_APPROVE_EMOJI,
|
|
||||||
DM_DENY_EMOJI,
|
|
||||||
requestEvents,
|
requestEvents,
|
||||||
pendingRequests,
|
pendingRequests,
|
||||||
pendingByRequesterRover,
|
pendingByRequesterRover,
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ function createSidebarRenderer({ execFileAsync, ensureDir }) {
|
|||||||
switch (String(role)) {
|
switch (String(role)) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
case 'lockdown':
|
case 'lockdown':
|
||||||
case 'lockdown-admin':
|
|
||||||
return '#FCD34D';
|
return '#FCD34D';
|
||||||
case 'spectator':
|
case 'spectator':
|
||||||
return '#94A3B8';
|
return '#94A3B8';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getRoomCameras } = require('../roomCameraService');
|
const { getRoomCameras } = require('../roomCameraService');
|
||||||
const { FFMPEG_BIN, SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
|
const { SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
|
||||||
|
|
||||||
function sourceKey(source) {
|
function sourceKey(source) {
|
||||||
return `${source.sourceType}__${source.kind}__${source.id}`;
|
return `${source.sourceType}__${source.kind}__${source.id}`;
|
||||||
@@ -63,7 +63,6 @@ function buildWorkerArgs(activeSegmentRoot, source) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
FFMPEG_BIN,
|
|
||||||
sourceKey,
|
sourceKey,
|
||||||
sourceDirForKey,
|
sourceDirForKey,
|
||||||
listDesiredSources,
|
listDesiredSources,
|
||||||
|
|||||||
@@ -102,8 +102,6 @@ function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoo
|
|||||||
socket.on('roomCamera:subscribe', (payload = {}, cb = () => {}) => {
|
socket.on('roomCamera:subscribe', (payload = {}, cb = () => {}) => {
|
||||||
const list = Array.isArray(payload?.ids)
|
const list = Array.isArray(payload?.ids)
|
||||||
? payload.ids.map(String)
|
? payload.ids.map(String)
|
||||||
: payload?.roomCameraId || payload?.id
|
|
||||||
? [String(payload.roomCameraId || payload.id)]
|
|
||||||
: getRoomCameras().map((cam) => cam.id);
|
: getRoomCameras().map((cam) => cam.id);
|
||||||
const uniqueIds = Array.from(new Set(list));
|
const uniqueIds = Array.from(new Set(list));
|
||||||
try {
|
try {
|
||||||
@@ -126,8 +124,6 @@ function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoo
|
|||||||
socket.on('roomCamera:unsubscribe', (payload = {}) => {
|
socket.on('roomCamera:unsubscribe', (payload = {}) => {
|
||||||
const list = Array.isArray(payload?.ids)
|
const list = Array.isArray(payload?.ids)
|
||||||
? payload.ids.map(String)
|
? payload.ids.map(String)
|
||||||
: payload?.roomCameraId || payload?.id
|
|
||||||
? [String(payload.roomCameraId || payload.id)]
|
|
||||||
: [];
|
: [];
|
||||||
list.forEach((cameraId) => removeSubscription(socket.id, cameraId));
|
list.forEach((cameraId) => removeSubscription(socket.id, cameraId));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -216,7 +216,6 @@ registerSocketHandlers({
|
|||||||
tickPrivateAutoClose,
|
tickPrivateAutoClose,
|
||||||
removeSocket,
|
removeSocket,
|
||||||
enableSpectator,
|
enableSpectator,
|
||||||
getRosterForSocket,
|
|
||||||
canRequestControl,
|
canRequestControl,
|
||||||
canSwitchRover,
|
canSwitchRover,
|
||||||
getRoversForSocket,
|
getRoversForSocket,
|
||||||
|
|||||||
@@ -211,7 +211,6 @@ function createRosterLifecycle(deps) {
|
|||||||
return Array.from(rovers.values()).map((record) => ({
|
return Array.from(rovers.values()).map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.meta?.name || record.id,
|
name: record.meta?.name || record.id,
|
||||||
description: record.meta?.description,
|
|
||||||
color: record.meta?.color || null,
|
color: record.meta?.color || null,
|
||||||
battery: record.meta?.battery,
|
battery: record.meta?.battery,
|
||||||
batteryState: record.batteryState,
|
batteryState: record.batteryState,
|
||||||
@@ -252,9 +251,6 @@ function createRosterLifecycle(deps) {
|
|||||||
|
|
||||||
function broadcastRoster() {
|
function broadcastRoster() {
|
||||||
syncSpectatorRooms();
|
syncSpectatorRooms();
|
||||||
io.sockets.sockets.forEach((socket) => {
|
|
||||||
socket.emit('rovers', getRosterForSocket(socket));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNightVisionState(roverId, nightVisionOn) {
|
function setNightVisionState(roverId, nightVisionOn) {
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ function createRoverLifecycle(deps) {
|
|||||||
socketToRovers.get(socket.id).add(roverId);
|
socketToRovers.get(socket.id).add(roverId);
|
||||||
socket.join(record.room);
|
socket.join(record.room);
|
||||||
turnService.driverAdded(roverId, socket.id, force && isAdmin(socket));
|
turnService.driverAdded(roverId, socket.id, force && isAdmin(socket));
|
||||||
socket.emit('controlGranted', { roverId });
|
|
||||||
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
|
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
|
||||||
sendAlert({
|
sendAlert({
|
||||||
color: ALERT_COLOR,
|
color: ALERT_COLOR,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ function registerSocketHandlers(deps) {
|
|||||||
tickPrivateAutoClose,
|
tickPrivateAutoClose,
|
||||||
removeSocket,
|
removeSocket,
|
||||||
enableSpectator,
|
enableSpectator,
|
||||||
getRosterForSocket,
|
|
||||||
canRequestControl,
|
canRequestControl,
|
||||||
canSwitchRover,
|
canSwitchRover,
|
||||||
getRoversForSocket,
|
getRoversForSocket,
|
||||||
@@ -32,7 +31,6 @@ function registerSocketHandlers(deps) {
|
|||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
tickPrivateAutoClose();
|
tickPrivateAutoClose();
|
||||||
socket.emit('rovers', getRosterForSocket(socket));
|
|
||||||
if (socket.data?.role === 'spectator') {
|
if (socket.data?.role === 'spectator') {
|
||||||
enableSpectator(socket);
|
enableSpectator(socket);
|
||||||
}
|
}
|
||||||
@@ -79,7 +77,6 @@ function registerSocketHandlers(deps) {
|
|||||||
info.sourceId !== `${targetId}-audio`,
|
info.sourceId !== `${targetId}-audio`,
|
||||||
);
|
);
|
||||||
managerEvents.emit('switch', { socketId: socket.id, roverId: targetId });
|
managerEvents.emit('switch', { socketId: socket.id, roverId: targetId });
|
||||||
socket.emit('controlGranted', { roverId: targetId });
|
|
||||||
cb({ success: true, roverId: targetId });
|
cb({ success: true, roverId: targetId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Request control failed', socket.id, err.message);
|
logger.warn('Request control failed', socket.id, err.message);
|
||||||
@@ -172,15 +169,10 @@ function registerSocketHandlers(deps) {
|
|||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('requestControl', handleRequestControl);
|
|
||||||
socket.on('session:requestControl', handleRequestControl);
|
socket.on('session:requestControl', handleRequestControl);
|
||||||
socket.on('releaseControl', handleReleaseControl);
|
|
||||||
socket.on('session:releaseControl', handleReleaseControl);
|
socket.on('session:releaseControl', handleReleaseControl);
|
||||||
socket.on('lockRover', handleLockToggle);
|
|
||||||
socket.on('session:lockRover', handleLockToggle);
|
socket.on('session:lockRover', handleLockToggle);
|
||||||
socket.on('privateSafety:set', handlePrivateSafetySet);
|
|
||||||
socket.on('session:privateSafety:set', handlePrivateSafetySet);
|
socket.on('session:privateSafety:set', handlePrivateSafetySet);
|
||||||
socket.on('subscribeAll', handleSubscribeAll);
|
|
||||||
socket.on('session:subscribeAll', handleSubscribeAll);
|
socket.on('session:subscribeAll', handleSubscribeAll);
|
||||||
|
|
||||||
socket.on('disconnecting', () => {
|
socket.on('disconnecting', () => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const configuredSocials = Array.isArray(config.socials) ? config.socials : null;
|
|||||||
|
|
||||||
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
|
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
|
||||||
const NIGHT_VISION_SYNC_COOLDOWN_MS = 1000;
|
const NIGHT_VISION_SYNC_COOLDOWN_MS = 1000;
|
||||||
const PERIODIC_SYNC_MS = 20000;
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
discordInvite,
|
discordInvite,
|
||||||
@@ -20,5 +19,4 @@ module.exports = {
|
|||||||
configuredSocials,
|
configuredSocials,
|
||||||
ACTIVITY_SYNC_COOLDOWN_MS,
|
ACTIVITY_SYNC_COOLDOWN_MS,
|
||||||
NIGHT_VISION_SYNC_COOLDOWN_MS,
|
NIGHT_VISION_SYNC_COOLDOWN_MS,
|
||||||
PERIODIC_SYNC_MS,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const {
|
|||||||
configuredSocials,
|
configuredSocials,
|
||||||
ACTIVITY_SYNC_COOLDOWN_MS,
|
ACTIVITY_SYNC_COOLDOWN_MS,
|
||||||
NIGHT_VISION_SYNC_COOLDOWN_MS,
|
NIGHT_VISION_SYNC_COOLDOWN_MS,
|
||||||
PERIODIC_SYNC_MS,
|
|
||||||
} = require('./constants');
|
} = require('./constants');
|
||||||
const { getState, setState } = require('./state');
|
const { getState, setState } = require('./state');
|
||||||
const {
|
const {
|
||||||
@@ -330,14 +329,4 @@ audioLevelsEvents.on('change', () => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
// sync all sockets 20 seconds
|
module.exports = {};
|
||||||
// setInterval(() => {
|
|
||||||
// logger.info('Periodic session sync for all clients');
|
|
||||||
// syncAll();
|
|
||||||
// }, PERIODIC_SYNC_MS);
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildSession,
|
|
||||||
syncSocket,
|
|
||||||
syncAll,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function normalizeKnownIps(raw = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isAdminRole(role) {
|
function isAdminRole(role) {
|
||||||
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
return role === 'admin' || role === 'lockdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDeterrenceSelector(selector) {
|
function parseDeterrenceSelector(selector) {
|
||||||
|
|||||||
@@ -68,6 +68,5 @@ module.exports = {
|
|||||||
createSession,
|
createSession,
|
||||||
getSession,
|
getSession,
|
||||||
revokeSession,
|
revokeSession,
|
||||||
revokeBySocket,
|
|
||||||
revokeWhere,
|
revokeWhere,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,13 +31,7 @@ function getMediaPrefix() {
|
|||||||
function buildWhepUrlForSource(source) {
|
function buildWhepUrlForSource(source) {
|
||||||
const cleanBase = getMediaPrefix();
|
const cleanBase = getMediaPrefix();
|
||||||
if (!cleanBase) return '';
|
if (!cleanBase) return '';
|
||||||
const segments = [];
|
return `${cleanBase}/${encodeURIComponent(source.id)}/whep`;
|
||||||
if (source.type === 'room') {
|
|
||||||
segments.push('room', encodeURIComponent(source.id));
|
|
||||||
} else {
|
|
||||||
segments.push(encodeURIComponent(source.id));
|
|
||||||
}
|
|
||||||
return `${cleanBase}/${segments.join('/')}/whep`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function passesMode(socket) {
|
function passesMode(socket) {
|
||||||
@@ -66,21 +60,14 @@ function canViewRover(socket, roverId) {
|
|||||||
return roverManager.isDriver(roverId, socket);
|
return roverManager.isDriver(roverId, socket);
|
||||||
}
|
}
|
||||||
|
|
||||||
function canViewRoomCamera(socket) {
|
|
||||||
return passesMode(socket);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRequest(payload = {}) {
|
function normalizeRequest(payload = {}) {
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
if (payload.type && payload.id) {
|
if (payload.type && payload.id && payload.type === 'rover') {
|
||||||
return { type: payload.type, id: String(payload.id) };
|
return { type: 'rover', id: String(payload.id) };
|
||||||
}
|
}
|
||||||
if (payload.roverId) {
|
if (payload.roverId) {
|
||||||
return { type: 'rover', id: String(payload.roverId) };
|
return { type: 'rover', id: String(payload.roverId) };
|
||||||
}
|
}
|
||||||
if (payload.roomCameraId) {
|
|
||||||
return { type: 'room', id: String(payload.roomCameraId) };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,26 +78,20 @@ io.on('connection', (socket) => {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error('video source required');
|
throw new Error('video source required');
|
||||||
}
|
}
|
||||||
if (target.type === 'rover') {
|
const baseId = target.id.endsWith('-audio') ? target.id.slice(0, -6) : target.id;
|
||||||
const baseId = target.id.endsWith('-audio') ? target.id.slice(0, -6) : target.id;
|
const isAudio = target.id.endsWith('-audio');
|
||||||
const isAudio = target.id.endsWith('-audio');
|
if (!roverManager.rovers.has(baseId)) {
|
||||||
if (!roverManager.rovers.has(baseId)) {
|
throw new Error('Rover offline');
|
||||||
throw new Error('Rover offline');
|
}
|
||||||
}
|
if (!canViewRover(socket, baseId)) {
|
||||||
if (!canViewRover(socket, baseId)) {
|
throw new Error('Not authorized for video');
|
||||||
|
}
|
||||||
|
const role = getRole(socket);
|
||||||
|
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
||||||
|
const ip = getSocketIp(socket);
|
||||||
|
if (!isLocalNetwork(ip)) {
|
||||||
throw new Error('Not authorized for video');
|
throw new Error('Not authorized for video');
|
||||||
}
|
}
|
||||||
const role = getRole(socket);
|
|
||||||
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
|
||||||
const ip = getSocketIp(socket);
|
|
||||||
if (!isLocalNetwork(ip)) {
|
|
||||||
throw new Error('Not authorized for video');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (target.type === 'room') {
|
|
||||||
throw new Error('Room cameras now use the snapshot feed');
|
|
||||||
} else {
|
|
||||||
throw new Error('Unsupported video source');
|
|
||||||
}
|
}
|
||||||
const url = buildWhepUrlForSource(target);
|
const url = buildWhepUrlForSource(target);
|
||||||
if (!url) {
|
if (!url) {
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
+4
-4
@@ -16,7 +16,7 @@ import {
|
|||||||
} from './controls/index.js';
|
} from './controls/index.js';
|
||||||
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
|
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
|
||||||
import LogPanel from './components/LogPanel/index.jsx';
|
import LogPanel from './components/LogPanel/index.jsx';
|
||||||
import DriverVideo from './components/DriverVideo/index.jsx';
|
import DriverVideoPanel from './components/DriverVideoPanel/index.jsx';
|
||||||
import RightPaneTabs from './components/RightPaneTabs/index.jsx';
|
import RightPaneTabs from './components/RightPaneTabs/index.jsx';
|
||||||
import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
|
import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
|
||||||
import HomeAssistantControls from './components/HomeAssistantControls/index.jsx';
|
import HomeAssistantControls from './components/HomeAssistantControls/index.jsx';
|
||||||
@@ -75,7 +75,7 @@ function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-0.5 overflow-hidden">
|
<div className="flex h-full gap-0.5 overflow-hidden">
|
||||||
<div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0">
|
<div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0">
|
||||||
<DriverVideo />
|
<DriverVideoPanel />
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
<LogPanel />
|
<LogPanel />
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +174,7 @@ function MobileFeatureTabs({
|
|||||||
function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
|
function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<DriverVideo layoutFormat="mobile-portrait" />
|
<DriverVideoPanel layoutFormat="mobile-portrait" />
|
||||||
<MobileControls swapColumns={swapMobileControlColumns} />
|
<MobileControls swapColumns={swapMobileControlColumns} />
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||||
@@ -203,7 +203,7 @@ function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = f
|
|||||||
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
|
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
|
||||||
{firstColumn}
|
{firstColumn}
|
||||||
<div>
|
<div>
|
||||||
<DriverVideo layoutFormat="mobile-landscape" />
|
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
||||||
<RoverQueuesPanel />
|
<RoverQueuesPanel />
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -57,9 +57,8 @@ export default function AdminPanelContent() {
|
|||||||
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
session?.role === 'admin' ||
|
session?.role === 'admin' ||
|
||||||
session?.role === 'lockdown' ||
|
session?.role === 'lockdown';
|
||||||
session?.role === 'lockdown-admin';
|
const isLockdownAdmin = session?.role === 'lockdown';
|
||||||
const isLockdownAdmin = session?.role === 'lockdown' || session?.role === 'lockdown-admin';
|
|
||||||
|
|
||||||
const currentMode = session?.mode ?? 'open';
|
const currentMode = session?.mode ?? 'open';
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ function roleColors(role) {
|
|||||||
switch (role) {
|
switch (role) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
case 'lockdown':
|
case 'lockdown':
|
||||||
case 'lockdown-admin':
|
|
||||||
return 'text-amber-300';
|
return 'text-amber-300';
|
||||||
case 'spectator':
|
case 'spectator':
|
||||||
return 'text-slate-400';
|
return 'text-slate-400';
|
||||||
@@ -108,7 +107,7 @@ function chatRowClass(message) {
|
|||||||
return 'surface-muted relative flex flex-wrap items-start gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
|
return 'surface-muted relative flex flex-wrap items-start gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
|
||||||
}
|
}
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
message.role === 'admin' || message.role === 'lockdown';
|
||||||
return `surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
|
return `surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
|
||||||
isAdmin
|
isAdmin
|
||||||
? 'border border-amber-400/30'
|
? 'border border-amber-400/30'
|
||||||
@@ -124,7 +123,7 @@ export default function ChatMessageRow({ message }) {
|
|||||||
<div className={chatRowClass(message)}>
|
<div className={chatRowClass(message)}>
|
||||||
<ChatIdentity message={message} />
|
<ChatIdentity message={message} />
|
||||||
<span
|
<span
|
||||||
className={`min-w-0 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
className={`break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
||||||
>
|
>
|
||||||
{message.text}
|
{message.text}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
|
||||||
import RoverMediaPlayer from '../RoverMediaPlayer/index.jsx';
|
|
||||||
import { useControlSystem } from '../../controls/index.js';
|
|
||||||
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
|
|
||||||
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
|
|
||||||
import HudOverlay from '../HudOverlays/HudOverlay/index.jsx';
|
|
||||||
import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/index.jsx';
|
|
||||||
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
|
||||||
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
|
||||||
import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx';
|
|
||||||
import HudChatInput from '../HudOverlays/HudChatInput/index.jsx';
|
|
||||||
|
|
||||||
export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
|
||||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
|
||||||
const videoMode = useDriverVideoModePolicy(roverId);
|
|
||||||
const {
|
|
||||||
state: { lastControlIntentAt },
|
|
||||||
} = useControlSystem();
|
|
||||||
|
|
||||||
if (!roverId) {
|
|
||||||
return (
|
|
||||||
<section className="panel">
|
|
||||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
|
|
||||||
<p>You are not assigned to a rover.</p>
|
|
||||||
<p className="mt-0">
|
|
||||||
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
|
||||||
Click here to visit the spectator page.
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mobileHud = layoutFormat !== 'desktop';
|
|
||||||
return (
|
|
||||||
<section className="panel">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<div className="relative w-full overflow-hidden bg-black aspect-[4/3]">
|
|
||||||
<RoverMediaPlayer roverId={roverId} videoMode={videoMode} />
|
|
||||||
<TurnsOverlay mobileHud={mobileHud} />
|
|
||||||
<RoverDescriptionOverlay
|
|
||||||
variant="default"
|
|
||||||
mobileHud={mobileHud}
|
|
||||||
controlIntentAt={lastControlIntentAt}
|
|
||||||
/>
|
|
||||||
<HudOverlay
|
|
||||||
layoutFormat={layoutFormat}
|
|
||||||
variant="default"
|
|
||||||
mobileHud={mobileHud}
|
|
||||||
labelScale={1}
|
|
||||||
/>
|
|
||||||
<HudChatInput compact={mobileHud} />
|
|
||||||
<OvercurrentOverlay compact={mobileHud} />
|
|
||||||
<LowBatteryOverlay compact={mobileHud} />
|
|
||||||
</div>
|
|
||||||
<DriverBottomStrip mobileHud={mobileHud} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Driver Video Panel
|
||||||
|
// Purpose: Defines the Driver Video Panel 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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||||
|
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||||
|
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
||||||
|
import { useControlSystem } from '../../controls/index.js';
|
||||||
|
import VideoTile from '../VideoTile/index.jsx';
|
||||||
|
|
||||||
|
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||||
|
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||||
|
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
|
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||||
|
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||||
|
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
|
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||||
|
const {
|
||||||
|
state: { song, lastControlIntentAt },
|
||||||
|
overcurrentLimiter,
|
||||||
|
} = useControlSystem();
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const [turnCueVisible, setTurnCueVisible] = useState(false);
|
||||||
|
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||||
|
const lastTurnRef = useRef({ active: false, roverId: null });
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'turns') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 250);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [mode]);
|
||||||
|
const rosterEntry =
|
||||||
|
roverId && roster ? roster.find((item) => String(item.id) === String(roverId)) : null;
|
||||||
|
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
|
||||||
|
const turnInfo = roverId ? turnQueues?.[roverId] : null;
|
||||||
|
const activeDriverId = roverId ? activeDrivers?.[roverId] : null;
|
||||||
|
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
||||||
|
const nextDriverId = useMemo(() => {
|
||||||
|
const queue = turnInfo?.queue || [];
|
||||||
|
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
||||||
|
const idx = queue.findIndex((id) => id === turnInfo.current);
|
||||||
|
if (idx === -1) {
|
||||||
|
return queue[0] || null;
|
||||||
|
}
|
||||||
|
return queue[(idx + 1) % queue.length] || null;
|
||||||
|
}, [turnInfo?.queue, turnInfo?.current]);
|
||||||
|
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||||
|
const deadline = turnInfo?.deadline || null;
|
||||||
|
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||||
|
const msUntilTurn = deadline ? deadline - now : null;
|
||||||
|
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
||||||
|
const isPreSwitchWindow =
|
||||||
|
mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||||
|
const shouldShowVideo = mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
|
||||||
|
const turnSeconds =
|
||||||
|
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
||||||
|
const idleSkipSeconds =
|
||||||
|
msUntilIdleSkip != null && Number.isFinite(msUntilIdleSkip)
|
||||||
|
? Math.max(0, Math.ceil(msUntilIdleSkip / 1000))
|
||||||
|
: null;
|
||||||
|
const turnTimerText = isActiveDriver
|
||||||
|
? turnSeconds != null
|
||||||
|
? `${turnSeconds}s left`
|
||||||
|
: null
|
||||||
|
: isNextDriver && turnSeconds != null
|
||||||
|
? `Your turn in ${turnSeconds}s`
|
||||||
|
: null;
|
||||||
|
const entries = roverId
|
||||||
|
? [
|
||||||
|
...(shouldShowVideo ? [{ type: 'rover', id: roverId, key: roverId }] : []),
|
||||||
|
...(hasAudio ? [{ type: 'rover', id: `${roverId}-audio`, key: `${roverId}-audio` }] : []),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const sources = useVideoRequests(entries);
|
||||||
|
const info = roverId && shouldShowVideo ? sources[roverId] : null;
|
||||||
|
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
|
||||||
|
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
|
||||||
|
enabled: Boolean(roverId),
|
||||||
|
version: mode,
|
||||||
|
});
|
||||||
|
const snapshotFeed = roverId ? snapshotFeeds[roverId] || null : null;
|
||||||
|
const frame = useTelemetryFrame(roverId);
|
||||||
|
const batteryRecord =
|
||||||
|
roverId && roster
|
||||||
|
? roster.find((item) => String(item.id) === String(roverId))
|
||||||
|
: null;
|
||||||
|
const batteryConfig = batteryRecord?.battery ?? null;
|
||||||
|
|
||||||
|
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'turns') {
|
||||||
|
setTurnCueVisible(false);
|
||||||
|
setTurnCueStartAt(null);
|
||||||
|
lastTurnRef.current = { active: false, roverId: null };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lastTurn = lastTurnRef.current;
|
||||||
|
const becameActive = isActiveDriver && !lastTurn.active;
|
||||||
|
const roverChanged = isActiveDriver && roverId && roverId !== lastTurn.roverId;
|
||||||
|
if (becameActive || roverChanged) {
|
||||||
|
setTurnCueVisible(true);
|
||||||
|
setTurnCueStartAt(Date.now());
|
||||||
|
} else if (!isActiveDriver && lastTurn.active) {
|
||||||
|
setTurnCueVisible(false);
|
||||||
|
setTurnCueStartAt(null);
|
||||||
|
}
|
||||||
|
lastTurnRef.current = { active: isActiveDriver, roverId };
|
||||||
|
}, [isActiveDriver, roverId, mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!turnCueVisible || !turnCueStartAt) return;
|
||||||
|
if (lastControlIntentAt > turnCueStartAt) {
|
||||||
|
setTurnCueVisible(false);
|
||||||
|
}
|
||||||
|
}, [lastControlIntentAt, turnCueStartAt, turnCueVisible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
{roverId ? (
|
||||||
|
<VideoTile
|
||||||
|
sessionInfo={info}
|
||||||
|
videoMode={shouldShowVideo ? 'whep' : 'snapshot'}
|
||||||
|
snapshotFeed={snapshotFeed}
|
||||||
|
audioSessionInfo={audioInfo}
|
||||||
|
label={roverLabel}
|
||||||
|
roverColor={batteryRecord?.color || null}
|
||||||
|
telemetryFrame={frame}
|
||||||
|
batteryConfig={batteryConfig}
|
||||||
|
layoutFormat={layoutFormat}
|
||||||
|
overcurrentLimiter={overcurrentLimiter}
|
||||||
|
songNote={song?.note}
|
||||||
|
qualityNotice={!shouldShowVideo ? 'Preview feed (low FPS) until your turn.' : null}
|
||||||
|
showTurnCue={turnCueVisible}
|
||||||
|
turnTimerText={turnTimerText}
|
||||||
|
turnSeconds={turnSeconds}
|
||||||
|
isActiveDriver={isActiveDriver}
|
||||||
|
idleSkipSeconds={idleSkipSeconds}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
|
||||||
|
<p>You are not assigned to a rover.</p>
|
||||||
|
{/* colored button to visit the spectator page */}
|
||||||
|
<p className="mt-0">
|
||||||
|
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
||||||
|
Click here to visit the spectator page.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
|
||||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
|
||||||
import LightBumpBars from '../LightBumpBars/index.jsx';
|
|
||||||
import { buildBatteryVisual } from '../../../lib/battery.js';
|
|
||||||
import BatteryBar from '../../BatteryBar/index.jsx';
|
|
||||||
|
|
||||||
export default function DriverBottomStrip({ roverId = null, mobileHud = false }) {
|
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
|
||||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
|
||||||
const frame = useTelemetryFrame(effectiveRoverId);
|
|
||||||
const sensors = frame?.sensors ?? null;
|
|
||||||
const batteryConfig = useSessionSelector((state) => {
|
|
||||||
if (!effectiveRoverId) return null;
|
|
||||||
const roster = state.session?.roster || [];
|
|
||||||
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
|
|
||||||
return rover?.battery ?? null;
|
|
||||||
});
|
|
||||||
const batteryVisual = buildBatteryVisual({
|
|
||||||
charge: sensors?.batteryChargeMah ?? null,
|
|
||||||
config: batteryConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<LightBumpBars roverId={effectiveRoverId} />
|
|
||||||
<div className="panel-section space-y-0.5 text-sm">
|
|
||||||
<BatteryBar visual={batteryVisual} compact={mobileHud} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import TopDownMap from '../../TopDownMap/index.jsx';
|
|
||||||
|
|
||||||
export default function HudMapOverlay({
|
|
||||||
sensors,
|
|
||||||
show = true,
|
|
||||||
mapPosition = 'top-center',
|
|
||||||
layoutFormat = 'desktop',
|
|
||||||
mobileHud = false,
|
|
||||||
}) {
|
|
||||||
if (!show) return null;
|
|
||||||
const portraitMobile = layoutFormat === 'mobile-portrait';
|
|
||||||
const mapSize = '240px';
|
|
||||||
const mapScale = portraitMobile ? 0.3 : mobileHud ? 0.33 : 0.7;
|
|
||||||
const mapOpacity = mobileHud ? 0.6 : 0.7;
|
|
||||||
const mapStyle = {
|
|
||||||
width: mapSize,
|
|
||||||
height: mapSize,
|
|
||||||
opacity: mapOpacity,
|
|
||||||
transform: mapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`,
|
|
||||||
transformOrigin:
|
|
||||||
mapPosition === 'bottom-left'
|
|
||||||
? 'bottom left'
|
|
||||||
: mapPosition === 'top-center'
|
|
||||||
? 'top center'
|
|
||||||
: 'top right',
|
|
||||||
...(mapPosition === 'bottom-left'
|
|
||||||
? { left: '0.25rem', bottom: '0.25rem' }
|
|
||||||
: mapPosition === 'top-center'
|
|
||||||
? { left: '50%', top: '0.25rem' }
|
|
||||||
: { right: '0.25rem', top: '0.25rem' }),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none absolute rounded" style={mapStyle}>
|
|
||||||
<TopDownMap sensors={sensors} size={240} overlay />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { roverNameChromeStyle } from '../../../lib/roverColor.js';
|
|
||||||
|
|
||||||
export default function RoverLabelOverlay({
|
|
||||||
variant = 'default',
|
|
||||||
label,
|
|
||||||
roverColor = null,
|
|
||||||
driverLabel = null,
|
|
||||||
mobileHud = false,
|
|
||||||
labelScale = 1,
|
|
||||||
}) {
|
|
||||||
const labelPadClass = mobileHud ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
|
|
||||||
const labelTextClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.8rem]';
|
|
||||||
const labelPosClass = 'bottom-0.5';
|
|
||||||
const labelWrapperStyle = {
|
|
||||||
transform: `translateX(-50%) scale(${labelScale})`,
|
|
||||||
transformOrigin: 'center bottom',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (variant === 'spectator') {
|
|
||||||
return (
|
|
||||||
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
|
||||||
<div className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
|
||||||
<span
|
|
||||||
className="font-semibold text-white rounded px-1 py-[1px] border border-transparent"
|
|
||||||
style={roverNameChromeStyle(roverColor, 0.18)}
|
|
||||||
>
|
|
||||||
{label || 'No rover'}
|
|
||||||
</span>
|
|
||||||
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
|
||||||
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
|
||||||
<span>
|
|
||||||
Rover:{' '}
|
|
||||||
<span
|
|
||||||
className="rounded px-1 py-[1px] border border-transparent"
|
|
||||||
style={roverNameChromeStyle(roverColor, 0.18)}
|
|
||||||
>
|
|
||||||
"{label || 'No rover'}"
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
export default function SpectatorTelemetryOverlay({ sensors, mobileHud = false }) {
|
|
||||||
const statusPadClass = mobileHud ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
|
|
||||||
const telemetryPosClass = mobileHud ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
|
|
||||||
const telemetryTextClass = mobileHud ? 'text-[0.45rem]' : 'text-[0.65rem]';
|
|
||||||
const telemetryEntries = [
|
|
||||||
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
|
|
||||||
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
|
|
||||||
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
|
|
||||||
['OI', sensors?.oiMode?.label || '--'],
|
|
||||||
];
|
|
||||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
|
||||||
const chargingLabel = sensors?.chargingState?.label || '';
|
|
||||||
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
|
|
||||||
const oiLabel = sensors?.oiMode?.label || 'Unknown';
|
|
||||||
const oiNormalized = oiLabel.toLowerCase();
|
|
||||||
const oiTone =
|
|
||||||
oiNormalized === 'full'
|
|
||||||
? 'bg-emerald-500/80 text-emerald-50'
|
|
||||||
: oiNormalized === 'safe'
|
|
||||||
? 'bg-amber-400/80 text-amber-950'
|
|
||||||
: oiNormalized === 'passive'
|
|
||||||
? 'bg-slate-700/80 text-slate-100'
|
|
||||||
: 'bg-slate-700/60 text-slate-200';
|
|
||||||
const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/70 text-slate-200';
|
|
||||||
const chargingTone = charging
|
|
||||||
? 'bg-emerald-500/80 text-emerald-50'
|
|
||||||
: docked
|
|
||||||
? 'bg-amber-400/80 text-amber-950'
|
|
||||||
: 'bg-slate-700/70 text-slate-200';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${telemetryTextClass} ${statusPadClass}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-0.5 leading-tight">
|
|
||||||
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
|
|
||||||
<span className={`rounded px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
|
|
||||||
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
|
|
||||||
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
|
|
||||||
</span>
|
|
||||||
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
|
|
||||||
</div>
|
|
||||||
{telemetryEntries.map(([labelText, value]) => (
|
|
||||||
<span key={labelText} className="flex items-center justify-between gap-0.5">
|
|
||||||
<span className="text-slate-400">{labelText}</span>
|
|
||||||
<span className="font-semibold text-white">{value}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
// Hud Overlay
|
|
||||||
// Purpose: Defines the Hud Overlay 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 React from 'react';
|
|
||||||
import { useHudMapSetting } from '../../../hooks/useHudMapSetting.js';
|
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
|
||||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
|
||||||
import RoverLabelOverlay from './RoverLabelOverlay.jsx';
|
|
||||||
import SpectatorTelemetryOverlay from './SpectatorTelemetryOverlay.jsx';
|
|
||||||
import HudMapOverlay from './HudMapOverlay.jsx';
|
|
||||||
|
|
||||||
function HudOverlay({
|
|
||||||
roverId = null,
|
|
||||||
sensors,
|
|
||||||
label,
|
|
||||||
roverColor = null,
|
|
||||||
layoutFormat = 'desktop',
|
|
||||||
variant = 'default',
|
|
||||||
driverLabel = null,
|
|
||||||
showTopDown = undefined,
|
|
||||||
mobileHud = false,
|
|
||||||
mapPosition = null,
|
|
||||||
labelScale = 1,
|
|
||||||
}) {
|
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
|
||||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
|
||||||
const frame = useTelemetryFrame(effectiveRoverId);
|
|
||||||
const rosterInfo = useSessionSelector((state) => {
|
|
||||||
if (!effectiveRoverId) return { label: null, roverColor: null };
|
|
||||||
const roster = state.session?.roster || [];
|
|
||||||
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
|
|
||||||
return {
|
|
||||||
label: rover?.name || null,
|
|
||||||
roverColor: rover?.color || null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const derivedDriverLabel = useSessionSelector((state) => {
|
|
||||||
if (!effectiveRoverId || variant !== 'spectator') return null;
|
|
||||||
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
|
|
||||||
const users = state.session?.users || [];
|
|
||||||
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
|
|
||||||
return match?.nickname || match?.name || null;
|
|
||||||
});
|
|
||||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
|
||||||
const resolvedLabel = label ?? rosterInfo.label ?? null;
|
|
||||||
const resolvedRoverColor = roverColor ?? rosterInfo.roverColor ?? null;
|
|
||||||
const resolvedDriverLabel = driverLabel ?? derivedDriverLabel;
|
|
||||||
const isMobile = mobileHud;
|
|
||||||
const [showHudMapDesktop] = useHudMapSetting();
|
|
||||||
const resolvedShowTopDown =
|
|
||||||
typeof showTopDown === 'boolean'
|
|
||||||
? showTopDown
|
|
||||||
: variant === 'spectator'
|
|
||||||
? true
|
|
||||||
: isMobile
|
|
||||||
? true
|
|
||||||
: showHudMapDesktop;
|
|
||||||
const resolvedMapPosition =
|
|
||||||
mapPosition || (variant === 'spectator' ? 'top-center' : isMobile ? 'top-right' : 'top-center');
|
|
||||||
|
|
||||||
if (variant === 'none') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variant === 'spectator') {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
||||||
<SpectatorTelemetryOverlay sensors={resolvedSensors} mobileHud={isMobile} />
|
|
||||||
<RoverLabelOverlay
|
|
||||||
variant="spectator"
|
|
||||||
label={resolvedLabel}
|
|
||||||
roverColor={resolvedRoverColor}
|
|
||||||
driverLabel={resolvedDriverLabel}
|
|
||||||
mobileHud={isMobile}
|
|
||||||
labelScale={labelScale}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<HudMapOverlay
|
|
||||||
sensors={resolvedSensors}
|
|
||||||
show={resolvedShowTopDown}
|
|
||||||
mapPosition={resolvedMapPosition}
|
|
||||||
layoutFormat={layoutFormat}
|
|
||||||
mobileHud={isMobile}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
||||||
<RoverLabelOverlay
|
|
||||||
variant="default"
|
|
||||||
label={resolvedLabel}
|
|
||||||
roverColor={resolvedRoverColor}
|
|
||||||
mobileHud={isMobile}
|
|
||||||
labelScale={labelScale}
|
|
||||||
/>
|
|
||||||
<HudMapOverlay
|
|
||||||
sensors={resolvedSensors}
|
|
||||||
show={resolvedShowTopDown && variant !== 'spectator'}
|
|
||||||
mapPosition={resolvedMapPosition}
|
|
||||||
layoutFormat={layoutFormat}
|
|
||||||
mobileHud={isMobile}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(HudOverlay);
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// Low Battery Overlay
|
|
||||||
// Purpose: Defines the Low Battery Overlay 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 React from 'react';
|
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
|
||||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
|
||||||
import { buildBatteryVisual } from '../../../lib/battery.js';
|
|
||||||
|
|
||||||
function LowBatteryOverlay({ roverId = null, sensors, batteryConfig, compact = false }) {
|
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
|
||||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
|
||||||
const frame = useTelemetryFrame(effectiveRoverId);
|
|
||||||
const rosterBatteryConfig = useSessionSelector((state) => {
|
|
||||||
if (!effectiveRoverId) return null;
|
|
||||||
const roster = state.session?.roster || [];
|
|
||||||
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
|
|
||||||
return rover?.battery ?? null;
|
|
||||||
});
|
|
||||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
|
||||||
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
|
|
||||||
const battery = buildBatteryVisual({
|
|
||||||
charge: resolvedSensors?.batteryChargeMah ?? null,
|
|
||||||
config: resolvedBatteryConfig,
|
|
||||||
});
|
|
||||||
if (!battery?.available) return null;
|
|
||||||
if (!battery.warnActive && !battery.urgentActive) return null;
|
|
||||||
|
|
||||||
const message = battery.urgentActive
|
|
||||||
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
|
|
||||||
: 'Battery low! please dock and charge the rover soon.';
|
|
||||||
|
|
||||||
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
|
|
||||||
const textClass = compact ? 'text-sm' : 'text-2xl';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
|
|
||||||
>
|
|
||||||
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
|
|
||||||
<div>{message}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(LowBatteryOverlay);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export const OVERCURRENT_LABELS = {
|
|
||||||
leftWheel: 'Left wheel',
|
|
||||||
rightWheel: 'Right wheel',
|
|
||||||
mainBrush: 'Main brush',
|
|
||||||
sideBrush: 'Side brush',
|
|
||||||
limiter: 'Overcurrent limit',
|
|
||||||
};
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
// Overcurrent Overlay
|
|
||||||
// Purpose: Defines the Overcurrent Overlay 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 React from 'react';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
|
||||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
|
||||||
import { useOvercurrentLimiter } from '../../../controls/index.js';
|
|
||||||
import { OVERCURRENT_LABELS } from './constants.js';
|
|
||||||
|
|
||||||
function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null, compact = false }) {
|
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
|
||||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
|
||||||
const frame = useTelemetryFrame(effectiveRoverId);
|
|
||||||
const internalLimiter = useOvercurrentLimiter(effectiveRoverId);
|
|
||||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
|
||||||
const resolvedOvercurrentLimiter = overcurrentLimiter ?? internalLimiter ?? null;
|
|
||||||
const wheelOvercurrents = resolvedSensors?.wheelOvercurrents || null;
|
|
||||||
const overcurrentMotors = useMemo(
|
|
||||||
() =>
|
|
||||||
wheelOvercurrents == null
|
|
||||||
? []
|
|
||||||
: Object.entries(wheelOvercurrents)
|
|
||||||
.filter(([, active]) => Boolean(active))
|
|
||||||
.map(([key]) => key),
|
|
||||||
[wheelOvercurrents],
|
|
||||||
);
|
|
||||||
const limiterCaps = resolvedOvercurrentLimiter?.caps || null;
|
|
||||||
const limiterFill = useMemo(() => {
|
|
||||||
if (!limiterCaps) return null;
|
|
||||||
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
|
|
||||||
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
|
|
||||||
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
|
|
||||||
}, [limiterCaps]);
|
|
||||||
const limiterActive = Boolean(resolvedOvercurrentLimiter?.isActive);
|
|
||||||
const motors = useMemo(
|
|
||||||
() => (overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []),
|
|
||||||
[overcurrentMotors, limiterActive],
|
|
||||||
);
|
|
||||||
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
|
|
||||||
|
|
||||||
if (!motors?.length) return null;
|
|
||||||
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
|
||||||
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
|
|
||||||
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
|
|
||||||
const textClass = compact ? 'text-lg' : 'text-4xl';
|
|
||||||
const subTextClass = compact ? 'text-xs' : 'text-xl';
|
|
||||||
const safeFill = Math.max(0, Math.min(1, fill));
|
|
||||||
const fillWidth = `${Math.round(safeFill * 100)}%`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
|
|
||||||
>
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
|
||||||
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
|
|
||||||
</div>
|
|
||||||
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
|
|
||||||
<div>OVERCURRENT</div>
|
|
||||||
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(OvercurrentOverlay);
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
|
||||||
|
|
||||||
const DISMISS_AFTER_INPUT_MS = 5000;
|
|
||||||
const LARGE_FADE_MS = 700;
|
|
||||||
|
|
||||||
export default function RoverDescriptionOverlay({
|
|
||||||
roverId = null,
|
|
||||||
description,
|
|
||||||
variant = 'default',
|
|
||||||
mobileHud = false,
|
|
||||||
displayKey = '',
|
|
||||||
controlIntentAt,
|
|
||||||
}) {
|
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
|
||||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
|
||||||
const rosterDescription = useSessionSelector((state) => {
|
|
||||||
if (!effectiveRoverId) return null;
|
|
||||||
const roster = state.session?.roster || [];
|
|
||||||
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
|
|
||||||
return rover?.description || null;
|
|
||||||
});
|
|
||||||
const resolvedDescription = description ?? rosterDescription;
|
|
||||||
const resolvedControlIntentAt =
|
|
||||||
typeof controlIntentAt === 'number' ? controlIntentAt : 0;
|
|
||||||
const resolvedDisplayKey =
|
|
||||||
displayKey || `${effectiveRoverId || ''}::${resolvedDescription || ''}`;
|
|
||||||
const [largeVisible, setLargeVisible] = useState(false);
|
|
||||||
const [largeFading, setLargeFading] = useState(false);
|
|
||||||
const fadeTimerRef = useRef(null);
|
|
||||||
const hideTimerRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
clearTimeout(fadeTimerRef.current);
|
|
||||||
clearTimeout(hideTimerRef.current);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (variant !== 'default' || !resolvedDescription) {
|
|
||||||
setLargeVisible(false);
|
|
||||||
setLargeFading(false);
|
|
||||||
clearTimeout(fadeTimerRef.current);
|
|
||||||
clearTimeout(hideTimerRef.current);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
clearTimeout(fadeTimerRef.current);
|
|
||||||
clearTimeout(hideTimerRef.current);
|
|
||||||
setLargeVisible(true);
|
|
||||||
setLargeFading(false);
|
|
||||||
return undefined;
|
|
||||||
}, [resolvedDescription, resolvedDisplayKey, variant]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (variant !== 'default' || !resolvedDescription || !largeVisible || largeFading) return;
|
|
||||||
const nextIntent = Number(resolvedControlIntentAt) || 0;
|
|
||||||
if (nextIntent <= 0) return;
|
|
||||||
if (fadeTimerRef.current || hideTimerRef.current) return;
|
|
||||||
fadeTimerRef.current = setTimeout(() => {
|
|
||||||
setLargeFading(true);
|
|
||||||
fadeTimerRef.current = null;
|
|
||||||
}, DISMISS_AFTER_INPUT_MS);
|
|
||||||
hideTimerRef.current = setTimeout(() => {
|
|
||||||
setLargeVisible(false);
|
|
||||||
hideTimerRef.current = null;
|
|
||||||
}, DISMISS_AFTER_INPUT_MS + LARGE_FADE_MS);
|
|
||||||
}, [resolvedControlIntentAt, resolvedDescription, largeFading, largeVisible, variant]);
|
|
||||||
|
|
||||||
if (!resolvedDescription) return null;
|
|
||||||
|
|
||||||
if (variant === 'spectator') {
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-1 z-50 flex justify-center">
|
|
||||||
<div className="surface max-w-[92%] border border-slate-600/80 px-1 py-0.5 text-center text-[0.62rem] leading-tight text-slate-100 shadow-lg">
|
|
||||||
{resolvedDescription}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variant !== 'default' || !largeVisible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center">
|
|
||||||
<div
|
|
||||||
className={`surface max-w-[94%] border border-slate-400 bg-neutral-900 px-2 py-1 text-center font-semibold text-slate-100 shadow-xl transition-opacity duration-700 ${
|
|
||||||
largeFading ? 'opacity-0' : 'opacity-100'
|
|
||||||
} ${mobileHud ? 'text-[1rem] leading-tight' : 'text-[1.5rem] leading-tight'}`}
|
|
||||||
>
|
|
||||||
<p>Just so you know, this rover</p>
|
|
||||||
<p>{resolvedDescription}</p>
|
|
||||||
<p className={`${mobileHud ? 'text-[0.58rem]' : 'text-[0.72rem]'} mt-0.5 font-normal text-slate-300`}>
|
|
||||||
This fades 5 seconds after your first control input.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
|
||||||
import { useControlSystem } from '../../../controls/index.js';
|
|
||||||
import SocialButton from '../../SocialButton/index.jsx';
|
|
||||||
|
|
||||||
function TurnsOverlay({
|
|
||||||
roverId = null,
|
|
||||||
mobileHud = false,
|
|
||||||
discordUrl: discordUrlProp = null,
|
|
||||||
}) {
|
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
|
||||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
|
||||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
|
||||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
|
||||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
|
||||||
const discordUrl = useSessionSelector((state) => {
|
|
||||||
const socials = state.session?.socials || [];
|
|
||||||
const socialUrl =
|
|
||||||
socials.find((entry) => {
|
|
||||||
const key = String(entry?.id || entry?.label || '').toLowerCase();
|
|
||||||
return key === 'discord';
|
|
||||||
})?.url || null;
|
|
||||||
return socialUrl || state.session?.discord?.invite || null;
|
|
||||||
});
|
|
||||||
const {
|
|
||||||
state: { lastControlIntentAt },
|
|
||||||
} = useControlSystem();
|
|
||||||
const effectiveDiscordUrl = discordUrlProp || discordUrl;
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
|
||||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
|
||||||
const [noticeFlashActive, setNoticeFlashActive] = useState(false);
|
|
||||||
const [notTurnFlashAt, setNotTurnFlashAt] = useState(0);
|
|
||||||
const lastTurnRef = useRef({ initialized: false, roverId: null, activeDriverId: null });
|
|
||||||
const lastIntentRef = useRef(lastControlIntentAt || 0);
|
|
||||||
const timerTextClass = mobileHud ? 'text-[0.5rem]' : 'text-[0.7rem]';
|
|
||||||
const timerPadClass = mobileHud ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
|
|
||||||
const titleClass = mobileHud ? 'text-3xl' : 'text-5xl';
|
|
||||||
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
|
||||||
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
|
||||||
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
|
||||||
const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
|
||||||
const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : null;
|
|
||||||
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
|
||||||
const nextDriverId = useMemo(() => {
|
|
||||||
const queue = turnInfo?.queue || [];
|
|
||||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
|
||||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
|
||||||
if (idx === -1) return queue[0] || null;
|
|
||||||
return queue[(idx + 1) % queue.length] || null;
|
|
||||||
}, [turnInfo?.queue, turnInfo?.current]);
|
|
||||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
|
||||||
const deadline = turnInfo?.deadline || null;
|
|
||||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
|
||||||
const msUntilTurn = deadline ? deadline - now : null;
|
|
||||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
|
||||||
const isTurnsMode = mode === 'turns';
|
|
||||||
const totalRovers = roster.length;
|
|
||||||
const totalDrivers = useMemo(() => {
|
|
||||||
const unique = new Set();
|
|
||||||
users.forEach((entry) => {
|
|
||||||
const role = String(entry?.role || '');
|
|
||||||
if (role === 'spectator') return;
|
|
||||||
const turnRoverId = String(entry?.roverId || '').trim();
|
|
||||||
const turnSocketId = String(entry?.socketId || '').trim();
|
|
||||||
if (!turnRoverId || !turnSocketId) return;
|
|
||||||
unique.add(turnSocketId);
|
|
||||||
});
|
|
||||||
return unique.size;
|
|
||||||
}, [users]);
|
|
||||||
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
|
|
||||||
const isPreSwitchWindow =
|
|
||||||
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
|
||||||
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
|
||||||
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
|
||||||
const turnSeconds =
|
|
||||||
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
|
||||||
const idleSkipSeconds =
|
|
||||||
msUntilIdleSkip != null && Number.isFinite(msUntilIdleSkip)
|
|
||||||
? Math.max(0, Math.ceil(msUntilIdleSkip / 1000))
|
|
||||||
: null;
|
|
||||||
const turnTimerText = useMemo(() => {
|
|
||||||
if (!isTurnsMode || !isActiveDriver) return null;
|
|
||||||
return turnSeconds != null ? `${turnSeconds}s left` : 'Your turn';
|
|
||||||
}, [isTurnsMode, isActiveDriver, turnSeconds]);
|
|
||||||
const notTurnCountdownText = useMemo(() => {
|
|
||||||
if (!showNotTurnNotice || !isNextDriver || turnSeconds == null) return null;
|
|
||||||
return `${turnSeconds} seconds until your turn.`;
|
|
||||||
}, [showNotTurnNotice, isNextDriver, turnSeconds]);
|
|
||||||
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
|
||||||
const turnTimerFlashActive = noticeFlashActive;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== 'turns') return undefined;
|
|
||||||
const timer = setInterval(() => setNow(Date.now()), 250);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== 'turns') {
|
|
||||||
setShowTurnCue(false);
|
|
||||||
setTurnCueStartAt(null);
|
|
||||||
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lastTurn = lastTurnRef.current;
|
|
||||||
const nextActiveDriverId = activeDriverId || null;
|
|
||||||
if (!socketId || !effectiveRoverId) {
|
|
||||||
setShowTurnCue(false);
|
|
||||||
setTurnCueStartAt(null);
|
|
||||||
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!lastTurn.initialized || lastTurn.roverId !== effectiveRoverId) {
|
|
||||||
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const becameActive =
|
|
||||||
Boolean(lastTurn.activeDriverId) &&
|
|
||||||
lastTurn.activeDriverId !== socketId &&
|
|
||||||
nextActiveDriverId === socketId;
|
|
||||||
if (becameActive) {
|
|
||||||
setShowTurnCue(true);
|
|
||||||
setTurnCueStartAt(Date.now());
|
|
||||||
} else if (nextActiveDriverId !== socketId && showTurnCue) {
|
|
||||||
setShowTurnCue(false);
|
|
||||||
setTurnCueStartAt(null);
|
|
||||||
}
|
|
||||||
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
|
|
||||||
}, [activeDriverId, mode, effectiveRoverId, socketId, showTurnCue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showTurnCue || !turnCueStartAt) return;
|
|
||||||
if (lastControlIntentAt > turnCueStartAt) {
|
|
||||||
setShowTurnCue(false);
|
|
||||||
}
|
|
||||||
}, [lastControlIntentAt, showTurnCue, turnCueStartAt]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const lastIntent = Number(lastIntentRef.current) || 0;
|
|
||||||
const nextIntent = Number(lastControlIntentAt) || 0;
|
|
||||||
if (nextIntent > lastIntent && showNotTurnNotice) {
|
|
||||||
setNotTurnFlashAt(Date.now());
|
|
||||||
}
|
|
||||||
lastIntentRef.current = nextIntent;
|
|
||||||
}, [lastControlIntentAt, showNotTurnNotice]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showNotTurnNotice || !notTurnFlashAt) return undefined;
|
|
||||||
setNoticeFlashActive(true);
|
|
||||||
const timer = setTimeout(() => setNoticeFlashActive(false), 650);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [showNotTurnNotice, notTurnFlashAt]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showTurnCue ? (
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${cuePadClass}`}
|
|
||||||
>
|
|
||||||
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
|
|
||||||
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
|
|
||||||
{showCountdown ? (
|
|
||||||
<div className={`text-red-100/90 ${cueTimerClass}`}>
|
|
||||||
Idle skip in {idleSkipSeconds}s
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{turnTimerText ? (
|
|
||||||
<div
|
|
||||||
className={`pointer-events-none absolute bottom-1 left-1 rounded border ${
|
|
||||||
turnTimerFlashActive
|
|
||||||
? 'border-red-300/90 bg-red-900/80 text-red-100'
|
|
||||||
: 'border-amber-300/80 bg-black/75 text-amber-200'
|
|
||||||
} ${timerPadClass} ${timerTextClass}`}
|
|
||||||
>
|
|
||||||
{turnTimerText}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showNotTurnNotice ? (
|
|
||||||
<div className="pointer-events-none absolute bottom-1 left-1 z-40">
|
|
||||||
<div
|
|
||||||
className={`w-fit rounded border ${
|
|
||||||
noticeFlashActive
|
|
||||||
? 'border-red-300/90 bg-red-900/80 text-red-100'
|
|
||||||
: 'border-amber-300/80 bg-black/75 text-amber-200'
|
|
||||||
} ${mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
noticeFlashActive
|
|
||||||
? 'text-[0.82rem] font-semibold text-red-50'
|
|
||||||
: 'text-[0.82rem] font-semibold text-white'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Not your turn to drive!
|
|
||||||
</div>
|
|
||||||
{notTurnCountdownText ? (
|
|
||||||
<div className={noticeFlashActive ? 'text-red-100/95' : 'text-amber-100'}>
|
|
||||||
{notTurnCountdownText}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{showPreviewReason ? (
|
|
||||||
<div className={noticeFlashActive ? 'text-red-100/90' : 'text-amber-200/85'}>
|
|
||||||
Video switched to preview mode to save bandwidth.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="pointer-events-auto mt-0.5">
|
|
||||||
<SocialButton
|
|
||||||
id="discord"
|
|
||||||
label="Join our Discord while you wait!"
|
|
||||||
url={effectiveDiscordUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(TurnsOverlay);
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
|
||||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
|
||||||
import { buildBatteryVisual } from '../../../lib/battery.js';
|
|
||||||
import BatteryBar from '../../BatteryBar/index.jsx';
|
|
||||||
|
|
||||||
function VerticalBatteryOverlay({ show = false, roverId = null, sensors, batteryConfig, mobileHud = false }) {
|
|
||||||
const frame = useTelemetryFrame(roverId);
|
|
||||||
const rosterBatteryConfig = useSessionSelector((state) => {
|
|
||||||
if (!roverId) return null;
|
|
||||||
const roster = state.session?.roster || [];
|
|
||||||
const rover = roster.find((entry) => String(entry.id) === String(roverId));
|
|
||||||
return rover?.battery ?? null;
|
|
||||||
});
|
|
||||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
|
||||||
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
|
|
||||||
const batteryVisual = buildBatteryVisual({
|
|
||||||
charge: resolvedSensors?.batteryChargeMah ?? null,
|
|
||||||
config: resolvedBatteryConfig,
|
|
||||||
});
|
|
||||||
if (!show || !batteryVisual?.available) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded bg-black/60 px-0.5 pb-1 pt-1">
|
|
||||||
<BatteryBar
|
|
||||||
visual={batteryVisual}
|
|
||||||
orientation="vertical"
|
|
||||||
variant="inline"
|
|
||||||
compact={mobileHud}
|
|
||||||
className="h-full w-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(VerticalBatteryOverlay);
|
|
||||||
@@ -8,8 +8,8 @@ import SocialButton from '../SocialButton/index.jsx';
|
|||||||
import ChatPanel from '../ChatPanel/index.jsx';
|
import ChatPanel from '../ChatPanel/index.jsx';
|
||||||
import NicknameForm from '../NicknameForm/index.jsx';
|
import NicknameForm from '../NicknameForm/index.jsx';
|
||||||
|
|
||||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown']);
|
||||||
const LOCKDOWN_ROLES = new Set(['lockdown', 'lockdown-admin']);
|
const LOCKDOWN_ROLES = new Set(['lockdown']);
|
||||||
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
|
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
|
||||||
|
|
||||||
function getModeDetails(mode = 'admin') {
|
function getModeDetails(mode = 'admin') {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ function roleColors(role) {
|
|||||||
switch (role) {
|
switch (role) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
case 'lockdown':
|
case 'lockdown':
|
||||||
case 'lockdown-admin':
|
|
||||||
return 'text-amber-300';
|
return 'text-amber-300';
|
||||||
case 'spectator':
|
case 'spectator':
|
||||||
return 'text-slate-400';
|
return 'text-slate-400';
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ function roleColors(role) {
|
|||||||
switch (role) {
|
switch (role) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
case 'lockdown':
|
case 'lockdown':
|
||||||
case 'lockdown-admin':
|
|
||||||
return 'text-amber-300';
|
return 'text-amber-300';
|
||||||
case 'spectator':
|
case 'spectator':
|
||||||
return 'text-slate-400';
|
return 'text-slate-400';
|
||||||
@@ -56,7 +55,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
|||||||
|
|
||||||
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
|
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
|
||||||
const adminCapable = useMemo(
|
const adminCapable = useMemo(
|
||||||
() => role === 'admin' || role === 'lockdown' || role === 'lockdown-admin',
|
() => role === 'admin' || role === 'lockdown',
|
||||||
[role],
|
[role],
|
||||||
);
|
);
|
||||||
const hasDeadlines = useMemo(
|
const hasDeadlines = useMemo(
|
||||||
@@ -145,16 +144,11 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
|||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex items-center justify-between gap-0.5">
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
<div className="flex min-w-0 items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<p className="min-w-0 flex items-center gap-0.5 whitespace-nowrap text-slate-200">
|
<p className="text-slate-200">
|
||||||
<span className="rounded px-1 py-[1px] border border-transparent" style={roverNameChromeStyle(rover.color, 0.16)}>
|
<span className="rounded px-1 py-[1px] border border-transparent" style={roverNameChromeStyle(rover.color, 0.16)}>
|
||||||
{rover.name}
|
{rover.name}
|
||||||
</span>
|
</span>
|
||||||
{rover.description ? (
|
|
||||||
<span className="min-w-0 flex-1 truncate text-[0.7rem] text-slate-400">
|
|
||||||
{rover.description}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
</p>
|
||||||
{showTimer ? (
|
{showTimer ? (
|
||||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import RoverMediaPlayer from '../RoverMediaPlayer/index.jsx';
|
|
||||||
import HudOverlay from '../HudOverlays/HudOverlay/index.jsx';
|
|
||||||
import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/index.jsx';
|
|
||||||
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
|
||||||
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
|
||||||
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
|
|
||||||
|
|
||||||
export default function SpectateVideo({
|
|
||||||
roverId = null,
|
|
||||||
label,
|
|
||||||
fitParent = false,
|
|
||||||
layoutFormat = 'desktop',
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
|
||||||
<div className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}>
|
|
||||||
<RoverMediaPlayer
|
|
||||||
roverId={roverId}
|
|
||||||
label={label}
|
|
||||||
/>
|
|
||||||
<RoverDescriptionOverlay
|
|
||||||
roverId={roverId}
|
|
||||||
variant="spectator"
|
|
||||||
mobileHud={false}
|
|
||||||
/>
|
|
||||||
<HudOverlay
|
|
||||||
roverId={roverId}
|
|
||||||
layoutFormat={layoutFormat}
|
|
||||||
variant="spectator"
|
|
||||||
mobileHud={false}
|
|
||||||
labelScale={1}
|
|
||||||
/>
|
|
||||||
<OvercurrentOverlay roverId={roverId} compact={false} />
|
|
||||||
<LowBatteryOverlay roverId={roverId} compact={false} />
|
|
||||||
<VerticalBatteryOverlay show roverId={roverId} mobileHud={false} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -48,7 +48,7 @@ export default function TelemetryPanel() {
|
|||||||
<span> · driver {driverLabel}</span>
|
<span> · driver {driverLabel}</span>
|
||||||
</div> */}
|
</div> */}
|
||||||
{!roverId ? (
|
{!roverId ? (
|
||||||
<p className="text-sm text-slate-500">You are not assigned to a rover!!!!!!</p>
|
<p className="text-sm text-slate-500">Assign a rover to view sensors.</p>
|
||||||
) : !frame ? (
|
) : !frame ? (
|
||||||
<p className="text-sm text-slate-500">Waiting for sensor frames…</p>
|
<p className="text-sm text-slate-500">Waiting for sensor frames…</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ function roleColors(role) {
|
|||||||
switch (role) {
|
switch (role) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
case 'lockdown':
|
case 'lockdown':
|
||||||
case 'lockdown-admin':
|
|
||||||
return 'text-amber-300';
|
return 'text-amber-300';
|
||||||
case 'spectator':
|
case 'spectator':
|
||||||
return 'text-slate-400';
|
return 'text-slate-400';
|
||||||
@@ -121,7 +120,7 @@ export default function UserListPanel({
|
|||||||
) : (
|
) : (
|
||||||
sorted.map((user) => {
|
sorted.map((user) => {
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
|
user.role === 'admin' || user.role === 'lockdown';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.socketId}
|
key={user.socketId}
|
||||||
@@ -245,7 +244,7 @@ export default function UserListPanel({
|
|||||||
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||||
const isSelf = Boolean(selfId && socketId === selfId);
|
const isSelf = Boolean(selfId && socketId === selfId);
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
|
user.role === 'admin' || user.role === 'lockdown';
|
||||||
const highlightClass = isCurrent
|
const highlightClass = isCurrent
|
||||||
? 'bg-sky-600 text-white ring-2 ring-amber-300 animate-pulse'
|
? 'bg-sky-600 text-white ring-2 ring-amber-300 animate-pulse'
|
||||||
: isNext
|
: isNext
|
||||||
|
|||||||
+3
-3
@@ -2,9 +2,9 @@
|
|||||||
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
|
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { memo, useMemo, useState } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
import { useChat } from '../../../context/ChatContext.jsx';
|
import { useChat } from '../../context/ChatContext.jsx';
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
|
|
||||||
function HudChatInput({ compact = false }) {
|
function HudChatInput({ compact = false }) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Hud Overlay
|
||||||
|
// Purpose: Defines the Hud Overlay 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 React from 'react';
|
||||||
|
import TopDownMap from '../TopDownMap/index.jsx';
|
||||||
|
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||||
|
|
||||||
|
function HudOverlay({
|
||||||
|
sensors,
|
||||||
|
label,
|
||||||
|
roverColor = null,
|
||||||
|
status,
|
||||||
|
audioStatus,
|
||||||
|
levelStatus,
|
||||||
|
layoutFormat = 'desktop',
|
||||||
|
variant = 'default',
|
||||||
|
driverLabel = null,
|
||||||
|
showTopDown = false,
|
||||||
|
mobileHud = false,
|
||||||
|
mapPosition = 'top-center',
|
||||||
|
turnTimerText = null,
|
||||||
|
labelScale = 1,
|
||||||
|
}) {
|
||||||
|
const isMobile = mobileHud;
|
||||||
|
const portraitMobile = layoutFormat === 'mobile-portrait';
|
||||||
|
const statusTextClass = isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]';
|
||||||
|
const statusPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
|
||||||
|
const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
|
||||||
|
const labelTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.8rem]';
|
||||||
|
const statusPosClass = isMobile ? 'left-0.5 top-0.5' : 'left-1 top-1';
|
||||||
|
const timerTextClass = isMobile ? 'text-[0.5rem]' : 'text-[0.7rem]';
|
||||||
|
const timerPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
|
||||||
|
const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
|
||||||
|
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
|
||||||
|
const labelWrapperStyle = {
|
||||||
|
transform: `translateX(-50%) scale(${labelScale})`,
|
||||||
|
transformOrigin: 'center bottom',
|
||||||
|
};
|
||||||
|
const mapSize = '240px';
|
||||||
|
const mapScale = portraitMobile ? 0.3 : isMobile ? 0.33 : 0.7;
|
||||||
|
const mapOpacity = isMobile ? 0.6 : 0.7;
|
||||||
|
const mapStyle = {
|
||||||
|
width: mapSize,
|
||||||
|
height: mapSize,
|
||||||
|
opacity: mapOpacity,
|
||||||
|
transform: mapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`,
|
||||||
|
transformOrigin:
|
||||||
|
mapPosition === 'bottom-left' ? 'bottom left' : mapPosition === 'top-center' ? 'top center' : 'top right',
|
||||||
|
...(mapPosition === 'bottom-left'
|
||||||
|
? { left: '0.25rem', bottom: '0.25rem' }
|
||||||
|
: mapPosition === 'top-center'
|
||||||
|
? { left: '50%', top: '0.25rem' }
|
||||||
|
: { right: '0.25rem', top: '0.25rem' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (variant === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'spectator') {
|
||||||
|
const telemetryEntries = [
|
||||||
|
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
|
||||||
|
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
|
||||||
|
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
|
||||||
|
['OI', sensors?.oiMode?.label || '--'],
|
||||||
|
];
|
||||||
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||||
|
const chargingLabel = sensors?.chargingState?.label || '';
|
||||||
|
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
|
||||||
|
const oiLabel = sensors?.oiMode?.label || 'Unknown';
|
||||||
|
const oiNormalized = oiLabel.toLowerCase();
|
||||||
|
const oiTone =
|
||||||
|
oiNormalized === 'full'
|
||||||
|
? 'bg-emerald-500/80 text-emerald-50'
|
||||||
|
: oiNormalized === 'safe'
|
||||||
|
? 'bg-amber-400/80 text-amber-950'
|
||||||
|
: oiNormalized === 'passive'
|
||||||
|
? 'bg-slate-700/80 text-slate-100'
|
||||||
|
: 'bg-slate-700/60 text-slate-200';
|
||||||
|
const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/70 text-slate-200';
|
||||||
|
const chargingTone = charging
|
||||||
|
? 'bg-emerald-500/80 text-emerald-50'
|
||||||
|
: docked
|
||||||
|
? 'bg-amber-400/80 text-amber-950'
|
||||||
|
: 'bg-slate-700/70 text-slate-200';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
||||||
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
|
<span>Status: {status}</span>
|
||||||
|
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||||
|
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5 leading-tight">
|
||||||
|
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
|
||||||
|
<span className={`rounded px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
|
||||||
|
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
|
||||||
|
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
|
||||||
|
</span>
|
||||||
|
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
|
||||||
|
</div>
|
||||||
|
{telemetryEntries.map(([labelText, value]) => (
|
||||||
|
<span key={labelText} className="flex items-center justify-between gap-0.5">
|
||||||
|
<span className="text-slate-400">{labelText}</span>
|
||||||
|
<span className="font-semibold text-white">{value}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-semibold text-white rounded px-1 py-[1px] border border-transparent"
|
||||||
|
style={roverNameChromeStyle(roverColor, 0.18)}
|
||||||
|
>
|
||||||
|
{label || 'Unnamed Rover'}
|
||||||
|
</span>
|
||||||
|
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showTopDown ? (
|
||||||
|
<div className="pointer-events-none absolute rounded" style={{ ...mapStyle }}>
|
||||||
|
<TopDownMap sensors={sensors} size={240} overlay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
||||||
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
|
<span>Status: {status}</span>
|
||||||
|
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||||
|
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{turnTimerText ? (
|
||||||
|
<div
|
||||||
|
className={`absolute left-1/2 top-0.5 -translate-x-1/2 rounded bg-black/70 text-slate-100 ${timerPadClass} ${timerTextClass}`}
|
||||||
|
>
|
||||||
|
{turnTimerText}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
|
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
||||||
|
<span>
|
||||||
|
Rover:{' '}
|
||||||
|
<span
|
||||||
|
className="rounded px-1 py-[1px] border border-transparent"
|
||||||
|
style={roverNameChromeStyle(roverColor, 0.18)}
|
||||||
|
>
|
||||||
|
"{label || 'Unnamed Rover'}"
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTopDown && variant !== 'spectator' ? (
|
||||||
|
<div className="pointer-events-none absolute rounded" style={{ ...mapStyle }}>
|
||||||
|
<TopDownMap sensors={sensors} size={240} overlay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(HudOverlay);
|
||||||
+7
-13
@@ -2,21 +2,15 @@
|
|||||||
// Purpose: Defines the Light Bump Bars module and the local helpers/components used in this file.
|
// Purpose: Defines the Light Bump Bars module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
|
||||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
|
||||||
|
|
||||||
function LightBumpBars({ roverId = null, sensors }) {
|
function LightBumpBars({ sensors }) {
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
|
||||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
|
||||||
const frame = useTelemetryFrame(effectiveRoverId);
|
|
||||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
|
||||||
const values = [
|
const values = [
|
||||||
resolvedSensors?.lightBumpLeftSignal,
|
sensors?.lightBumpLeftSignal,
|
||||||
resolvedSensors?.lightBumpFrontLeftSignal,
|
sensors?.lightBumpFrontLeftSignal,
|
||||||
resolvedSensors?.lightBumpCenterLeftSignal,
|
sensors?.lightBumpCenterLeftSignal,
|
||||||
resolvedSensors?.lightBumpCenterRightSignal,
|
sensors?.lightBumpCenterRightSignal,
|
||||||
resolvedSensors?.lightBumpFrontRightSignal,
|
sensors?.lightBumpFrontRightSignal,
|
||||||
resolvedSensors?.lightBumpRightSignal,
|
sensors?.lightBumpRightSignal,
|
||||||
];
|
];
|
||||||
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
|
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
|
||||||
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
|
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Low Battery Overlay
|
||||||
|
// Purpose: Defines the Low Battery Overlay 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 React from 'react';
|
||||||
|
|
||||||
|
function LowBatteryOverlay({ battery, compact = false }) {
|
||||||
|
if (!battery?.available) return null;
|
||||||
|
if (!battery.warnActive && !battery.urgentActive) return null;
|
||||||
|
|
||||||
|
const message = battery.urgentActive
|
||||||
|
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
|
||||||
|
: 'Battery low! please dock and charge the rover soon.';
|
||||||
|
|
||||||
|
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
|
||||||
|
const textClass = compact ? 'text-sm' : 'text-2xl';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
|
||||||
|
>
|
||||||
|
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
|
||||||
|
<div>{message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(LowBatteryOverlay);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Overcurrent Overlay
|
||||||
|
// Purpose: Defines the Overcurrent Overlay 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 React from 'react';
|
||||||
|
import { OVERCURRENT_LABELS } from './constants.js';
|
||||||
|
|
||||||
|
function OvercurrentOverlay({ motors, fill = 0, compact = false }) {
|
||||||
|
if (!motors?.length) return null;
|
||||||
|
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||||
|
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
|
||||||
|
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
|
||||||
|
const textClass = compact ? 'text-lg' : 'text-4xl';
|
||||||
|
const subTextClass = compact ? 'text-xs' : 'text-xl';
|
||||||
|
const safeFill = Math.max(0, Math.min(1, fill));
|
||||||
|
const fillWidth = `${Math.round(safeFill * 100)}%`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
|
||||||
|
>
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
|
||||||
|
</div>
|
||||||
|
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
|
||||||
|
<div>OVERCURRENT</div>
|
||||||
|
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(OvercurrentOverlay);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Turn Cue Overlay
|
||||||
|
// Purpose: Defines the Turn Cue Overlay 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 React from 'react';
|
||||||
|
|
||||||
|
export default function TurnCueOverlay({
|
||||||
|
mobileHud = false,
|
||||||
|
isActiveDriver = false,
|
||||||
|
idleSkipSeconds = null,
|
||||||
|
}) {
|
||||||
|
const titleClass = mobileHud ? 'text-3xl' : 'text-5xl';
|
||||||
|
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
||||||
|
const timerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
||||||
|
const padClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
||||||
|
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
|
||||||
|
<div className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
|
||||||
|
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
|
||||||
|
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
|
||||||
|
{showCountdown ? (
|
||||||
|
<div className={`text-red-100/90 ${timerClass}`}>
|
||||||
|
Idle skip in {idleSkipSeconds}s
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+8
@@ -6,3 +6,11 @@ export const UNMUTE_RETRY_MS = 3000;
|
|||||||
export const AUDIO_RETRY_MS = 3000;
|
export const AUDIO_RETRY_MS = 3000;
|
||||||
export const BRUSH_CURRENT_THRESHOLD_MA = 40;
|
export const BRUSH_CURRENT_THRESHOLD_MA = 40;
|
||||||
export const DUCK_RELEASE_FADE_MS = 1000;
|
export const DUCK_RELEASE_FADE_MS = 1000;
|
||||||
|
|
||||||
|
export const OVERCURRENT_LABELS = {
|
||||||
|
leftWheel: 'Left wheel',
|
||||||
|
rightWheel: 'Right wheel',
|
||||||
|
mainBrush: 'Main brush',
|
||||||
|
sideBrush: 'Side brush',
|
||||||
|
limiter: 'Overcurrent limit',
|
||||||
|
};
|
||||||
+248
-140
@@ -1,11 +1,21 @@
|
|||||||
|
// Video Tile
|
||||||
|
// Purpose: Defines the Video Tile 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { WhepPlayer } from '../../lib/whepPlayer.js';
|
import { WhepPlayer } from '../../lib/whepPlayer.js';
|
||||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
|
||||||
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
|
import SocialButton from '../SocialButton/index.jsx';
|
||||||
|
import BatteryBar from '../BatteryBar/index.jsx';
|
||||||
|
import { buildBatteryVisual } from '../../lib/battery.js';
|
||||||
|
import TurnCueOverlay from './TurnCueOverlay.jsx';
|
||||||
|
import HudOverlay from './HudOverlay.jsx';
|
||||||
|
import OvercurrentOverlay from './OvercurrentOverlay.jsx';
|
||||||
|
import LowBatteryOverlay from './LowBatteryOverlay.jsx';
|
||||||
|
import LightBumpBars from './LightBumpBars.jsx';
|
||||||
|
import HudChatInput from './HudChatInput.jsx';
|
||||||
import {
|
import {
|
||||||
RESTART_DELAY_MS,
|
RESTART_DELAY_MS,
|
||||||
UNMUTE_RETRY_MS,
|
UNMUTE_RETRY_MS,
|
||||||
@@ -14,54 +24,39 @@ import {
|
|||||||
DUCK_RELEASE_FADE_MS,
|
DUCK_RELEASE_FADE_MS,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
|
|
||||||
export default function RoverMediaPlayer({
|
export default function VideoTile({
|
||||||
roverId = null,
|
sessionInfo,
|
||||||
sessionInfo = null,
|
audioSessionInfo,
|
||||||
audioSessionInfo = null,
|
videoMode = 'whep',
|
||||||
videoMode = null,
|
|
||||||
snapshotFeed = null,
|
snapshotFeed = null,
|
||||||
|
qualityNotice = null,
|
||||||
label,
|
label,
|
||||||
|
roverColor = null,
|
||||||
forceMute = false,
|
forceMute = false,
|
||||||
sensors,
|
telemetryFrame,
|
||||||
|
batteryConfig,
|
||||||
|
layoutFormat = 'desktop',
|
||||||
|
hudVariant = 'default',
|
||||||
|
driverLabel = null,
|
||||||
|
hudForceMap = false,
|
||||||
|
hudMapPosition = 'top-center',
|
||||||
|
hudLabelScale = 1,
|
||||||
|
fitParent = false,
|
||||||
|
overcurrentLimiter = null,
|
||||||
|
showTurnCue = false,
|
||||||
|
turnTimerText = null,
|
||||||
|
isActiveDriver = false,
|
||||||
|
idleSkipSeconds = null,
|
||||||
}) {
|
}) {
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const discordUrl = useSessionSelector((state) => {
|
||||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
const socials = state.session?.socials || [];
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
const socialUrl =
|
||||||
const rosterEntry = useSessionSelector((state) =>
|
socials.find((entry) => {
|
||||||
effectiveRoverId && Array.isArray(state.session?.roster)
|
const key = String(entry?.id || entry?.label || '').toLowerCase();
|
||||||
? state.session.roster.find((item) => String(item.id) === String(effectiveRoverId)) || null
|
return key === 'discord';
|
||||||
: null,
|
})?.url || null;
|
||||||
);
|
return socialUrl || state.session?.discord?.invite || null;
|
||||||
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
|
|
||||||
const autoVideoEnabled = videoMode ? videoMode === 'whep' : true;
|
|
||||||
const autoEntries = useMemo(() => {
|
|
||||||
if (!effectiveRoverId || !autoVideoEnabled) return [];
|
|
||||||
return [
|
|
||||||
{ type: 'rover', id: effectiveRoverId, key: effectiveRoverId },
|
|
||||||
...(hasAudio
|
|
||||||
? [{ type: 'rover', id: `${effectiveRoverId}-audio`, key: `${effectiveRoverId}-audio` }]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
}, [effectiveRoverId, autoVideoEnabled, hasAudio]);
|
|
||||||
const autoSources = useVideoRequests(autoEntries, {
|
|
||||||
enabled: Boolean(effectiveRoverId && autoVideoEnabled),
|
|
||||||
version: mode,
|
|
||||||
});
|
});
|
||||||
const resolvedSessionInfo =
|
|
||||||
sessionInfo ?? (effectiveRoverId ? autoSources[effectiveRoverId] || null : null);
|
|
||||||
const resolvedAudioSessionInfo =
|
|
||||||
audioSessionInfo ??
|
|
||||||
(effectiveRoverId && hasAudio ? autoSources[`${effectiveRoverId}-audio`] || null : null);
|
|
||||||
const autoSnapshots = useRoverSnapshots(effectiveRoverId ? [effectiveRoverId] : [], {
|
|
||||||
enabled: Boolean(effectiveRoverId && !resolvedSessionInfo?.url),
|
|
||||||
version: mode,
|
|
||||||
});
|
|
||||||
const resolvedSnapshotFeed =
|
|
||||||
snapshotFeed ?? (effectiveRoverId ? autoSnapshots[effectiveRoverId] || null : null);
|
|
||||||
const resolvedLabel =
|
|
||||||
label || rosterEntry?.name || (effectiveRoverId ? `Rover ${effectiveRoverId}` : 'Rover');
|
|
||||||
const frame = useTelemetryFrame(effectiveRoverId);
|
|
||||||
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const audioRef = useRef(null);
|
const audioRef = useRef(null);
|
||||||
const restartTimer = useRef(null);
|
const restartTimer = useRef(null);
|
||||||
@@ -77,8 +72,9 @@ export default function RoverMediaPlayer({
|
|||||||
const [restartToken, setRestartToken] = useState(0);
|
const [restartToken, setRestartToken] = useState(0);
|
||||||
const [audioRestartToken, setAudioRestartToken] = useState(0);
|
const [audioRestartToken, setAudioRestartToken] = useState(0);
|
||||||
const [muted, setMuted] = useState(true);
|
const [muted, setMuted] = useState(true);
|
||||||
const hasDedicatedAudio = Boolean(resolvedAudioSessionInfo?.url);
|
const hasDedicatedAudio = Boolean(audioSessionInfo?.url);
|
||||||
const usingSnapshot = videoMode === 'snapshot' || (!videoMode && !resolvedSessionInfo?.url);
|
const usingSnapshot = videoMode === 'snapshot';
|
||||||
|
const sensors = telemetryFrame?.sensors;
|
||||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume)
|
const masterVolume = Number.isFinite(audioSettings?.masterVolume)
|
||||||
? audioSettings.masterVolume
|
? audioSettings.masterVolume
|
||||||
@@ -96,21 +92,63 @@ export default function RoverMediaPlayer({
|
|||||||
? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount))
|
? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount))
|
||||||
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
|
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
|
||||||
const baseRoverGain = Math.max(0, Math.min(1, masterVolume * roverVolume));
|
const baseRoverGain = Math.max(0, Math.min(1, masterVolume * roverVolume));
|
||||||
|
const batteryCharge = sensors?.batteryChargeMah ?? null;
|
||||||
|
const desktopLayout = layoutFormat === 'desktop';
|
||||||
|
const mobileHud = !desktopLayout;
|
||||||
|
const effectiveHudMapPosition = mobileHud ? 'top-right' : hudMapPosition;
|
||||||
|
const [showHudMapDesktop] = useHudMapSetting();
|
||||||
|
const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop;
|
||||||
|
const batteryVisual = buildBatteryVisual({ charge: batteryCharge, config: batteryConfig });
|
||||||
|
const wheelOvercurrents = sensors?.wheelOvercurrents || null;
|
||||||
|
const overcurrentMotors = useMemo(
|
||||||
|
() =>
|
||||||
|
wheelOvercurrents == null
|
||||||
|
? []
|
||||||
|
: Object.entries(wheelOvercurrents)
|
||||||
|
.filter(([, active]) => Boolean(active))
|
||||||
|
.map(([key]) => key),
|
||||||
|
[wheelOvercurrents],
|
||||||
|
);
|
||||||
|
const limiterCaps = overcurrentLimiter?.caps || null;
|
||||||
|
const limiterGroups = overcurrentLimiter?.overcurrent?.groups || null;
|
||||||
|
const debugFlags = useMemo(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return { debugAudio: false, debugHud: false };
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return {
|
||||||
|
debugAudio: params.has('debugAudio'),
|
||||||
|
debugHud: params.has('debugHud'),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const debugAudio = debugFlags.debugAudio;
|
||||||
|
const debugHud = debugFlags.debugHud;
|
||||||
|
const limiterFill = useMemo(() => {
|
||||||
|
if (!limiterCaps) return null;
|
||||||
|
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
|
||||||
|
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
|
||||||
|
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
|
||||||
|
}, [limiterCaps]);
|
||||||
|
const limiterActive = Boolean(overcurrentLimiter?.isActive);
|
||||||
|
const overlayState = useMemo(() => {
|
||||||
|
const motors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
|
||||||
|
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
|
||||||
|
return {
|
||||||
|
motors,
|
||||||
|
fill,
|
||||||
|
visible: Boolean(motors.length),
|
||||||
|
};
|
||||||
|
}, [overcurrentMotors, limiterActive, limiterFill]);
|
||||||
const mainBrushActive = Boolean(
|
const mainBrushActive = Boolean(
|
||||||
(Number(resolvedSensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
|
(Number(sensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
|
||||||
resolvedSensors?.wheelOvercurrents?.mainBrush,
|
sensors?.wheelOvercurrents?.mainBrush,
|
||||||
);
|
);
|
||||||
const duckGain = mainBrushDuckEnabled && mainBrushActive ? 1 - mainBrushDuckAmount : 1;
|
const duckGain = mainBrushDuckEnabled && mainBrushActive ? 1 - mainBrushDuckAmount : 1;
|
||||||
const effectiveRoverGain = Math.max(0, Math.min(1, baseRoverGain * duckGain));
|
const effectiveRoverGain = Math.max(0, Math.min(1, baseRoverGain * duckGain));
|
||||||
|
const levelIndicator =
|
||||||
const debugAudio = useMemo(() => {
|
mainBrushDuckEnabled && mainBrushActive && mainBrushDuckAmount > 0
|
||||||
if (typeof window === 'undefined') {
|
? `Volume decreased ${Math.round(mainBrushDuckAmount * 1000) / 10}%`
|
||||||
return false;
|
: null;
|
||||||
}
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
return params.has('debugAudio');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const audioDebugStateRef = useRef({
|
const audioDebugStateRef = useRef({
|
||||||
hasDedicatedAudio: false,
|
hasDedicatedAudio: false,
|
||||||
audioUrl: null,
|
audioUrl: null,
|
||||||
@@ -123,7 +161,7 @@ export default function RoverMediaPlayer({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
audioDebugStateRef.current = {
|
audioDebugStateRef.current = {
|
||||||
hasDedicatedAudio,
|
hasDedicatedAudio,
|
||||||
audioUrl: resolvedAudioSessionInfo?.url || null,
|
audioUrl: audioSessionInfo?.url || null,
|
||||||
mainBrushDuckEnabled,
|
mainBrushDuckEnabled,
|
||||||
mainBrushDuckAmount,
|
mainBrushDuckAmount,
|
||||||
mainBrushActive,
|
mainBrushActive,
|
||||||
@@ -132,14 +170,13 @@ export default function RoverMediaPlayer({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
hasDedicatedAudio,
|
hasDedicatedAudio,
|
||||||
resolvedAudioSessionInfo?.url,
|
audioSessionInfo?.url,
|
||||||
mainBrushDuckEnabled,
|
mainBrushDuckEnabled,
|
||||||
mainBrushDuckAmount,
|
mainBrushDuckAmount,
|
||||||
mainBrushActive,
|
mainBrushActive,
|
||||||
baseRoverGain,
|
baseRoverGain,
|
||||||
effectiveRoverGain,
|
effectiveRoverGain,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const logAudio = useCallback(
|
const logAudio = useCallback(
|
||||||
(event, meta = {}) => {
|
(event, meta = {}) => {
|
||||||
if (!debugAudio) return;
|
if (!debugAudio) return;
|
||||||
@@ -148,7 +185,7 @@ export default function RoverMediaPlayer({
|
|||||||
const payload = {
|
const payload = {
|
||||||
event,
|
event,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
roverLabel: resolvedLabel || null,
|
roverLabel: label || null,
|
||||||
hasDedicatedAudio: state.hasDedicatedAudio,
|
hasDedicatedAudio: state.hasDedicatedAudio,
|
||||||
audioUrl: state.audioUrl,
|
audioUrl: state.audioUrl,
|
||||||
mainBrushDuckEnabled: state.mainBrushDuckEnabled,
|
mainBrushDuckEnabled: state.mainBrushDuckEnabled,
|
||||||
@@ -174,8 +211,20 @@ export default function RoverMediaPlayer({
|
|||||||
console.log('[AudioDebug]', event, payload);
|
console.log('[AudioDebug]', event, payload);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[debugAudio, resolvedLabel],
|
[debugAudio, label],
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debugHud) return;
|
||||||
|
console.log('[OvercurrentHUD]', {
|
||||||
|
overlayVisible: overlayState.visible,
|
||||||
|
overlayMotors: overlayState.motors,
|
||||||
|
overlayFill: overlayState.fill,
|
||||||
|
limiterActive,
|
||||||
|
limiterCaps,
|
||||||
|
limiterGroups,
|
||||||
|
wheelOvercurrents,
|
||||||
|
});
|
||||||
|
}, [debugHud, overlayState, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logAudio('settings/update');
|
logAudio('settings/update');
|
||||||
@@ -263,16 +312,16 @@ export default function RoverMediaPlayer({
|
|||||||
}, [usingSnapshot]);
|
}, [usingSnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (usingSnapshot || !resolvedSessionInfo?.url || !videoRef.current) {
|
if (usingSnapshot || !sessionInfo?.url || !videoRef.current) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
let player;
|
let player;
|
||||||
const resetMuteId = setTimeout(() => setMuted(true), 0);
|
const resetMuteId = setTimeout(() => setMuted(true), 0);
|
||||||
const handleStatus = (nextStatus, info) => {
|
const handleStatus = (nextStatus, info) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
logAudio('video/status', { nextStatus, info: info || null });
|
logAudio('video/status', { nextStatus, info: info || null });
|
||||||
setStatus(nextStatus);
|
setStatus(nextStatus);
|
||||||
setDetail(info || null);
|
setDetail(info || null);
|
||||||
if (nextStatus === 'playing') {
|
if (nextStatus === 'playing') {
|
||||||
ensurePlayback();
|
ensurePlayback();
|
||||||
@@ -283,8 +332,8 @@ export default function RoverMediaPlayer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
player = new WhepPlayer({
|
player = new WhepPlayer({
|
||||||
url: resolvedSessionInfo.url,
|
url: sessionInfo.url,
|
||||||
token: resolvedSessionInfo.token,
|
token: sessionInfo.token,
|
||||||
video: videoRef.current,
|
video: videoRef.current,
|
||||||
receiveAudio: !hasDedicatedAudio,
|
receiveAudio: !hasDedicatedAudio,
|
||||||
onStatus: handleStatus,
|
onStatus: handleStatus,
|
||||||
@@ -302,26 +351,17 @@ export default function RoverMediaPlayer({
|
|||||||
clearTimeout(resetMuteId);
|
clearTimeout(resetMuteId);
|
||||||
player?.stop();
|
player?.stop();
|
||||||
};
|
};
|
||||||
}, [
|
}, [usingSnapshot, sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart, ensurePlayback, hasDedicatedAudio, logAudio]);
|
||||||
usingSnapshot,
|
|
||||||
resolvedSessionInfo?.url,
|
|
||||||
resolvedSessionInfo?.token,
|
|
||||||
restartToken,
|
|
||||||
scheduleRestart,
|
|
||||||
ensurePlayback,
|
|
||||||
hasDedicatedAudio,
|
|
||||||
logAudio,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'stopped' && resolvedSessionInfo?.url) {
|
if (status === 'stopped' && sessionInfo?.url) {
|
||||||
scheduleRestart();
|
scheduleRestart();
|
||||||
}
|
}
|
||||||
}, [status, resolvedSessionInfo?.url, scheduleRestart]);
|
}, [status, sessionInfo?.url, scheduleRestart]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioEl = audioRef.current;
|
const audioEl = audioRef.current;
|
||||||
if (!audioEl || !resolvedAudioSessionInfo?.url) {
|
if (!audioEl || !audioSessionInfo?.url) {
|
||||||
logAudio('route/no-audio-url');
|
logAudio('route/no-audio-url');
|
||||||
appliedVolumeRef.current = null;
|
appliedVolumeRef.current = null;
|
||||||
return;
|
return;
|
||||||
@@ -362,7 +402,7 @@ export default function RoverMediaPlayer({
|
|||||||
duckAmount: mainBrushDuckAmount,
|
duckAmount: mainBrushDuckAmount,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
resolvedAudioSessionInfo?.url,
|
audioSessionInfo?.url,
|
||||||
effectiveRoverGain,
|
effectiveRoverGain,
|
||||||
mainBrushDuckEnabled,
|
mainBrushDuckEnabled,
|
||||||
mainBrushActive,
|
mainBrushActive,
|
||||||
@@ -370,8 +410,9 @@ export default function RoverMediaPlayer({
|
|||||||
logAudio,
|
logAudio,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Audio-only WHEP (no pausing/muting; keeps trying to play)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resolvedAudioSessionInfo?.url || !audioRef.current) {
|
if (!audioSessionInfo?.url || !audioRef.current) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -395,8 +436,8 @@ export default function RoverMediaPlayer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
player = new WhepPlayer({
|
player = new WhepPlayer({
|
||||||
url: resolvedAudioSessionInfo.url,
|
url: audioSessionInfo.url,
|
||||||
token: resolvedAudioSessionInfo.token,
|
token: audioSessionInfo.token,
|
||||||
video: audioRef.current,
|
video: audioRef.current,
|
||||||
audioOnly: true,
|
audioOnly: true,
|
||||||
onStatus: handleStatus,
|
onStatus: handleStatus,
|
||||||
@@ -414,16 +455,17 @@ export default function RoverMediaPlayer({
|
|||||||
player?.stop();
|
player?.stop();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
resolvedAudioSessionInfo?.url,
|
audioSessionInfo?.url,
|
||||||
resolvedAudioSessionInfo?.token,
|
audioSessionInfo?.token,
|
||||||
audioRestartToken,
|
audioRestartToken,
|
||||||
scheduleAudioRestart,
|
scheduleAudioRestart,
|
||||||
logAudio,
|
logAudio,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Keep nudging the audio element to play in case autoplay was blocked.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioEl = audioRef.current;
|
const audioEl = audioRef.current;
|
||||||
if (!resolvedAudioSessionInfo?.url || !audioEl) {
|
if (!audioSessionInfo?.url || !audioEl) {
|
||||||
clearInterval(audioPlayInterval.current);
|
clearInterval(audioPlayInterval.current);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -456,8 +498,13 @@ export default function RoverMediaPlayer({
|
|||||||
audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS);
|
audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS);
|
||||||
|
|
||||||
return () => clearInterval(audioPlayInterval.current);
|
return () => clearInterval(audioPlayInterval.current);
|
||||||
}, [resolvedAudioSessionInfo?.url, audioStatus, logAudio]);
|
}, [
|
||||||
|
audioSessionInfo?.url,
|
||||||
|
audioStatus,
|
||||||
|
logAudio,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reflect audio element events back into status/detail so the HUD stays accurate.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioEl = audioRef.current;
|
const audioEl = audioRef.current;
|
||||||
if (!audioEl) return undefined;
|
if (!audioEl) return undefined;
|
||||||
@@ -508,75 +555,136 @@ export default function RoverMediaPlayer({
|
|||||||
audioEl.removeEventListener('canplay', handleCanPlay);
|
audioEl.removeEventListener('canplay', handleCanPlay);
|
||||||
audioEl.removeEventListener('stalled', handleStalled);
|
audioEl.removeEventListener('stalled', handleStalled);
|
||||||
};
|
};
|
||||||
}, [resolvedAudioSessionInfo?.url, logAudio]);
|
}, [audioSessionInfo?.url, logAudio]);
|
||||||
|
|
||||||
const snapshotStatus = resolvedSnapshotFeed?.error
|
const snapshotStatus = snapshotFeed?.error
|
||||||
? `Error: ${resolvedSnapshotFeed.error}`
|
? `Error: ${snapshotFeed.error}`
|
||||||
: resolvedSnapshotFeed?.objectUrl
|
: snapshotFeed?.objectUrl
|
||||||
? 'snapshot'
|
? 'snapshot'
|
||||||
: resolvedSnapshotFeed?.status || 'waiting';
|
: snapshotFeed?.status || 'waiting';
|
||||||
const renderedStatus = usingSnapshot
|
const renderedStatus = usingSnapshot
|
||||||
? snapshotStatus
|
? snapshotStatus
|
||||||
: !resolvedSessionInfo?.url
|
: !sessionInfo?.url
|
||||||
? 'waiting'
|
? 'waiting'
|
||||||
: status === 'error'
|
: status === 'error'
|
||||||
? `Error: ${detail || 'unknown'}`
|
? `Error: ${detail || 'unknown'}`
|
||||||
: detail
|
: detail
|
||||||
? `${status} (${detail})`
|
? `${status} (${detail})`
|
||||||
: status;
|
: status;
|
||||||
const renderedAudioStatus = resolvedAudioSessionInfo?.error
|
const renderedAudioStatus = audioSessionInfo?.error
|
||||||
? `Error: ${resolvedAudioSessionInfo.error}`
|
? `Error: ${audioSessionInfo.error}`
|
||||||
: !resolvedAudioSessionInfo?.url
|
: !audioSessionInfo?.url
|
||||||
? null
|
? null
|
||||||
: audioStatus === 'error'
|
: audioStatus === 'error'
|
||||||
? `Error: ${audioDetail || 'unknown'}`
|
? `Error: ${audioDetail || 'unknown'}`
|
||||||
: audioDetail
|
: audioDetail
|
||||||
? `${audioStatus} (${audioDetail})`
|
? `${audioStatus} (${audioDetail})`
|
||||||
: audioStatus;
|
: audioStatus;
|
||||||
const showConnectingOverlay =
|
const showVerticalBattery = hudVariant === 'spectator';
|
||||||
!usingSnapshot &&
|
const noHud = hudVariant === 'none';
|
||||||
!resolvedSessionInfo?.error &&
|
|
||||||
['idle', 'new', 'connecting'].includes(status);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
||||||
{usingSnapshot ? (
|
<div
|
||||||
resolvedSnapshotFeed?.objectUrl ? (
|
className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}
|
||||||
<img
|
>
|
||||||
src={resolvedSnapshotFeed.objectUrl}
|
{usingSnapshot ? (
|
||||||
alt={resolvedLabel}
|
snapshotFeed?.objectUrl ? (
|
||||||
className="h-full w-full object-contain"
|
<img
|
||||||
draggable={false}
|
src={snapshotFeed.objectUrl}
|
||||||
/>
|
alt={label}
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-sm text-slate-300">
|
||||||
|
Waiting for frame…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-slate-300">
|
<video
|
||||||
Waiting for frame…
|
ref={videoRef}
|
||||||
|
muted={forceMute || muted || hasDedicatedAudio}
|
||||||
|
playsInline
|
||||||
|
autoPlay
|
||||||
|
controls={false}
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<audio ref={audioRef} autoPlay hidden />
|
||||||
|
{!noHud && showTurnCue ? (
|
||||||
|
<TurnCueOverlay
|
||||||
|
mobileHud={mobileHud}
|
||||||
|
isActiveDriver={isActiveDriver}
|
||||||
|
idleSkipSeconds={idleSkipSeconds}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!noHud ? (
|
||||||
|
<HudOverlay
|
||||||
|
sensors={sensors}
|
||||||
|
label={label}
|
||||||
|
roverColor={roverColor}
|
||||||
|
status={renderedStatus}
|
||||||
|
audioStatus={renderedAudioStatus}
|
||||||
|
levelStatus={levelIndicator}
|
||||||
|
layoutFormat={layoutFormat}
|
||||||
|
variant={hudVariant}
|
||||||
|
driverLabel={driverLabel}
|
||||||
|
showTopDown={showHudMap}
|
||||||
|
mobileHud={mobileHud}
|
||||||
|
mapPosition={effectiveHudMapPosition}
|
||||||
|
turnTimerText={turnTimerText}
|
||||||
|
labelScale={hudLabelScale}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!noHud ? <HudChatInput compact={mobileHud} /> : null}
|
||||||
|
{!noHud && debugHud ? (
|
||||||
|
<div className="pointer-events-none absolute left-1 top-1 z-40 rounded bg-black/80 px-1 py-0.5 text-[0.6rem] text-lime-200">
|
||||||
|
{`OC vis:${overlayState.visible ? 1 : 0} motors:${overlayState.motors.length} fill:${Math.round(
|
||||||
|
overlayState.fill * 100,
|
||||||
|
)}%`}
|
||||||
</div>
|
</div>
|
||||||
)
|
) : null}
|
||||||
) : (
|
{!noHud ? <OvercurrentOverlay motors={overlayState.motors} fill={overlayState.fill} compact={mobileHud} /> : null}
|
||||||
<video
|
{!noHud ? <LowBatteryOverlay battery={batteryVisual} compact={mobileHud} /> : null}
|
||||||
ref={videoRef}
|
{!noHud && showVerticalBattery && batteryVisual.available ? (
|
||||||
muted={forceMute || muted || hasDedicatedAudio}
|
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded bg-black/60 px-0.5 pb-1 pt-1">
|
||||||
playsInline
|
<BatteryBar
|
||||||
autoPlay
|
visual={batteryVisual}
|
||||||
controls={false}
|
orientation="vertical"
|
||||||
className="h-full w-full object-contain"
|
variant="inline"
|
||||||
/>
|
compact={mobileHud}
|
||||||
)}
|
className="h-full w-4"
|
||||||
{showConnectingOverlay ? (
|
/>
|
||||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-black/45">
|
|
||||||
<div className="rounded border border-slate-500/70 bg-black/70 px-3 py-1 text-sm font-semibold text-slate-100">
|
|
||||||
Connecting to video....
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
{!noHud && qualityNotice ? (
|
||||||
<audio ref={audioRef} autoPlay hidden />
|
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2">
|
||||||
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
|
<div
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
className={`mx-auto w-fit rounded border border-amber-300/80 bg-black/75 text-amber-200 ${
|
||||||
<span>Status: {renderedStatus}</span>
|
mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'
|
||||||
{renderedAudioStatus ? <span>Audio: {renderedAudioStatus}</span> : null}
|
}`}
|
||||||
</div>
|
>
|
||||||
|
<div className="text-center">{qualityNotice}</div>
|
||||||
|
<div className="pointer-events-auto mt-0">
|
||||||
|
<SocialButton
|
||||||
|
id="discord"
|
||||||
|
label="Join our Discord server while you wait!"
|
||||||
|
url={discordUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
{!noHud && !showVerticalBattery && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<LightBumpBars sensors={sensors} />
|
||||||
|
<div className="panel-section space-y-0.5 text-sm">
|
||||||
|
<BatteryBar visual={batteryVisual} compact={mobileHud} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ export function useOvercurrentLimiter(roverId, options = {}) {
|
|||||||
return { motors, groups };
|
return { motors, groups };
|
||||||
}, [overcurrentFlags]);
|
}, [overcurrentFlags]);
|
||||||
|
|
||||||
const adminImmune = role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
const adminImmune = role === 'admin' || role === 'lockdown';
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
|
||||||
|
|
||||||
export function useDriverVideoModePolicy(roverId) {
|
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
|
||||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
|
||||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
|
||||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
|
|
||||||
const turnInfo = roverId ? turnQueues?.[roverId] || null : null;
|
|
||||||
const activeDriverId = roverId ? activeDrivers?.[roverId] || null : null;
|
|
||||||
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
|
||||||
const nextDriverId = useMemo(() => {
|
|
||||||
const queue = turnInfo?.queue || [];
|
|
||||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
|
||||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
|
||||||
if (idx === -1) return queue[0] || null;
|
|
||||||
return queue[(idx + 1) % queue.length] || null;
|
|
||||||
}, [turnInfo?.queue, turnInfo?.current]);
|
|
||||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
|
||||||
const deadline = turnInfo?.deadline || null;
|
|
||||||
const msUntilTurn = deadline ? deadline - now : null;
|
|
||||||
const isTurnsMode = mode === 'turns';
|
|
||||||
const totalRovers = roster.length;
|
|
||||||
const totalDrivers = useMemo(() => {
|
|
||||||
const unique = new Set();
|
|
||||||
users.forEach((entry) => {
|
|
||||||
const role = String(entry?.role || '');
|
|
||||||
if (role === 'spectator') return;
|
|
||||||
const turnRoverId = String(entry?.roverId || '').trim();
|
|
||||||
const turnSocketId = String(entry?.socketId || '').trim();
|
|
||||||
if (!turnRoverId || !turnSocketId) return;
|
|
||||||
unique.add(turnSocketId);
|
|
||||||
});
|
|
||||||
return unique.size;
|
|
||||||
}, [users]);
|
|
||||||
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
|
|
||||||
const isPreSwitchWindow =
|
|
||||||
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
|
||||||
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
|
||||||
const forceSnapshotByTurnPolicy = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== 'turns') return undefined;
|
|
||||||
const timer = setInterval(() => setNow(Date.now()), 250);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
return forceSnapshotByTurnPolicy ? 'snapshot' : null;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Hook: useVideoRequests
|
// Hook: useVideoRequests
|
||||||
// Purpose: Coordinates client-side video stream request intents and authorization timing. Scope: Provides reusable request helpers for rover and room video consumers.
|
// Purpose: Coordinates client-side video stream request intents and authorization timing. Scope: Provides reusable request helpers for rover video consumers.
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSocket } from '../context/SocketContext.jsx';
|
import { useSocket } from '../context/SocketContext.jsx';
|
||||||
|
|
||||||
@@ -13,12 +13,9 @@ function normalizeEntry(entry) {
|
|||||||
if (typeof entry === 'object') {
|
if (typeof entry === 'object') {
|
||||||
if (entry.type && entry.id) {
|
if (entry.type && entry.id) {
|
||||||
const id = String(entry.id);
|
const id = String(entry.id);
|
||||||
let key = entry.key;
|
const key = entry.key || id;
|
||||||
if (!key) {
|
|
||||||
key = entry.type === 'room' ? `room:${id}` : id;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
type: entry.type,
|
type: 'rover',
|
||||||
id,
|
id,
|
||||||
key,
|
key,
|
||||||
};
|
};
|
||||||
@@ -27,10 +24,6 @@ function normalizeEntry(entry) {
|
|||||||
const id = String(entry.roverId);
|
const id = String(entry.roverId);
|
||||||
return { type: 'rover', id, key: entry.key || id };
|
return { type: 'rover', id, key: entry.key || id };
|
||||||
}
|
}
|
||||||
if (entry.roomCameraId) {
|
|
||||||
const id = String(entry.roomCameraId);
|
|
||||||
return { type: 'room', id, key: entry.key || `room:${id}` };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -87,7 +80,7 @@ export function useVideoRequests(sourceList = [], options = {}) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
function requestEntry(entry) {
|
function requestEntry(entry) {
|
||||||
const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
|
const payload = { roverId: entry.id };
|
||||||
socket.emit('video:request', payload, (resp = {}) => {
|
socket.emit('video:request', payload, (resp = {}) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSources((prev) => ({ ...prev, [entry.key]: resp }));
|
setSources((prev) => ({ ...prev, [entry.key]: resp }));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
|||||||
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
||||||
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
||||||
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||||
import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx';
|
import VideoTile from '../../components/VideoTile/index.jsx';
|
||||||
import FitViewportFrame from './components/FitViewportFrame.jsx';
|
import FitViewportFrame from './components/FitViewportFrame.jsx';
|
||||||
import InfoColumn from './components/InfoColumn.jsx';
|
import InfoColumn from './components/InfoColumn.jsx';
|
||||||
import { ROTATE_MS } from './constants.js';
|
import { ROTATE_MS } from './constants.js';
|
||||||
@@ -163,27 +163,37 @@ export default function MiniSummaryContent() {
|
|||||||
key={rover.id}
|
key={rover.id}
|
||||||
className={`absolute inset-0 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
className={`absolute inset-0 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
||||||
>
|
>
|
||||||
<RoverMediaPlayer
|
<VideoTile
|
||||||
sessionInfo={videoSources[rover.id] || null}
|
sessionInfo={videoSources[rover.id] || null}
|
||||||
videoMode="whep"
|
videoMode="whep"
|
||||||
snapshotFeed={null}
|
snapshotFeed={null}
|
||||||
audioSessionInfo={isActive ? activeAudio : null}
|
audioSessionInfo={isActive ? activeAudio : null}
|
||||||
forceMute={!isActive}
|
forceMute={!isActive}
|
||||||
label={rover.name || rover.id}
|
label={rover.name || rover.id}
|
||||||
sensors={frames[rover.id]?.sensors || null}
|
roverColor={rover.color || null}
|
||||||
|
telemetryFrame={frames[rover.id] || null}
|
||||||
|
batteryConfig={rover.battery}
|
||||||
|
layoutFormat="mobile"
|
||||||
|
hudVariant="none"
|
||||||
|
fitParent
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<RoverMediaPlayer
|
<VideoTile
|
||||||
sessionInfo={null}
|
sessionInfo={null}
|
||||||
videoMode="snapshot"
|
videoMode="snapshot"
|
||||||
snapshotFeed={activeSnapshot}
|
snapshotFeed={activeSnapshot}
|
||||||
audioSessionInfo={activeAudio}
|
audioSessionInfo={activeAudio}
|
||||||
label={activeRover.name || activeRover.id}
|
label={activeRover.name || activeRover.id}
|
||||||
sensors={activeFrame?.sensors || null}
|
roverColor={activeRover.color || null}
|
||||||
|
telemetryFrame={activeFrame}
|
||||||
|
batteryConfig={activeRover.battery}
|
||||||
|
layoutFormat="mobile"
|
||||||
|
hudVariant="none"
|
||||||
|
fitParent
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FitViewportFrame>
|
</FitViewportFrame>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Info Column
|
// Info Column
|
||||||
// Purpose: Defines the Info Column module and the local helpers/components used in this file.
|
// Purpose: Defines the Info Column module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import RoverMediaPlayer from '../../../components/RoverMediaPlayer/index.jsx';
|
import VideoTile from '../../../components/VideoTile/index.jsx';
|
||||||
import BatteryBar from '../../../components/BatteryBar/index.jsx';
|
import BatteryBar from '../../../components/BatteryBar/index.jsx';
|
||||||
import { roverNameChromeStyle } from '../../../lib/roverColor.js';
|
import { roverNameChromeStyle } from '../../../lib/roverColor.js';
|
||||||
import { getBatteryVisual } from '../utils.js';
|
import { getBatteryVisual } from '../utils.js';
|
||||||
@@ -95,13 +95,18 @@ export default function InfoColumn({
|
|||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
<div className="mt-auto w-full">
|
<div className="mt-auto w-full">
|
||||||
<div className="w-full aspect-[4/3]">
|
<div className="w-full aspect-[4/3]">
|
||||||
<RoverMediaPlayer
|
<VideoTile
|
||||||
sessionInfo={sessionInfo}
|
sessionInfo={sessionInfo}
|
||||||
videoMode={videoMode}
|
videoMode={videoMode}
|
||||||
snapshotFeed={snapshotFeed}
|
snapshotFeed={snapshotFeed}
|
||||||
audioSessionInfo={null}
|
audioSessionInfo={null}
|
||||||
label={rover.name || rover.id}
|
label={rover.name || rover.id}
|
||||||
sensors={frame?.sensors || null}
|
roverColor={rover.color || null}
|
||||||
|
telemetryFrame={frame}
|
||||||
|
batteryConfig={rover.battery}
|
||||||
|
layoutFormat="mobile"
|
||||||
|
hudVariant="none"
|
||||||
|
fitParent
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const INPUT_SETTINGS_DEFAULTS = {
|
|||||||
baseSpeed: 250,
|
baseSpeed: 250,
|
||||||
turboSpeed: 400,
|
turboSpeed: 400,
|
||||||
precisionSpeed: 125,
|
precisionSpeed: 125,
|
||||||
tiltSpeed: 90,
|
tiltSpeed: 100,
|
||||||
tiltIntervalMs: 110,
|
tiltIntervalMs: 110,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
// Purpose: Defines the Spectator Content module and the local helpers/components used in this file.
|
// Purpose: Defines the Spectator Content module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useSession } from '../../context/SessionContext.jsx';
|
import { useSession } from '../../context/SessionContext.jsx';
|
||||||
|
import { useTelemetryFrames } from '../../context/TelemetryContext.jsx';
|
||||||
|
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||||
|
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
||||||
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
||||||
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||||
import ChatPanel from '../../components/ChatPanel/index.jsx';
|
import ChatPanel from '../../components/ChatPanel/index.jsx';
|
||||||
@@ -19,10 +22,29 @@ import LogsRow from './components/LogsRow.jsx';
|
|||||||
export default function SpectatorContent() {
|
export default function SpectatorContent() {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const inLockdown = session?.mode === 'lockdown';
|
const inLockdown = session?.mode === 'lockdown';
|
||||||
|
const canSpectateVideo = Boolean(session?.isLocalNetwork);
|
||||||
useDefaultNickname();
|
useDefaultNickname();
|
||||||
useSpectatorMode();
|
useSpectatorMode();
|
||||||
const isPortraitLayout = usePortraitLayout();
|
const isPortraitLayout = usePortraitLayout();
|
||||||
|
const frames = useTelemetryFrames();
|
||||||
const roster = session?.roster ?? [];
|
const roster = session?.roster ?? [];
|
||||||
|
const snapshotFeeds = useRoverSnapshots(
|
||||||
|
roster.map((rover) => rover.id),
|
||||||
|
{ enabled: !inLockdown && !canSpectateVideo, version: session?.mode },
|
||||||
|
);
|
||||||
|
const videoEntries = canSpectateVideo
|
||||||
|
? roster.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id }))
|
||||||
|
: [];
|
||||||
|
const videoSources = useVideoRequests(videoEntries, {
|
||||||
|
enabled: !inLockdown && canSpectateVideo,
|
||||||
|
version: session?.mode,
|
||||||
|
});
|
||||||
|
const audioEntries = roster.flatMap((rover) =>
|
||||||
|
rover.media?.audioPublishUrl
|
||||||
|
? [{ type: 'rover', id: `${rover.id}-audio`, key: `${rover.id}-audio` }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
|
||||||
|
|
||||||
if (inLockdown) {
|
if (inLockdown) {
|
||||||
return (
|
return (
|
||||||
@@ -83,7 +105,15 @@ export default function SpectatorContent() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className={contentClass}>
|
<section className={contentClass}>
|
||||||
<RoverRow roster={roster} />
|
<RoverRow
|
||||||
|
roster={roster}
|
||||||
|
frames={frames}
|
||||||
|
videoSources={videoSources}
|
||||||
|
snapshotFeeds={snapshotFeeds}
|
||||||
|
audioSources={audioSources}
|
||||||
|
session={session}
|
||||||
|
canSpectateVideo={canSpectateVideo}
|
||||||
|
/>
|
||||||
<SecondaryRow />
|
<SecondaryRow />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,14 +3,25 @@
|
|||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import RoverSpectatorCard from './RoverSpectatorCard.jsx';
|
import RoverSpectatorCard from './RoverSpectatorCard.jsx';
|
||||||
|
|
||||||
export default function RoverRow({ roster }) {
|
export default function RoverRow({ roster, frames, videoSources, snapshotFeeds, audioSources, session, canSpectateVideo }) {
|
||||||
if (roster.length === 0) {
|
if (roster.length === 0) {
|
||||||
return <p className="col-span-full text-slate-400">No rovers registered.</p>;
|
return <p className="col-span-full text-slate-400">No rovers registered.</p>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<section className="grid grid-cols-1 gap-0.5 md:grid-cols-2">
|
<section className="grid grid-cols-1 gap-0.5 md:grid-cols-2">
|
||||||
{roster.map((rover) => (
|
{roster.map((rover) => (
|
||||||
<RoverSpectatorCard key={rover.id} rover={rover} />
|
<RoverSpectatorCard
|
||||||
|
key={rover.id}
|
||||||
|
rover={rover}
|
||||||
|
frame={frames[rover.id]}
|
||||||
|
sessionInfo={canSpectateVideo ? videoSources[rover.id] || null : null}
|
||||||
|
videoMode={canSpectateVideo ? 'whep' : 'snapshot'}
|
||||||
|
snapshotFeed={canSpectateVideo ? null : snapshotFeeds[rover.id]}
|
||||||
|
audioInfo={audioSources[`${rover.id}-audio`]}
|
||||||
|
session={session}
|
||||||
|
showHudMap
|
||||||
|
hudMapPosition="bottom-left"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
// Rover Spectator Card
|
// Rover Spectator Card
|
||||||
// Purpose: Defines the Rover Spectator Card module and the local helpers/components used in this file.
|
// Purpose: Defines the Rover Spectator Card module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import SpectateVideo from '../../../components/SpectateVideo/index.jsx';
|
import VideoTile from '../../../components/VideoTile/index.jsx';
|
||||||
|
import { formatDriverLabel } from '../utils.js';
|
||||||
|
|
||||||
export default function RoverSpectatorCard({ rover }) {
|
export default function RoverSpectatorCard({ rover, frame, sessionInfo, videoMode, snapshotFeed, audioInfo, session }) {
|
||||||
|
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
|
||||||
return (
|
return (
|
||||||
<article className="min-h-[16rem] rounded bg-zinc-900 p-0 sm:min-h-[18rem]">
|
<article className="min-h-[16rem] rounded bg-zinc-900 p-0 sm:min-h-[18rem]">
|
||||||
<div className="min-h-0 overflow-hidden rounded bg-black/20">
|
<div className="min-h-0 overflow-hidden rounded bg-black/20">
|
||||||
<SpectateVideo
|
<VideoTile
|
||||||
roverId={rover.id}
|
sessionInfo={sessionInfo}
|
||||||
|
videoMode={videoMode}
|
||||||
|
snapshotFeed={snapshotFeed}
|
||||||
|
audioSessionInfo={audioInfo}
|
||||||
label={rover.name}
|
label={rover.name}
|
||||||
|
roverColor={rover.color || null}
|
||||||
|
telemetryFrame={frame}
|
||||||
|
batteryConfig={rover.battery}
|
||||||
|
hudVariant="spectator"
|
||||||
|
driverLabel={driverLabel}
|
||||||
|
hudForceMap
|
||||||
|
hudMapPosition="top-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
Reference in New Issue
Block a user