Files
Diablo_Rain 0b761f684b harden: relay timeouts, DNS resolver reuse, cache hygiene + frontend polish
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>
2026-06-15 14:20:06 -04:00

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