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>
176 lines
6.3 KiB
JavaScript
176 lines
6.3 KiB
JavaScript
// zapper.js — channel tuning: number entry, OSD banner, up/down, last-channel,
|
|
// and the "Scrying Static" channel-change transition.
|
|
|
|
const ENTRY_DEBOUNCE = 1200; // ms cable-box number accumulation
|
|
const BANNER_MS = 4000;
|
|
const STATIC_MIN = 300; // never flash shorter than this
|
|
const STATIC_MAX = 3000; // clear regardless after this
|
|
|
|
export class Zapper {
|
|
/**
|
|
* @param {object} els
|
|
* @param {import('./player.js').Player} player
|
|
* @param {import('./providers/source-provider.js').ChannelAggregator} agg
|
|
* @param {{memory?:any, health?:import('./health.js').Health}} [opts]
|
|
*/
|
|
constructor(els, player, agg, opts = {}) {
|
|
this.els = els;
|
|
this.player = player;
|
|
this.agg = agg;
|
|
this.memory = opts.memory || null;
|
|
this.health = opts.health || null;
|
|
this.current = null; // current Channel
|
|
this.previous = null; // for last-channel toggle
|
|
this._entry = '';
|
|
this._entryT = 0;
|
|
this._bannerT = 0;
|
|
this._staticFrames = [];
|
|
this._reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
this._prepStatic();
|
|
// player calls this when first fragment buffers → clear the static
|
|
this.player.onFirstFrag = () => this._clearStatic();
|
|
}
|
|
|
|
// ---------- public tuning API ----------
|
|
async tuneToNumber(n) {
|
|
const ch = this.agg.getChannelByNumber(n);
|
|
if (!ch) { this._flashEntry(String(n), true); return; }
|
|
return this.tune(ch);
|
|
}
|
|
|
|
async tuneToIndexOffset(delta) {
|
|
const list = this.agg.channels;
|
|
if (!list.length) return;
|
|
const start = this.current ? list.findIndex((c) => c.id === this.current.id) : -1;
|
|
let idx = start;
|
|
// Step in the requested direction, skipping channels the Oracle knows are dead.
|
|
for (let n = 0; n < list.length; n++) {
|
|
idx = (idx + delta + list.length) % list.length;
|
|
const ch = list[idx];
|
|
if (!this.health || !this.health.isKnownDead(ch.id)) return this.tune(ch);
|
|
}
|
|
// everything known-dead → just tune the immediate neighbour
|
|
return this.tune(list[(start + delta + list.length) % list.length]);
|
|
}
|
|
|
|
async tuneLast() { if (this.previous) return this.tune(this.previous); }
|
|
|
|
/** @param {import('./providers/source-provider.js').Channel} ch */
|
|
async tune(ch) {
|
|
if (!ch) return;
|
|
if (this.current && this.current.id !== ch.id) this.previous = this.current;
|
|
this.current = ch;
|
|
if (this.memory) this.memory.rememberChannel(ch.id);
|
|
if (this.health) this.health.probe(ch); // refresh this channel's reliability
|
|
this._showStatic();
|
|
this._safetyClear = setTimeout(() => this._clearStatic(), STATIC_MAX);
|
|
|
|
const handle = await this.agg.getStreamHandle(ch.id);
|
|
if (!handle) { this.player._showNoSignal?.(); this._clearStatic(); }
|
|
else {
|
|
const fallbacks = (ch.streamUrls || []).slice(1);
|
|
await this.player.play(handle, fallbacks);
|
|
}
|
|
this._showBanner(ch);
|
|
this._announce(ch);
|
|
return ch;
|
|
}
|
|
|
|
// ---------- number entry (incantation) ----------
|
|
pushDigit(d) {
|
|
this._entry = (this._entry + d).slice(-4);
|
|
this._flashEntry(this._entry, false);
|
|
clearTimeout(this._entryT);
|
|
this._entryT = setTimeout(() => {
|
|
const n = parseInt(this._entry, 10);
|
|
this._entry = '';
|
|
this.els.osdNumber.hidden = true;
|
|
if (Number.isFinite(n)) this.tuneToNumber(n);
|
|
}, ENTRY_DEBOUNCE);
|
|
}
|
|
_flashEntry(text, invalid) {
|
|
this.els.osdNumber.hidden = false;
|
|
this.els.osdNumberVal.textContent = (text + '___').slice(0, 3);
|
|
this.els.osdNumberVal.style.color = invalid ? 'var(--ink-dim)' : '';
|
|
if (invalid) setTimeout(() => { this.els.osdNumber.hidden = true; }, 900);
|
|
}
|
|
|
|
// ---------- channel banner OSD ----------
|
|
async _showBanner(ch) {
|
|
const e = this.els;
|
|
e.osdNum.textContent = String(ch.number ?? '--');
|
|
e.osdName.textContent = ch.name || ''; // textContent: untrusted-safe
|
|
e.osdLogo.src = ch.logo || '';
|
|
e.osdLogo.alt = '';
|
|
e.osdNow.textContent = '';
|
|
e.osdProgFill.style.width = '0%';
|
|
e.osdChannel.hidden = false;
|
|
requestAnimationFrame(() => e.osdChannel.classList.add('show'));
|
|
clearTimeout(this._bannerT);
|
|
this._bannerT = setTimeout(() => {
|
|
e.osdChannel.classList.remove('show');
|
|
setTimeout(() => { e.osdChannel.hidden = true; }, 350);
|
|
}, BANNER_MS);
|
|
|
|
// now-playing (best effort)
|
|
try {
|
|
const { now } = await this.agg.nowNext(ch.id);
|
|
if (now && this.current && this.current.id === ch.id) {
|
|
e.osdNow.textContent = now.title;
|
|
const pct = Math.min(100, Math.max(0, ((Date.now() - now.start) / (now.stop - now.start)) * 100));
|
|
e.osdProgFill.style.width = pct.toFixed(1) + '%';
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
_announce(ch) {
|
|
// aria-live region already on osd-channel; ensure SR gets a concise message
|
|
this.els.osdChannel.setAttribute('aria-label', `Channel ${ch.number}, ${ch.name}`);
|
|
}
|
|
|
|
// ---------- scrying static ----------
|
|
_prepStatic() {
|
|
if (this._reduced) return;
|
|
const c = this.els.staticCanvas;
|
|
const w = c.width = 160, h = c.height = 90;
|
|
const ctx = c.getContext('2d');
|
|
for (let f = 0; f < 8; f++) {
|
|
const img = ctx.createImageData(w, h);
|
|
for (let i = 0; i < img.data.length; i += 4) {
|
|
const v = (Math.random() * 90) | 0;
|
|
img.data[i] = v + (Math.random() * 80 | 0); // red-tinged
|
|
img.data[i+1] = v * 0.3;
|
|
img.data[i+2] = v * 0.3;
|
|
img.data[i+3] = 255;
|
|
}
|
|
this._staticFrames.push(img);
|
|
}
|
|
}
|
|
_showStatic() {
|
|
const c = this.els.staticCanvas;
|
|
this._staticShownAt = performance.now();
|
|
if (this._reduced) {
|
|
// reduced-motion: brief red flash instead of noise
|
|
c.classList.add('show'); c.style.background = 'var(--red-deep)';
|
|
return;
|
|
}
|
|
c.style.background = '';
|
|
c.classList.add('show');
|
|
const ctx = c.getContext('2d');
|
|
let i = 0;
|
|
clearInterval(this._staticTimer);
|
|
this._staticTimer = setInterval(() => {
|
|
ctx.putImageData(this._staticFrames[i++ % this._staticFrames.length], 0, 0);
|
|
}, 1000 / 18);
|
|
}
|
|
_clearStatic() {
|
|
clearTimeout(this._safetyClear);
|
|
const elapsed = performance.now() - (this._staticShownAt || 0);
|
|
const wait = Math.max(0, STATIC_MIN - elapsed);
|
|
setTimeout(() => {
|
|
clearInterval(this._staticTimer);
|
|
this.els.staticCanvas.classList.remove('show');
|
|
}, wait);
|
|
}
|
|
}
|