video player!

This commit is contained in:
legop3
2025-11-13 23:45:02 -05:00
parent 2bc5baa156
commit fafc3e0312
5 changed files with 306 additions and 24 deletions
+59 -20
View File
@@ -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: <a href=\"${resp.url}\" target=\"_blank\">${resp.url}</a>`;
});
});
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;
}
}
});
+162
View File
@@ -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;
});