initial commit
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
/* Game Stream App - dark theme, video fills viewport, minimal chrome. */
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0b;
|
||||
--fg: #e6e6e6;
|
||||
--fg-dim: #8a8a8a;
|
||||
--accent: #4da3ff;
|
||||
--live: #34c759;
|
||||
--offline: #8a8a8a;
|
||||
--error: #ff453a;
|
||||
--card-bg: rgba(20, 20, 22, 0.92);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--status-bar-height: 36px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* --- video stage ------------------------------------------------------- */
|
||||
|
||||
#stage {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* --- offline / loading overlay ---------------------------------------- */
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
z-index: 10;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overlay-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px 44px;
|
||||
text-align: center;
|
||||
min-width: 280px;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.overlay-card h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.overlay-card p {
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--fg-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 auto;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.overlay-card.error .spinner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overlay-card.error h1 {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* --- status bar ------------------------------------------------------- */
|
||||
|
||||
#status-bar {
|
||||
flex: 0 0 var(--status-bar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
background: #111113;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#status-bar b {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#status-bar .separator {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#status-bar .spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.status.live {
|
||||
background: var(--live);
|
||||
color: #003d15;
|
||||
}
|
||||
|
||||
.status.live::before {
|
||||
background: #003d15;
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status.offline {
|
||||
background: #2a2a2d;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: var(--error);
|
||||
color: #3d0000;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
#unmute-btn {
|
||||
background: var(--accent);
|
||||
color: #00224d;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#unmute-btn:hover {
|
||||
background: #66b5ff;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>Game Stream</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<!-- hls.js is only loaded if WebRTC fails and we need the HLS fallback. -->
|
||||
</head>
|
||||
<body>
|
||||
<main id="stage">
|
||||
<video id="video" autoplay playsinline muted></video>
|
||||
|
||||
<div id="overlay" class="overlay">
|
||||
<div class="overlay-card">
|
||||
<h1 id="overlay-title">Stream offline</h1>
|
||||
<p id="overlay-message">Waiting for the streamer to start…</p>
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer id="status-bar">
|
||||
<span id="status-indicator" class="status offline">OFFLINE</span>
|
||||
<span class="separator">|</span>
|
||||
<span>Viewers: <b id="viewer-count">0</b></span>
|
||||
<span class="separator">|</span>
|
||||
<span>Latency: <b id="latency">--</b></span>
|
||||
<span class="separator">|</span>
|
||||
<span>Transport: <b id="transport">--</b></span>
|
||||
<span class="spacer"></span>
|
||||
<button id="unmute-btn" type="button" hidden>Click to unmute</button>
|
||||
</footer>
|
||||
|
||||
<script src="/js/player.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* app.js - Glue between the DOM and StreamPlayer.
|
||||
*
|
||||
* - Polls the MediaMTX API (/v3/paths/get/game) to determine if the stream
|
||||
* is live, and updates the status bar accordingly.
|
||||
* - Starts/stops StreamPlayer based on that status.
|
||||
* - Wires up the "Click to unmute" button (browsers autoplay muted).
|
||||
* - Authentik forward auth is handled at the NPM layer, so by the time
|
||||
* this JS runs the user is already authenticated. No auth logic here.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
const PATH_NAME = 'game';
|
||||
const API_URL = `/v3/paths/get/${PATH_NAME}`;
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
const els = {
|
||||
video: document.getElementById('video'),
|
||||
overlay: document.getElementById('overlay'),
|
||||
overlayTitle: document.getElementById('overlay-title'),
|
||||
overlayMessage: document.getElementById('overlay-message'),
|
||||
overlayCard: document.querySelector('.overlay-card'),
|
||||
statusIndicator: document.getElementById('status-indicator'),
|
||||
viewerCount: document.getElementById('viewer-count'),
|
||||
latency: document.getElementById('latency'),
|
||||
transport: document.getElementById('transport'),
|
||||
unmuteBtn: document.getElementById('unmute-btn'),
|
||||
};
|
||||
|
||||
const state = {
|
||||
pathReady: false,
|
||||
playerState: 'offline',
|
||||
};
|
||||
|
||||
// ---- overlay helpers -------------------------------------------------
|
||||
|
||||
function showOverlay(title, message, isError) {
|
||||
els.overlayTitle.textContent = title;
|
||||
els.overlayMessage.textContent = message;
|
||||
els.overlayCard.classList.toggle('error', !!isError);
|
||||
els.overlay.hidden = false;
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
els.overlay.hidden = true;
|
||||
}
|
||||
|
||||
// ---- status bar helpers ---------------------------------------------
|
||||
|
||||
function setStatusIndicator(label, cls) {
|
||||
els.statusIndicator.textContent = label;
|
||||
els.statusIndicator.className = 'status ' + cls;
|
||||
}
|
||||
|
||||
function setViewerCount(n) {
|
||||
els.viewerCount.textContent = String(n);
|
||||
}
|
||||
|
||||
function setLatency(ms) {
|
||||
els.latency.textContent = ms == null ? '--' : `${ms} ms`;
|
||||
}
|
||||
|
||||
function setTransport(label) {
|
||||
els.transport.textContent = label || '--';
|
||||
}
|
||||
|
||||
// ---- MediaMTX API polling -------------------------------------------
|
||||
|
||||
async function pollPathStatus() {
|
||||
try {
|
||||
const res = await fetch(API_URL, { credentials: 'include' });
|
||||
if (res.status === 404) {
|
||||
applyPathStatus(false, 0);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
// 401/403 means session expired - force reload so NPM can redirect to Authentik.
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
window.location.reload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
applyPathStatus(Boolean(data.ready), (data.readers || []).length);
|
||||
} catch (_) {
|
||||
applyPathStatus(false, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPathStatus(ready, viewers) {
|
||||
setViewerCount(viewers);
|
||||
|
||||
if (ready && !state.pathReady) {
|
||||
state.pathReady = true;
|
||||
setStatusIndicator('LIVE', 'live');
|
||||
showOverlay('Connecting to stream…', 'Negotiating WebRTC', false);
|
||||
window.StreamPlayer.start(els.video);
|
||||
} else if (!ready && state.pathReady) {
|
||||
state.pathReady = false;
|
||||
setStatusIndicator('OFFLINE', 'offline');
|
||||
setTransport('--');
|
||||
setLatency(null);
|
||||
showOverlay('Stream offline', 'Waiting for the streamer to start…', false);
|
||||
window.StreamPlayer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Unmute button --------------------------------------------------
|
||||
//
|
||||
// Browsers require a user gesture to play audio. The <video> element
|
||||
// starts muted so autoplay works; we surface a button to unmute once
|
||||
// playback begins.
|
||||
|
||||
els.unmuteBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
els.video.muted = false;
|
||||
await els.video.play();
|
||||
els.unmuteBtn.hidden = true;
|
||||
} catch (err) {
|
||||
console.warn('Unmute failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
function maybeShowUnmute() {
|
||||
els.unmuteBtn.hidden = !els.video.muted;
|
||||
}
|
||||
|
||||
// ---- StreamPlayer event wiring --------------------------------------
|
||||
|
||||
window.StreamPlayer.on('statechange', (s) => {
|
||||
state.playerState = s;
|
||||
if (s === 'playing') {
|
||||
hideOverlay();
|
||||
maybeShowUnmute();
|
||||
} else if (s === 'connecting') {
|
||||
showOverlay('Connecting…', 'Negotiating transport', false);
|
||||
} else if (s === 'error') {
|
||||
showOverlay('Playback error', 'Retrying…', true);
|
||||
} else if (s === 'offline' && state.pathReady) {
|
||||
showOverlay('Reconnecting…', 'The stream dropped; trying again', false);
|
||||
}
|
||||
});
|
||||
|
||||
window.StreamPlayer.on('transport', (label) => setTransport(label));
|
||||
window.StreamPlayer.on('latency', (ms) => setLatency(ms));
|
||||
window.StreamPlayer.on('error', (err) => {
|
||||
console.warn('Stream error:', err);
|
||||
});
|
||||
|
||||
// ---- boot -----------------------------------------------------------
|
||||
|
||||
setStatusIndicator('OFFLINE', 'offline');
|
||||
showOverlay('Stream offline', 'Waiting for the streamer to start…', false);
|
||||
|
||||
pollPathStatus();
|
||||
setInterval(pollPathStatus, POLL_INTERVAL_MS);
|
||||
})();
|
||||
@@ -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