a1e3e6852f
Resilience + scale pass on top of the v1 core. Stream health prober: - New SSRF-validated /probe endpoint (HEAD -> ranged-GET fallback, 6s timeout, 5-min TTL cache, concurrency-capped, no-store) + orb_probe_total metrics - Front-end health.js: lazy bounded-concurrency probing (current + sampled channels, not the whole catalog), LIVE/DEGRADED/DEAD scoring, guide skull glyph, instant no-signal on zap to a known-dead channel Oracle's Memory (memory.js): - Session resurrection (last channel, volume, mute, theater/dim/ambient) - Exponential-decay per-channel uptime history; pinning; dead-channel auto-skip Off-thread XMLTV: - lib/xmltv-parse.js (pure, hand-rolled scanner, no DOMParser/regex backtracking) run in epg-worker.js module worker; wired into the iptv-org provider - Unit-tested in node: entity decode, TZ offsets, 20k programmes off-thread Service worker / PWA: - manifest.webmanifest + orb icons; sw.js (cache-first shell + hls.js, stale-while-revalidate for JSON-over-relay, never caches streams) - CSP gains worker-src + manifest-src; relay sends no-store on /relay + /probe so the SW is the sole metadata freshness authority; webmanifest MIME fix Docs: README de-emojified (title only) and reframed as a peer parallel build. Verified: SSRF probes blocked, legit probe + cache, new headers present, PWA assets served, parser tests 8/8, JS syntax clean, zero Rust warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
216 lines
7.6 KiB
JavaScript
216 lines
7.6 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();
|
|
}
|
|
|
|
/**
|
|
* 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 {}
|
|
}
|
|
|
|
destroy() { this._teardown(); }
|
|
|
|
// ---------- dead-stream "the orb clouds over" ----------
|
|
_showNoSignal() { this.els.nosignal.hidden = 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();
|
|
}
|
|
}
|