// 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();