a1e3e6852f
Resilience + scale pass on top of the v1 core. Stream health prober: - New SSRF-validated /probe endpoint (HEAD -> ranged-GET fallback, 6s timeout, 5-min TTL cache, concurrency-capped, no-store) + orb_probe_total metrics - Front-end health.js: lazy bounded-concurrency probing (current + sampled channels, not the whole catalog), LIVE/DEGRADED/DEAD scoring, guide skull glyph, instant no-signal on zap to a known-dead channel Oracle's Memory (memory.js): - Session resurrection (last channel, volume, mute, theater/dim/ambient) - Exponential-decay per-channel uptime history; pinning; dead-channel auto-skip Off-thread XMLTV: - lib/xmltv-parse.js (pure, hand-rolled scanner, no DOMParser/regex backtracking) run in epg-worker.js module worker; wired into the iptv-org provider - Unit-tested in node: entity decode, TZ offsets, 20k programmes off-thread Service worker / PWA: - manifest.webmanifest + orb icons; sw.js (cache-first shell + hls.js, stale-while-revalidate for JSON-over-relay, never caches streams) - CSP gains worker-src + manifest-src; relay sends no-store on /relay + /probe so the SW is the sole metadata freshness authority; webmanifest MIME fix Docs: README de-emojified (title only) and reframed as a peer parallel build. Verified: SSRF probes blocked, legit probe + cache, new headers present, PWA assets served, parser tests 8/8, JS syntax clean, zero Rust warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
205 lines
7.8 KiB
JavaScript
205 lines
7.8 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 { Health } from './health.js';
|
||
import { memory } from './memory.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();
|
||
memory.rememberModes(modes);
|
||
}
|
||
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));
|
||
|
||
const { agg, label } = pickProviders();
|
||
const hasRelay = !!document.querySelector('meta[name="orb-relay"]');
|
||
|
||
ambient = new Ambient(els.video, els.ambientCanvas);
|
||
player = new Player(els, (msg) => console.warn('[orb] stream fatal:', msg));
|
||
const health = new Health(agg, hasRelay);
|
||
zapper = new Zapper(els, player, agg, { memory, health });
|
||
guide = new Guide(els, agg, (ch) => zapper.tune(ch));
|
||
|
||
// Health results → guide reliability glyphs (and the Oracle's history).
|
||
health.onResult = (id, state) => guide.setHealth(id, state !== 'dead');
|
||
|
||
// The Oracle's Memory: restore last session.
|
||
els.vol.value = Math.round((memory.settings.volume ?? 1) * 100);
|
||
player.video.volume = memory.settings.volume ?? 1;
|
||
player.video.muted = !!memory.settings.muted;
|
||
player._syncMute?.();
|
||
for (const m of ['theater', 'dim', 'ambient']) {
|
||
if (memory.settings.modes?.[m]) setMode(m, true);
|
||
}
|
||
|
||
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) {
|
||
// resurrect last channel if it still exists, else first
|
||
const last = memory.lastChannelId && agg.getChannel(memory.lastChannelId);
|
||
await zapper.tune(last || channels[0]);
|
||
}
|
||
// probe a bounded sample so the guide gets reliability glyphs without a stampede
|
||
health.probeMany(channels.slice(0, 60));
|
||
} catch (e) {
|
||
console.error('[orb] boot failed:', e);
|
||
player._showNoSignal?.();
|
||
}
|
||
}
|
||
|
||
// Register the service worker (PWA / offline shell). Best-effort; never blocks boot.
|
||
if ('serviceWorker' in navigator) {
|
||
window.addEventListener('load', () => {
|
||
navigator.serviceWorker.register('/sw.js').catch((e) => console.warn('[orb] sw:', e));
|
||
});
|
||
}
|
||
|
||
boot();
|