0b761f684b
Server (relay/SSRF): - Bound connect+TLS+request under CONNECT_TIMEOUT and body download under BODY_TIMEOUT so a stalling upstream can't pin a relay permit indefinitely - Add inbound header_read_timeout to defeat slow-loris connections - Allowlist relayed Content-Type (fall back to octet-stream) to block content-confusion from attacker-controlled stream origins - Reuse one DNS resolver process-wide instead of rebuilding it per request (restores hickory's DNS cache; drops per-segment resolver setup) - Probe cache: random per-process hash seed (DefaultHasher was deterministic) + bounded growth with expired-entry eviction - Real Ctrl-C handler for a clean shutdown (honours the banner) - Collapse the wrap_raw no-op alias into wrap Frontend: - Guide: visible focus ring tracking arrow-key navigation - Player: buffering/tuning indicator while <video> is stalled - prefers-contrast: more pass (lifts --ink-dim to meet WCAG AA) - Service worker: date-stamped cache names so shell updates propagate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
236 lines
8.4 KiB
JavaScript
236 lines
8.4 KiB
JavaScript
// player.js — HLS.js wiring, live-aware ghost controls, dead-stream handling.
|
|
|
|
import { memory } from './memory.js';
|
|
|
|
const HLS_CONFIG = {
|
|
enableWorker: true,
|
|
lowLatencyMode: false,
|
|
maxBufferLength: 30,
|
|
maxMaxBufferLength: 60,
|
|
manifestLoadingMaxRetry: 4,
|
|
manifestLoadingRetryDelay: 800,
|
|
fragLoadingMaxRetry: 4,
|
|
};
|
|
|
|
export class Player {
|
|
/**
|
|
* @param {object} els DOM refs
|
|
* @param {(msg:string)=>void} [onFatal] called when a stream is unplayable
|
|
*/
|
|
constructor(els, onFatal) {
|
|
this.els = els;
|
|
this.video = els.video;
|
|
this.hls = null;
|
|
this.onFatal = onFatal || (() => {});
|
|
this.onFirstFrag = null; // set by zapper to clear scrying static
|
|
this._fallbacks = [];
|
|
this._fallbackIdx = 0;
|
|
this._wireControls();
|
|
this._wireActivity();
|
|
this._wireBuffering();
|
|
}
|
|
|
|
/**
|
|
* Play a StreamHandle (with optional fallback URLs for resilience).
|
|
* @param {{url:string}} handle
|
|
* @param {string[]} [fallbackUrls]
|
|
*/
|
|
async play(handle, fallbackUrls = []) {
|
|
this._fallbacks = [handle.url, ...fallbackUrls.filter((u) => u !== handle.url)];
|
|
this._fallbackIdx = 0;
|
|
this._hideNoSignal();
|
|
this._loadCurrent();
|
|
}
|
|
|
|
_loadCurrent() {
|
|
const url = this._fallbacks[this._fallbackIdx];
|
|
if (!url) { this._showNoSignal(); this.onFatal('no playable source'); return; }
|
|
this._teardown();
|
|
|
|
const canNative = this.video.canPlayType('application/vnd.apple.mpegurl');
|
|
if (window.Hls && window.Hls.isSupported()) {
|
|
const hls = new window.Hls(HLS_CONFIG);
|
|
this.hls = hls;
|
|
hls.loadSource(url);
|
|
hls.attachMedia(this.video);
|
|
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
|
this.video.play().catch(() => {});
|
|
this._refreshLiveChrome();
|
|
this._buildQualityMenu();
|
|
});
|
|
hls.on(window.Hls.Events.FRAG_BUFFERED, () => {
|
|
if (this.onFirstFrag) { this.onFirstFrag(); }
|
|
});
|
|
hls.on(window.Hls.Events.ERROR, (_e, data) => {
|
|
if (!data || !data.fatal) return;
|
|
this._tryFallbackOrFail(`hls fatal: ${data.type}/${data.details}`);
|
|
});
|
|
} else if (canNative) {
|
|
this.video.src = url;
|
|
this.video.addEventListener('loadedmetadata', () => {
|
|
this.video.play().catch(() => {});
|
|
this._refreshLiveChrome();
|
|
}, { once: true });
|
|
this.video.addEventListener('error', () => this._tryFallbackOrFail('native hls error'), { once: true });
|
|
} else {
|
|
this._showNoSignal();
|
|
this.onFatal('HLS unsupported in this browser');
|
|
}
|
|
}
|
|
|
|
_tryFallbackOrFail(reason) {
|
|
this._fallbackIdx++;
|
|
if (this._fallbackIdx < this._fallbacks.length) {
|
|
console.warn(`[player] ${reason} — trying fallback ${this._fallbackIdx}`);
|
|
this._loadCurrent();
|
|
} else {
|
|
console.warn(`[player] ${reason} — no fallbacks left`);
|
|
this._showNoSignal();
|
|
this.onFatal(reason);
|
|
}
|
|
}
|
|
|
|
_teardown() {
|
|
if (this.hls) { try { this.hls.destroy(); } catch {} this.hls = null; }
|
|
this.video.removeAttribute('src');
|
|
try { this.video.load(); } catch {}
|
|
this._setBuffering(false); // never leave the indicator stuck across a zap
|
|
}
|
|
|
|
destroy() { this._teardown(); }
|
|
|
|
// ---------- buffering / stall indicator ----------
|
|
// Bridge the gap between the scrying static clearing and the first frame:
|
|
// without a hint, a slow-loading stream is indistinguishable from a dead one.
|
|
_wireBuffering() {
|
|
const v = this.video;
|
|
const on = () => this._setBuffering(true);
|
|
const off = () => this._setBuffering(false);
|
|
v.addEventListener('waiting', on);
|
|
v.addEventListener('stalled', on);
|
|
v.addEventListener('playing', off);
|
|
v.addEventListener('canplay', off);
|
|
v.addEventListener('pause', off);
|
|
v.addEventListener('emptied', off);
|
|
}
|
|
_setBuffering(on) {
|
|
if (this.els.screen) this.els.screen.classList.toggle('buffering', on);
|
|
}
|
|
|
|
// ---------- dead-stream "the orb clouds over" ----------
|
|
_showNoSignal() { this.els.nosignal.hidden = false; this._setBuffering(false); }
|
|
_hideNoSignal() { this.els.nosignal.hidden = true; }
|
|
|
|
// ---------- live-aware chrome ----------
|
|
_refreshLiveChrome() {
|
|
const live = this._isLive();
|
|
this.els.osdLive.hidden = !live;
|
|
this.els.ctlLiveWrap.hidden = !live;
|
|
}
|
|
_isLive() {
|
|
if (this.hls && this.hls.levels && this.hls.levels.length) {
|
|
const d = this.hls.levels[this.hls.currentLevel]?.details;
|
|
if (d) return d.live === true;
|
|
}
|
|
return !Number.isFinite(this.video.duration);
|
|
}
|
|
|
|
_buildQualityMenu() {
|
|
const menu = this.els.qualMenu;
|
|
menu.innerHTML = '';
|
|
if (!this.hls || !this.hls.levels || this.hls.levels.length < 2) {
|
|
this.els.btnQual.hidden = true;
|
|
return;
|
|
}
|
|
this.els.btnQual.hidden = false;
|
|
const mk = (label, levelIdx) => {
|
|
const b = document.createElement('button');
|
|
b.setAttribute('role', 'menuitemradio');
|
|
b.textContent = label;
|
|
b.setAttribute('aria-checked', String(this.hls.autoLevelEnabled ? levelIdx === -1 : this.hls.currentLevel === levelIdx));
|
|
b.addEventListener('click', () => {
|
|
this.hls.currentLevel = levelIdx; // -1 = auto
|
|
this.els.btnQual.textContent = levelIdx === -1 ? 'AUTO' : label;
|
|
menu.hidden = true;
|
|
});
|
|
return b;
|
|
};
|
|
menu.appendChild(mk('Auto', -1));
|
|
// de-dup by height, descending
|
|
const seen = new Set();
|
|
this.hls.levels
|
|
.map((l, i) => ({ h: l.height, i }))
|
|
.filter((x) => x.h && !seen.has(x.h) && seen.add(x.h))
|
|
.sort((a, b) => b.h - a.h)
|
|
.forEach((x) => menu.appendChild(mk(`${x.h}p`, x.i)));
|
|
}
|
|
|
|
// ---------- controls ----------
|
|
_wireControls() {
|
|
const e = this.els;
|
|
e.btnPlay.addEventListener('click', () => this.togglePlay());
|
|
e.btnMute.addEventListener('click', () => this.toggleMute());
|
|
e.vol.addEventListener('input', () => { this.video.volume = e.vol.value / 100; this.video.muted = this.video.volume === 0; this._syncMute(); });
|
|
e.btnFull.addEventListener('click', () => this.toggleFullscreen());
|
|
e.btnCc.addEventListener('click', () => this.toggleCaptions());
|
|
e.btnQual.addEventListener('click', () => { e.qualMenu.hidden = !e.qualMenu.hidden; });
|
|
if (document.pictureInPictureEnabled) {
|
|
e.btnPip.hidden = false;
|
|
e.btnPip.addEventListener('click', () => this.togglePip());
|
|
}
|
|
this.video.addEventListener('play', () => { e.btnPlay.textContent = '❚❚'; e.btnPlay.setAttribute('aria-pressed','true'); });
|
|
this.video.addEventListener('pause', () => { e.btnPlay.textContent = '▶'; e.btnPlay.setAttribute('aria-pressed','false'); });
|
|
// wall-clock for live
|
|
setInterval(() => {
|
|
const t = new Date();
|
|
e.ctlClock.textContent = `${String(t.getHours()).padStart(2,'0')}:${String(t.getMinutes()).padStart(2,'0')}`;
|
|
}, 1000);
|
|
}
|
|
|
|
togglePlay() { this.video.paused ? this.video.play().catch(()=>{}) : this.video.pause(); }
|
|
toggleMute() { this.video.muted = !this.video.muted; this._syncMute(); }
|
|
_syncMute() {
|
|
const m = this.video.muted || this.video.volume === 0;
|
|
this.els.btnMute.textContent = m ? '🔇' : '🔊';
|
|
this.els.btnMute.setAttribute('aria-pressed', String(m));
|
|
memory.rememberVolume(this.video.volume, this.video.muted);
|
|
}
|
|
setVolume(delta) {
|
|
this.video.muted = false;
|
|
this.video.volume = Math.min(1, Math.max(0, this.video.volume + delta));
|
|
this.els.vol.value = Math.round(this.video.volume * 100);
|
|
this._syncMute();
|
|
}
|
|
async toggleFullscreen() {
|
|
try {
|
|
if (document.fullscreenElement) await document.exitFullscreen();
|
|
else await this.els.root.requestFullscreen();
|
|
} catch {}
|
|
}
|
|
async togglePip() {
|
|
try {
|
|
if (document.pictureInPictureElement) await document.exitPictureInPicture();
|
|
else await this.video.requestPictureInPicture();
|
|
} catch {}
|
|
}
|
|
toggleCaptions() {
|
|
const tracks = this.video.textTracks;
|
|
if (!tracks || !tracks.length) return;
|
|
const on = tracks[0].mode === 'showing';
|
|
for (const tr of tracks) tr.mode = on ? 'disabled' : 'showing';
|
|
this.els.btnCc.setAttribute('aria-pressed', String(!on));
|
|
}
|
|
|
|
// ---------- ghost-control auto-hide ----------
|
|
_wireActivity() {
|
|
const show = () => {
|
|
this.els.screen.classList.add('controls-visible');
|
|
clearTimeout(this._hideT);
|
|
this._hideT = setTimeout(() => this.els.screen.classList.remove('controls-visible'), 3000);
|
|
};
|
|
['mousemove','touchstart','keydown'].forEach((ev) =>
|
|
this.els.screen.addEventListener(ev, show, { passive: true }));
|
|
show();
|
|
}
|
|
}
|