Files
pondering-orb/web/player.js
T
Diablo_Rain a1e3e6852f feat: Phase 1.5 — health prober, Oracle's Memory, off-thread EPG, PWA
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>
2026-06-15 11:25:55 -04:00

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