diff --git a/server/public/index.html b/server/public/index.html index 470c148e..bb34ee51 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -15,6 +15,10 @@ #authPanel { margin: 12px 0; display: flex; gap: 8px; align-items: center; } #authPanel input { padding: 4px; } #activeDriver { margin-bottom: 8px; font-weight: bold; } + #videoPanel { margin-bottom: 12px; } + #videoContainer { width: 100%; background: #000; min-height: 200px; display: flex; align-items: center; justify-content: center; } + #videoContainer video { width: 100%; max-height: 320px; background: #000; } + #videoStatus { margin-top: 6px; font-size: 0.9rem; color: #444; } @@ -61,8 +65,13 @@

Sensor Frames

Active rover: --
Driver: --
- - +
+
+ +
+
No rover selected
+ +

         

Use WASD keys to drive the selected rover. Shift increases speed.

@@ -75,6 +84,7 @@ + diff --git a/server/public/spectate/index.html b/server/public/spectate/index.html index c0601dd9..5938b2eb 100644 --- a/server/public/spectate/index.html +++ b/server/public/spectate/index.html @@ -11,6 +11,9 @@ .rover-card h3 { margin: 0 0 8px 0; } .sensor { font-family: monospace; white-space: pre-wrap; word-break: break-word; } .meta { font-size: 0.9rem; color: #555; margin-bottom: 8px; } + .video-wrap { background: #000; min-height: 160px; margin-bottom: 6px; display: flex; align-items: center; justify-content: center; } + .video-wrap video { width: 100%; max-height: 200px; background: #000; } + .video-status { font-size: 0.85rem; color: #444; margin-bottom: 4px; } @@ -24,6 +27,7 @@ + diff --git a/server/public/spectate/spectator.js b/server/public/spectate/spectator.js index a26b1c3d..1b540675 100644 --- a/server/public/spectate/spectator.js +++ b/server/public/spectate/spectator.js @@ -1,5 +1,6 @@ registerModule('spectator/main', (require, exports) => { const { socket } = require('globals/socket'); + const videoPlayer = require('services/videoPlayer'); const statusEl = document.getElementById('status'); const grid = document.getElementById('grid'); @@ -11,19 +12,23 @@ registerModule('spectator/main', (require, exports) => { socket.on('disconnect', () => { statusEl.textContent = 'Disconnected'; + stopAllVideos(); }); socket.on('lockdown', ({ message }) => { statusEl.textContent = message || 'Spectator mode disabled'; + stopAllVideos(); }); socket.on('rovers', (list) => { - const seen = new Set(); list.forEach((rover) => { const card = ensureCard(rover.id); card.querySelector('.meta').textContent = `Locked: ${rover.locked ? 'yes' : 'no'} | Last seen: ${new Date(rover.lastSeen).toLocaleTimeString()}`; + if (!card.dataset.videoActive) { + connectVideo(rover.id); + } }); - cards.forEach((_, id) => { + Array.from(cards.keys()).forEach((id) => { if (!list.find((r) => r.id === id)) { removeCard(id); } @@ -41,23 +46,85 @@ registerModule('spectator/main', (require, exports) => { } const card = document.createElement('div'); card.className = 'rover-card'; + card.dataset.videoActive = ''; card.innerHTML = `

${roverId}

+
+
Video not connected
+
Waiting for data...
`; + const reconnectBtn = card.querySelector('.video-reconnect'); + reconnectBtn.addEventListener('click', () => connectVideo(roverId, true)); grid.appendChild(card); cards.set(roverId, card); + connectVideo(roverId, true); return card; } + function connectVideo(roverId, force = false) { + const card = ensureCard(roverId); + const status = card.querySelector('.video-status'); + const wrap = card.querySelector('.video-wrap'); + const playerId = `spectator-${roverId}`; + if (!force && card.dataset.videoActive === 'yes') { + return; + } + status.textContent = 'Connecting video…'; + socket.emit('video:request', { roverId }, (resp = {}) => { + if (resp.error) { + status.textContent = `Video error: ${resp.error}`; + videoPlayer.stopStream(playerId); + card.dataset.videoActive = ''; + return; + } + videoPlayer + .startStream(playerId, { + url: resp.url, + mount: wrap, + onStatus: (state, detail) => { + if (state === 'playing') { + status.textContent = 'Video live'; + card.dataset.videoActive = 'yes'; + } else if (state === 'connecting') { + status.textContent = 'Connecting video…'; + } else if (state === 'error') { + status.textContent = `Video error: ${detail}`; + card.dataset.videoActive = ''; + } else if (state === 'stopped') { + status.textContent = 'Video stopped'; + card.dataset.videoActive = ''; + } + }, + }) + .catch((err) => { + status.textContent = `Video error: ${err.message}`; + card.dataset.videoActive = ''; + }); + }); + } + function removeCard(roverId) { const card = cards.get(roverId); if (card) { + videoPlayer.stopStream(`spectator-${roverId}`); card.remove(); cards.delete(roverId); } } + + function stopAllVideos() { + Array.from(cards.keys()).forEach((id) => { + videoPlayer.stopStream(`spectator-${id}`); + const card = cards.get(id); + if (card) { + const status = card.querySelector('.video-status'); + status.textContent = 'Video stopped'; + card.dataset.videoActive = ''; + } + }); + } }); requireModule('spectator/main'); diff --git a/server/public/src/services/roverUI.js b/server/public/src/services/roverUI.js index 5ce67cbd..cdffc984 100644 --- a/server/public/src/services/roverUI.js +++ b/server/public/src/services/roverUI.js @@ -1,6 +1,7 @@ registerModule('services/roverUI', (require, exports) => { const { socket } = require('globals/socket'); const { formatHex } = require('helpers/formatters'); + const videoPlayer = require('services/videoPlayer'); const state = require('services/state'); const statusEl = document.getElementById('status'); @@ -13,11 +14,14 @@ registerModule('services/roverUI', (require, exports) => { const modeSelect = document.getElementById('modeSelect'); const activeDriverEl = document.getElementById('activeDriver'); const currentRoverEl = document.getElementById('currentRover'); - const videoBtn = document.getElementById('videoRequest'); - const videoLinkEl = document.getElementById('videoLink'); + const videoContainer = document.getElementById('videoContainer'); + const driverVideo = document.getElementById('driverVideo'); + const videoStatus = document.getElementById('videoStatus'); + const videoReconnect = document.getElementById('videoReconnect'); let roster = []; let lastSelection = null; + let currentVideoRover = null; state.onRoleChange((role) => { const isAdmin = role === 'admin' || role === 'lockdown'; @@ -47,7 +51,7 @@ registerModule('services/roverUI', (require, exports) => { } else if (!list.length) { state.setSelected(null); } - renderSelection(); + renderSelection(true); }); roverSelect.addEventListener('change', () => { @@ -64,6 +68,7 @@ registerModule('services/roverUI', (require, exports) => { state.setSelected(roverId); renderSelection(true); statusEl.textContent = `Driving ${roverId}`; + maybeConnectVideo(true); }); socket.on('sensorFrame', ({ roverId, frame, sensors }) => { @@ -102,20 +107,7 @@ registerModule('services/roverUI', (require, exports) => { } }); - videoBtn?.addEventListener('click', () => { - const roverId = state.getSelected(); - if (!roverId) { - if (videoLinkEl) videoLinkEl.textContent = 'Select a rover first'; - return; - } - socket.emit('video:request', { roverId }, (resp = {}) => { - if (resp.error) { - videoLinkEl.textContent = `Video error: ${resp.error}`; - return; - } - videoLinkEl.innerHTML = `Video URL: ${resp.url}`; - }); - }); + videoReconnect?.addEventListener('click', () => maybeConnectVideo(true)); socket.on('connect', () => { statusEl.textContent = 'Connected'; @@ -137,9 +129,56 @@ registerModule('services/roverUI', (require, exports) => { if (sensorOutput && (clearOutput || roverId !== lastSelection)) { sensorOutput.textContent = roverId ? 'Waiting for sensor data...' : ''; } - if (clearOutput && videoLinkEl) { - videoLinkEl.textContent = ''; - } lastSelection = roverId; + maybeConnectVideo(clearOutput); + } + + function maybeConnectVideo(force = false) { + const roverId = state.getSelected(); + if (!roverId) { + currentVideoRover = null; + videoPlayer.stopStream('driver'); + setVideoStatus('No rover selected'); + return; + } + if (!force && roverId === currentVideoRover) { + return; + } + currentVideoRover = roverId; + setVideoStatus('Connecting video…'); + socket.emit('video:request', { roverId }, (resp = {}) => { + if (resp.error) { + setVideoStatus(`Video error: ${resp.error}`); + videoPlayer.stopStream('driver'); + return; + } + const { url } = resp; + videoPlayer + .startStream('driver', { + url, + mount: videoContainer, + videoEl: driverVideo, + onStatus: (state, detail) => { + if (state === 'playing') { + setVideoStatus('Video live'); + } else if (state === 'connecting') { + setVideoStatus('Connecting video…'); + } else if (state === 'error') { + setVideoStatus(`Video error: ${detail}`); + } else if (state === 'stopped') { + setVideoStatus('Video stopped'); + } + }, + }) + .catch((err) => { + setVideoStatus(`Video error: ${err.message}`); + }); + }); + } + + function setVideoStatus(message) { + if (videoStatus) { + videoStatus.textContent = message; + } } }); diff --git a/server/public/src/services/videoPlayer.js b/server/public/src/services/videoPlayer.js new file mode 100644 index 00000000..91bc186c --- /dev/null +++ b/server/public/src/services/videoPlayer.js @@ -0,0 +1,162 @@ +registerModule('services/videoPlayer', (require, exports) => { + const players = new Map(); + + class Player { + constructor(id, options = {}) { + this.id = id; + this.options = options; + this.onStatus = options.onStatus || (() => {}); + this.mount = options.mount || null; + this.externalVideo = options.videoEl || null; + this.video = this.externalVideo || this.createVideoElement(); + this.video.muted = true; + this.status('idle'); + } + + createVideoElement() { + const el = document.createElement('video'); + el.autoplay = true; + el.playsInline = true; + el.controls = false; + el.muted = true; + el.style.width = '100%'; + el.style.maxHeight = '360px'; + if (this.mount) { + this.mount.innerHTML = ''; + this.mount.appendChild(el); + } + return el; + } + + attachAutoPlayLoop() { + this.autoPlayTimer = window.setInterval(() => { + if (!this.video) return; + if (!this.video.paused && !this.video.muted) { + clearInterval(this.autoPlayTimer); + this.autoPlayTimer = null; + return; + } + const attempt = () => this.video.play().catch(() => {}); + if (this.video.paused) { + attempt(); + } + if (this.video.muted) { + this.video.muted = false; + attempt(); + } + }, 2500); + } + + status(state, detail) { + this.onStatus(state, detail); + } + + async start(url, token) { + this.stopPeer(); + this.status('connecting'); + this.pc = new RTCPeerConnection(); + this.stream = new MediaStream(); + this.video.srcObject = this.stream; + this.pc.addTransceiver('video', { direction: 'recvonly' }); + this.pc.addTransceiver('audio', { direction: 'recvonly' }); + this.pc.ontrack = (event) => { + const [remoteStream] = event.streams; + if (remoteStream) { + this.video.srcObject = remoteStream; + } else { + this.stream.addTrack(event.track); + } + this.video.play().catch(() => {}); + }; + this.attachAutoPlayLoop(); + + const credential = token || extractSessionToken(url); + if (!credential) { + throw new Error('Missing video session token'); + } + const offer = await this.pc.createOffer(); + await this.pc.setLocalDescription(offer); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/sdp', + Authorization: `Basic ${btoa(`${credential}:${credential}`)}`, + }, + body: offer.sdp, + }); + if (!response.ok) { + throw new Error(`WHEP HTTP ${response.status}`); + } + const answer = await response.text(); + await this.pc.setRemoteDescription({ type: 'answer', sdp: answer }); + this.status('playing'); + } + + stopPeer() { + if (this.autoPlayTimer) { + clearInterval(this.autoPlayTimer); + this.autoPlayTimer = null; + } + if (this.pc) { + this.pc.getSenders().forEach((sender) => sender.track && sender.track.stop()); + this.pc.getReceivers().forEach((receiver) => receiver.track && receiver.track.stop()); + this.pc.close(); + this.pc = null; + } + if (this.video) { + const tracks = this.video.srcObject?.getTracks() || []; + tracks.forEach((t) => t.stop()); + this.video.pause(); + this.video.srcObject = null; + } + } + + stop() { + this.stopPeer(); + if (!this.externalVideo && this.video && this.video.parentElement) { + this.video.parentElement.removeChild(this.video); + } + this.status('stopped'); + } + } + + function extractSessionToken(url) { + try { + const parsed = new URL(url, window.location.origin); + return parsed.searchParams.get('session'); + } catch (err) { + return null; + } + } + + async function startStream(id, { url, token, mount, videoEl, onStatus } = {}) { + stopStream(id); + const player = new Player(id, { mount, videoEl, onStatus }); + players.set(id, player); + try { + await player.start(url, token); + return player.video; + } catch (err) { + player.status('error', err.message); + player.stop(); + players.delete(id); + throw err; + } + } + + function stopStream(id) { + const existing = players.get(id); + if (existing) { + existing.stop(); + players.delete(id); + } + } + + function stopAll() { + Array.from(players.keys()).forEach(stopStream); + } + + exports.startStream = startStream; + exports.stopStream = stopStream; + exports.stopAll = stopAll; +});