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>
185 lines
6.9 KiB
JavaScript
185 lines
6.9 KiB
JavaScript
// 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 = [
|
||
['0–9', '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();
|