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>
This commit is contained in:
2026-06-15 05:34:09 -04:00
commit c58fa9591b
22 changed files with 3851 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
// 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; }
}
+184
View File
@@ -0,0 +1,184 @@
// app.js — bootstrap + global state + keyboard map + immersion modes.
import { ChannelAggregator } from './providers/source-provider.js';
import { MockProvider } from './providers/mock.js';
import { IptvOrgProvider } from './providers/iptv-org.js';
import { Player } from './player.js';
import { Zapper } from './zapper.js';
import { Guide } from './guide.js';
import { Ambient } from './ambient.js';
import { applyLexicon, setMundane, isMundane } from './lexicon.js';
const $ = (id) => document.getElementById(id);
const els = {
root: $('orb-root'),
screen: $('screen'),
video: $('v'),
ambientCanvas: $('ambient'),
staticCanvas: $('static'),
nosignal: $('nosignal'),
// channel OSD
osdChannel: $('osd-channel'), osdLogo: $('osd-logo'), osdNum: $('osd-num'),
osdName: $('osd-name'), osdNow: $('osd-now'), osdProgFill: $('osd-prog-fill'), osdLive: $('osd-live'),
// number OSD
osdNumber: $('osd-number'), osdNumberVal: $('osd-number-val'),
// controls
btnPlay: $('btn-play'), btnChup: $('btn-chup'), btnChdn: $('btn-chdn'),
btnMute: $('btn-mute'), vol: $('vol'), btnCc: $('btn-cc'), btnQual: $('btn-qual'),
btnPip: $('btn-pip'), btnGuide: $('btn-guide'), btnFull: $('btn-full'),
ctlLiveWrap: $('ctl-live-wrap'), ctlClock: $('ctl-clock'), qualMenu: $('qual-menu'),
// guide
guide: $('guide'), guideScroll: $('guide-scroll'), guideRows: $('guide-rows'),
guideTimehdr: $('guide-timehdr'), guidePlayhead: $('guide-playhead'),
guideClock: $('guide-clock'), guideClose: $('guide-close'),
// help
help: $('help'), helpList: $('help-list'), helpClose: $('help-close'), mundane: $('mundane'),
};
// ---- immersion modes ----
const modes = { theater: false, dim: false, ambient: false };
let ambient;
function setMode(name, on) {
modes[name] = on;
els.root.setAttribute(`data-mode-${name}`, String(on));
if (name === 'ambient') on ? ambient.start() : ambient.stop();
}
function toggleMode(name) { setMode(name, !modes[name]); }
function collapseModes() {
if (modes.dim) return setMode('dim', false);
if (modes.theater) return setMode('theater', false);
if (modes.ambient) return setMode('ambient', false);
}
// dim mode: brighten scrim briefly on activity
let dimActT;
function dimActivity() {
if (!modes.dim) return;
els.root.classList.add('activity');
clearTimeout(dimActT);
dimActT = setTimeout(() => els.root.classList.remove('activity'), 2500);
}
// ---- keyboard help ----
const KEYS = [
['09', 'Tune channel (incantation)'],
['↑ / ↓', 'Volume (player) · navigate (guide)'],
['PgUp/PgDn', 'Channel up / down'],
['Backspace', 'Last channel'],
['G', 'Toggle the Almanac (guide)'],
['T', 'Theater mode'],
['D', 'Dim the hall'],
['A', 'Orb glow (ambient)'],
['F', 'Fullscreen'],
['M', 'Mute'],
['P', 'Picture-in-picture'],
['C', 'Captions'],
['Esc', 'Close / collapse'],
['?', 'This help'],
];
function buildHelp() {
els.helpList.innerHTML = '';
for (const [k, d] of KEYS) {
const dt = document.createElement('dt');
const kbd = document.createElement('kbd'); kbd.textContent = k; dt.appendChild(kbd);
const dd = document.createElement('dd'); dd.textContent = d;
els.helpList.append(dt, dd);
}
}
function toggleHelp(force) {
const show = force ?? els.help.hidden;
els.help.hidden = !show;
}
// ---- global keymap ----
let player, zapper, guide;
function onKeydown(e) {
dimActivity();
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (guide.open && ['ArrowUp','ArrowDown','Enter'].includes(e.key)) return; // guide handles
if (e.key >= '0' && e.key <= '9') { zapper.pushDigit(e.key); return; }
switch (e.key) {
case 'g': case 'G': e.preventDefault(); guide.toggle(); break;
case 't': case 'T': toggleMode('theater'); break;
case 'd': case 'D': toggleMode('dim'); break;
case 'a': case 'A': toggleMode('ambient'); break;
case 'f': case 'F': player.toggleFullscreen(); break;
case 'm': case 'M': player.toggleMute(); break;
case 'p': case 'P': player.togglePip(); break;
case 'c': case 'C': player.toggleCaptions(); break;
case 'ArrowUp': if (!guide.open){ e.preventDefault(); player.setVolume(+0.1);} break;
case 'ArrowDown': if (!guide.open){ e.preventDefault(); player.setVolume(-0.1);} break;
case 'PageUp': e.preventDefault(); zapper.tuneToIndexOffset(-1); break;
case 'PageDown': e.preventDefault(); zapper.tuneToIndexOffset(+1); break;
case 'Backspace': e.preventDefault(); zapper.tuneLast(); break;
case '?': toggleHelp(); break;
case 'Escape':
if (!els.help.hidden) { toggleHelp(false); break; }
if (guide.open) { guide.hide(); break; }
collapseModes();
break;
}
}
// ---- provider selection ----
// ?provider=mock | iptv (default: mock unless the relay injected the meta tag)
function pickProviders() {
const params = new URLSearchParams(location.search);
const which = params.get('provider') || 'auto';
const agg = new ChannelAggregator();
if (which === 'mock') { agg.register(new MockProvider()); return { agg, label: 'mock' }; }
if (which === 'iptv') { agg.register(new IptvOrgProvider()); return { agg, label: 'iptv' }; }
const servedByRelay = !!document.querySelector('meta[name="orb-relay"]');
if (servedByRelay) { agg.register(new IptvOrgProvider()); return { agg, label: 'iptv' }; }
agg.register(new MockProvider());
return { agg, label: 'mock' };
}
// ---- boot ----
async function boot() {
applyLexicon();
buildHelp();
els.mundane.checked = isMundane();
els.mundane.addEventListener('change', () => setMundane(els.mundane.checked));
els.helpClose.addEventListener('click', () => toggleHelp(false));
ambient = new Ambient(els.video, els.ambientCanvas);
player = new Player(els, (msg) => console.warn('[orb] stream fatal:', msg));
const { agg, label } = pickProviders();
zapper = new Zapper(els, player, agg);
guide = new Guide(els, agg, (ch) => zapper.tune(ch));
els.btnChup.addEventListener('click', () => zapper.tuneToIndexOffset(-1));
els.btnChdn.addEventListener('click', () => zapper.tuneToIndexOffset(+1));
els.btnGuide.addEventListener('click', () => guide.toggle());
document.addEventListener('keydown', onKeydown);
['mousemove','click','touchstart'].forEach((ev) =>
document.addEventListener(ev, dimActivity, { passive: true }));
try {
const channels = await agg.listAllChannels();
console.info(`[orb] ${label} provider: ${channels.length} channels`);
if (channels.length) await zapper.tune(channels[0]);
runHealthPass(agg, guide);
} catch (e) {
console.error('[orb] boot failed:', e);
player._showNoSignal?.();
}
}
// background health pass → guide skull glyphs (Oracle's Memory seed)
async function runHealthPass(agg, guide) {
for (const ch of agg.channels) {
const owner = agg.providers.get(ch.source);
if (!owner?.healthCheck) continue;
try { const r = await owner.healthCheck(ch.id); guide.setHealth(ch.id, r.ok); }
catch { guide.setHealth(ch.id, false); }
}
}
boot();
+189
View File
@@ -0,0 +1,189 @@
// guide.js — "The Almanac": 1994-style scrolling EPG overlay.
// Virtualized rows (fixed height) so thousands of channels stay smooth.
const ROW_H = 46;
const CH_COL = 150;
const HOUR_W = 180; // px per hour
const WINDOW_HRS = 6; // how far right the grid extends
const OVERSCAN = 4;
export class Guide {
/**
* @param {object} els
* @param {import('./providers/source-provider.js').ChannelAggregator} agg
* @param {(ch:any)=>void} onTune
*/
constructor(els, agg, onTune) {
this.els = els;
this.agg = agg;
this.onTune = onTune;
this.open = false;
this.focusRow = 0;
this._programCache = new Map(); // channelId -> Program[]
this._health = new Map(); // channelId -> bool
this.scroll = els.guideScroll;
this.rowsEl = els.guideRows;
this.scroll.addEventListener('scroll', () => this._renderViewport(), { passive: true });
this.scroll.addEventListener('keydown', (e) => this._onKey(e));
els.guideClose.addEventListener('click', () => this.hide());
this._baseT = 0;
}
async toggle() { this.open ? this.hide() : this.show(); }
async show() {
const list = this.agg.channels;
if (!list.length) return;
this.open = true;
const g = this.els.guide;
g.hidden = false;
g.setAttribute('aria-hidden', 'false');
requestAnimationFrame(() => g.classList.add('show'));
// time base = top of current half-hour
const now = Date.now();
this._baseT = now - (now % (30 * 60 * 1000));
this.rowsEl.style.setProperty('--row-h', ROW_H + 'px');
this.scroll.style.setProperty('--ch-col', CH_COL + 'px');
this.scroll.style.setProperty('--hour-w', HOUR_W + 'px');
this.rowsEl.style.height = (list.length * ROW_H) + 'px';
this._buildTimeHeader();
this._renderViewport();
this._positionPlayhead();
this._clockTimer = setInterval(() => { this._tickClock(); this._positionPlayhead(); }, 30000);
this._tickClock();
this.scroll.focus();
}
hide() {
this.open = false;
const g = this.els.guide;
g.classList.remove('show');
g.setAttribute('aria-hidden', 'true');
clearInterval(this._clockTimer);
setTimeout(() => { if (!this.open) g.hidden = true; }, 300);
}
setHealth(channelId, ok) { this._health.set(channelId, ok); }
_tickClock() {
const t = new Date();
this.els.guideClock.textContent =
`${String(t.getHours()).padStart(2,'0')}:${String(t.getMinutes()).padStart(2,'0')}`;
}
_buildTimeHeader() {
const hdr = this.els.guideTimehdr;
hdr.innerHTML = '';
hdr.style.marginLeft = CH_COL + 'px';
for (let h = 0; h < WINDOW_HRS; h++) {
const t = new Date(this._baseT + h * 3600 * 1000);
const tick = document.createElement('div');
tick.className = 'tick';
tick.style.flex = `0 0 ${HOUR_W}px`;
tick.textContent = `${String(t.getHours()).padStart(2,'0')}:${String(t.getMinutes()).padStart(2,'0')}`;
hdr.appendChild(tick);
}
}
_positionPlayhead() {
const ph = this.els.guidePlayhead;
const offset = ((Date.now() - this._baseT) / 3600000) * HOUR_W;
if (offset < 0 || offset > WINDOW_HRS * HOUR_W) { ph.style.display = 'none'; return; }
ph.style.display = '';
ph.style.left = (CH_COL + offset - this.scroll.scrollLeft) + 'px';
}
async _renderViewport() {
const list = this.agg.channels;
const top = this.scroll.scrollTop;
const h = this.scroll.clientHeight;
const first = Math.max(0, Math.floor(top / ROW_H) - OVERSCAN);
const last = Math.min(list.length, Math.ceil((top + h) / ROW_H) + OVERSCAN);
this.rowsEl.innerHTML = '';
for (let i = first; i < last; i++) {
this.rowsEl.appendChild(this._renderRow(list[i], i));
}
this._positionPlayhead();
// lazily fetch programs for visible rows
for (let i = first; i < last; i++) this._ensurePrograms(list[i]);
}
_renderRow(ch, idx) {
const row = document.createElement('div');
row.className = 'guide-row';
row.style.position = 'absolute';
row.style.top = (idx * ROW_H) + 'px';
row.style.left = '0'; row.style.right = '0';
row.setAttribute('role', 'row');
const cell = document.createElement('div');
cell.className = 'guide-chcell';
cell.setAttribute('role', 'rowheader');
const num = document.createElement('span'); num.className = 'num'; num.textContent = ch.number;
const nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = ch.name; // safe
cell.append(num, nm);
if (this._health.get(ch.id) === false) {
const sk = document.createElement('span'); sk.className = 'skull'; sk.textContent = '☠';
sk.title = 'no signal'; cell.appendChild(sk);
}
cell.addEventListener('click', () => { this.onTune(ch); this.hide(); });
const progs = document.createElement('div');
progs.className = 'guide-progs';
const cached = this._programCache.get(ch.id);
if (cached) this._layoutPrograms(progs, cached, ch);
row.append(cell, progs);
return row;
}
_layoutPrograms(container, programs, ch) {
const now = Date.now();
const winEnd = this._baseT + WINDOW_HRS * 3600 * 1000;
for (const p of programs) {
if (p.stop <= this._baseT || p.start >= winEnd) continue;
const left = ((Math.max(p.start, this._baseT) - this._baseT) / 3600000) * HOUR_W;
const right = ((Math.min(p.stop, winEnd) - this._baseT) / 3600000) * HOUR_W;
const el = document.createElement('div');
el.className = 'guide-prog' + (p.start <= now && now < p.stop ? ' airing' : '');
el.style.left = left + 'px';
el.style.width = Math.max(8, right - left - 2) + 'px';
el.setAttribute('role', 'gridcell');
el.tabIndex = -1;
const tEl = document.createElement('span'); tEl.className = 't';
const s = new Date(p.start);
tEl.textContent = `${String(s.getHours()).padStart(2,'0')}:${String(s.getMinutes()).padStart(2,'0')} `;
el.appendChild(tEl);
el.appendChild(document.createTextNode(p.title)); // safe
el.addEventListener('click', () => { this.onTune(ch); this.hide(); });
container.appendChild(el);
}
}
async _ensurePrograms(ch) {
if (this._programCache.has(ch.id)) return;
this._programCache.set(ch.id, []); // mark in-flight
const progs = await this.agg.getPrograms(ch.id, {
from: this._baseT - 3600 * 1000,
to: this._baseT + WINDOW_HRS * 3600 * 1000,
});
this._programCache.set(ch.id, progs);
if (this.open) this._renderViewport();
}
_onKey(e) {
const list = this.agg.channels;
switch (e.key) {
case 'ArrowDown': e.preventDefault(); this.focusRow = Math.min(list.length-1, this.focusRow+1); this._scrollToFocus(); break;
case 'ArrowUp': e.preventDefault(); this.focusRow = Math.max(0, this.focusRow-1); this._scrollToFocus(); break;
case 'Enter': e.preventDefault(); { const ch = list[this.focusRow]; if (ch){ this.onTune(ch); this.hide(); } } break;
case 'Escape': e.preventDefault(); this.hide(); break;
}
}
_scrollToFocus() {
const y = this.focusRow * ROW_H;
if (y < this.scroll.scrollTop) this.scroll.scrollTop = y;
else if (y + ROW_H > this.scroll.scrollTop + this.scroll.clientHeight)
this.scroll.scrollTop = y + ROW_H - this.scroll.clientHeight;
}
}
+130
View File
@@ -0,0 +1,130 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>The Pondering Orb</title>
<!-- DEV baseline CSP (meta). The Rust server sends a STRICTER same-origin CSP header
in production; when both are present the browser enforces the intersection, so
this loose-for-standalone policy never weakens prod. https: here only lets the
mock realm play CORS-clean test streams when served by a plain static server. -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data: https:;
media-src 'self' blob: https:;
connect-src 'self' https:;
font-src 'self';
object-src 'none';
base-uri 'none';
frame-src 'none';
form-action 'none'">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="dark">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="orb-root" class="orb-root" data-mode-theater="false" data-mode-dim="false" data-mode-ambient="false">
<!-- ============ THE SCREEN (the orb) ============ -->
<div id="screen" class="screen" role="region" aria-label="TV player">
<canvas id="ambient" class="ambient" aria-hidden="true"></canvas>
<div class="bezel">
<video id="v" class="video" playsinline aria-label="Live TV stream"></video>
<!-- scrying static (channel-change transition) -->
<canvas id="static" class="static" aria-hidden="true"></canvas>
<!-- the orb clouds over (dead-stream / no-signal) -->
<div id="nosignal" class="nosignal" hidden role="status" aria-live="polite">
<div class="nosignal-bars" aria-hidden="true"></div>
<div class="nosignal-text" data-lex="noSignal">THE ORB CLOUDS OVER</div>
<div class="nosignal-sub" data-lex="noSignalSub">no signal — seeking another vision</div>
</div>
<!-- channel banner OSD (now-playing) -->
<div id="osd-channel" class="osd osd-channel" hidden role="status" aria-live="polite" aria-atomic="true">
<img id="osd-logo" class="osd-logo" alt="" width="96" height="54">
<div class="osd-meta">
<div class="osd-line1"><span id="osd-num" class="osd-num">--</span><span id="osd-name" class="osd-name"></span></div>
<div id="osd-now" class="osd-now"></div>
<div class="osd-prog"><span id="osd-prog-fill" class="osd-prog-fill"></span></div>
</div>
<div id="osd-live" class="osd-live" hidden><span class="live-dot" aria-hidden="true"></span><span data-lex="live">LIVE</span></div>
</div>
<!-- incantation (number entry) OSD -->
<div id="osd-number" class="osd osd-number" hidden role="status" aria-live="assertive" aria-atomic="true">
<span id="osd-number-val" class="seg">---</span>
</div>
<!-- ghost controls -->
<div id="controls" class="controls" role="toolbar" aria-label="Player controls">
<button id="btn-play" class="ctl" aria-label="Play/Pause" aria-pressed="false"></button>
<button id="btn-chdn" class="ctl" aria-label="Channel down">CH ▼</button>
<button id="btn-chup" class="ctl" aria-label="Channel up">CH ▲</button>
<div class="ctl-live" id="ctl-live-wrap" hidden><span class="live-dot" aria-hidden="true"></span><span data-lex="live">LIVE</span></div>
<span id="ctl-clock" class="ctl-clock" aria-hidden="true">--:--</span>
<span class="ctl-spacer"></span>
<button id="btn-mute" class="ctl" aria-label="Mute" aria-pressed="false">🔊</button>
<input id="vol" class="vol" type="range" min="0" max="100" value="100" aria-label="Volume">
<button id="btn-cc" class="ctl" aria-label="Captions" aria-pressed="false">CC</button>
<button id="btn-qual" class="ctl" aria-label="Quality">AUTO</button>
<button id="btn-pip" class="ctl" aria-label="Picture in picture" hidden>PiP</button>
<button id="btn-guide" class="ctl" aria-label="TV guide" data-lex="guideShort">Almanac</button>
<button id="btn-full" class="ctl" aria-label="Fullscreen"></button>
</div>
<!-- quality menu -->
<div id="qual-menu" class="qual-menu" hidden role="menu" aria-label="Quality"></div>
<!-- CRT overlays (scanlines / vignette) -->
<div class="crt-scan" aria-hidden="true"></div>
<div class="crt-vignette" aria-hidden="true"></div>
</div>
<div class="dim-scrim" id="dim-scrim" aria-hidden="true"></div>
</div>
<!-- ============ THE ALMANAC (guide) ============ -->
<section id="guide" class="guide" hidden aria-label="TV Guide" aria-hidden="true">
<header class="guide-head">
<h2 class="guide-title" data-lex="guide">The Almanac</h2>
<div class="guide-clock" id="guide-clock">--:--</div>
<button id="guide-close" class="ctl" aria-label="Close guide"></button>
</header>
<div class="guide-scroll" id="guide-scroll" role="grid" aria-label="Channel schedule" tabindex="0">
<div class="guide-timehdr" id="guide-timehdr" role="row" aria-hidden="true"></div>
<div class="guide-rows" id="guide-rows"></div>
<div class="guide-playhead" id="guide-playhead" aria-hidden="true"></div>
</div>
</section>
<!-- ============ INCANTATIONS (keyboard help) ============ -->
<div id="help" class="help" hidden role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
<div class="help-card">
<h2 data-lex="help">Incantations</h2>
<dl id="help-list"></dl>
<button id="help-close" class="ctl" aria-label="Close">close (Esc)</button>
<label class="mundane-toggle">
<input type="checkbox" id="mundane"> plain-English labels (mundane mode)
</label>
</div>
</div>
<!-- brand bug -->
<div class="brand-bug" aria-hidden="true" data-lex="brand">THE PONDERING ORB</div>
</div>
<!-- HLS engine: local, pinned (v1.5.17), SRI-verified. -->
<script src="lib/hls.min.js"
integrity="sha384-9v3HcdYrO3D+OPDTjZ40RXocgE4GtXVCd3/mCS62JsM93JXgI1afJVuwjFvsu6ni"
crossorigin="anonymous"></script>
<script type="module" src="app.js"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
// lexicon.js — in-world UI vocabulary (upgrade #3).
//
// Lore names are flavor layered on top of clear function. Visual labels get the
// lore word; ARIA labels ALWAYS stay plain-English (set directly in markup), so
// screen readers and the "mundane mode" toggle never lose meaning.
export const LEXICON = {
guide: 'The Almanac',
guideShort: 'Almanac',
favorites: 'The Grimoire',
search: 'Scry',
channelEntry: 'Incantation',
sleepTimer: 'The Orb Dims',
volume: 'Resonance',
noSignal: 'THE ORB CLOUDS OVER',
noSignalSub: 'no signal — seeking another vision',
loading: 'Summoning…',
nextUp: 'What Fate Foretells',
live: 'LIVE',
help: 'Incantations', // keyboard-help title
theater: 'Theater',
dim: 'Dim the Hall',
ambient: 'Orb Glow',
brand: 'THE PONDERING ORB',
};
const PLAIN = {
guide: 'TV Guide', guideShort: 'Guide', favorites: 'Favorites', search: 'Search',
channelEntry: 'Channel', sleepTimer: 'Sleep timer', volume: 'Volume',
noSignal: 'NO SIGNAL', noSignalSub: 'stream unavailable — trying another',
loading: 'Loading…', nextUp: 'Next up', live: 'LIVE', help: 'Keyboard shortcuts',
theater: 'Theater', dim: 'Dim', ambient: 'Ambient glow', brand: 'THE PONDERING ORB',
};
const KEY = 'orb.mundaneMode';
let mundane = (() => { try { return localStorage.getItem(KEY) === '1'; } catch { return false; } })();
/** @param {string} k @returns {string} */
export function lex(k) { return (mundane ? PLAIN : LEXICON)[k] ?? PLAIN[k] ?? k; }
export function isMundane() { return mundane; }
export function setMundane(on) {
mundane = !!on;
try { localStorage.setItem(KEY, mundane ? '1' : '0'); } catch { /* ignore */ }
applyLexicon();
}
/** Fill every [data-lex] element's textContent. Never touches aria-label. */
export function applyLexicon(root = document) {
for (const el of root.querySelectorAll('[data-lex]')) {
const k = el.getAttribute('data-lex');
el.textContent = lex(k);
}
}
+2
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
VERSION=1.5.17
SHA256=484054e8cd03d3f6d1781fb7f402bdc318d8a4c527f933a95c624e27cc9a9470
SRI=sha384-9v3HcdYrO3D+OPDTjZ40RXocgE4GtXVCd3/mCS62JsM93JXgI1afJVuwjFvsu6ni
+1
View File
@@ -0,0 +1 @@
sha384-9v3HcdYrO3D+OPDTjZ40RXocgE4GtXVCd3/mCS62JsM93JXgI1afJVuwjFvsu6ni
+212
View File
@@ -0,0 +1,212 @@
// player.js — HLS.js wiring, live-aware ghost controls, dead-stream handling.
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));
}
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();
}
}
+176
View File
@@ -0,0 +1,176 @@
// iptv-org.js — IptvOrgProvider. Real public channels via the iptv-org JSON API.
//
// Data source of truth = the JSON API (CORS-clean GitHub Pages), NOT raw M3U:
// join channels.json + streams.json + logos.json, EPG via guides.json (XMLTV).
// Stream URLs are routed through the same-origin /relay so HLS.js can play them
// without CORS and the ambient canvas stays untainted.
//
// SECURITY: every field from the API is treated as hostile input. We never use
// innerHTML anywhere; here we additionally scheme-allowlist URLs and cap sizes.
import { SourceProvider } from './source-provider.js';
const API = 'https://iptv-org.github.io/api';
const MAX_CHANNELS = 20000; // anti client-side DoS
const MAX_FIELD = 200; // cap any displayed string
const clip = (s) => (typeof s === 'string' ? s.slice(0, MAX_FIELD) : '');
/** Only https logos (or data:). Anything else → dropped. */
function safeLogo(url) {
if (typeof url !== 'string') return undefined;
if (/^https:\/\//i.test(url) || /^data:image\//i.test(url)) return url;
return undefined;
}
/** Only https stream URLs survive. */
function safeStream(url) {
return (typeof url === 'string' && /^https:\/\//i.test(url)) ? url : null;
}
/** Wrap an upstream URL so it flows through the hardened same-origin relay. */
function viaRelay(url) { return '/relay?url=' + encodeURIComponent(url); }
async function getJSON(url, useRelay) {
// Under the production CSP (connect-src 'self') the JSON API must also flow
// through the relay; on a plain static server we fetch it directly.
const target = useRelay ? viaRelay(url) : url;
const r = await fetch(target, { credentials: 'omit', referrerPolicy: 'no-referrer' });
if (!r.ok) throw new Error(`${url}${r.status}`);
return r.json();
}
export class IptvOrgProvider extends SourceProvider {
constructor(opts = {}) {
super();
this.useRelay = opts.useRelay !== false; // default on (same-origin server)
this._channels = null;
this._streamsByCh = new Map(); // channelId -> https url[]
this._guideByCh = new Map(); // channelId -> xmltv url
this._epgCache = new Map(); // channelId -> Program[]
}
get id() { return 'iptv-org'; }
get name() { return 'iptv-org'; }
get supportsGuides() { return true; }
async listChannels() {
if (this._channels) return this._channels;
const [channels, streams, logos, guides] = await Promise.all([
getJSON(`${API}/channels.json`, this.useRelay),
getJSON(`${API}/streams.json`, this.useRelay),
getJSON(`${API}/logos.json`, this.useRelay).catch(() => []),
getJSON(`${API}/guides.json`, this.useRelay).catch(() => []),
]);
// streams: channel -> [https urls]
for (const s of streams) {
const cid = s.channel;
const url = safeStream(s.url);
if (!cid || !url) continue;
(this._streamsByCh.get(cid) || this._streamsByCh.set(cid, []).get(cid)).push(url);
}
// first guide source per channel
for (const g of guides) {
if (g.channel && g.site && !this._guideByCh.has(g.channel)) {
// guides.json entries point at grabber sites, not always direct XMLTV;
// we store the channel's xmltv id for matching when an EPG feed is wired.
this._guideByCh.set(g.channel, g);
}
}
const logoByCh = new Map();
for (const l of logos) {
if (l.channel && !logoByCh.has(l.channel)) {
const u = safeLogo(l.url); if (u) logoByCh.set(l.channel, u);
}
}
/** @type {import('./source-provider.js').Channel[]} */
const out = [];
for (const c of channels) {
if (out.length >= MAX_CHANNELS) break;
const urls = this._streamsByCh.get(c.id);
if (!urls || !urls.length) continue; // only channels we can actually play
const playable = this.useRelay ? urls.map(viaRelay) : urls;
out.push({
id: `iptv-org:${c.id}`,
source: 'iptv-org',
name: clip(c.name) || c.id,
logo: logoByCh.get(c.id),
group: clip((c.categories && c.categories[0]) || ''),
country: clip(c.country || ''),
language: clip((c.languages && c.languages[0]) || ''),
kind: 'live',
streamUrls: playable,
guideId: c.id,
});
}
this._channels = out;
return out;
}
async getStreamHandle(channelId) {
const ch = (this._channels || []).find((c) => c.id === channelId);
if (!ch || !ch.streamUrls.length) return null;
return { url: ch.streamUrls[0], protocol: 'hls' };
}
async healthCheck(channelId) {
const ch = (this._channels || []).find((c) => c.id === channelId);
if (!ch || !ch.streamUrls.length) return { ok: false, error: 'no stream' };
try {
// probe through the relay (same SSRF-validated path); HEAD then bail
const r = await fetch(ch.streamUrls[0], { method: 'GET', credentials: 'omit' });
return { ok: r.ok, error: r.ok ? undefined : `status ${r.status}` };
} catch (e) { return { ok: false, error: String(e) }; }
}
/**
* EPG. Parses XMLTV with DOMParser (never innerHTML) into Program[].
* Phase-1 stub: returns cached programs if an XMLTV feed has been provided via
* setEpgXml(); otherwise empty (guide still renders channel rows + now/next is
* simply blank until an EPG source is wired in Phase 1.5).
*/
async getPrograms(channelId, window) {
const cached = this._epgCache.get(channelId);
if (!cached) return [];
return cached.filter((p) => p.stop > window.from && p.start < window.to);
}
/** Parse a raw XMLTV string and populate the EPG cache (safe: DOMParser). */
setEpgXml(xmlString) {
const doc = new DOMParser().parseFromString(xmlString, 'application/xml');
if (doc.querySelector('parsererror')) return;
// map xmltv channel id -> our namespaced id
const idMap = new Map((this._channels || []).map((c) => [c.guideId, c.id]));
for (const prog of doc.getElementsByTagName('programme')) {
const xmlCh = prog.getAttribute('channel');
const cid = idMap.get(xmlCh);
if (!cid) continue;
const start = parseXmltvTime(prog.getAttribute('start'));
const stop = parseXmltvTime(prog.getAttribute('stop'));
if (!start || !stop) continue;
const titleEl = prog.getElementsByTagName('title')[0];
const descEl = prog.getElementsByTagName('desc')[0];
const arr = this._epgCache.get(cid) || this._epgCache.set(cid, []).get(cid);
arr.push({
channelId: cid,
title: clip(titleEl ? titleEl.textContent : 'Program'),
desc: clip(descEl ? descEl.textContent : ''),
start, stop,
});
}
}
}
/** XMLTV time: "20260615013000 +0000" → epoch ms. */
function parseXmltvTime(s) {
if (!s) return 0;
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?\s*([+-]\d{4})?/.exec(s.trim());
if (!m) return 0;
const [, Y, Mo, D, H, Mi, S, tz] = m;
let ms = Date.UTC(+Y, +Mo - 1, +D, +H, +Mi, +(S || 0));
if (tz) {
const sign = tz[0] === '-' ? -1 : 1;
ms -= sign * ((+tz.slice(1, 3)) * 60 + (+tz.slice(3, 5))) * 60000;
}
return ms;
}
+117
View File
@@ -0,0 +1,117 @@
// mock.js — MockProvider. Built FIRST, on purpose.
//
// Proves the UI is fully source-agnostic with ZERO network: fake channels, a
// rolling "scheduled-live" EPG timeline (so now/next + the guide populate), and
// a couple of real CORS-clean public TEST streams so playback can be exercised
// standalone (no relay needed). One channel points at a dead URL to exercise the
// "the orb clouds over" no-signal path.
import { SourceProvider } from './source-provider.js';
/** Tiny inline-SVG logo so channels render with no network. */
function logo(initials, accent = '#ff2b2b') {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="54">` +
`<rect width="96" height="54" fill="#0a0000"/>` +
`<rect x="1" y="1" width="94" height="52" fill="none" stroke="${accent}" stroke-width="1.5"/>` +
`<text x="48" y="34" font-family="monospace" font-size="22" font-weight="bold" ` +
`text-anchor="middle" fill="${accent}">${initials}</text></svg>`;
return 'data:image/svg+xml,' + encodeURIComponent(svg);
}
// CORS-clean public test streams (HLS). Stable, broadcaster-hosted demos.
const TEST_STREAMS = {
bipbop: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8',
mux: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
bbb: 'https://test-streams.mux.dev/test_001/stream.m3u8',
dead: 'https://stream.invalid.example/offline.m3u8',
};
const TITLE_POOL = {
'Arcane Cinema': ['The Reanimator', 'Hex of the Hollow', 'Salt & Sigil', 'Midnight Grimoire',
'The Pondering Hour', 'Witchlight', 'The Veil Lifts', 'Candle & Bone'],
'Hack the Planet': ['Packets at Dawn', 'Root of All Evil', 'The Blue Box', 'Zero Cool',
'Buffer Overflow', 'The Last Daemon', 'Kernel Panic', 'Shell Games'],
'Old Signals': ['Test Pattern Theatre', 'Static & Snow', 'The Late Late Broadcast',
'Color Bars', 'Sign-Off Sermon', 'Dead Air'],
'The Almanac': ['What Fate Foretells', 'Stars & Portents', 'The Long Now', 'Tomorrow, Maybe'],
};
function buildSchedule(channelId, group, now) {
/** @type {import('./source-provider.js').Program[]} */
const programs = [];
const titles = TITLE_POOL[group] || TITLE_POOL['Old Signals'];
// Deterministic-ish per channel so reloads look stable.
let seed = [...channelId].reduce((a, c) => a + c.charCodeAt(0), 0);
const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
// Start two hours back, fill ~10h.
let t = now - 2 * 3600 * 1000;
t = t - (t % (30 * 60 * 1000)); // align to :00/:30
const end = now + 8 * 3600 * 1000;
let i = Math.floor(rand() * titles.length);
while (t < end) {
const slots = 1 + Math.floor(rand() * 3); // 30/60/90 min
const dur = slots * 30 * 60 * 1000;
programs.push({
channelId,
title: titles[i % titles.length],
desc: 'A scheduled broadcast. Tuning in drops you mid-program, live — just like real TV.',
start: t,
stop: t + dur,
});
t += dur;
i++;
}
return programs;
}
const CHANNELS = [
{ n: 2, name: 'Arcane Cinema', group: 'Arcane Cinema', stream: 'mux', init: 'AC' },
{ n: 3, name: 'Hack the Planet',group: 'Hack the Planet', stream: 'bbb', init: 'HP' },
{ n: 4, name: 'Bip-Bop Test', group: 'Old Signals', stream: 'bipbop', init: 'BB' },
{ n: 5, name: 'The Late Show', group: 'Old Signals', stream: 'mux', init: 'LS' },
{ n: 6, name: 'Dead Air', group: 'Old Signals', stream: 'dead', init: 'XX' },
{ n: 7, name: 'The Almanac', group: 'The Almanac', stream: 'bbb', init: 'AL' },
];
export class MockProvider extends SourceProvider {
get id() { return 'mock'; }
get name() { return 'The Mock Realm'; }
get supportsGuides() { return true; }
async listChannels() {
return CHANNELS.map((c) => ({
id: `mock:${c.n}`,
source: 'mock',
name: c.name,
number: c.n,
logo: logo(c.init),
group: c.group,
country: 'XX',
language: 'eng',
kind: 'live',
streamUrls: [TEST_STREAMS[c.stream]],
guideId: `mock:${c.n}`,
}));
}
async getStreamHandle(channelId) {
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
if (!c) return null;
// Mock returns direct URLs (test streams are CORS-clean) so the UI runs standalone.
return { url: TEST_STREAMS[c.stream], protocol: 'hls' };
}
async getPrograms(channelId, window) {
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
if (!c) return [];
const all = buildSchedule(channelId, c.group, Date.now());
return all.filter((p) => p.stop > window.from && p.start < window.to);
}
async healthCheck(channelId) {
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
if (c && c.stream === 'dead') return { ok: false, error: 'mock offline channel' };
return { ok: true };
}
}
+185
View File
@@ -0,0 +1,185 @@
// source-provider.js — the load-bearing abstraction.
//
// A channel is "a named thing on a guide that resolves to an HLS stream at a
// point in time." Public iptv-org streams and your future self-hosted media are
// just different SourceProviders feeding the SAME UI. Build providers against
// this contract; the UI (guide/zapper/player) only ever talks to the aggregator.
/**
* @typedef {Object} Channel
* @property {string} id Namespaced id, "source:rawId" (e.g. "iptv-org:CNN.us").
* @property {string} source Provider id that owns this channel.
* @property {string} name Display name (UNTRUSTED — render as textContent only).
* @property {number} [number] Assigned tuning number (filled by the aggregator).
* @property {string} [logo] Logo URL (UNTRUSTED — scheme-allowlisted before use).
* @property {string} [group] Category / group-title.
* @property {string} [country]
* @property {string} [language]
* @property {'live'|'vod'} kind Always 'live' for Phase 1; self-hosted "channels" are also 'live'.
* @property {string[]} streamUrls One or more upstream HLS URLs (primary + fallbacks).
* @property {string} [guideId] Key used to match EPG/program data.
*/
/**
* @typedef {Object} Program
* @property {string} channelId
* @property {string} title UNTRUSTED — textContent only.
* @property {string} [desc]
* @property {number} start Epoch ms (inclusive).
* @property {number} stop Epoch ms (exclusive).
*/
/**
* @typedef {Object} StreamHandle
* @property {string} url Playable URL handed to HLS.js (may be relay-wrapped).
* @property {'hls'} protocol
* @property {Object<string,string>} [headers]
*/
/**
* The contract every source implements. Extend this or just match the shape.
* @abstract
*/
export class SourceProvider {
/** @type {string} stable namespace, e.g. "iptv-org" */
get id() { throw new Error('provider must define id'); }
/** @type {string} human label */
get name() { return this.id; }
/** @type {boolean} */
get supportsGuides() { return false; }
/** @returns {Promise<Channel[]>} */
async listChannels() { throw new Error('not implemented'); }
/**
* Resolve a channel to something HLS.js can play (relay-wrapped if needed).
* @param {string} _channelId
* @returns {Promise<StreamHandle|null>}
*/
async getStreamHandle(_channelId) { throw new Error('not implemented'); }
/**
* EPG slice for a time window. Default: none.
* @param {string} _channelId
* @param {{from:number,to:number}} _window
* @returns {Promise<Program[]>}
*/
async getPrograms(_channelId, _window) { return []; }
/**
* Optional liveness probe.
* @param {string} _channelId
* @returns {Promise<{ok:boolean, error?:string}>}
*/
async healthCheck(_channelId) { return { ok: true }; }
}
/**
* Merges channels from many providers behind one interface and routes
* per-channel calls back to the owning provider by source-prefix.
*/
export class ChannelAggregator {
constructor() {
/** @type {Map<string, SourceProvider>} */
this.providers = new Map();
/** @type {Channel[]} */
this._channels = [];
/** @type {Map<string, Channel>} */
this._byId = new Map();
}
/** @param {SourceProvider} provider */
register(provider) {
this.providers.set(provider.id, provider);
return this;
}
/**
* Load + merge all providers. De-dups by id, assigns sequential tuning numbers.
* @returns {Promise<Channel[]>}
*/
async listAllChannels() {
const lists = await Promise.all(
[...this.providers.values()].map(async (p) => {
try { return await p.listChannels(); }
catch (e) { console.warn(`[aggregator] provider ${p.id} failed:`, e); return []; }
}),
);
const seen = new Set();
/** @type {Channel[]} */
const merged = [];
for (const list of lists) {
for (const ch of list) {
if (!ch || !ch.id || seen.has(ch.id)) continue;
seen.add(ch.id);
merged.push(ch);
}
}
// Respect any explicit number, otherwise assign by order starting at 2 (channel 1 reserved).
let next = 2;
const used = new Set(merged.filter((c) => Number.isFinite(c.number)).map((c) => c.number));
for (const ch of merged) {
if (!Number.isFinite(ch.number)) {
while (used.has(next)) next++;
ch.number = next;
used.add(next);
}
}
merged.sort((a, b) => a.number - b.number);
this._channels = merged;
this._byId = new Map(merged.map((c) => [c.id, c]));
return merged;
}
/** @returns {Channel[]} */
get channels() { return this._channels; }
/** @param {string} id @returns {Channel|undefined} */
getChannel(id) { return this._byId.get(id); }
/** @param {number} number @returns {Channel|undefined} */
getChannelByNumber(number) { return this._channels.find((c) => c.number === number); }
/** @param {string} channelId @returns {SourceProvider|undefined} */
_ownerOf(channelId) {
const source = channelId.split(':', 1)[0];
return this.providers.get(source);
}
/** @param {string} channelId @returns {Promise<StreamHandle|null>} */
async getStreamHandle(channelId) {
const owner = this._ownerOf(channelId);
if (!owner) return null;
return owner.getStreamHandle(channelId);
}
/**
* @param {string} channelId
* @param {{from:number,to:number}} window
* @returns {Promise<Program[]>}
*/
async getPrograms(channelId, window) {
const owner = this._ownerOf(channelId);
if (!owner) return [];
try { return await owner.getPrograms(channelId, window); }
catch { return []; }
}
/**
* now/next helper used by the OSD banner and guide.
* @param {string} channelId
* @param {number} [at] epoch ms (default: now)
* @returns {Promise<{now: Program|null, next: Program|null}>}
*/
async nowNext(channelId, at = Date.now()) {
const horizon = at + 6 * 3600 * 1000;
const programs = await this.getPrograms(channelId, { from: at - 3600 * 1000, to: horizon });
programs.sort((a, b) => a.start - b.start);
let now = null, next = null;
for (const p of programs) {
if (p.start <= at && at < p.stop) now = p;
else if (p.start > at && !next) next = p;
}
return { now, next };
}
}
+206
View File
@@ -0,0 +1,206 @@
/* The Pondering Orb — red/black phosphor CRT theme.
All CRT effects are CSS (no WebGL). prefers-reduced-motion kills every animation. */
:root{
--red: #ff2b2b;
--red-dim: #b21d1d;
--red-deep: #6e0f0f;
--red-glow: rgba(255,43,43,.55);
--amber: #ff6a3d;
--bg: #050102;
--panel: #120406;
--panel-2: #1a0608;
--line: #3a0d0f;
--ink: #ffd9d4; /* warm off-white text on red/black */
--ink-dim: #c98b86;
--seg-on: #ff2b2b;
--seg-off: #2a0809;
--focus: #ff2b2b;
--osd-bg: rgba(10,1,2,.82);
--ease: cubic-bezier(.2,.7,.2,1);
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);
font-family:"Consolas","DejaVu Sans Mono","Courier New",monospace;overflow:hidden}
body{line-height:1.4}
button{font-family:inherit;color:inherit}
img{display:block}
/* ===================== layout root ===================== */
.orb-root{position:fixed;inset:0;display:flex;flex-direction:column;background:radial-gradient(120% 90% at 50% 0%, #160406 0%, var(--bg) 70%)}
/* ===================== the screen ===================== */
.screen{position:relative;flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;
padding:clamp(8px,2vw,28px);transition:padding .25s var(--ease)}
.bezel{position:relative;width:100%;height:100%;max-width:100%;max-height:100%;aspect-ratio:16/9;
margin:auto;background:#000;border-radius:18px/26px;overflow:hidden;
box-shadow:0 0 0 2px #240608, 0 0 0 10px #0c0203, 0 0 60px rgba(0,0,0,.9),
inset 0 0 120px rgba(0,0,0,.9);}
.video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000}
/* ambient backlight canvas sits BEHIND the bezel */
.ambient{position:absolute;inset:0;width:100%;height:100%;
filter:blur(60px) saturate(1.7);transform:scale(1.18);opacity:0;
transition:opacity .5s var(--ease);pointer-events:none;z-index:0}
.orb-root[data-mode-ambient="true"] .ambient{opacity:.85}
.screen{z-index:1}
/* ---- CRT overlays ---- */
.crt-scan{position:absolute;inset:0;pointer-events:none;z-index:6;
background:repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px, rgba(0,0,0,.16) 3px)}
.crt-vignette{position:absolute;inset:0;pointer-events:none;z-index:6;
background:radial-gradient(120% 120% at 50% 50%, rgba(0,0,0,0) 55%, rgba(0,0,0,.55) 100%);
animation:breathe 4s ease-in-out infinite}
@keyframes breathe{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.012);opacity:1}}
/* ===================== OSD (shared) ===================== */
.osd{position:absolute;z-index:7;background:var(--osd-bg);border:1px solid var(--line);
color:var(--ink);backdrop-filter:blur(2px);box-shadow:0 0 18px rgba(0,0,0,.7)}
.osd[hidden]{display:none}
/* channel banner */
.osd-channel{left:18px;bottom:18px;display:flex;gap:14px;align-items:center;padding:12px 16px;border-radius:8px;
min-width:300px;max-width:70%;opacity:0;transform:translateY(8px) scale(.98);
transition:opacity .3s var(--ease),transform .3s var(--ease)}
.osd-channel.show{opacity:1;transform:none}
.osd-logo{width:96px;height:54px;object-fit:contain;background:#000;border:1px solid var(--line);border-radius:4px}
.osd-meta{min-width:0;flex:1}
.osd-line1{display:flex;align-items:baseline;gap:10px}
.osd-num{font-weight:bold;color:var(--red);font-size:26px;text-shadow:0 0 10px var(--red-glow)}
.osd-name{font-size:18px;font-weight:bold;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.osd-now{color:var(--ink-dim);font-size:13px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.osd-prog{height:4px;background:var(--seg-off);border-radius:2px;margin-top:7px;overflow:hidden}
.osd-prog-fill{display:block;height:100%;width:0;background:var(--red);box-shadow:0 0 8px var(--red-glow)}
.osd-live{display:flex;align-items:center;gap:6px;color:var(--red);font-weight:bold;font-size:13px;align-self:flex-start}
/* number entry (7-seg look) */
.osd-number{right:22px;top:22px;padding:8px 14px;border-radius:6px}
.seg{font-weight:bold;font-size:40px;letter-spacing:6px;color:var(--seg-on);
text-shadow:0 0 14px var(--red-glow);font-variant-numeric:tabular-nums}
.live-dot{width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 8px var(--red);
display:inline-block;animation:pulse 2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
/* ===================== scrying static ===================== */
.static{position:absolute;inset:0;width:100%;height:100%;z-index:5;opacity:0;pointer-events:none;
transition:opacity .12s linear}
.static.show{opacity:.9}
/* ===================== the orb clouds over ===================== */
.nosignal{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:10px;background:#050102;text-align:center;padding:20px}
.nosignal[hidden]{display:none}
.nosignal-bars{width:min(70%,520px);height:46%;border:1px solid var(--line);
background:linear-gradient(90deg,#6e0f0f 0 14%,#b21d1d 14% 28%,#ff6a3d 28% 42%,#ffd9d4 42% 56%,
#b21d1d 56% 70%,#6e0f0f 70% 84%,#1a0608 84% 100%);opacity:.5;filter:saturate(.8)}
.nosignal-text{color:var(--red);font-weight:bold;font-size:clamp(18px,3vw,30px);letter-spacing:4px;
text-shadow:0 0 14px var(--red-glow)}
.nosignal-sub{color:var(--ink-dim);font-size:13px;letter-spacing:1px}
/* ===================== ghost controls ===================== */
.controls{position:absolute;left:0;right:0;bottom:0;z-index:8;display:flex;align-items:center;gap:8px;
padding:12px 14px;background:linear-gradient(to top, rgba(5,1,2,.92), rgba(5,1,2,0));
opacity:0;transform:translateY(6px);transition:opacity .25s var(--ease),transform .25s var(--ease);
pointer-events:none;flex-wrap:wrap}
.screen.controls-visible .controls,.controls:focus-within{opacity:1;transform:none;pointer-events:auto}
.ctl{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:6px;
padding:6px 10px;font-size:13px;cursor:pointer;min-height:34px;transition:border-color .15s,box-shadow .15s,color .15s}
.ctl:hover{border-color:var(--red-dim);color:#fff}
.ctl-spacer{flex:1 1 auto}
.ctl-clock{color:var(--ink-dim);font-size:13px;font-variant-numeric:tabular-nums;padding:0 4px}
.ctl-live{display:flex;align-items:center;gap:6px;color:var(--red);font-weight:bold;font-size:12px}
.vol{width:90px;accent-color:var(--red)}
.qual-menu{position:absolute;right:14px;bottom:58px;z-index:9;background:var(--panel-2);border:1px solid var(--line);
border-radius:6px;padding:4px;min-width:120px;box-shadow:0 0 18px rgba(0,0,0,.8)}
.qual-menu[hidden]{display:none}
.qual-menu button{display:block;width:100%;text-align:left;background:none;border:0;color:var(--ink);
padding:7px 10px;border-radius:4px;cursor:pointer;font-size:13px}
.qual-menu button:hover,.qual-menu button[aria-checked="true"]{background:var(--red-deep);color:#fff}
/* ===================== dim mode ===================== */
.dim-scrim{position:absolute;inset:0;z-index:4;background:rgba(0,0,0,.72);opacity:0;pointer-events:none;
transition:opacity .3s var(--ease)}
.orb-root[data-mode-dim="true"] .dim-scrim{opacity:1}
.orb-root[data-mode-dim="true"] .bezel{z-index:5;position:relative}
.orb-root[data-mode-dim="true"].activity .dim-scrim{opacity:.4}
/* ===================== theater mode ===================== */
.orb-root[data-mode-theater="true"] .screen{padding:0}
.orb-root[data-mode-theater="true"] .bezel{border-radius:0;box-shadow:none}
.orb-root[data-mode-theater="true"] .brand-bug{opacity:0}
/* ===================== the almanac (guide) ===================== */
.guide{position:absolute;left:0;right:0;bottom:0;height:46%;z-index:20;background:var(--panel);
border-top:2px solid var(--red-deep);box-shadow:0 -12px 40px rgba(0,0,0,.8);
transform:translateY(100%);transition:transform .28s var(--ease);display:flex;flex-direction:column}
.guide:not([hidden]){display:flex}
.guide.show{transform:translateY(0)}
.guide-head{display:flex;align-items:center;gap:14px;padding:8px 14px;border-bottom:1px solid var(--line);
background:var(--panel-2)}
.guide-title{margin:0;font-size:15px;letter-spacing:3px;color:var(--red);text-shadow:0 0 10px var(--red-glow)}
.guide-clock{color:var(--ink-dim);font-variant-numeric:tabular-nums;margin-left:auto;font-size:13px}
.guide-scroll{position:relative;flex:1;overflow:auto;outline:none}
.guide-timehdr{position:sticky;top:0;z-index:3;display:flex;height:26px;background:var(--panel-2);
border-bottom:1px solid var(--line);margin-left:var(--ch-col,150px)}
.guide-timehdr .tick{flex:0 0 var(--hour-w,180px);font-size:11px;color:var(--ink-dim);
padding:5px 8px;border-left:1px solid var(--line)}
.guide-rows{position:relative}
.guide-row{display:flex;height:var(--row-h,46px);border-bottom:1px solid #220708}
.guide-chcell{position:sticky;left:0;z-index:2;flex:0 0 var(--ch-col,150px);display:flex;align-items:center;gap:8px;
padding:0 8px;background:var(--panel-2);border-right:1px solid var(--line);cursor:pointer}
.guide-chcell:hover{background:var(--red-deep)}
.guide-chcell .num{color:var(--red);font-weight:bold;font-variant-numeric:tabular-nums;min-width:24px}
.guide-chcell .nm{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.guide-chcell .skull{margin-left:auto;opacity:.55;font-size:12px}
.guide-progs{position:relative;flex:1}
.guide-prog{position:absolute;top:3px;bottom:3px;background:var(--panel-2);border:1px solid var(--line);
border-radius:4px;padding:5px 8px;font-size:12px;overflow:hidden;cursor:pointer;white-space:nowrap;text-overflow:ellipsis}
.guide-prog:hover,.guide-prog:focus{border-color:var(--red-dim);outline:none}
.guide-prog.airing{background:rgba(255,43,43,.14);border-color:var(--red-dim)}
.guide-prog .t{color:var(--ink-dim);font-size:10px}
.guide-playhead{position:absolute;top:0;bottom:0;width:2px;background:var(--red);box-shadow:0 0 8px var(--red);
z-index:4;pointer-events:none;left:var(--ch-col,150px)}
/* ===================== help / dialog ===================== */
.help{position:absolute;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;
background:rgba(5,1,2,.7);backdrop-filter:blur(2px)}
.help[hidden]{display:none}
.help-card{background:var(--panel-2);border:1px solid var(--red-deep);border-radius:10px;padding:22px 26px;
max-width:560px;width:90%;box-shadow:0 0 50px rgba(0,0,0,.85)}
.help-card h2{margin:0 0 14px;color:var(--red);letter-spacing:3px;text-shadow:0 0 10px var(--red-glow)}
.help-card dl{display:grid;grid-template-columns:auto 1fr;gap:6px 16px;margin:0 0 16px}
.help-card dt{color:var(--red);font-weight:bold;white-space:nowrap}
.help-card dd{margin:0;color:var(--ink-dim)}
.mundane-toggle{display:flex;align-items:center;gap:8px;margin-top:12px;color:var(--ink-dim);font-size:13px}
kbd{background:#000;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:1px 6px;
font-family:inherit;color:var(--ink)}
/* ===================== brand bug ===================== */
.brand-bug{position:absolute;right:14px;top:10px;z-index:7;font-size:11px;letter-spacing:3px;
color:var(--red);opacity:.6;text-shadow:0 0 8px var(--red-glow);pointer-events:none;transition:opacity .3s}
/* ===================== focus rings (a11y) ===================== */
:focus-visible{outline:2px solid var(--focus);outline-offset:2px;box-shadow:0 0 8px var(--red-glow)}
/* ===================== responsive ===================== */
@media (max-width:768px){
.guide{height:70%}
.osd-channel{min-width:0;max-width:88%}
.vol{display:none}
.brand-bug{display:none}
}
/* ===================== reduced motion kill-switch ===================== */
@media (prefers-reduced-motion: reduce){
.crt-vignette{animation:none}
.live-dot{animation:none}
.ambient{display:none}
.static{display:none}
*{transition-duration:.001ms !important;animation-duration:.001ms !important;animation-iteration-count:1 !important}
}
+163
View File
@@ -0,0 +1,163 @@
// 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);
}
}