Files
pondering-orb/web/zapper.js
T
Diablo_Rain c58fa9591b feat: initial release of The Pondering Orb
A retro CRT-themed web TV that channel-surfs live HLS streams through a
security-hardened, same-origin Rust relay.

Front end (vanilla JS + HLS.js, no framework/build step):
- SourceProvider abstraction + canonical Channel/Program model so public
  streams and future self-hosted media share one UI (MockProvider built first
  to keep the UI provably source-agnostic; IptvOrgProvider for live data)
- Red/black phosphor CRT theme, virtualized 1994-style guide (The Almanac),
  cable-box zapping with 7-segment OSD, scrying-static transitions
- Theater/Dim/Ambient immersion modes, dead-stream "orb clouds over" handling
- The Lexicon in-world naming with mundane-mode; full a11y + reduced-motion
- HLS.js pinned + Subresource-Integrity verified

Rust relay (hyper 1.x + tokio-rustls/ring + hickory-resolver):
- Serves the SPA and proxies HLS from one origin (no CORS, untainted canvas)
- SSRF defense: resolve -> reject private/loopback/link-local/metadata IPs ->
  connect to the validated IP (no re-resolve, defeats DNS-rebind), fail-closed
- HTTPS-only egress, re-validated redirects, manifest URL rewrite, size/
  concurrency caps, strict CSP + security headers, path-traversal guard
- /metrics observability + structured logs (host only, never raw URLs)

Verified: all SSRF probes rejected, CSP clean, real Apple HLS manifest
rewritten, ~39k iptv-org channels fetched end-to-end, tests green, zero warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 05:34:09 -04:00

164 lines
5.6 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
*/
constructor(els, player, agg) {
this.els = els;
this.player = player;
this.agg = agg;
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;
let idx = this.current ? list.findIndex((c) => c.id === this.current.id) : -1;
idx = (idx + delta + list.length) % list.length;
return this.tune(list[idx]);
}
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;
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);
}
}