From c915ab330fd31881df0459ab830b49e25e090c49 Mon Sep 17 00:00:00 2001 From: ConnorN Date: Sat, 13 Dec 2025 12:27:30 -0500 Subject: [PATCH 1/4] feat: add video controls to main dashboard --- src/components/SrtStats.tsx | 180 ++++++++++ src/components/VideoCustomPresetForm.tsx | 270 +++++++++++++++ ...PresetsPanel.tsx => VideoPresetsPanel.tsx} | 57 ++-- src/components/WebRTCClientPage.tsx | 8 +- src/components/WebRTCCustomPresetForm.tsx | 186 ----------- src/components/panels/GasSensor.tsx | 9 - src/components/panels/GoalSetterPanel.tsx | 1 - src/components/panels/MosaicDashboard.tsx | 47 ++- .../panels/NetworkHealthTelemetryPanel.tsx | 13 - src/components/panels/NodeManagerPanel.tsx | 110 ------ .../panels/OrientationDisplayPanel.tsx | 1 - .../panels/SystemTelemetryPanel.tsx | 1 - src/components/panels/VideoControls.tsx | 156 +++++++++ src/components/panels/WaypointList.tsx | 1 - src/components/panels/WebRTCClientPanel.tsx | 316 ------------------ 15 files changed, 665 insertions(+), 691 deletions(-) create mode 100644 src/components/SrtStats.tsx create mode 100644 src/components/VideoCustomPresetForm.tsx rename src/components/{WebRTCPresetsPanel.tsx => VideoPresetsPanel.tsx} (65%) delete mode 100644 src/components/WebRTCCustomPresetForm.tsx delete mode 100644 src/components/panels/NodeManagerPanel.tsx create mode 100644 src/components/panels/VideoControls.tsx delete mode 100644 src/components/panels/WebRTCClientPanel.tsx diff --git a/src/components/SrtStats.tsx b/src/components/SrtStats.tsx new file mode 100644 index 0000000..098c2a1 --- /dev/null +++ b/src/components/SrtStats.tsx @@ -0,0 +1,180 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import ROSLIB from "roslib"; +import { useROS } from "@/ros/ROSContext"; + +type SrtStatsMsg = { + rtt: number; // seconds + bandwidth: number; // bits/sec + packets_sent: number; + packets_lost: number; + packets_retransmitted: number; +}; + +const formatNumber = (v: number | null | undefined) => { + if (v === null || v === undefined) return "—"; + return v.toLocaleString(); +}; + +const formatSecondsMs = (sec: number | null | undefined) => { + if (sec === null || sec === undefined) return "—"; + const ms = sec * 1000.0; + if (!Number.isFinite(ms)) return "—"; + return ms >= 10 ? `${ms.toFixed(0)} ms` : `${ms.toFixed(1)} ms`; +}; + +const formatBandwidth = (bps: number | null | undefined) => { + if (bps === null || bps === undefined) return "—"; + if (!Number.isFinite(bps)) return "—"; + const abs = Math.abs(bps); + const units = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"]; + let i = 0; + let val = abs; + while (val >= 1000 && i < units.length - 1) { + val /= 1000; + i++; + } + const sign = bps < 0 ? "-" : ""; + const fixed = val >= 100 ? 0 : val >= 10 ? 1 : 2; + return `${sign}${val.toFixed(fixed)} ${units[i]}`; +}; + +const SrtStats: React.FC = () => { + const { ros, connectionStatus } = useROS(); + + const [stats, setStats] = useState(null); + const [lastUpdateMs, setLastUpdateMs] = useState(null); + + useEffect(() => { + if (!ros || connectionStatus !== "connected") { + setStats(null); + setLastUpdateMs(null); + return; + } + + const topic = new ROSLIB.Topic({ + ros, + name: "/srt_node/stats", + messageType: "interfaces/msg/srtstats", + }); + + const onMsg = (msg: any) => { + setStats({ + rtt: Number(msg.rtt), + bandwidth: Number(msg.bandwidth), + packets_sent: Number(msg.packets_sent), + packets_lost: Number(msg.packets_lost), + packets_retransmitted: Number(msg.packets_retransmitted), + }); + setLastUpdateMs(Date.now()); + }; + + topic.subscribe(onMsg); + + return () => { + topic.unsubscribe(onMsg); + }; + }, [ros, connectionStatus]); + + const derived = useMemo(() => { + if (!stats) return { lossPct: null as number | null, retransPct: null as number | null }; + + const sent = stats.packets_sent; + const lost = stats.packets_lost; + const retrans = stats.packets_retransmitted; + + const lossPct = + Number.isFinite(sent) && sent > 0 && Number.isFinite(lost) ? (lost / sent) * 100 : null; + + const retransPct = + Number.isFinite(sent) && sent > 0 && Number.isFinite(retrans) ? (retrans / sent) * 100 : null; + + return { lossPct, retransPct }; + }, [stats]); + + const stale = + lastUpdateMs !== null ? Date.now() - lastUpdateMs > 2000 : true; + + return ( +
+
+
+

+ SRT Stats: +

+
+
+
+ RTT + {formatSecondsMs(stats?.rtt)} +
+ +
+ Bandwidth + {formatBandwidth(stats?.bandwidth)} +
+ +
+ Sent + {formatNumber(stats?.packets_sent)} +
+ +
+ Lost + + {formatNumber(stats?.packets_lost)} + {derived.lossPct !== null ? ` (${derived.lossPct.toFixed(2)}%)` : ""} + +
+ +
+ Retrans + + {formatNumber(stats?.packets_retransmitted)} + {derived.retransPct !== null ? ` (${derived.retransPct.toFixed(2)}%)` : ""} + +
+ +
+ Updated + + {lastUpdateMs ? `${Math.max(0, Date.now() - lastUpdateMs)} ms ago` : "—"} + +
+
+
+
+ ); +}; + +export default SrtStats; diff --git a/src/components/VideoCustomPresetForm.tsx b/src/components/VideoCustomPresetForm.tsx new file mode 100644 index 0000000..02fa7f9 --- /dev/null +++ b/src/components/VideoCustomPresetForm.tsx @@ -0,0 +1,270 @@ +"use client"; + +import React, { useState } from "react"; +import CameraSourceDropdown from "./CameraSourceDropdown"; +import { VideoOutRequest, VideoSource } from "./WebRTCClientPage"; + + +interface CustomPresetFormProps { + onSubmit: (preset: VideoOutRequest) => void; +} + +const VideoCustomPresetForm: React.FC = ({ onSubmit }) => { + const [sources, setSources] = useState([ + { name: "Source1", width: 100, height: 100, origin_x: 0, origin_y: 0 }, + ]); + + const handleSourceChange = (index: number, field: keyof VideoSource, value: string | number) => { + const updatedSources = [...sources]; + if (field === "name") { + updatedSources[index][field] = value as string; + } else { + updatedSources[index][field] = Number(value); + } + setSources(updatedSources); + }; + + const addSource = () => { + setSources([ + ...sources, + { name: `Source${sources.length + 1}`, width: 100, height: 100, origin_x: 0, origin_y: 0 }, + ]); + }; + + const removeSource = (index: number) => { + const updatedSources = sources.filter((_, i) => i !== index); + setSources(updatedSources); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const preset: VideoOutRequest = { + num_sources: sources.length, + sources, + }; + onSubmit(preset); + }; + const fieldStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "0.25rem", + color: "#aaa", + fontSize: "0.85rem", + flex: "1 1 0", + }; + + const inputStyle: React.CSSProperties = { + width: "100%", + padding: "0.5rem", + borderRadius: "0.25rem", + border: "1px solid #444", + background: "#1e1e1e", + color: "#f1f1f1", + }; + + const rowStyle: React.CSSProperties = { + display: "flex", + gap: "0.5rem", + }; + + return ( +
+ {/* Header (fixed) */} +
+

+ Custom Preset +

+ + Sources: {sources.length} + +
+ + {/* Sources list (scrolls) */} +
+ {sources.map((source, index) => ( +
+

+ Source {index + 1} +

+ + + +
+ + + +
+ +
+ + + +
+ + +
+ ))} +
+ +
+ + + +
+
+ ); +}; +export default VideoCustomPresetForm; diff --git a/src/components/WebRTCPresetsPanel.tsx b/src/components/VideoPresetsPanel.tsx similarity index 65% rename from src/components/WebRTCPresetsPanel.tsx rename to src/components/VideoPresetsPanel.tsx index f6f1fb9..683d783 100644 --- a/src/components/WebRTCPresetsPanel.tsx +++ b/src/components/VideoPresetsPanel.tsx @@ -1,23 +1,15 @@ "use client"; import React from "react"; -import { VideoOutRequest } from "./WebRTCClientPage"; // or from a shared types file if you have one +import { VideoOutRequest } from "./WebRTCClientPage"; -// Prop Types -interface WebRTCPresetsPanelProps { +interface VideoPresetsPanelProps { onPresetSelect: (presetName: string, preset: VideoOutRequest) => void; } -// Inline Styles -const buttonStyle: React.CSSProperties = { - fontSize: "1.5rem", - width: "100%", - height: "100%", -}; - -// Component -const WebRTCPresetsPanel: React.FC = ({ onPresetSelect }) => { - // Local preset definitions +const VideoPresetsPanel: React.FC = ({ + onPresetSelect, +}) => { const presets: { name: string; preset: VideoOutRequest }[] = [ { name: "Drive", @@ -52,9 +44,9 @@ const WebRTCPresetsPanel: React.FC = ({ onPresetSelect preset: { num_sources: 2, sources: [ - { name: "Drive", width: 100, height: 100, origin_x:0, origin_y: 0}, - { name: "EndEffector", width: 30, height: 30, origin_x: 70, origin_y: 0} - ] + { name: "Drive", width: 100, height: 100, origin_x: 0, origin_y: 0 }, + { name: "EndEffector", width: 30, height: 30, origin_x: 70, origin_y: 0 }, + ], }, }, { @@ -62,19 +54,36 @@ const WebRTCPresetsPanel: React.FC = ({ onPresetSelect preset: { num_sources: 2, sources: [ - { name: "EndEffector", width: 100, height: 100, origin_x: 0, origin_y: 0}, - { name: "Drive", width: 30, height: 30, origin_x: 70, origin_y: 0} - ] - } - } + { name: "EndEffector", width: 100, height: 100, origin_x: 0, origin_y: 0 }, + { name: "Drive", width: 30, height: 30, origin_x: 70, origin_y: 0 }, + ], + }, + }, ]; + const buttonStyle = (): React.CSSProperties => ({ + border: "1px solid #444", + borderRadius: "4px", + backgroundColor: "#1e1e1e", + color: "#f1f1f1", + padding: "0.45rem 0.6rem", + fontSize: "0.85rem", + whiteSpace: "nowrap", + }); + return ( -
+
{presets.map(({ name, preset }) => (
diff --git a/src/components/WebRTCCustomPresetForm.tsx b/src/components/WebRTCCustomPresetForm.tsx deleted file mode 100644 index 3f52941..0000000 --- a/src/components/WebRTCCustomPresetForm.tsx +++ /dev/null @@ -1,186 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import CameraSourceDropdown from "./CameraSourceDropdown"; -import { VideoOutRequest, VideoSource } from "./WebRTCClientPage"; - - -interface CustomPresetFormProps { - onSubmit: (preset: VideoOutRequest) => void; -} - -const WebRTCCustomPresetForm: React.FC = ({ onSubmit }) => { - const [sources, setSources] = useState([ - { name: "Source1", width: 100, height: 100, origin_x: 0, origin_y: 0 }, - ]); - - const handleSourceChange = (index: number, field: keyof VideoSource, value: string | number) => { - const updatedSources = [...sources]; - if (field === "name") { - updatedSources[index][field] = value as string; - } else { - updatedSources[index][field] = Number(value); - } - setSources(updatedSources); - }; - - const addSource = () => { - setSources([ - ...sources, - { name: `Source${sources.length + 1}`, width: 100, height: 100, origin_x: 0, origin_y: 0 }, - ]); - }; - - const removeSource = (index: number) => { - const updatedSources = sources.filter((_, i) => i !== index); - setSources(updatedSources); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const preset: VideoOutRequest = { - num_sources: sources.length, - sources, - }; - onSubmit(preset); - }; - - return ( -
-

Custom Preset

- - {sources.map((source, index) => ( -
-

Source {index + 1}

- - - - - - - {/* Remove Source Button */} - -
- ))} - - - - -
- ); -}; - -export default WebRTCCustomPresetForm; diff --git a/src/components/panels/GasSensor.tsx b/src/components/panels/GasSensor.tsx index d670dcb..60656ee 100644 --- a/src/components/panels/GasSensor.tsx +++ b/src/components/panels/GasSensor.tsx @@ -102,15 +102,6 @@ return ( height: '100%', padding: '1rem' }}> -

- Science -

  • { return (
    -

    Set Navigation Goal

    { // TODO: paramaterize layout for custom layout configs @@ -29,27 +28,30 @@ const MosaicDashboard: React.FC = () => { first: 'mapView', second: { direction: 'row', - first: 'goalSetter', + first: { + direction: 'row', + first: 'rosMonitor', + second: 'networkHealthMonitor', + }, second: 'orientationDisplay', - splitPercentage: 50, + splitPercentage: 55, }, - splitPercentage: 50, + splitPercentage: 55, }, second: { direction: 'column', - first: { - direction: 'row', - first: 'rosMonitor', - second: 'networkHealthMonitor', - splitPercentage: 60 - }, + first: 'videoControls', second: { direction: 'row', first: 'waypointList', - second: 'gasSensor', - splitPercentage: 70, + second: { + direction: 'row', + first: 'gasSensor', + second: 'goalSetter', + }, + splitPercentage: 50, }, - splitPercentage: 40, + splitPercentage: 50, }, splitPercentage: 60, }); @@ -70,16 +72,10 @@ const MosaicDashboard: React.FC = () => { ); - case 'nodeManager': - return ( - title="Node Manager" path={path}> - - - ); - case 'webrtcStream': + case 'videoControls': return ( title="Video Stream" path={path}> - + ); case 'rosMonitor': @@ -109,7 +105,7 @@ const MosaicDashboard: React.FC = () => { case 'goalSetter': return ( - title="Goal Setter" path={path}> + title="Nav2" path={path}> ); @@ -137,7 +133,8 @@ const MosaicDashboard: React.FC = () => { } .mosaic-window-title { background-color: #2d2d2d; - color: #f1f1f1; + color: #f1f1f1 !important; + font-size: 1.25rem; border-bottom: 1px solid #444; } .mosaic-window-body { diff --git a/src/components/panels/NetworkHealthTelemetryPanel.tsx b/src/components/panels/NetworkHealthTelemetryPanel.tsx index c29bb2e..7496694 100644 --- a/src/components/panels/NetworkHealthTelemetryPanel.tsx +++ b/src/components/panels/NetworkHealthTelemetryPanel.tsx @@ -83,19 +83,6 @@ const NetworkHealthTelemetryPanel: React.FC = () => { flexDirection: "column", }} > -

    - Connection Health (Mbps) -

    diff --git a/src/components/panels/NodeManagerPanel.tsx b/src/components/panels/NodeManagerPanel.tsx deleted file mode 100644 index 6c292c7..0000000 --- a/src/components/panels/NodeManagerPanel.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client'; - -import React, { useEffect, useState } from 'react'; -import { useROS } from '@/ros/ROSContext'; -import ROSLIB from 'roslib'; - -interface NodeInfo { - name: string; - status: 'running' | 'stopped' | 'error'; -} - -const NodeManagerPanel: React.FC = () => { - const { ros, connectionStatus } = useROS(); - const [nodes, setNodes] = useState([]); - const [loading, setLoading] = useState(false); - - const listNodes = () => { - if (!ros || connectionStatus !== 'connected') return; - setLoading(true); - const listService = new ROSLIB.Service({ - ros: ros, - name: '/node_manager/list_nodes', - serviceType: 'NodeManager/ListNodes', - }); - const request = new ROSLIB.ServiceRequest({}); - listService.callService(request, (result: any) => { - setNodes(result.nodes); - setLoading(false); - }); - }; - - // get node list on connect - useEffect(() => { - if (ros && connectionStatus === 'connected') { - listNodes(); - } - }, [ros, connectionStatus]); - - // service to do basic operations on the nodes and shit - const callNodeService = (serviceName: 'start' | 'stop' | 'restart', nodeName: string) => { - if (!ros || connectionStatus !== 'connected') return; - const service = new ROSLIB.Service({ - ros: ros, - name: `/node_manager/${serviceName}`, - serviceType: `NodeManager/${capitalizeFirstLetter(serviceName)}Node`, - }); - const request = new ROSLIB.ServiceRequest({ node_name: nodeName }); - service.callService(request, (result: any) => { - console.log(`${serviceName} result: `, result); - // run it back - listNodes(); - }); - }; - - const handleStart = (nodeName: string) => callNodeService('start', nodeName); - const handleStop = (nodeName: string) => callNodeService('stop', nodeName); - const handleRestart = (nodeName: string) => callNodeService('restart', nodeName); - - const capitalizeFirstLetter = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - - return ( -
    -

    Node Manager

    - {connectionStatus !== 'connected' ? ( -

    Not connected to ROS. Please ensure the connection is active.

    - ) : ( - <> - - {loading ? ( -

    Loading nodes...

    - ) : ( - - - - - - - - - - {nodes.map((node) => ( - - - - - - ))} - -
    Node NameStatusActions
    {node.name}{node.status} - - - -
    - )} - - )} -
    - ); -}; - -export default NodeManagerPanel; - diff --git a/src/components/panels/OrientationDisplayPanel.tsx b/src/components/panels/OrientationDisplayPanel.tsx index b06ac24..f828c85 100644 --- a/src/components/panels/OrientationDisplayPanel.tsx +++ b/src/components/panels/OrientationDisplayPanel.tsx @@ -204,7 +204,6 @@ const OrientationDisplayPanel: React.FC = () => { return (
    -

    UGV Orientation

    {getBusVoltage() ? `Bus Voltage: ${getBusVoltage()} V` : 'Waiting for voltage...'}
    {renderMotorInfo('Front Left', motorStats.fl)}
    diff --git a/src/components/panels/SystemTelemetryPanel.tsx b/src/components/panels/SystemTelemetryPanel.tsx index 500e3cd..3689963 100644 --- a/src/components/panels/SystemTelemetryPanel.tsx +++ b/src/components/panels/SystemTelemetryPanel.tsx @@ -57,7 +57,6 @@ const SystemTelemetryPanel: React.FC = () => { return (
    -

    System Telemetry

    diff --git a/src/components/panels/VideoControls.tsx b/src/components/panels/VideoControls.tsx new file mode 100644 index 0000000..c19685f --- /dev/null +++ b/src/components/panels/VideoControls.tsx @@ -0,0 +1,156 @@ +'use client'; + +import React from "react"; +import VideoCustomPresetForm from "../VideoCustomPresetForm"; +import ROSLIB from "roslib"; +import { useROS } from "@/ros/ROSContext"; +import VideoPresetsPanel from "../VideoPresetsPanel"; +import SrtStats from "../SrtStats"; + + +export interface VideoSource { + name: string; + width: number; + height: number; + origin_x: number; + origin_y: number; +} + +interface VideoOutResponse { + success: boolean; +} + +export interface VideoOutRequest { + num_sources: number; + sources: VideoSource[]; +} + +const VideoControls: React.FC = () => { + const { ros, connectionStatus: rosStatus } = useROS(); + + const newPreset = (presetName: string, camRequest: VideoOutRequest) => { + console.log(`Setting new video preset: ${presetName}`, camRequest); + + if (!ros || rosStatus !== "connected") { + console.error("Not connected to ROS"); + return; + } + + const startVideoSrv = new ROSLIB.Service({ + ros, + name: "/start_video", + serviceType: "interfaces/srv/VideoOut", + }); + + startVideoSrv.callService( + new ROSLIB.ServiceRequest(camRequest), + (response: VideoOutResponse) => { + console[response.success ? "log" : "error"]( + response.success + ? `Video stream set to new preset ${presetName}` + : `Failed to change video preset to ${presetName}`, + ); + }, + ); + }; + + // callbacks intentionally blank for now + const onRestart = () => {}; + const onSnapshot = () => {}; + const onPanoramic = () => {}; + + const connected = !!ros && rosStatus === "connected"; + + const buttonStyle = (enabled: boolean) => ({ + border: "1px solid #444", + borderRadius: "4px", + backgroundColor: enabled ? "#1e1e1e" : "#222", + color: enabled ? "#f1f1f1" : "#777", + padding: "0.45rem 0.6rem", + fontSize: "0.85rem", + }); + + return ( +
    +
    + {/* Left half: controls */} +
    +
    +
    + Quick Controls +
    + +
    + + + + +
    + +
    + newPreset(name, preset)} + /> +
    +
    + +
    + + {/* Right half: form */} +
    + newPreset("Custom", preset)} /> +
    +
    +
    + ); +}; + +export default VideoControls; diff --git a/src/components/panels/WaypointList.tsx b/src/components/panels/WaypointList.tsx index 4395b01..686b48c 100644 --- a/src/components/panels/WaypointList.tsx +++ b/src/components/panels/WaypointList.tsx @@ -36,7 +36,6 @@ const WaypointList: React.FC = () => { color: '#f1f1f1', }} > -

    Waypoint List

    {waypoints.length === 0 ? (

    No waypoints added. Click on the map to add one👿

    ) : ( diff --git a/src/components/panels/WebRTCClientPanel.tsx b/src/components/panels/WebRTCClientPanel.tsx deleted file mode 100644 index 3f9027e..0000000 --- a/src/components/panels/WebRTCClientPanel.tsx +++ /dev/null @@ -1,316 +0,0 @@ -'use client'; - -import React, { useEffect, useRef, useState } from 'react'; -import ROSLIB from 'roslib'; -import { useROS } from '@/ros/ROSContext'; - -export interface VideoSource { - name: string; - width: number; - height: number; - origin_x: number; - origin_y: number; -} - -export interface VideoOutRequest { - height: number; - width: number; - framerate: number; - num_sources: number; - sources: VideoSource[]; -} - -export interface VideoOutResponse { - success: boolean; -} - -export interface WebRTCClientConfig { - signalingUrl?: string; - stunServers?: string[]; // tbh we dont need any stun servers, we arent traversing NAT - videoServiceName?: string; - videoServiceMessageType?: string; - defaultVideoRequest?: VideoOutRequest; - mockMode?: boolean; -} - -export interface WebRTCClientPanelProps { - config?: WebRTCClientConfig; - createPeerConnection?: (config: WebRTCClientConfig) => RTCPeerConnection; - createWebSocket?: (url: string) => WebSocket; -} - -const defaultConfig: WebRTCClientConfig = { - signalingUrl: 'ws://localhost:8080/signalling', - stunServers: ['stun:stun.l.google.com:19302'], // like same comment here, very stunning but not needed - videoServiceName: 'start_video', - videoServiceMessageType: 'interfaces/srv/VideoOut', - defaultVideoRequest: { - height: 480, - width: 640, - framerate: 30, - num_sources: 1, - sources: [ - { - name: 'test', - width: 100, - height: 100, - origin_x: 0, - origin_y: 0, - }, - ], - }, - mockMode: false, -}; - -const WebRTCClientPanel: React.FC = ({ - config = defaultConfig, - createPeerConnection, - createWebSocket, -}) => { - const videoRef = useRef(null); - const [pc, setPc] = useState(null); - const [socket, setSocket] = useState(null); - const [webrtcStatus, setWebrtcStatus] = useState('disconnected'); - const [streamStarted, setStreamStarted] = useState(false); - const [stats, setStats] = useState(null); - - const { ros, connectionStatus: rosStatus } = useROS(); - - // wtf even is this syntax, i hate react - const createPC = - createPeerConnection ?? - ((cfg: WebRTCClientConfig) => - new RTCPeerConnection({ - iceServers: (cfg.stunServers ?? defaultConfig.stunServers!).map((url) => ({ - urls: url, - })), - })); - const createWS = createWebSocket ?? ((url: string) => new WebSocket(url)); - - // async that shit, for setting up webrtc - const setupWebRTC = async () => { - const peerConnection = createPC(config); - setPc(peerConnection); - - peerConnection.onconnectionstatechange = () => { - setWebrtcStatus(peerConnection.connectionState); - }; - - // this one only sets up video receive cause that is all we are doing - peerConnection.addTransceiver('video', { direction: 'recvonly' }); - peerConnection.ontrack = (event) => { - if (videoRef.current) { - videoRef.current.srcObject = event.streams[0]; - console.log('Remote track event:', event.streams); - } - }; - - // ICE on my wrist - peerConnection.onicecandidate = (event) => { - if (event.candidate && socket && socket.readyState === WebSocket.OPEN) { - socket.send( - JSON.stringify({ - type: 'candidate', - candidate: event.candidate, - }) - ); - } - }; - - // important signalling - const ws = createWS(config.signalingUrl || defaultConfig.signalingUrl!); - setSocket(ws); - - ws.onopen = async () => { - // react shit here and there again - setWebrtcStatus('connecting'); - - const offer = await peerConnection.createOffer(); - await peerConnection.setLocalDescription(offer); - ws.send( - JSON.stringify({ - type: 'newPeer', - peerId: crypto.randomUUID(), - roles: ['consumer'], - meta: null, - sdp: offer.sdp, - }) - ); - }; - - ws.onmessage = async (message) => { - const data = JSON.parse(message.data); - if (data.type === 'answer' || data.type === 'peer') { - const answer = new RTCSessionDescription({ type: 'answer', sdp: data.sdp }); - await peerConnection.setRemoteDescription(answer); - } else if (data.type === 'candidate') { - try { - await peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate)); - } catch (e) { - console.error('Error adding ICE candidate', e); - } - } - }; - - ws.onclose = () => { - setWebrtcStatus('disconnected'); - }; - }; - - // more react bullshit to get the stats of the webrtc stream - useEffect(() => { - let interval: NodeJS.Timeout; - if (pc) { - interval = setInterval(async () => { - const statsReport = await pc.getStats(); - const statsArray: any[] = []; - statsReport.forEach((report) => { - statsArray.push(report); - }); - setStats(statsArray); - }, 1000); - } - return () => { - if (interval) clearInterval(interval); - }; - }, [pc]); - - const startVideoService = () => { - if (config.mockMode) { - console.log('Mock mode enabled, bypassing ROS2 services and what not'); - setStreamStarted(true); - setupWebRTC(); - return; - } - - if (!ros || rosStatus !== 'connected') { - console.error('Not connected to ROS'); - return; - } - - const startVideoSrv = new ROSLIB.Service({ - ros, - name: config.videoServiceName || defaultConfig.videoServiceName!, - serviceType: config.videoServiceMessageType || defaultConfig.videoServiceMessageType!, - }); - - const request: VideoOutRequest = config.defaultVideoRequest || defaultConfig.defaultVideoRequest!; - startVideoSrv.callService(new ROSLIB.ServiceRequest(request), (response: VideoOutResponse) => { - if (response.success) { - console.log('Video stream started successfully on rover.'); - setStreamStarted(true); - setupWebRTC(); - } else { - console.error('Failed to start video stream on rover.'); - } - }); - }; - - const stopVideoStream = () => { - if (pc) { - pc.close(); - setPc(null); - } - if (socket) { - socket.close(); - setSocket(null); - } - setStreamStarted(false); - setWebrtcStatus('disconnected'); - }; - - // cleaning up on unmount ;) 🍆🍆 - useEffect(() => { - return () => { - if (pc) pc.close(); - if (socket) socket.close(); - }; - }, [pc, socket]); - - return ( -
    -

    WebRTC Video Stream

    -
    - Status: {webrtcStatus} - {streamStarted && ( - - )} -
    - {!streamStarted && ( - - )} -
    -
    -
    - ); -}; - -export default WebRTCClientPanel; - From 4efab95b84eff0b99163ad5319a5dfcd7356e3b2 Mon Sep 17 00:00:00 2001 From: ConnorN Date: Sat, 13 Dec 2025 13:17:03 -0500 Subject: [PATCH 2/4] Apply suggestions from code review --- src/components/SrtStats.tsx | 27 +++++++++++------------- src/components/VideoCustomPresetForm.tsx | 1 + src/components/panels/VideoControls.tsx | 24 +++++---------------- 3 files changed, 18 insertions(+), 34 deletions(-) diff --git a/src/components/SrtStats.tsx b/src/components/SrtStats.tsx index 098c2a1..69bb524 100644 --- a/src/components/SrtStats.tsx +++ b/src/components/SrtStats.tsx @@ -56,7 +56,7 @@ const SrtStats: React.FC = () => { const topic = new ROSLIB.Topic({ ros, name: "/srt_node/stats", - messageType: "interfaces/msg/srtstats", + messageType: "interfaces/msg/SrtStats", }); const onMsg = (msg: any) => { @@ -93,9 +93,6 @@ const SrtStats: React.FC = () => { return { lossPct, retransPct }; }, [stats]); - const stale = - lastUpdateMs !== null ? Date.now() - lastUpdateMs > 2000 : true; - return (
    { padding: "0.6rem", }} > -
    -

    + borderBottom: "1px solid #444", + paddingBottom: "0.3rem", + flex: "0 0 auto", + }} + > +

    SRT Stats: -

    +
    = ({ onSubmit }) => ); }; + export default VideoCustomPresetForm; diff --git a/src/components/panels/VideoControls.tsx b/src/components/panels/VideoControls.tsx index c19685f..872fb86 100644 --- a/src/components/panels/VideoControls.tsx +++ b/src/components/panels/VideoControls.tsx @@ -6,25 +6,11 @@ import ROSLIB from "roslib"; import { useROS } from "@/ros/ROSContext"; import VideoPresetsPanel from "../VideoPresetsPanel"; import SrtStats from "../SrtStats"; - - -export interface VideoSource { - name: string; - width: number; - height: number; - origin_x: number; - origin_y: number; -} +import type { VideoSource, VideoOutRequest } from "../WebRTCClientPage"; interface VideoOutResponse { success: boolean; } - -export interface VideoOutRequest { - num_sources: number; - sources: VideoSource[]; -} - const VideoControls: React.FC = () => { const { ros, connectionStatus: rosStatus } = useROS(); @@ -130,7 +116,7 @@ const VideoControls: React.FC = () => { }} > newPreset(name, preset)} + onPresetSelect={(name, preset) => newPreset(name, preset)} />
    @@ -139,14 +125,14 @@ const VideoControls: React.FC = () => { {/* Right half: form */}
    - newPreset("Custom", preset)} /> + newPreset("Custom", preset)} />
    From 4b24e00d141ab7dd3f4f51e250034a59c795b05b Mon Sep 17 00:00:00 2001 From: ConnorN Date: Sat, 13 Dec 2025 13:26:26 -0500 Subject: [PATCH 3/4] fix: remove unnecessary buffer zone --- src/components/panels/VideoControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/panels/VideoControls.tsx b/src/components/panels/VideoControls.tsx index 872fb86..2993251 100644 --- a/src/components/panels/VideoControls.tsx +++ b/src/components/panels/VideoControls.tsx @@ -65,7 +65,7 @@ const VideoControls: React.FC = () => { padding: "1rem", }} > -
    +
    {/* Left half: controls */}
    Date: Sat, 13 Dec 2025 18:35:22 -0500 Subject: [PATCH 4/4] fix: code review suggestions --- src/components/SrtStats.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/SrtStats.tsx b/src/components/SrtStats.tsx index 69bb524..de6de64 100644 --- a/src/components/SrtStats.tsx +++ b/src/components/SrtStats.tsx @@ -35,9 +35,8 @@ const formatBandwidth = (bps: number | null | undefined) => { val /= 1000; i++; } - const sign = bps < 0 ? "-" : ""; const fixed = val >= 100 ? 0 : val >= 10 ? 1 : 2; - return `${sign}${val.toFixed(fixed)} ${units[i]}`; + return `${val.toFixed(fixed)} ${units[i]}`; }; const SrtStats: React.FC = () => { @@ -60,13 +59,14 @@ const SrtStats: React.FC = () => { }); const onMsg = (msg: any) => { - setStats({ - rtt: Number(msg.rtt), - bandwidth: Number(msg.bandwidth), - packets_sent: Number(msg.packets_sent), - packets_lost: Number(msg.packets_lost), - packets_retransmitted: Number(msg.packets_retransmitted), - }); + const newData: SrtStatsMsg = { + rtt: msg.rtt, + bandwidth: msg.bandwidth, + packets_sent: msg.packets_sent, + packets_lost: msg.packets_lost, + packets_retransmitted: msg.packets_retransmitted, + }; + setStats(newData); setLastUpdateMs(Date.now()); };