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