324 lines
11 KiB
JavaScript
324 lines
11 KiB
JavaScript
/*
|
|
* player.js - WHEP client with HLS fallback.
|
|
*
|
|
* Exposes a global `StreamPlayer` object used by app.js. Keeps a single
|
|
* <video> element up to date with the live MediaMTX stream.
|
|
*
|
|
* Control flow:
|
|
* StreamPlayer.start() -> attempt WHEP, fall back to HLS on failure
|
|
* StreamPlayer.stop() -> tear down whichever transport is active
|
|
*
|
|
* WHEP protocol (RFC 9725):
|
|
* 1. Create RTCPeerConnection, add recvonly transceivers.
|
|
* 2. Create SDP offer, POST to `${WHEP_URL}` as application/sdp.
|
|
* 3. Set the returned SDP as the remote description.
|
|
* 4. Media flows over UDP via ICE negotiation.
|
|
*/
|
|
|
|
(function () {
|
|
const WHEP_URL = '/whep/game/whep';
|
|
const HLS_URL = '/hls/game/index.m3u8';
|
|
const HLS_JS_CDN = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js';
|
|
const WHEP_TIMEOUT_MS = 10000;
|
|
const RECONNECT_DELAY_MS = 3000;
|
|
|
|
const listeners = {
|
|
statechange: [], // (state) -> void state: 'connecting'|'playing'|'offline'|'error'
|
|
transport: [], // (label) -> void label: 'WebRTC'|'HLS'|'--'
|
|
latency: [], // (ms|null) -> void
|
|
error: [], // (error) -> void
|
|
};
|
|
|
|
const state = {
|
|
pc: null, // RTCPeerConnection
|
|
hls: null, // Hls.js instance
|
|
videoEl: null, // HTMLVideoElement
|
|
transport: null, // 'webrtc' | 'hls' | null
|
|
running: false, // whether start() is in effect
|
|
reconnectTimer: null,
|
|
latencyTimer: null,
|
|
whepResourceUrl: null, // Location header returned by WHEP for DELETE cleanup
|
|
};
|
|
|
|
function emit(name, payload) {
|
|
(listeners[name] || []).forEach((cb) => {
|
|
try { cb(payload); } catch (e) { /* swallow */ }
|
|
});
|
|
}
|
|
|
|
function on(name, cb) {
|
|
if (!listeners[name]) return;
|
|
listeners[name].push(cb);
|
|
}
|
|
|
|
// ---------------- WebRTC / WHEP ----------------
|
|
|
|
async function startWhep() {
|
|
emit('statechange', 'connecting');
|
|
emit('transport', 'WebRTC');
|
|
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
|
bundlePolicy: 'max-bundle',
|
|
});
|
|
state.pc = pc;
|
|
|
|
// Passive viewer - we only receive.
|
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
|
|
const remoteStream = new MediaStream();
|
|
pc.addEventListener('track', (ev) => {
|
|
remoteStream.addTrack(ev.track);
|
|
if (state.videoEl && state.videoEl.srcObject !== remoteStream) {
|
|
state.videoEl.srcObject = remoteStream;
|
|
}
|
|
});
|
|
|
|
pc.addEventListener('connectionstatechange', () => {
|
|
const cs = pc.connectionState;
|
|
if (cs === 'connected') {
|
|
emit('statechange', 'playing');
|
|
startLatencyPolling();
|
|
} else if (cs === 'failed' || cs === 'disconnected' || cs === 'closed') {
|
|
stopLatencyPolling();
|
|
if (state.running) {
|
|
emit('statechange', 'offline');
|
|
scheduleReconnect();
|
|
}
|
|
}
|
|
});
|
|
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
// Wait for ICE gathering so the offer we POST includes candidates
|
|
// (MediaMTX does not require trickle, and this keeps the HTTP flow
|
|
// simpler). Most implementations finish gathering in <500ms.
|
|
await waitForIceGatheringComplete(pc, 2000);
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), WHEP_TIMEOUT_MS);
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(WHEP_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/sdp' },
|
|
body: pc.localDescription.sdp,
|
|
signal: controller.signal,
|
|
credentials: 'include',
|
|
});
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`WHEP POST failed: HTTP ${response.status}`);
|
|
}
|
|
|
|
const answer = await response.text();
|
|
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
|
state.whepResourceUrl = response.headers.get('Location') || null;
|
|
}
|
|
|
|
function waitForIceGatheringComplete(pc, timeoutMs) {
|
|
return new Promise((resolve) => {
|
|
if (pc.iceGatheringState === 'complete') return resolve();
|
|
const timer = setTimeout(resolve, timeoutMs);
|
|
pc.addEventListener('icegatheringstatechange', () => {
|
|
if (pc.iceGatheringState === 'complete') {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function stopWhep() {
|
|
stopLatencyPolling();
|
|
if (state.whepResourceUrl) {
|
|
// Best-effort session teardown so MediaMTX frees resources.
|
|
try {
|
|
await fetch(state.whepResourceUrl, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
});
|
|
} catch (_) { /* ignore */ }
|
|
state.whepResourceUrl = null;
|
|
}
|
|
if (state.pc) {
|
|
try { state.pc.close(); } catch (_) {}
|
|
state.pc = null;
|
|
}
|
|
if (state.videoEl) {
|
|
state.videoEl.srcObject = null;
|
|
}
|
|
}
|
|
|
|
// ---------------- HLS fallback ----------------
|
|
|
|
function loadHlsJs() {
|
|
if (window.Hls) return Promise.resolve(window.Hls);
|
|
return new Promise((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = HLS_JS_CDN;
|
|
s.onload = () => resolve(window.Hls);
|
|
s.onerror = () => reject(new Error('Failed to load hls.js'));
|
|
document.head.appendChild(s);
|
|
});
|
|
}
|
|
|
|
async function startHls() {
|
|
emit('statechange', 'connecting');
|
|
emit('transport', 'HLS');
|
|
|
|
const video = state.videoEl;
|
|
|
|
// Safari can play HLS natively.
|
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
video.src = HLS_URL;
|
|
video.addEventListener('playing', () => emit('statechange', 'playing'), { once: true });
|
|
try { await video.play(); } catch (_) { /* autoplay may require mute */ }
|
|
return;
|
|
}
|
|
|
|
const Hls = await loadHlsJs();
|
|
if (!Hls.isSupported()) {
|
|
throw new Error('HLS not supported in this browser');
|
|
}
|
|
const hls = new Hls({
|
|
lowLatencyMode: true,
|
|
backBufferLength: 10,
|
|
maxBufferLength: 4,
|
|
});
|
|
state.hls = hls;
|
|
hls.loadSource(HLS_URL);
|
|
hls.attachMedia(video);
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
video.play().catch(() => { /* autoplay may require mute */ });
|
|
emit('statechange', 'playing');
|
|
});
|
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
|
if (data && data.fatal) {
|
|
emit('statechange', 'offline');
|
|
if (state.running) scheduleReconnect();
|
|
}
|
|
});
|
|
}
|
|
|
|
function stopHls() {
|
|
if (state.hls) {
|
|
try { state.hls.destroy(); } catch (_) {}
|
|
state.hls = null;
|
|
}
|
|
if (state.videoEl) {
|
|
state.videoEl.removeAttribute('src');
|
|
state.videoEl.load();
|
|
}
|
|
}
|
|
|
|
// ---------------- Latency polling (WebRTC only) ----------------
|
|
|
|
function startLatencyPolling() {
|
|
stopLatencyPolling();
|
|
state.latencyTimer = setInterval(async () => {
|
|
if (!state.pc) return;
|
|
try {
|
|
const stats = await state.pc.getStats();
|
|
let jitterBufferMs = null;
|
|
let rttMs = null;
|
|
stats.forEach((report) => {
|
|
if (report.type === 'inbound-rtp' && report.kind === 'video') {
|
|
if (typeof report.jitterBufferDelay === 'number' &&
|
|
typeof report.jitterBufferEmittedCount === 'number' &&
|
|
report.jitterBufferEmittedCount > 0) {
|
|
jitterBufferMs = (report.jitterBufferDelay / report.jitterBufferEmittedCount) * 1000;
|
|
}
|
|
}
|
|
if (report.type === 'candidate-pair' && report.state === 'succeeded' &&
|
|
typeof report.currentRoundTripTime === 'number') {
|
|
rttMs = report.currentRoundTripTime * 1000;
|
|
}
|
|
});
|
|
let total = null;
|
|
if (jitterBufferMs != null || rttMs != null) {
|
|
total = (jitterBufferMs || 0) + (rttMs != null ? rttMs / 2 : 0);
|
|
}
|
|
emit('latency', total != null ? Math.round(total) : null);
|
|
} catch (_) {
|
|
emit('latency', null);
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
function stopLatencyPolling() {
|
|
if (state.latencyTimer) {
|
|
clearInterval(state.latencyTimer);
|
|
state.latencyTimer = null;
|
|
}
|
|
emit('latency', null);
|
|
}
|
|
|
|
// ---------------- Reconnect logic ----------------
|
|
|
|
function scheduleReconnect() {
|
|
if (state.reconnectTimer) return;
|
|
state.reconnectTimer = setTimeout(() => {
|
|
state.reconnectTimer = null;
|
|
if (!state.running) return;
|
|
restart();
|
|
}, RECONNECT_DELAY_MS);
|
|
}
|
|
|
|
async function restart() {
|
|
await teardown();
|
|
try {
|
|
state.transport = 'webrtc';
|
|
await startWhep();
|
|
} catch (err) {
|
|
emit('error', err);
|
|
try {
|
|
await stopWhep();
|
|
state.transport = 'hls';
|
|
await startHls();
|
|
} catch (err2) {
|
|
emit('error', err2);
|
|
emit('statechange', 'error');
|
|
scheduleReconnect();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function teardown() {
|
|
if (state.transport === 'webrtc') {
|
|
await stopWhep();
|
|
} else if (state.transport === 'hls') {
|
|
stopHls();
|
|
}
|
|
state.transport = null;
|
|
}
|
|
|
|
// ---------------- Public API ----------------
|
|
|
|
async function start(videoEl) {
|
|
if (state.running) return;
|
|
state.running = true;
|
|
state.videoEl = videoEl;
|
|
await restart();
|
|
}
|
|
|
|
async function stop() {
|
|
state.running = false;
|
|
if (state.reconnectTimer) {
|
|
clearTimeout(state.reconnectTimer);
|
|
state.reconnectTimer = null;
|
|
}
|
|
await teardown();
|
|
stopLatencyPolling();
|
|
emit('statechange', 'offline');
|
|
emit('transport', '--');
|
|
}
|
|
|
|
window.StreamPlayer = { start, stop, on };
|
|
})();
|