initial commit
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* 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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user