c58fa9591b
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>
48 lines
1.6 KiB
JavaScript
48 lines
1.6 KiB
JavaScript
// ambient.js — YouTube-style ambient backlight (upgrade in v1 UI).
|
|
//
|
|
// A tiny 32x18 offscreen sample of the video, drawn to a canvas BEHIND the
|
|
// bezel and blurred huge by CSS, so the screen's edge colors bleed outward as a
|
|
// phosphor halo. Cheap: ~20fps, sampled every 3rd rAF. Works only because the
|
|
// relay makes video same-origin (canvas never tainted). Respects reduced-motion.
|
|
|
|
const W = 32, H = 18;
|
|
|
|
export class Ambient {
|
|
/** @param {HTMLVideoElement} video @param {HTMLCanvasElement} canvas */
|
|
constructor(video, canvas) {
|
|
this.video = video;
|
|
this.canvas = canvas;
|
|
this.canvas.width = W;
|
|
this.canvas.height = H;
|
|
this.ctx = canvas.getContext('2d', { willReadFrequently: false });
|
|
this.raf = 0;
|
|
this.frame = 0;
|
|
this.active = false;
|
|
this._reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
}
|
|
|
|
start() {
|
|
if (this.active || this._reduced) return;
|
|
this.active = true;
|
|
const loop = () => {
|
|
if (!this.active) return;
|
|
this.raf = requestAnimationFrame(loop);
|
|
if ((this.frame++ % 3) !== 0) return; // ~20fps
|
|
if (document.hidden) return;
|
|
const v = this.video;
|
|
if (v.readyState < 2 || v.videoWidth === 0) return;
|
|
try { this.ctx.drawImage(v, 0, 0, W, H); }
|
|
catch { /* tainted (cross-origin) — bail quietly */ this.stop(); }
|
|
};
|
|
this.raf = requestAnimationFrame(loop);
|
|
}
|
|
|
|
stop() {
|
|
this.active = false;
|
|
if (this.raf) cancelAnimationFrame(this.raf);
|
|
this.raf = 0;
|
|
}
|
|
|
|
toggle() { this.active ? this.stop() : this.start(); return this.active; }
|
|
}
|