Files
pondering-orb/web/app.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

185 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();