mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87dd9a24a0 | ||
|
|
bdcb488988 | ||
|
|
9e6053d5c0 | ||
|
|
4d699e1c4c | ||
|
|
5e7581c82d | ||
|
|
f7e4269959 | ||
|
|
f3f1db1398 | ||
|
|
133c061ee9 | ||
|
|
6859a6c048 | ||
|
|
465bddb9dd | ||
|
|
c7a639a568 | ||
|
|
df952b27e0 | ||
|
|
36f28a9708 |
Vendored
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
#configuration for roverd
|
#configuration for roverd
|
||||||
name: dummy2
|
name: dummy2
|
||||||
serverUrl: ws://192.168.0.84:8080/rover
|
serverUrl: ws://127.0.0.1:8080/rover
|
||||||
serial:
|
serial:
|
||||||
device: /dev/ttyS0
|
device: /dev/ttyS0
|
||||||
baud: 115200
|
baud: 115200
|
||||||
@@ -18,3 +18,5 @@ media:
|
|||||||
service: mediamtx.service
|
service: mediamtx.service
|
||||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||||
healthInterval: 30s
|
healthInterval: 30s
|
||||||
|
nightVision:
|
||||||
|
enabled: false
|
||||||
|
|||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#configuration for roverd
|
||||||
|
name: dummy3
|
||||||
|
serverUrl: ws://127.0.0.1:8080/rover
|
||||||
|
serial:
|
||||||
|
device: /dev/ttyS0
|
||||||
|
baud: 115200
|
||||||
|
brc:
|
||||||
|
gpioPin: 25
|
||||||
|
pulseEvery: 1m
|
||||||
|
pulseWidth: 1s
|
||||||
|
battery:
|
||||||
|
full: 2068
|
||||||
|
warn: 1700
|
||||||
|
urgent: 1650
|
||||||
|
maxWheelSpeed: 350
|
||||||
|
media:
|
||||||
|
manage: false
|
||||||
|
service: mediamtx.service
|
||||||
|
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||||
|
healthInterval: 30s
|
||||||
|
nightVision:
|
||||||
|
enabled: false
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Disable history expansion so PUBLISH_URL values with "!" are safe when sourcing env files.
|
|
||||||
|
# Keep history expansion off so values containing "!" are safe.
|
||||||
set +H
|
set +H
|
||||||
|
|
||||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
||||||
@@ -10,17 +11,76 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# shellcheck disable=SC1090
|
# Load KEY=VALUE pairs from ENV_FILE WITHOUT evaluating shell metacharacters.
|
||||||
source "$ENV_FILE"
|
# This makes URLs containing characters like '&' and '#!' safe without requiring quoting.
|
||||||
|
load_env_file() {
|
||||||
|
local content=""
|
||||||
|
|
||||||
|
if [[ -r "$ENV_FILE" ]]; then
|
||||||
|
content="$(cat "$ENV_FILE")"
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then
|
||||||
|
# Try to read via sudo without prompting (useful when the service runs as an unprivileged user)
|
||||||
|
content="$(sudo -n cat "$ENV_FILE" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$content" ]]; then
|
||||||
|
echo "Cannot read ${ENV_FILE} (permission denied). Run as a user that can read it, or allow sudo -n for cat." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local line key val
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
# Skip blank lines and full-line comments.
|
||||||
|
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||||
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
|
||||||
|
# Support optional leading 'export '
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*export[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||||
|
key="${BASH_REMATCH[1]}"
|
||||||
|
val="${BASH_REMATCH[2]}"
|
||||||
|
elif [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||||
|
key="${BASH_REMATCH[1]}"
|
||||||
|
val="${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
# Ignore anything that isn't a simple assignment.
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trim leading/trailing whitespace in value.
|
||||||
|
val="${val#${val%%[![:space:]]*}}"
|
||||||
|
val="${val%${val##*[![:space:]]}}"
|
||||||
|
|
||||||
|
# If value is wrapped in matching single or double quotes, unwrap.
|
||||||
|
if [[ "$val" =~ ^\".*\"$ ]]; then
|
||||||
|
val="${val:1:${#val}-2}"
|
||||||
|
elif [[ "$val" =~ ^\'.*\'$ ]]; then
|
||||||
|
val="${val:1:${#val}-2}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Assign without evaluation.
|
||||||
|
printf -v "$key" '%s' "$val"
|
||||||
|
export "$key"
|
||||||
|
done <<< "$content"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_file
|
||||||
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
||||||
|
|
||||||
VIDEO_WIDTH="${VIDEO_WIDTH:-1920}"
|
# Defaults tuned for OV5647: use 4:3 output and force the common 2x2 binned full-FOV mode.
|
||||||
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
|
VIDEO_WIDTH="640"
|
||||||
VIDEO_FPS="${VIDEO_FPS:-30}"
|
VIDEO_HEIGHT="480"
|
||||||
|
VIDEO_FPS="30"
|
||||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
||||||
|
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
|
||||||
|
|
||||||
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
||||||
FLIP_ARGS=(--rotation 180)
|
FLIP_ARGS=(--rotation 180)
|
||||||
|
|
||||||
|
MODE_ARGS=()
|
||||||
|
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
|
||||||
|
MODE_ARGS=(--mode "${VIDEO_SENSOR_MODE}")
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
|
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
|
||||||
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
|
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
|
||||||
elif command -v rpicam-vid >/dev/null 2>&1; then
|
elif command -v rpicam-vid >/dev/null 2>&1; then
|
||||||
@@ -45,6 +105,7 @@ run_pipeline() {
|
|||||||
"${LIBCAMERA_BIN_PATH}" \
|
"${LIBCAMERA_BIN_PATH}" \
|
||||||
--inline \
|
--inline \
|
||||||
--timeout 0 \
|
--timeout 0 \
|
||||||
|
"${MODE_ARGS[@]}" \
|
||||||
--width "${VIDEO_WIDTH}" \
|
--width "${VIDEO_WIDTH}" \
|
||||||
--height "${VIDEO_HEIGHT}" \
|
--height "${VIDEO_HEIGHT}" \
|
||||||
"${FLIP_ARGS[@]}" \
|
"${FLIP_ARGS[@]}" \
|
||||||
|
|||||||
@@ -215,9 +215,6 @@ cat > /var/lib/roverd/video.env <<'ENV'
|
|||||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||||
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
VIDEO_WIDTH=1280
|
|
||||||
VIDEO_HEIGHT=720
|
|
||||||
VIDEO_FPS=30
|
|
||||||
VIDEO_BITRATE=2000000
|
VIDEO_BITRATE=2000000
|
||||||
AUDIO_ENABLE=0
|
AUDIO_ENABLE=0
|
||||||
AUDIO_DEVICE=hw:0,0
|
AUDIO_DEVICE=hw:0,0
|
||||||
|
|||||||
@@ -136,9 +136,6 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
Media: MediaConfig{
|
Media: MediaConfig{
|
||||||
PublishPort: 9000,
|
PublishPort: 9000,
|
||||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||||
VideoWidth: 1280,
|
|
||||||
VideoHeight: 720,
|
|
||||||
VideoFPS: 30,
|
|
||||||
VideoBitrate: 2000000,
|
VideoBitrate: 2000000,
|
||||||
},
|
},
|
||||||
CameraServo: CameraServoConfig{
|
CameraServo: CameraServoConfig{
|
||||||
@@ -197,15 +194,6 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
|
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
|
||||||
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
|
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
|
||||||
}
|
}
|
||||||
if cfg.Media.VideoWidth <= 0 {
|
|
||||||
cfg.Media.VideoWidth = 1280
|
|
||||||
}
|
|
||||||
if cfg.Media.VideoHeight <= 0 {
|
|
||||||
cfg.Media.VideoHeight = 720
|
|
||||||
}
|
|
||||||
if cfg.Media.VideoFPS <= 0 {
|
|
||||||
cfg.Media.VideoFPS = 30
|
|
||||||
}
|
|
||||||
if cfg.Media.VideoBitrate <= 0 {
|
if cfg.Media.VideoBitrate <= 0 {
|
||||||
cfg.Media.VideoBitrate = 3000000
|
cfg.Media.VideoBitrate = 3000000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ 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.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
|
||||||
return fmt.Errorf("invalid media dimensions/bitrate")
|
return fmt.Errorf("invalid media dimensions/bitrate")
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
||||||
@@ -27,9 +27,15 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
|||||||
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
||||||
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
||||||
}
|
}
|
||||||
|
if media.VideoWidth > 0 {
|
||||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
||||||
|
}
|
||||||
|
if media.VideoHeight > 0 {
|
||||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
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_FPS=%d\n", media.VideoFPS)
|
||||||
|
}
|
||||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
||||||
audioDevice := audio.CaptureDevice
|
audioDevice := audio.CaptureDevice
|
||||||
if audioDevice == "" || audioDevice == "rovermic" {
|
if audioDevice == "" || audioDevice == "rovermic" {
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ maxWheelSpeed: 350
|
|||||||
media:
|
media:
|
||||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||||
publishPort: 9000
|
publishPort: 9000
|
||||||
videoWidth: 1280
|
|
||||||
videoHeight: 720
|
|
||||||
videoFps: 30
|
|
||||||
videoBitrate: 2000000
|
videoBitrate: 2000000
|
||||||
manage: true
|
manage: true
|
||||||
service: video-publisher.service
|
service: video-publisher.service
|
||||||
|
|||||||
@@ -13,15 +13,6 @@ media:
|
|||||||
# http://<base>/<roverId>/whep
|
# http://<base>/<roverId>/whep
|
||||||
# Example: http://192.168.0.86:8889/video
|
# Example: http://192.168.0.86:8889/video
|
||||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
whepBaseUrl: "http://192.168.0.86:8889/video"
|
||||||
preview:
|
|
||||||
enabled: false
|
|
||||||
codec: "av1"
|
|
||||||
transport: "rtsp"
|
|
||||||
fps: 10
|
|
||||||
width: 640
|
|
||||||
roomBitrateKbps: 200
|
|
||||||
roverBitrateKbps: 350
|
|
||||||
gopSeconds: 2
|
|
||||||
|
|
||||||
homeAssistant:
|
homeAssistant:
|
||||||
url: "http://homeassistant.local:8123"
|
url: "http://homeassistant.local:8123"
|
||||||
|
|||||||
@@ -30,6 +30,5 @@ require('./src/services/sessionService');
|
|||||||
require('./src/services/batteryManager');
|
require('./src/services/batteryManager');
|
||||||
require('./src/services/replaySocketService');
|
require('./src/services/replaySocketService');
|
||||||
require('./src/services/replaySegmentManager');
|
require('./src/services/replaySegmentManager');
|
||||||
require('./src/services/media/previewTranscoderService');
|
|
||||||
require('./src/services/discordBotService');
|
require('./src/services/discordBotService');
|
||||||
require('./src/services/httpServer');
|
require('./src/services/httpServer');
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ metricsAddress: 0.0.0.0:9998
|
|||||||
pprof: no
|
pprof: no
|
||||||
pprofAddress: 127.0.0.1:9999
|
pprofAddress: 127.0.0.1:9999
|
||||||
|
|
||||||
rtsp: yes
|
rtsp: no
|
||||||
rtmp: no
|
rtmp: no
|
||||||
hls: no
|
hls: no
|
||||||
|
|
||||||
|
|||||||
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="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-B7raLs13.js"></script>
|
<script type="module" crossorigin src="/assets/index-BxYZsNCL.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Fk2eqSbH.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BjSf29Wv.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ function maybeSpeak(socket, message, ttsOptions) {
|
|||||||
const audio = record?.meta?.audio || {};
|
const audio = record?.meta?.audio || {};
|
||||||
const ttsEnabled = Boolean(audio.ttsEnabled);
|
const ttsEnabled = Boolean(audio.ttsEnabled);
|
||||||
if (!ttsEnabled) return;
|
if (!ttsEnabled) return;
|
||||||
// if (!roverManager.canDrive(message.roverId, socket)) return;
|
if (!roverManager.canDrive(message.roverId, socket)) return;
|
||||||
try {
|
try {
|
||||||
issueCommand(message.roverId, {
|
issueCommand(message.roverId, {
|
||||||
type: 'tts',
|
type: 'tts',
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
const { spawn } = require('child_process');
|
|
||||||
const EventEmitter = require('events');
|
|
||||||
const logger = require('../../globals/logger').child('previewTranscoder');
|
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
|
||||||
const roverManager = require('../roverManager');
|
|
||||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
|
||||||
|
|
||||||
const events = new EventEmitter();
|
|
||||||
const config = loadConfig();
|
|
||||||
const mediaConfig = config.media || {};
|
|
||||||
const previewConfig = mediaConfig.preview || {};
|
|
||||||
|
|
||||||
const ENABLED = Boolean(previewConfig.enabled);
|
|
||||||
const PREVIEW_CODEC = String(previewConfig.codec || 'av1').toLowerCase();
|
|
||||||
const PREVIEW_TRANSPORT = String(previewConfig.transport || 'rtsp').toLowerCase();
|
|
||||||
const PREVIEW_FPS = Number(previewConfig.fps || 10);
|
|
||||||
const PREVIEW_WIDTH = Number(previewConfig.width || 640);
|
|
||||||
const ROOM_BITRATE_KBPS = Number(previewConfig.roomBitrateKbps || 200);
|
|
||||||
const ROVER_BITRATE_KBPS = Number(previewConfig.roverBitrateKbps || 350);
|
|
||||||
const PRESET = Number.isFinite(previewConfig.preset) ? String(previewConfig.preset) : '8';
|
|
||||||
const GOP_SECONDS = Number(previewConfig.gopSeconds || 2);
|
|
||||||
const FFMPEG_BIN = previewConfig.ffmpegBin || process.env.FFMPEG_BIN || 'ffmpeg';
|
|
||||||
|
|
||||||
const recorders = new Map(); // key -> { proc, source }
|
|
||||||
let syncTimer = null;
|
|
||||||
|
|
||||||
function encodeStreamId(streamId) {
|
|
||||||
return encodeURIComponent(streamId).replace(/%2F/g, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSrtReadUrl(streamId) {
|
|
||||||
return `srt://127.0.0.1:9000?streamid=read:${encodeStreamId(streamId)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSrtPublishUrl(streamId) {
|
|
||||||
const encoded = encodeStreamId(streamId);
|
|
||||||
return `srt://127.0.0.1:9000?streamid=#!::r=${encoded},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRtspPublishUrl(streamId) {
|
|
||||||
return `rtsp://127.0.0.1:8554/${streamId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeCodec(codec) {
|
|
||||||
return String(codec || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'av1';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPreviewId(id, codec) {
|
|
||||||
return `${id}-preview-${sanitizeCodec(codec)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRoomCameraStream(camera) {
|
|
||||||
if (camera.streamUrl) return camera.streamUrl;
|
|
||||||
const url = String(camera.url || '');
|
|
||||||
if (url.includes('.mjpg') || url.includes('mjpeg') || url.includes('stream')) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listSources() {
|
|
||||||
const rooms = getRoomCameras()
|
|
||||||
.map((camera) => {
|
|
||||||
const streamUrl = getRoomCameraStream(camera);
|
|
||||||
if (!streamUrl) return null;
|
|
||||||
const id = String(camera.id);
|
|
||||||
return {
|
|
||||||
type: 'room',
|
|
||||||
id,
|
|
||||||
label: camera.name || id,
|
|
||||||
inputUrl: streamUrl,
|
|
||||||
outputId: `room/${buildPreviewId(id, PREVIEW_CODEC)}`,
|
|
||||||
bitrateKbps: ROOM_BITRATE_KBPS,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
const rovers = roverManager.getRoster().map((rover) => {
|
|
||||||
const id = String(rover.id);
|
|
||||||
return {
|
|
||||||
type: 'rover',
|
|
||||||
id,
|
|
||||||
label: rover.name || id,
|
|
||||||
inputUrl: buildSrtReadUrl(id),
|
|
||||||
outputId: buildPreviewId(id, PREVIEW_CODEC),
|
|
||||||
bitrateKbps: ROVER_BITRATE_KBPS,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return [...rooms, ...rovers];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildKey(source) {
|
|
||||||
return `${source.type}:${source.id}:${source.outputId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildArgs(source) {
|
|
||||||
const gop = Math.max(1, Math.round(GOP_SECONDS * PREVIEW_FPS));
|
|
||||||
const maxrate = Math.floor(source.bitrateKbps * 1.1);
|
|
||||||
const bufsize = Math.max(1, source.bitrateKbps * 2);
|
|
||||||
const codec = PREVIEW_CODEC === 'av1' ? 'libsvtav1' : 'libx264';
|
|
||||||
const extraCodecArgs =
|
|
||||||
codec === 'libx264'
|
|
||||||
? ['-tune', 'zerolatency', '-profile:v', 'baseline', '-sc_threshold', '0']
|
|
||||||
: [];
|
|
||||||
const outputUrl =
|
|
||||||
PREVIEW_TRANSPORT === 'rtsp' ? buildRtspPublishUrl(source.outputId) : buildSrtPublishUrl(source.outputId);
|
|
||||||
const outputArgs =
|
|
||||||
PREVIEW_TRANSPORT === 'rtsp'
|
|
||||||
? ['-f', 'rtsp', '-rtsp_transport', 'tcp']
|
|
||||||
: ['-f', 'mpegts'];
|
|
||||||
return {
|
|
||||||
outputUrl,
|
|
||||||
args: [
|
|
||||||
'-hide_banner',
|
|
||||||
'-loglevel',
|
|
||||||
'info',
|
|
||||||
'-fflags',
|
|
||||||
'nobuffer',
|
|
||||||
'-flags',
|
|
||||||
'low_delay',
|
|
||||||
'-i',
|
|
||||||
source.inputUrl,
|
|
||||||
'-an',
|
|
||||||
'-vf',
|
|
||||||
`fps=${PREVIEW_FPS},scale=${PREVIEW_WIDTH}:-1`,
|
|
||||||
'-c:v',
|
|
||||||
codec,
|
|
||||||
'-preset',
|
|
||||||
PRESET,
|
|
||||||
...extraCodecArgs,
|
|
||||||
'-g',
|
|
||||||
String(gop),
|
|
||||||
'-keyint_min',
|
|
||||||
String(gop),
|
|
||||||
'-b:v',
|
|
||||||
`${source.bitrateKbps}k`,
|
|
||||||
'-maxrate',
|
|
||||||
`${maxrate}k`,
|
|
||||||
'-bufsize',
|
|
||||||
`${bufsize}k`,
|
|
||||||
'-pix_fmt',
|
|
||||||
'yuv420p',
|
|
||||||
...outputArgs,
|
|
||||||
outputUrl,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnRecorder(source) {
|
|
||||||
const key = buildKey(source);
|
|
||||||
if (recorders.has(key)) return;
|
|
||||||
const { args, outputUrl } = buildArgs(source);
|
|
||||||
logger.info('Preview transcoder starting', {
|
|
||||||
key,
|
|
||||||
transport: PREVIEW_TRANSPORT,
|
|
||||||
codec: PREVIEW_CODEC,
|
|
||||||
inputUrl: source.inputUrl,
|
|
||||||
outputUrl,
|
|
||||||
});
|
|
||||||
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
||||||
const stderrChunks = [];
|
|
||||||
let stderrSize = 0;
|
|
||||||
proc.stderr.on('data', (chunk) => {
|
|
||||||
if (!chunk || stderrSize > 8192) return;
|
|
||||||
stderrChunks.push(chunk);
|
|
||||||
stderrSize += chunk.length;
|
|
||||||
});
|
|
||||||
recorders.set(key, { proc, source });
|
|
||||||
proc.on('exit', (code, signal) => {
|
|
||||||
recorders.delete(key);
|
|
||||||
const stderrBuffer = stderrChunks.length ? Buffer.concat(stderrChunks) : null;
|
|
||||||
if (stderrBuffer && stderrBuffer.length) {
|
|
||||||
const preview = stderrBuffer.toString('utf8', 0, 600).trim();
|
|
||||||
logger.warn('Preview transcoder stderr', {
|
|
||||||
key,
|
|
||||||
stderrBytes: stderrBuffer.length,
|
|
||||||
stderrPreview: preview || '<non-utf8>',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.warn('Preview transcoder stderr', { key, stderrBytes: 0 });
|
|
||||||
}
|
|
||||||
if (!ENABLED) return;
|
|
||||||
const delay = 2000;
|
|
||||||
logger.warn('Preview transcoder exited; restarting', { key, code, signal });
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!recorders.has(key) && ENABLED) {
|
|
||||||
spawnRecorder(source);
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
});
|
|
||||||
events.emit('spawn', { key, source });
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRecorder(key) {
|
|
||||||
const entry = recorders.get(key);
|
|
||||||
if (!entry) return;
|
|
||||||
entry.proc.kill('SIGTERM');
|
|
||||||
recorders.delete(key);
|
|
||||||
events.emit('stop', { key, source: entry.source });
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncRecorders() {
|
|
||||||
const sources = listSources();
|
|
||||||
const desiredKeys = new Set();
|
|
||||||
sources.forEach((source) => {
|
|
||||||
const key = buildKey(source);
|
|
||||||
desiredKeys.add(key);
|
|
||||||
if (!recorders.has(key)) {
|
|
||||||
spawnRecorder(source);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Array.from(recorders.keys()).forEach((key) => {
|
|
||||||
if (!desiredKeys.has(key)) {
|
|
||||||
stopRecorder(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.info('Preview transcoders synced', { total: desiredKeys.size });
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
if (!ENABLED) {
|
|
||||||
logger.info('Preview transcoders disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
syncRecorders();
|
|
||||||
if (!syncTimer) {
|
|
||||||
syncTimer = setInterval(syncRecorders, 10000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
if (syncTimer) {
|
|
||||||
clearInterval(syncTimer);
|
|
||||||
syncTimer = null;
|
|
||||||
}
|
|
||||||
Array.from(recorders.keys()).forEach((key) => stopRecorder(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
roverManager.managerEvents.on('rover', () => {
|
|
||||||
if (ENABLED) {
|
|
||||||
syncRecorders();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
roomCameraEvents.on('update', () => {
|
|
||||||
if (ENABLED) {
|
|
||||||
syncRecorders();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
start();
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
previewEvents: events,
|
|
||||||
syncPreviewTranscoders: syncRecorders,
|
|
||||||
stopPreviewTranscoders: stop,
|
|
||||||
};
|
|
||||||
@@ -476,7 +476,13 @@ function isDriver(roverId, socket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canDrive(roverId, socket) {
|
function canDrive(roverId, socket) {
|
||||||
return turnService.canDrive(roverId, socket) || isAdmin(socket);
|
if (isAdmin(socket)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!socket || !isDriver(roverId, socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return turnService.canDrive(roverId, socket);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRoversForSocket(socketId) {
|
function getRoversForSocket(socketId) {
|
||||||
@@ -571,6 +577,9 @@ io.on('connection', (socket) => {
|
|||||||
if (socket.data?.role === 'spectator') {
|
if (socket.data?.role === 'spectator') {
|
||||||
throw new Error('Spectators cannot drive');
|
throw new Error('Spectators cannot drive');
|
||||||
}
|
}
|
||||||
|
if ((getMode() === MODES.ADMIN || getMode() === MODES.LOCKDOWN) && !isAdmin(socket)) {
|
||||||
|
throw new Error('Admins only');
|
||||||
|
}
|
||||||
const targetId = roverId || Array.from(rovers.keys())[0];
|
const targetId = roverId || Array.from(rovers.keys())[0];
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
throw new Error('No rovers available');
|
throw new Error('No rovers available');
|
||||||
@@ -582,8 +591,9 @@ io.on('connection', (socket) => {
|
|||||||
throw new Error(message || 'Switch denied');
|
throw new Error(message || 'Switch denied');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info('Request control', socket.id, targetId, { force });
|
const forceAllowed = Boolean(force) && isAdmin(socket);
|
||||||
requestControl(targetId, socket, { force: Boolean(force), allowUser: true });
|
logger.info('Request control', socket.id, targetId, { force: forceAllowed });
|
||||||
|
requestControl(targetId, socket, { force: forceAllowed, allowUser: true });
|
||||||
previousJoined.forEach((rid) => {
|
previousJoined.forEach((rid) => {
|
||||||
if (rid !== targetId) {
|
if (rid !== targetId) {
|
||||||
releaseControl(rid, socket);
|
releaseControl(rid, socket);
|
||||||
|
|||||||
@@ -46,11 +46,7 @@ function extractStreamInfo(path) {
|
|||||||
const remaining = segments.slice(start, end);
|
const remaining = segments.slice(start, end);
|
||||||
if (remaining.length === 1) {
|
if (remaining.length === 1) {
|
||||||
const rawId = remaining[0] || '';
|
const rawId = remaining[0] || '';
|
||||||
let baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
|
const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
|
||||||
const previewMatch = baseId.match(/^(.*)-preview-[a-z0-9]+$/);
|
|
||||||
if (previewMatch) {
|
|
||||||
baseId = previewMatch[1];
|
|
||||||
}
|
|
||||||
return { type: 'rover', id: rawId, baseId };
|
return { type: 'rover', id: rawId, baseId };
|
||||||
}
|
}
|
||||||
if (remaining.length === 2 && remaining[0] === 'room') {
|
if (remaining.length === 2 && remaining[0] === 'room') {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const { getMode, MODES } = require('./modeManager');
|
|||||||
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
||||||
const videoSessions = require('./videoSessions');
|
const videoSessions = require('./videoSessions');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
const { getRoomCamera } = require('./roomCameraService');
|
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -37,17 +36,6 @@ function buildWhepUrlForSource(source) {
|
|||||||
return `${cleanBase}/${segments.join('/')}/whep`;
|
return `${cleanBase}/${segments.join('/')}/whep`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeCodec(codec) {
|
|
||||||
return String(codec || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPreviewId(id, codec) {
|
|
||||||
const cleanCodec = sanitizeCodec(codec) || 'av1';
|
|
||||||
return `${id}-preview-${cleanCodec}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function passesMode(socket) {
|
function passesMode(socket) {
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
if (mode === MODES.LOCKDOWN) {
|
if (mode === MODES.LOCKDOWN) {
|
||||||
@@ -77,21 +65,14 @@ function canViewRoomCamera(socket) {
|
|||||||
|
|
||||||
function normalizeRequest(payload = {}) {
|
function normalizeRequest(payload = {}) {
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
const preview = Boolean(payload.preview || payload.mode === 'preview');
|
|
||||||
const codec = payload.codec ? String(payload.codec) : null;
|
|
||||||
if (payload.type && payload.id) {
|
if (payload.type && payload.id) {
|
||||||
return {
|
return { type: payload.type, id: String(payload.id) };
|
||||||
type: payload.type,
|
|
||||||
id: String(payload.id),
|
|
||||||
preview,
|
|
||||||
codec,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (payload.roverId) {
|
if (payload.roverId) {
|
||||||
return { type: 'rover', id: String(payload.roverId), preview, codec };
|
return { type: 'rover', id: String(payload.roverId) };
|
||||||
}
|
}
|
||||||
if (payload.roomCameraId) {
|
if (payload.roomCameraId) {
|
||||||
return { type: 'room', id: String(payload.roomCameraId), preview, codec };
|
return { type: 'room', id: String(payload.roomCameraId) };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -112,30 +93,16 @@ io.on('connection', (socket) => {
|
|||||||
throw new Error('Not authorized for video');
|
throw new Error('Not authorized for video');
|
||||||
}
|
}
|
||||||
} else if (target.type === 'room') {
|
} else if (target.type === 'room') {
|
||||||
if (!target.preview) {
|
|
||||||
throw new Error('Room cameras now use the snapshot feed');
|
throw new Error('Room cameras now use the snapshot feed');
|
||||||
}
|
|
||||||
if (!getRoomCamera(target.id)) {
|
|
||||||
throw new Error('Room camera not found');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Unsupported video source');
|
throw new Error('Unsupported video source');
|
||||||
}
|
}
|
||||||
const requestId = target.preview ? buildPreviewId(target.id, target.codec) : target.id;
|
const url = buildWhepUrlForSource(target);
|
||||||
const requestTarget = { ...target, id: requestId };
|
|
||||||
const url = buildWhepUrlForSource(requestTarget);
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error('Server video base URL missing');
|
throw new Error('Server video base URL missing');
|
||||||
}
|
}
|
||||||
const sessionId = videoSessions.createSession(socket, requestTarget);
|
const sessionId = videoSessions.createSession(socket, target);
|
||||||
cb({
|
cb({ url, token: sessionId, type: target.type, id: target.id });
|
||||||
url,
|
|
||||||
token: sessionId,
|
|
||||||
type: requestTarget.type,
|
|
||||||
id: requestTarget.id,
|
|
||||||
preview: Boolean(target.preview),
|
|
||||||
codec: target.codec || null,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('video request failed: %s', err.message);
|
logger.warn('video request failed: %s', err.message);
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
|
|||||||
+2
-9
@@ -61,16 +61,9 @@ function useLayoutMode() {
|
|||||||
function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
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.8] flex-col gap-0.5 overflow-y-auto pr-0.5">
|
<div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0.5">
|
||||||
<DriverVideoPanel />
|
<DriverVideoPanel />
|
||||||
<div className="grid h-52 grid-cols-2 gap-0.5">
|
<TelemetryPanel />
|
||||||
<div className="h-full min-h-0">
|
|
||||||
<UserListPanel fillHeight />
|
|
||||||
</div>
|
|
||||||
<div className="h-full min-h-0">
|
|
||||||
<ChatPanel fillHeight />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<LogPanel />
|
<LogPanel />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function buildKey(alert) {
|
|||||||
return `${alert.title || 'alert'}-${alert.message}`;
|
return `${alert.title || 'alert'}-${alert.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlertFeed() {
|
export default function AlertFeed({ scale = 1 }) {
|
||||||
const { alerts } = useSession();
|
const { alerts } = useSession();
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const latest = useMemo(() => alerts.slice(-3).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
const latest = useMemo(() => alerts.slice(-3).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
||||||
@@ -31,8 +31,20 @@ export default function AlertFeed() {
|
|||||||
|
|
||||||
if (!visible.length) return null;
|
if (!visible.length) return null;
|
||||||
|
|
||||||
|
const containerStyle =
|
||||||
|
scale === 1
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
transform: `translateX(-50%) scale(${scale})`,
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
};
|
||||||
|
const containerClass =
|
||||||
|
scale === 1
|
||||||
|
? 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5'
|
||||||
|
: 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex flex-col gap-0.5';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5">
|
<div className={containerClass} style={containerStyle}>
|
||||||
{visible.map((toast) => (
|
{visible.map((toast) => (
|
||||||
<AlertToast key={toast.key} alert={toast.alert} />
|
<AlertToast key={toast.key} alert={toast.alert} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function ChatMessageRow({ message }) {
|
|||||||
{displayName(message)}
|
{displayName(message)}
|
||||||
</span>
|
</span>
|
||||||
{message.roverId && (
|
{message.roverId && (
|
||||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {message.roverId}</span>
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">{message.roverId}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{message.text}</span>
|
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{message.text}</span>
|
||||||
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
|
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useControlSystem } from '../controls/index.js';
|
|||||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||||
import TopDownMap from './TopDownMap.jsx';
|
import TopDownMap from './TopDownMap.jsx';
|
||||||
import RoverRoster from './RoverRoster.jsx';
|
import RoverRoster from './RoverRoster.jsx';
|
||||||
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
|
|
||||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||||
|
|
||||||
export function RoverRosterPanel({ title = 'Rovers' }) {
|
export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||||
@@ -46,7 +45,7 @@ export function RoverRosterPanel({ title = 'Rovers' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ControlSummary({ showRoster = true }) {
|
export default function ControlSummary() {
|
||||||
const {
|
const {
|
||||||
state: { roverId, keymap },
|
state: { roverId, keymap },
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
@@ -57,7 +56,7 @@ export default function ControlSummary({ showRoster = true }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section">
|
<section className="panel-section">
|
||||||
<div className="grid items-stretch gap-1 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] md:min-h-[22rem]">
|
<div className="grid items-stretch gap-1 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
|
||||||
<div className="flex h-full w-full items-stretch justify-center">
|
<div className="flex h-full w-full items-stretch justify-center">
|
||||||
<div className="aspect-square h-full w-full">
|
<div className="aspect-square h-full w-full">
|
||||||
<TopDownMap sensors={sensors} />
|
<TopDownMap sensors={sensors} />
|
||||||
@@ -70,19 +69,11 @@ export default function ControlSummary({ showRoster = true }) {
|
|||||||
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showRoster ? (
|
|
||||||
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
|
||||||
<div>
|
|
||||||
<ReplaySourcesPanel />
|
|
||||||
</div>
|
|
||||||
<RoverRosterPanel />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineCameraTilt({ keymap }) {
|
export function InlineCameraTilt({ keymap }) {
|
||||||
const {
|
const {
|
||||||
state: { roverId, camera },
|
state: { roverId, camera },
|
||||||
actions: { setServoAngle },
|
actions: { setServoAngle },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useSession } from "../context/SessionContext";
|
import { useSession } from "../context/SessionContext";
|
||||||
import { FaDiscord } from "react-icons/fa";
|
import { FaDiscord } from "react-icons/fa";
|
||||||
|
|
||||||
export default function DiscordInviteButton({text = "Join our Discord!"}) {
|
export default function DiscordInviteButton({ text = "Join our Discord!", className = "" }) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const discordInvite = session?.discord?.invite || null;
|
const discordInvite = session?.discord?.invite || null;
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export default function DiscordInviteButton({text = "Join our Discord!"}) {
|
|||||||
href={discordInvite}
|
href={discordInvite}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center"
|
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center ${className}`}
|
||||||
// animated rainbow backgound
|
// animated rainbow backgound
|
||||||
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
|
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
|||||||
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
||||||
import { useControlSystem } from '../controls/index.js';
|
import { useControlSystem } from '../controls/index.js';
|
||||||
import VideoTile from './VideoTile.jsx';
|
import VideoTile from './VideoTile.jsx';
|
||||||
import { supportsAv1WebRtc } from '../lib/mediaSupport.js';
|
|
||||||
|
|
||||||
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
@@ -70,20 +69,6 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
const sources = useVideoRequests(entries);
|
const sources = useVideoRequests(entries);
|
||||||
const info = roverId && shouldShowVideo ? sources[roverId] : null;
|
const info = roverId && shouldShowVideo ? sources[roverId] : null;
|
||||||
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
|
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
|
||||||
const av1Supported = supportsAv1WebRtc();
|
|
||||||
const previewEntries = roverId
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'rover',
|
|
||||||
id: roverId,
|
|
||||||
key: `rover:${roverId}:preview:av1`,
|
|
||||||
preview: true,
|
|
||||||
codec: 'av1',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
const previewSources = useVideoRequests(previewEntries, { enabled: Boolean(roverId) && av1Supported });
|
|
||||||
const previewSession = roverId ? previewSources[`rover:${roverId}:preview:av1`] || null : null;
|
|
||||||
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
|
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
|
||||||
enabled: Boolean(roverId),
|
enabled: Boolean(roverId),
|
||||||
version: session?.mode,
|
version: session?.mode,
|
||||||
@@ -129,8 +114,8 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
<section className="panel">
|
<section className="panel">
|
||||||
{roverId ? (
|
{roverId ? (
|
||||||
<VideoTile
|
<VideoTile
|
||||||
sessionInfo={shouldShowVideo ? info : previewSession?.url ? previewSession : null}
|
sessionInfo={info}
|
||||||
videoMode={shouldShowVideo ? 'whep' : previewSession?.url ? 'whep' : 'snapshot'}
|
videoMode={shouldShowVideo ? 'whep' : 'snapshot'}
|
||||||
snapshotFeed={snapshotFeed}
|
snapshotFeed={snapshotFeed}
|
||||||
audioSessionInfo={audioInfo}
|
audioSessionInfo={audioInfo}
|
||||||
label={roverLabel}
|
label={roverLabel}
|
||||||
@@ -138,13 +123,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
batteryConfig={batteryConfig}
|
batteryConfig={batteryConfig}
|
||||||
layoutFormat={layoutFormat}
|
layoutFormat={layoutFormat}
|
||||||
songNote={song?.note}
|
songNote={song?.note}
|
||||||
qualityNotice={
|
qualityNotice={!shouldShowVideo ? 'Preview feed (low FPS) until your turn.' : null}
|
||||||
!shouldShowVideo
|
|
||||||
? previewSession?.url
|
|
||||||
? 'Preview feed (AV1) until your turn.'
|
|
||||||
: 'Preview feed (snapshots) until your turn.'
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
showTurnCue={turnCueVisible}
|
showTurnCue={turnCueVisible}
|
||||||
turnTimerText={turnTimerText}
|
turnTimerText={turnTimerText}
|
||||||
turnSeconds={turnSeconds}
|
turnSeconds={turnSeconds}
|
||||||
@@ -152,7 +131,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
idleSkipSeconds={idleSkipSeconds}
|
idleSkipSeconds={idleSkipSeconds}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video">
|
<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>You are not assigned to a rover.</p>
|
||||||
{/* colored button to visit the spectator page */}
|
{/* colored button to visit the spectator page */}
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function EntityRow({ entity, connected, onToggle }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(entity.id)}
|
onClick={() => onToggle(entity.id)}
|
||||||
disabled={disableToggle}
|
disabled={disableToggle}
|
||||||
className={`flex min-w-[12rem] flex-1 items-center justify-between gap-1 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
|
className={`flex min-w-[10rem] flex-1 items-center justify-between gap-1 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useSession } from "../context/SessionContext";
|
import { useSession } from "../context/SessionContext";
|
||||||
import { FaCoffee } from "react-icons/fa";
|
import { FaCoffee } from "react-icons/fa";
|
||||||
|
|
||||||
export default function KoFiButton({ text = "Support me on Ko-fi!" }) {
|
export default function KoFiButton({ text = "Support me on Ko-fi!", className = "" }) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const kofiLink = session?.kofi?.link || null;
|
const kofiLink = session?.kofi?.link || null;
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export default function KoFiButton({ text = "Support me on Ko-fi!" }) {
|
|||||||
href={kofiLink}
|
href={kofiLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white kofi-animate-bg transition justify-center"
|
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white kofi-animate-bg transition justify-center ${className}`}
|
||||||
>
|
>
|
||||||
<FaCoffee className="mr-1" />
|
<FaCoffee className="mr-1" />
|
||||||
{text}
|
{text}
|
||||||
|
|||||||
@@ -31,16 +31,20 @@ export default function NicknameForm({ compact = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex w-full gap-0.5" onSubmit={handleSave}>
|
<form className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-0.5" onSubmit={handleSave}>
|
||||||
<input
|
<input
|
||||||
className="field-input flex-1"
|
className="field-input flex-1 min-w-0"
|
||||||
value={nicknameInput}
|
value={nicknameInput}
|
||||||
onChange={(e) => setNicknameInput(e.target.value)}
|
onChange={(e) => setNicknameInput(e.target.value)}
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
placeholder="Enter a nickname"
|
placeholder="Enter a nickname"
|
||||||
disabled={!canSetNickname}
|
disabled={!canSetNickname}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={!canSetNickname || saving} className="button-dark disabled:opacity-50">
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSetNickname || saving}
|
||||||
|
className="button-dark shrink-0 whitespace-nowrap disabled:opacity-50"
|
||||||
|
>
|
||||||
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
|
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function normalizeSources(list = []) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReplaySourcesPanel({ panelId = 'replay-sources' }) {
|
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) {
|
||||||
const { session, triggerReplay } = useSession();
|
const { session, triggerReplay } = useSession();
|
||||||
const sources = normalizeSources(session?.replaySources || []);
|
const sources = normalizeSources(session?.replaySources || []);
|
||||||
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
||||||
@@ -100,13 +100,16 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources' }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const containerClass = fillHeight ? 'h-full flex flex-col' : '';
|
||||||
|
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section p-0.75 text-sm">
|
<section className={`panel-section p-0.75 text-sm ${containerClass}`}>
|
||||||
<header className="panel-muted flex items-center justify-between text-xs">
|
<header className="panel-muted flex items-center justify-between text-xs">
|
||||||
<span>Replay Sources</span>
|
<span>Replay Sources</span>
|
||||||
<span>{sources.length}</span>
|
<span>{sources.length}</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="grid gap-0.5 md:grid-cols-2">
|
<div className={`grid gap-0.5 md:grid-cols-2 ${listWrapClass}`}>
|
||||||
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
||||||
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,47 @@
|
|||||||
import TelemetryPanel from './TelemetryPanel.jsx';
|
import { InlineCameraTilt, RoverRosterPanel } from './ControlSummary.jsx';
|
||||||
import ControlSummary from './ControlSummary.jsx';
|
|
||||||
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
import RoomCameraPanel from './RoomCameraPanel.jsx';
|
||||||
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
import HomeAssistantControls from './HomeAssistantControls.jsx';
|
||||||
import SettingsPanel from './SettingsPanel.jsx';
|
import SettingsPanel from './SettingsPanel.jsx';
|
||||||
import HelpPanel from './HelpPanel.jsx';
|
import HelpPanel from './HelpPanel.jsx';
|
||||||
|
import ChatPanel from './ChatPanel.jsx';
|
||||||
|
import UserListPanel, { LinkButtonsPanel, NicknameEntryPanel } from './UserListPanel.jsx';
|
||||||
|
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
|
||||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
||||||
|
import TopDownMap from './TopDownMap.jsx';
|
||||||
|
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||||
|
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||||
|
import { useControlSystem } from '../controls/index.js';
|
||||||
|
|
||||||
|
function TopDownMapPanel() {
|
||||||
|
const {
|
||||||
|
state: { roverId },
|
||||||
|
} = useControlSystem();
|
||||||
|
const frame = useTelemetryFrame(roverId);
|
||||||
|
const sensors = frame?.sensors || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel-section">
|
||||||
|
<div className="aspect-square w-full">
|
||||||
|
<TopDownMap sensors={sensors} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DriveDockPanel() {
|
||||||
|
const {
|
||||||
|
state: { roverId, keymap },
|
||||||
|
} = useControlSystem();
|
||||||
|
const driveDockState = useDriveDockState(roverId);
|
||||||
|
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel-section flex h-full flex-col gap-0.5">
|
||||||
|
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
|
||||||
|
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||||
return (
|
return (
|
||||||
@@ -18,10 +55,24 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
|||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel id="telemetry">
|
<TabPanel id="telemetry">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<ControlSummary />
|
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.35fr)_minmax(0,0.95fr)]">
|
||||||
|
<TopDownMapPanel />
|
||||||
|
<DriveDockPanel />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,0.75fr)]">
|
||||||
|
<RoverRosterPanel />
|
||||||
|
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||||
|
<LinkButtonsPanel />
|
||||||
|
</div>
|
||||||
|
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.7fr)] h-[14rem]">
|
||||||
|
<ChatPanel fillHeight />
|
||||||
|
<div className="grid min-h-0 gap-0.5 grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<NicknameEntryPanel compact />
|
||||||
|
<UserListPanel compact hideNicknameForm fillHeight />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<HomeAssistantControls />
|
<HomeAssistantControls />
|
||||||
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
||||||
<TelemetryPanel />
|
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="help">
|
<TabPanel id="help">
|
||||||
|
|||||||
@@ -1,74 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { WhepPlayer } from '../lib/whepPlayer.js';
|
|
||||||
|
|
||||||
function RoomCameraVideo({ sessionInfo, label, onStatus }) {
|
export default function RoomCameraFeed({ feed, label }) {
|
||||||
const videoRef = useRef(null);
|
|
||||||
const [status, setStatus] = useState('idle');
|
|
||||||
const [detail, setDetail] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sessionInfo?.url || !videoRef.current) return undefined;
|
|
||||||
let active = true;
|
|
||||||
let player;
|
|
||||||
|
|
||||||
const handleStatus = (nextStatus, info) => {
|
|
||||||
if (!active) return;
|
|
||||||
setStatus(nextStatus);
|
|
||||||
setDetail(info || null);
|
|
||||||
if (typeof onStatus === 'function') {
|
|
||||||
onStatus(nextStatus);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
player = new WhepPlayer({
|
|
||||||
url: sessionInfo.url,
|
|
||||||
token: sessionInfo.token,
|
|
||||||
video: videoRef.current,
|
|
||||||
onStatus: handleStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
player.start().catch((err) => {
|
|
||||||
if (!active) return;
|
|
||||||
setStatus('error');
|
|
||||||
setDetail(err.message);
|
|
||||||
if (typeof onStatus === 'function') {
|
|
||||||
onStatus('error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
player?.stop();
|
|
||||||
};
|
|
||||||
}, [sessionInfo?.url, sessionInfo?.token, onStatus]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
autoPlay
|
|
||||||
controls={false}
|
|
||||||
aria-label={label}
|
|
||||||
/>
|
|
||||||
{status !== 'playing' && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-slate-300">
|
|
||||||
{detail ? `Video error: ${detail}` : 'Connecting video…'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RoomCameraFeed({ feed, label, videoSession = null, preferVideo = false }) {
|
|
||||||
const [blink, setBlink] = useState(false);
|
const [blink, setBlink] = useState(false);
|
||||||
const [videoFailed, setVideoFailed] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setVideoFailed(false);
|
|
||||||
}, [videoSession?.url, videoSession?.token]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!feed) return;
|
if (!feed) return;
|
||||||
@@ -82,27 +15,12 @@ export default function RoomCameraFeed({ feed, label, videoSession = null, prefe
|
|||||||
return feed.status || 'Connecting…';
|
return feed.status || 'Connecting…';
|
||||||
}, [feed]);
|
}, [feed]);
|
||||||
|
|
||||||
const showVideo = Boolean(preferVideo && videoSession?.url && !videoFailed);
|
|
||||||
const showSnapshot = Boolean(!showVideo && feed?.objectUrl);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full overflow-hidden rounded bg-black" style={{ aspectRatio: '4 / 3' }}>
|
<div className="relative w-full overflow-hidden rounded bg-black" style={{ aspectRatio: '4 / 3' }}>
|
||||||
{showVideo ? (
|
{feed?.objectUrl ? (
|
||||||
<RoomCameraVideo
|
|
||||||
sessionInfo={videoSession}
|
|
||||||
label={label}
|
|
||||||
onStatus={(nextStatus) => {
|
|
||||||
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
|
|
||||||
setVideoFailed(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : showSnapshot ? (
|
|
||||||
<img src={feed.objectUrl} alt={label} className="h-full w-full object-cover" />
|
<img src={feed.objectUrl} alt={label} className="h-full w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-slate-300">
|
<div className="flex h-full w-full items-center justify-center text-sm text-slate-300">Waiting for frame…</div>
|
||||||
{preferVideo ? 'Waiting for video…' : 'Waiting for frame…'}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-0.5 py-0.5 text-xs font-semibold text-white">
|
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-0.5 py-0.5 text-xs font-semibold text-white">
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
|
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
|
||||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
|
||||||
import { supportsAv1WebRtc } from '../lib/mediaSupport.js';
|
|
||||||
import RoomCameraFeed from './RoomCameraFeed.jsx';
|
import RoomCameraFeed from './RoomCameraFeed.jsx';
|
||||||
|
|
||||||
function EmptyState() {
|
function EmptyState() {
|
||||||
@@ -34,15 +32,6 @@ export default function RoomCameraPanel({
|
|||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const cameras = session?.roomCameras || [];
|
const cameras = session?.roomCameras || [];
|
||||||
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
||||||
const av1Supported = supportsAv1WebRtc();
|
|
||||||
const previewEntries = cameras.map((camera) => ({
|
|
||||||
type: 'room',
|
|
||||||
id: camera.id,
|
|
||||||
key: `room:${camera.id}:preview:av1`,
|
|
||||||
preview: true,
|
|
||||||
codec: 'av1',
|
|
||||||
}));
|
|
||||||
const previewSources = useVideoRequests(previewEntries, { enabled: av1Supported });
|
|
||||||
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
||||||
const [orientation, setOrientation] = useState(() =>
|
const [orientation, setOrientation] = useState(() =>
|
||||||
normalizeOrientation(
|
normalizeOrientation(
|
||||||
@@ -107,19 +96,13 @@ export default function RoomCameraPanel({
|
|||||||
<div className={containerClass}>
|
<div className={containerClass}>
|
||||||
{cameras.map((camera) => {
|
{cameras.map((camera) => {
|
||||||
const feed = feedMap[camera.id] || null;
|
const feed = feedMap[camera.id] || null;
|
||||||
const previewSession = previewSources[`room:${camera.id}:preview:av1`] || null;
|
|
||||||
return (
|
return (
|
||||||
<article key={camera.id} className="w-full space-y-0.5 rounded bg-zinc-950 p-0.5 shadow-inner shadow-black/40">
|
<article key={camera.id} className="w-full space-y-0.5 rounded bg-zinc-950 p-0.5 shadow-inner shadow-black/40">
|
||||||
{/* <header className="space-y-0.5">
|
{/* <header className="space-y-0.5">
|
||||||
<p className="text-lg font-semibold text-white">{camera.name || camera.id}</p>
|
<p className="text-lg font-semibold text-white">{camera.name || camera.id}</p>
|
||||||
{camera.description && <p className="text-xs text-slate-500">{camera.description}</p>}
|
{camera.description && <p className="text-xs text-slate-500">{camera.description}</p>}
|
||||||
</header> */}
|
</header> */}
|
||||||
<RoomCameraFeed
|
<RoomCameraFeed feed={feed} label={camera.name || camera.id} />
|
||||||
feed={feed}
|
|
||||||
label={camera.name || camera.id}
|
|
||||||
videoSession={previewSession}
|
|
||||||
preferVideo={Boolean(previewSession?.url)}
|
|
||||||
/>
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ export default function SessionSnapshot() {
|
|||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
|
const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
|
||||||
return (
|
return (
|
||||||
<div className="panel-section space-y-0.5 text-xs">
|
<div className="panel-section flex min-h-0 flex-col gap-0.5 text-xs">
|
||||||
<p className="text-sm text-slate-400">Session snapshot</p>
|
<p className="text-sm text-slate-400">Session snapshot</p>
|
||||||
<pre className="surface h-64 overflow-y-auto font-mono text-[0.7rem] text-lime-300">{payload}</pre>
|
<pre className="surface min-h-0 flex-1 overflow-y-auto font-mono text-[0.7rem] text-lime-300">
|
||||||
|
{payload}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import NicknameForm from './NicknameForm.jsx';
|
|||||||
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||||
import KoFiButton from './KoFiButton.jsx';
|
import KoFiButton from './KoFiButton.jsx';
|
||||||
|
|
||||||
|
export function NicknameEntryPanel({ compact = false }) {
|
||||||
|
return (
|
||||||
|
<section className="panel-section flex h-full min-h-0 flex-col gap-0.5 text-base">
|
||||||
|
<div className="surface flex w-full items-center">
|
||||||
|
<NicknameForm compact={compact} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkButtonsPanel() {
|
||||||
|
return (
|
||||||
|
<section className="panel-section flex h-full min-h-0 flex-col gap-0.5 text-base">
|
||||||
|
<div className="grid flex-1 min-h-0 gap-0.5 grid-rows-2">
|
||||||
|
<DiscordInviteButton className="h-full" />
|
||||||
|
<KoFiButton className="h-full" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function roleColors(role) {
|
function roleColors(role) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
@@ -28,7 +49,14 @@ function formatLabel(user, selfId) {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserListPanel({ hideNicknameForm = false, hideHeader = false, className = '', fillHeight = false }) {
|
export default function UserListPanel({
|
||||||
|
hideNicknameForm = false,
|
||||||
|
hideHeader = false,
|
||||||
|
className = '',
|
||||||
|
fillHeight = false,
|
||||||
|
compact = false,
|
||||||
|
showBothTurnsAndUsers = false,
|
||||||
|
}) {
|
||||||
const { session, setNickname } = useSession();
|
const { session, setNickname } = useSession();
|
||||||
const { value } = useSettingsNamespace('profile', { nickname: '' });
|
const { value } = useSettingsNamespace('profile', { nickname: '' });
|
||||||
const lastSyncedSocketRef = useRef(null);
|
const lastSyncedSocketRef = useRef(null);
|
||||||
@@ -39,6 +67,12 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
const isTurnsMode = session?.mode === 'turns';
|
const isTurnsMode = session?.mode === 'turns';
|
||||||
const turnQueues = session?.turnQueues || {};
|
const turnQueues = session?.turnQueues || {};
|
||||||
const roster = session?.roster || [];
|
const roster = session?.roster || [];
|
||||||
|
const [turnView, setTurnView] = useState('queues');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTurnsMode) return;
|
||||||
|
setTurnView('queues');
|
||||||
|
}, [isTurnsMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canSetNickname) return;
|
if (!canSetNickname) return;
|
||||||
@@ -93,9 +127,23 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
return Math.ceil(ms / 1000);
|
return Math.ceil(ms / 1000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const baseListClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
|
const baseListClass = fillHeight
|
||||||
const turnsListClass = isTurnsMode && fillHeight ? 'max-h-40 overflow-y-auto' : baseListClass;
|
? 'flex-1 min-h-0 overflow-y-auto'
|
||||||
const usersListClass = isTurnsMode && fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : baseListClass;
|
: compact
|
||||||
|
? 'h-28 overflow-y-auto'
|
||||||
|
: 'h-48 overflow-y-auto';
|
||||||
|
const turnsListClass =
|
||||||
|
isTurnsMode && fillHeight
|
||||||
|
? 'max-h-40 overflow-y-auto'
|
||||||
|
: isTurnsMode && compact
|
||||||
|
? 'max-h-32 overflow-y-auto'
|
||||||
|
: baseListClass;
|
||||||
|
const usersListClass =
|
||||||
|
isTurnsMode && fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : baseListClass;
|
||||||
|
const showToggle = isTurnsMode && !showBothTurnsAndUsers;
|
||||||
|
const showQueuesSection = isTurnsMode && (showBothTurnsAndUsers || turnView === 'queues');
|
||||||
|
const showUsersSection = !isTurnsMode || (showToggle && turnView === 'users');
|
||||||
|
const showUsersSecondary = isTurnsMode && showBothTurnsAndUsers;
|
||||||
|
|
||||||
const renderUserList = () =>
|
const renderUserList = () =>
|
||||||
sorted.length === 0 ? (
|
sorted.length === 0 ? (
|
||||||
@@ -107,7 +155,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.socketId}
|
key={user.socketId}
|
||||||
className="surface-muted flex items-center gap-1 text-sm"
|
className={`surface-muted flex items-center gap-1 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
|
||||||
>
|
>
|
||||||
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
||||||
{user.roverId ? (
|
{user.roverId ? (
|
||||||
@@ -131,13 +179,13 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
>
|
>
|
||||||
{!hideNicknameForm && (
|
{!hideNicknameForm && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-stretch gap-0.5">
|
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||||
<div className="flex w-1/2 min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="surface flex w-full items-center">
|
<div className="surface flex w-full items-center">
|
||||||
<NicknameForm />
|
<NicknameForm compact={compact} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-1/2 flex-col gap-0.5">
|
<div className="grid gap-0.5 sm:grid-cols-2 md:grid-cols-1">
|
||||||
<DiscordInviteButton />
|
<DiscordInviteButton />
|
||||||
<KoFiButton />
|
<KoFiButton />
|
||||||
</div>
|
</div>
|
||||||
@@ -148,16 +196,42 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
|
|
||||||
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
|
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
<div className={`flex items-center justify-between text-sm text-slate-400 ${compact ? 'text-xs' : ''}`}>
|
||||||
<span>{isTurnsMode ? 'Turn queues' : 'Users'}</span>
|
<div className="flex items-center gap-0.5">
|
||||||
|
<span>
|
||||||
|
{isTurnsMode
|
||||||
|
? showQueuesSection
|
||||||
|
? 'Turn queues'
|
||||||
|
: 'Users'
|
||||||
|
: 'Users'}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-xs text-slate-500">
|
||||||
{isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length}
|
{showQueuesSection && isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{showToggle ? (
|
||||||
|
<div className="inline-flex overflow-hidden rounded border border-slate-700 text-[0.7rem]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-1 py-0.5 ${turnView === 'queues' ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||||
|
onClick={() => setTurnView('queues')}
|
||||||
|
>
|
||||||
|
Queues
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-1 py-0.5 ${turnView === 'users' ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||||
|
onClick={() => setTurnView('users')}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`surface space-y-0.25 ${isTurnsMode ? turnsListClass : baseListClass}`}>
|
{showQueuesSection ? (
|
||||||
{isTurnsMode ? (
|
<div className={`surface space-y-0.25 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
||||||
Object.keys(turnQueues || {}).length === 0 ? (
|
{Object.keys(turnQueues || {}).length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(turnQueues).map(([roverId, info]) => {
|
Object.entries(turnQueues).map(([roverId, info]) => {
|
||||||
@@ -173,7 +247,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
: queue[0]
|
: queue[0]
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<div key={roverId} className="surface-muted flex flex-col gap-0.25 text-sm">
|
<div key={roverId} className={`surface-muted flex flex-col gap-0.25 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
|
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
|
||||||
{remaining != null && (
|
{remaining != null && (
|
||||||
@@ -201,7 +275,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={`${roverId}-${socketId}-${idx}`}
|
key={`${roverId}-${socketId}-${idx}`}
|
||||||
className={`flex items-center gap-0.5 rounded px-1 text-[0.8rem] ${highlightClass}`}
|
className={`flex items-center gap-0.5 rounded px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
|
||||||
>
|
>
|
||||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||||
{formatLabel(user, selfId)}
|
{formatLabel(user, selfId)}
|
||||||
@@ -218,21 +292,23 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)
|
|
||||||
) : (
|
|
||||||
renderUserList()
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isTurnsMode ? (
|
{showUsersSection ? (
|
||||||
|
<div className={`surface space-y-0.25 ${usersListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
||||||
|
{renderUserList()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showUsersSecondary ? (
|
||||||
<div className={`space-y-0.25 ${fillHeight ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
|
<div className={`space-y-0.25 ${fillHeight ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
<span className="text-[0.7rem] text-slate-500">{sorted.length}</span>
|
<span className="text-[0.7rem] text-slate-500">{sorted.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`surface space-y-0.25 ${usersListClass}`}>
|
<div className={`surface space-y-0.25 ${usersListClass}`}>{renderUserList()}</div>
|
||||||
{renderUserList()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
|
|||||||
import { useChat } from '../context/ChatContext.jsx';
|
import { useChat } from '../context/ChatContext.jsx';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||||
|
|
||||||
const RESTART_DELAY_MS = 2000;
|
const RESTART_DELAY_MS = 2000;
|
||||||
const UNMUTE_RETRY_MS = 3000;
|
const UNMUTE_RETRY_MS = 3000;
|
||||||
@@ -51,6 +52,7 @@ export default function VideoTile({
|
|||||||
driverLabel = null,
|
driverLabel = null,
|
||||||
hudForceMap = false,
|
hudForceMap = false,
|
||||||
hudMapPosition = 'top-right',
|
hudMapPosition = 'top-right',
|
||||||
|
hudLabelScale = 1,
|
||||||
fitParent = false,
|
fitParent = false,
|
||||||
showTurnCue = false,
|
showTurnCue = false,
|
||||||
turnTimerText = null,
|
turnTimerText = null,
|
||||||
@@ -366,7 +368,7 @@ export default function VideoTile({
|
|||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
||||||
<div
|
<div
|
||||||
className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-video'}`}
|
className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}
|
||||||
>
|
>
|
||||||
{usingSnapshot ? (
|
{usingSnapshot ? (
|
||||||
snapshotFeed?.objectUrl ? (
|
snapshotFeed?.objectUrl ? (
|
||||||
@@ -415,6 +417,7 @@ export default function VideoTile({
|
|||||||
mobileHud={mobileHud}
|
mobileHud={mobileHud}
|
||||||
mapPosition={hudMapPosition}
|
mapPosition={hudMapPosition}
|
||||||
turnTimerText={turnTimerText}
|
turnTimerText={turnTimerText}
|
||||||
|
labelScale={hudLabelScale}
|
||||||
/>
|
/>
|
||||||
<HudChatInput compact={mobileHud} />
|
<HudChatInput compact={mobileHud} />
|
||||||
<OvercurrentOverlay motors={overcurrentMotors} compact={mobileHud} />
|
<OvercurrentOverlay motors={overcurrentMotors} compact={mobileHud} />
|
||||||
@@ -429,7 +432,10 @@ export default function VideoTile({
|
|||||||
mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'
|
mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{qualityNotice}
|
<div className="text-center">{qualityNotice}</div>
|
||||||
|
<div className="pointer-events-auto mt-1">
|
||||||
|
<DiscordInviteButton text={'Join our Discord server while you wait!'} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -544,6 +550,7 @@ function HudOverlay({
|
|||||||
mobileHud = false,
|
mobileHud = false,
|
||||||
mapPosition = 'top-right',
|
mapPosition = 'top-right',
|
||||||
turnTimerText = null,
|
turnTimerText = null,
|
||||||
|
labelScale = 1,
|
||||||
}) {
|
}) {
|
||||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
@@ -565,6 +572,10 @@ function HudOverlay({
|
|||||||
const timerPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
|
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 telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
|
||||||
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
|
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
|
||||||
|
const labelWrapperStyle = {
|
||||||
|
transform: `translateX(-50%) scale(${labelScale})`,
|
||||||
|
transformOrigin: 'center bottom',
|
||||||
|
};
|
||||||
const mapSize = '240px';
|
const mapSize = '240px';
|
||||||
const mapScale = portraitMobile ? 0.36 : isMobile ? 0.45 : 0.7;
|
const mapScale = portraitMobile ? 0.36 : isMobile ? 0.45 : 0.7;
|
||||||
const mapOpacity = isMobile ? 0.85 : 0.7;
|
const mapOpacity = isMobile ? 0.85 : 0.7;
|
||||||
@@ -626,13 +637,15 @@ function HudOverlay({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
<div
|
<div
|
||||||
className={`absolute ${labelPosClass} left-1/2 flex -translate-x-1/2 items-center gap-1 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
className={`flex items-center gap-1 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
|
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
|
||||||
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,12 +664,12 @@ function HudOverlay({
|
|||||||
{turnTimerText}
|
{turnTimerText}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
className={`absolute ${labelPosClass} left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
||||||
>
|
|
||||||
<span>Rover: "{label || 'Unnamed Rover'}"</span>
|
<span>Rover: "{label || 'Unnamed Rover'}"</span>
|
||||||
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showTopDown && variant !== 'spectator' ? (
|
{showTopDown && variant !== 'spectator' ? (
|
||||||
<div
|
<div
|
||||||
@@ -826,7 +839,7 @@ function HudChatInput({ compact = false }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ref={(el) => registerInputRef(el, { target: 'hud' })}
|
ref={(el) => registerInputRef(el, { target: 'hud' })}
|
||||||
placeholder={canChat ? 'Chat…' : 'Spectator'}
|
placeholder={canChat ? 'Chat (TTS)' : 'Spectator'}
|
||||||
disabled={!canChat}
|
disabled={!canChat}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -834,7 +847,7 @@ function HudChatInput({ compact = false }) {
|
|||||||
disabled={!canChat || sending}
|
disabled={!canChat || sending}
|
||||||
className={buttonClass}
|
className={buttonClass}
|
||||||
>
|
>
|
||||||
Send
|
Speak
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,42 +11,23 @@ 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);
|
||||||
const preview = Boolean(entry.preview);
|
|
||||||
const codec = entry.codec ? String(entry.codec) : null;
|
|
||||||
let key = entry.key;
|
let key = entry.key;
|
||||||
if (!key) {
|
if (!key) {
|
||||||
key = entry.type === 'room' ? `room:${id}` : id;
|
key = entry.type === 'room' ? `room:${id}` : id;
|
||||||
if (preview) {
|
|
||||||
key = `${key}:preview${codec ? `:${codec}` : ''}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: entry.type,
|
type: entry.type,
|
||||||
id,
|
id,
|
||||||
key,
|
key,
|
||||||
preview,
|
|
||||||
codec,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (entry.roverId) {
|
if (entry.roverId) {
|
||||||
const id = String(entry.roverId);
|
const id = String(entry.roverId);
|
||||||
return {
|
return { type: 'rover', id, key: entry.key || id };
|
||||||
type: 'rover',
|
|
||||||
id,
|
|
||||||
key: entry.key || id,
|
|
||||||
preview: Boolean(entry.preview),
|
|
||||||
codec: entry.codec ? String(entry.codec) : null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (entry.roomCameraId) {
|
if (entry.roomCameraId) {
|
||||||
const id = String(entry.roomCameraId);
|
const id = String(entry.roomCameraId);
|
||||||
return {
|
return { type: 'room', id, key: entry.key || `room:${id}` };
|
||||||
type: 'room',
|
|
||||||
id,
|
|
||||||
key: entry.key || `room:${id}`,
|
|
||||||
preview: Boolean(entry.preview),
|
|
||||||
codec: entry.codec ? String(entry.codec) : null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -105,12 +86,6 @@ export function useVideoRequests(sourceList = [], options = {}) {
|
|||||||
|
|
||||||
function requestEntry(entry) {
|
function requestEntry(entry) {
|
||||||
const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
|
const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
|
||||||
if (entry.preview) {
|
|
||||||
payload.preview = true;
|
|
||||||
if (entry.codec) {
|
|
||||||
payload.codec = entry.codec;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 }));
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
let cachedAv1Support = null;
|
|
||||||
|
|
||||||
function hasAv1CodecCapability() {
|
|
||||||
if (typeof RTCRtpReceiver !== 'undefined' && RTCRtpReceiver.getCapabilities) {
|
|
||||||
const caps = RTCRtpReceiver.getCapabilities('video');
|
|
||||||
const codecs = caps?.codecs || [];
|
|
||||||
return codecs.some((codec) => {
|
|
||||||
const mime = (codec?.mimeType || '').toLowerCase();
|
|
||||||
return mime === 'video/av1' || mime === 'video/av01' || mime === 'video/av1x';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
const video = document.createElement('video');
|
|
||||||
if (typeof video.canPlayType === 'function') {
|
|
||||||
const result = video.canPlayType('video/mp4; codecs="av01.0.05M.08"');
|
|
||||||
return result === 'probably' || result === 'maybe';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function supportsAv1WebRtc() {
|
|
||||||
if (cachedAv1Support != null) {
|
|
||||||
return cachedAv1Support;
|
|
||||||
}
|
|
||||||
cachedAv1Support = hasAv1CodecCapability();
|
|
||||||
return cachedAv1Support;
|
|
||||||
}
|
|
||||||
@@ -5,15 +5,13 @@ import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
|||||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
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 { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
|
|
||||||
import VideoTile from '../components/VideoTile.jsx';
|
import VideoTile from '../components/VideoTile.jsx';
|
||||||
import ChatPanel from '../components/ChatPanel.jsx';
|
import ChatPanel from '../components/ChatPanel.jsx';
|
||||||
import AlertFeed from '../components/AlertFeed.jsx';
|
import AlertFeed from '../components/AlertFeed.jsx';
|
||||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||||
import RoomCameraFeed from '../components/RoomCameraFeed.jsx';
|
|
||||||
import { supportsAv1WebRtc } from '../lib/mediaSupport.js';
|
|
||||||
|
|
||||||
const ROTATE_MS = 20000;
|
const ROTATE_MS = 20000;
|
||||||
|
const HARD_REFRESH_MS = 3 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function formatDriverLabel({ roverId, session }) {
|
function formatDriverLabel({ roverId, session }) {
|
||||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||||
@@ -31,64 +29,42 @@ function MiniSummaryContent() {
|
|||||||
const inLockdown = session?.mode === 'lockdown';
|
const inLockdown = session?.mode === 'lockdown';
|
||||||
const frames = useTelemetryFrames();
|
const frames = useTelemetryFrames();
|
||||||
const roster = session?.roster ?? [];
|
const roster = session?.roster ?? [];
|
||||||
const roomCameras = session?.roomCameras || [];
|
|
||||||
const feeds = useRoomCameraSnapshots(roomCameras.map((camera) => ({ id: camera.id })), {
|
|
||||||
enabled: !inLockdown,
|
|
||||||
version: session?.mode,
|
|
||||||
});
|
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
|
const activeDrivers = session?.activeDrivers || {};
|
||||||
|
const driverRoster = useMemo(
|
||||||
|
() => roster.filter((rover) => activeDrivers[rover.id]),
|
||||||
|
[roster, activeDrivers],
|
||||||
|
);
|
||||||
|
|
||||||
const snapshotFeeds = useRoverSnapshots(
|
const snapshotFeeds = useRoverSnapshots(
|
||||||
roster.map((rover) => rover.id),
|
driverRoster.map((rover) => rover.id),
|
||||||
{ enabled: !inLockdown, version: session?.mode },
|
{ enabled: !inLockdown, version: session?.mode },
|
||||||
);
|
);
|
||||||
const av1Supported = supportsAv1WebRtc();
|
|
||||||
const previewEntries = roster.map((rover) => ({
|
|
||||||
type: 'rover',
|
|
||||||
id: rover.id,
|
|
||||||
key: `rover:${rover.id}:preview:av1`,
|
|
||||||
preview: true,
|
|
||||||
codec: 'av1',
|
|
||||||
}));
|
|
||||||
const roomPreviewEntries = roomCameras.map((camera) => ({
|
|
||||||
type: 'room',
|
|
||||||
id: camera.id,
|
|
||||||
key: `room:${camera.id}:preview:av1`,
|
|
||||||
preview: true,
|
|
||||||
codec: 'av1',
|
|
||||||
}));
|
|
||||||
const previewSources = useVideoRequests(previewEntries, { enabled: !inLockdown && av1Supported });
|
|
||||||
const roomPreviewSources = useVideoRequests(roomPreviewEntries, { enabled: !inLockdown && av1Supported });
|
|
||||||
const audioEntries = useMemo(
|
const audioEntries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
roster.flatMap((rover) => {
|
driverRoster.flatMap((rover) => {
|
||||||
if (!rover?.id || !rover.media?.audioPublishUrl) return [];
|
if (!rover?.id || !rover.media?.audioPublishUrl) return [];
|
||||||
const id = String(rover.id);
|
const id = String(rover.id);
|
||||||
return [{ type: 'rover', id: `${id}-audio`, key: `${id}-audio` }];
|
return [{ type: 'rover', id: `${id}-audio`, key: `${id}-audio` }];
|
||||||
}),
|
}),
|
||||||
[roster],
|
[driverRoster],
|
||||||
);
|
);
|
||||||
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
|
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
|
||||||
|
|
||||||
const roverPool = useMemo(() => {
|
const roverPool = useMemo(() => {
|
||||||
if (!roster.length) return [];
|
if (!driverRoster.length) return [];
|
||||||
const withSnapshot = roster.filter((rover) => snapshotFeeds[rover.id]?.objectUrl);
|
const withSnapshot = driverRoster.filter((rover) => snapshotFeeds[rover.id]?.objectUrl);
|
||||||
return withSnapshot.length ? withSnapshot : roster;
|
return withSnapshot.length ? withSnapshot : driverRoster;
|
||||||
}, [roster, snapshotFeeds]);
|
}, [driverRoster, snapshotFeeds]);
|
||||||
|
|
||||||
const rotationPool = useMemo(() => {
|
const rotationPool = useMemo(() => {
|
||||||
const items = [];
|
return roverPool.map((rover) => ({ type: 'rover', rover }));
|
||||||
roverPool.forEach((rover) => items.push({ type: 'rover', rover }));
|
}, [roverPool]);
|
||||||
roomCameras.forEach((camera) => items.push({ type: 'room', camera }));
|
|
||||||
return items;
|
|
||||||
}, [roverPool, roomCameras]);
|
|
||||||
|
|
||||||
const rotationKey = useMemo(
|
const rotationKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rotationPool
|
rotationPool
|
||||||
.map((entry) =>
|
.map((entry) => `r:${entry.rover.id}`)
|
||||||
entry.type === 'rover' ? `r:${entry.rover.id}` : `room:${entry.camera.id}`,
|
|
||||||
)
|
|
||||||
.join('|'),
|
.join('|'),
|
||||||
[rotationPool],
|
[rotationPool],
|
||||||
);
|
);
|
||||||
@@ -107,21 +83,11 @@ function MiniSummaryContent() {
|
|||||||
|
|
||||||
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
|
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
|
||||||
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
|
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
|
||||||
const activeCamera = activeEntry?.type === 'room' ? activeEntry.camera : null;
|
|
||||||
|
|
||||||
const activeSnapshot = activeRover ? snapshotFeeds[activeRover.id] || null : null;
|
const activeSnapshot = activeRover ? snapshotFeeds[activeRover.id] || null : null;
|
||||||
const activePreview =
|
|
||||||
activeRover && previewSources[`rover:${activeRover.id}:preview:av1`]
|
|
||||||
? previewSources[`rover:${activeRover.id}:preview:av1`]
|
|
||||||
: null;
|
|
||||||
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
|
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
|
||||||
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
|
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
|
||||||
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
|
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
|
||||||
const activeFeed = activeCamera ? feeds[activeCamera.id] || null : null;
|
|
||||||
const activeRoomPreview =
|
|
||||||
activeCamera && roomPreviewSources[`room:${activeCamera.id}:preview:av1`]
|
|
||||||
? roomPreviewSources[`room:${activeCamera.id}:preview:av1`]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (inLockdown) {
|
if (inLockdown) {
|
||||||
return (
|
return (
|
||||||
@@ -145,8 +111,8 @@ function MiniSummaryContent() {
|
|||||||
) : activeRover ? (
|
) : activeRover ? (
|
||||||
<FitViewportFrame>
|
<FitViewportFrame>
|
||||||
<VideoTile
|
<VideoTile
|
||||||
sessionInfo={activePreview?.url ? activePreview : null}
|
sessionInfo={null}
|
||||||
videoMode={activePreview?.url ? 'whep' : 'snapshot'}
|
videoMode="snapshot"
|
||||||
snapshotFeed={activeSnapshot}
|
snapshotFeed={activeSnapshot}
|
||||||
audioSessionInfo={activeAudio}
|
audioSessionInfo={activeAudio}
|
||||||
label={activeRover.name || activeRover.id}
|
label={activeRover.name || activeRover.id}
|
||||||
@@ -155,23 +121,15 @@ function MiniSummaryContent() {
|
|||||||
layoutFormat="mobile"
|
layoutFormat="mobile"
|
||||||
hudVariant="spectator"
|
hudVariant="spectator"
|
||||||
driverLabel={driverLabel}
|
driverLabel={driverLabel}
|
||||||
|
hudLabelScale={5}
|
||||||
hudForceMap
|
hudForceMap
|
||||||
hudMapPosition="bottom-left"
|
hudMapPosition="bottom-left"
|
||||||
fitParent
|
fitParent
|
||||||
/>
|
/>
|
||||||
</FitViewportFrame>
|
</FitViewportFrame>
|
||||||
) : activeCamera ? (
|
|
||||||
<FitViewportFrame>
|
|
||||||
<RoomCameraFrame
|
|
||||||
camera={activeCamera}
|
|
||||||
feed={activeFeed}
|
|
||||||
videoSession={activeRoomPreview}
|
|
||||||
preferVideo={Boolean(activeRoomPreview?.url)}
|
|
||||||
/>
|
|
||||||
</FitViewportFrame>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
||||||
No sources available.
|
{driverRoster.length ? 'No sources available.' : 'No active drivers.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -180,29 +138,26 @@ function MiniSummaryContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MiniSummaryApp() {
|
export default function MiniSummaryApp() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return undefined;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('refresh', Date.now().toString());
|
||||||
|
window.location.replace(url.toString());
|
||||||
|
}, HARD_REFRESH_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<>
|
<>
|
||||||
<MiniSummaryContent />
|
<MiniSummaryContent />
|
||||||
<AlertFeed />
|
<AlertFeed scale={3} />
|
||||||
</>
|
</>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoomCameraFrame({ camera, feed, videoSession, preferVideo }) {
|
|
||||||
return (
|
|
||||||
<div className="relative h-full w-full bg-zinc-950">
|
|
||||||
<RoomCameraFeed
|
|
||||||
feed={feed}
|
|
||||||
label={camera.name || camera.id}
|
|
||||||
videoSession={videoSession}
|
|
||||||
preferVideo={preferVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChatOverlay() {
|
function ChatOverlay() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -225,11 +180,11 @@ function FitViewportFrame({ children }) {
|
|||||||
<div
|
<div
|
||||||
className="relative flex items-center justify-center overflow-hidden bg-black"
|
className="relative flex items-center justify-center overflow-hidden bg-black"
|
||||||
style={{
|
style={{
|
||||||
width: 'min(100%, calc(100vh * 16 / 9))',
|
width: 'min(100%, calc(100vh * 4 / 3))',
|
||||||
height: 'min(100%, calc(100vw * 9 / 16))',
|
height: 'min(100%, calc(100vw * 3 / 4))',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
aspectRatio: '16 / 9',
|
aspectRatio: '4 / 3',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">{children}</div>
|
<div className="flex h-full w-full items-center justify-center overflow-hidden">{children}</div>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import RoverRoster from '../components/RoverRoster.jsx';
|
|||||||
import AlertFeed from '../components/AlertFeed.jsx';
|
import AlertFeed from '../components/AlertFeed.jsx';
|
||||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||||
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
|
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
|
||||||
import { supportsAv1WebRtc } from '../lib/mediaSupport.js';
|
|
||||||
|
|
||||||
function formatDriverLabel({ roverId, session }) {
|
function formatDriverLabel({ roverId, session }) {
|
||||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||||
@@ -26,15 +25,14 @@ function formatDriverLabel({ roverId, session }) {
|
|||||||
return driverText;
|
return driverText;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoverSpectatorCard({ rover, frame, snapshotFeed, previewSession, audioInfo, session }) {
|
function RoverSpectatorCard({ rover, frame, snapshotFeed, audioInfo, session }) {
|
||||||
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
|
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
|
||||||
const hasPreview = Boolean(previewSession?.url);
|
|
||||||
return (
|
return (
|
||||||
<article className="min-h-[16rem] rounded bg-zinc-900 p-0.5 sm:min-h-[18rem]">
|
<article className="min-h-[16rem] rounded bg-zinc-900 p-0.5 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">
|
||||||
<VideoTile
|
<VideoTile
|
||||||
sessionInfo={hasPreview ? previewSession : null}
|
sessionInfo={null}
|
||||||
videoMode={hasPreview ? 'whep' : 'snapshot'}
|
videoMode="snapshot"
|
||||||
snapshotFeed={snapshotFeed}
|
snapshotFeed={snapshotFeed}
|
||||||
audioSessionInfo={audioInfo}
|
audioSessionInfo={audioInfo}
|
||||||
label={rover.name}
|
label={rover.name}
|
||||||
@@ -50,7 +48,7 @@ function RoverSpectatorCard({ rover, frame, snapshotFeed, previewSession, audioI
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoverRow({ roster, frames, snapshotFeeds, previewSources, audioSources, session }) {
|
function RoverRow({ roster, frames, snapshotFeeds, audioSources, session }) {
|
||||||
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>;
|
||||||
}
|
}
|
||||||
@@ -62,7 +60,6 @@ function RoverRow({ roster, frames, snapshotFeeds, previewSources, audioSources,
|
|||||||
rover={rover}
|
rover={rover}
|
||||||
frame={frames[rover.id]}
|
frame={frames[rover.id]}
|
||||||
snapshotFeed={snapshotFeeds[rover.id]}
|
snapshotFeed={snapshotFeeds[rover.id]}
|
||||||
previewSession={previewSources[`rover:${rover.id}:preview:av1`] || null}
|
|
||||||
audioInfo={audioSources[`${rover.id}-audio`]}
|
audioInfo={audioSources[`${rover.id}-audio`]}
|
||||||
session={session}
|
session={session}
|
||||||
showHudMap
|
showHudMap
|
||||||
@@ -107,15 +104,6 @@ function SpectatorContent() {
|
|||||||
roster.map((rover) => rover.id),
|
roster.map((rover) => rover.id),
|
||||||
{ enabled: !inLockdown, version: session?.mode },
|
{ enabled: !inLockdown, version: session?.mode },
|
||||||
);
|
);
|
||||||
const av1Supported = supportsAv1WebRtc();
|
|
||||||
const previewEntries = roster.map((rover) => ({
|
|
||||||
type: 'rover',
|
|
||||||
id: rover.id,
|
|
||||||
key: `rover:${rover.id}:preview:av1`,
|
|
||||||
preview: true,
|
|
||||||
codec: 'av1',
|
|
||||||
}));
|
|
||||||
const previewSources = useVideoRequests(previewEntries, { enabled: !inLockdown && av1Supported, version: session?.mode });
|
|
||||||
const audioEntries = roster.flatMap((rover) =>
|
const audioEntries = roster.flatMap((rover) =>
|
||||||
rover.media?.audioPublishUrl
|
rover.media?.audioPublishUrl
|
||||||
? [{ type: 'rover', id: `${rover.id}-audio`, key: `${rover.id}-audio` }]
|
? [{ type: 'rover', id: `${rover.id}-audio`, key: `${rover.id}-audio` }]
|
||||||
@@ -142,7 +130,6 @@ function SpectatorContent() {
|
|||||||
roster={roster}
|
roster={roster}
|
||||||
frames={frames}
|
frames={frames}
|
||||||
snapshotFeeds={snapshotFeeds}
|
snapshotFeeds={snapshotFeeds}
|
||||||
previewSources={previewSources}
|
|
||||||
audioSources={audioSources}
|
audioSources={audioSources}
|
||||||
session={session}
|
session={session}
|
||||||
/>
|
/>
|
||||||
@@ -154,7 +141,7 @@ function SpectatorContent() {
|
|||||||
<RoverRoster roster={roster} title="Rovers" emptyText="No rovers registered." />
|
<RoverRoster roster={roster} title="Rovers" emptyText="No rovers registered." />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||||
<UserListPanel hideNicknameForm hideHeader fillHeight className="h-full" />
|
<UserListPanel hideNicknameForm hideHeader fillHeight className="h-full" showBothTurnsAndUsers />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 min-w-0 flex-[1.1] overflow-hidden">
|
<div className="min-h-0 min-w-0 flex-[1.1] overflow-hidden">
|
||||||
<ChatPanel hideInput hideSpectatorNotice fillHeight />
|
<ChatPanel hideInput hideSpectatorNotice fillHeight />
|
||||||
|
|||||||
Reference in New Issue
Block a user