Files
Diablo_Rain a1e3e6852f feat: Phase 1.5 — health prober, Oracle's Memory, off-thread EPG, PWA
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>
2026-06-15 11:25:55 -04:00

205 lines
7.8 KiB
JavaScript
Raw Permalink 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 { 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 = [
['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));
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();