Files
obs-game-stream-plugin/frontend/js/player.js
T
2026-04-05 17:16:51 -04:00

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 };
})();