Files
pondering-orb/web/guide.js
T
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

// guide.js — "The Almanac": 1994-style scrolling EPG overlay.
// Virtualized rows (fixed height) so thousands of channels stay smooth.
import { memory } from './memory.js';
const ROW_H = 46;
const CH_COL = 150;
const HOUR_W = 180; // px per hour
const WINDOW_HRS = 6; // how far right the grid extends
const OVERSCAN = 4;
export class Guide {
/**
* @param {object} els
* @param {import('./providers/source-provider.js').ChannelAggregator} agg
* @param {(ch:any)=>void} onTune
*/
constructor(els, agg, onTune) {
this.els = els;
this.agg = agg;
this.onTune = onTune;
this.open = false;
this.focusRow = 0;
this._programCache = new Map(); // channelId -> Program[]
this._health = new Map(); // channelId -> bool
this.scroll = els.guideScroll;
this.rowsEl = els.guideRows;
this.scroll.addEventListener('scroll', () => this._renderViewport(), { passive: true });
this.scroll.addEventListener('keydown', (e) => this._onKey(e));
els.guideClose.addEventListener('click', () => this.hide());
this._baseT = 0;
}
async toggle() { this.open ? this.hide() : this.show(); }
async show() {
const list = this.agg.channels;
if (!list.length) return;
this.open = true;
const g = this.els.guide;
g.hidden = false;
g.setAttribute('aria-hidden', 'false');
requestAnimationFrame(() => g.classList.add('show'));
// time base = top of current half-hour
const now = Date.now();
this._baseT = now - (now % (30 * 60 * 1000));
this.rowsEl.style.setProperty('--row-h', ROW_H + 'px');
this.scroll.style.setProperty('--ch-col', CH_COL + 'px');
this.scroll.style.setProperty('--hour-w', HOUR_W + 'px');
this.rowsEl.style.height = (list.length * ROW_H) + 'px';
this._buildTimeHeader();
this._renderViewport();
this._positionPlayhead();
this._clockTimer = setInterval(() => { this._tickClock(); this._positionPlayhead(); }, 30000);
this._tickClock();
this.scroll.focus();
}
hide() {
this.open = false;
const g = this.els.guide;
g.classList.remove('show');
g.setAttribute('aria-hidden', 'true');
clearInterval(this._clockTimer);
setTimeout(() => { if (!this.open) g.hidden = true; }, 300);
}
setHealth(channelId, ok) { this._health.set(channelId, ok); }
_tickClock() {
const t = new Date();
this.els.guideClock.textContent =
`${String(t.getHours()).padStart(2,'0')}:${String(t.getMinutes()).padStart(2,'0')}`;
}
_buildTimeHeader() {
const hdr = this.els.guideTimehdr;
hdr.innerHTML = '';
hdr.style.marginLeft = CH_COL + 'px';
for (let h = 0; h < WINDOW_HRS; h++) {
const t = new Date(this._baseT + h * 3600 * 1000);
const tick = document.createElement('div');
tick.className = 'tick';
tick.style.flex = `0 0 ${HOUR_W}px`;
tick.textContent = `${String(t.getHours()).padStart(2,'0')}:${String(t.getMinutes()).padStart(2,'0')}`;
hdr.appendChild(tick);
}
}
_positionPlayhead() {
const ph = this.els.guidePlayhead;
const offset = ((Date.now() - this._baseT) / 3600000) * HOUR_W;
if (offset < 0 || offset > WINDOW_HRS * HOUR_W) { ph.style.display = 'none'; return; }
ph.style.display = '';
ph.style.left = (CH_COL + offset - this.scroll.scrollLeft) + 'px';
}
async _renderViewport() {
const list = this.agg.channels;
const top = this.scroll.scrollTop;
const h = this.scroll.clientHeight;
const first = Math.max(0, Math.floor(top / ROW_H) - OVERSCAN);
const last = Math.min(list.length, Math.ceil((top + h) / ROW_H) + OVERSCAN);
this.rowsEl.innerHTML = '';
for (let i = first; i < last; i++) {
this.rowsEl.appendChild(this._renderRow(list[i], i));
}
this._positionPlayhead();
// lazily fetch programs for visible rows
for (let i = first; i < last; i++) this._ensurePrograms(list[i]);
}
_renderRow(ch, idx) {
const row = document.createElement('div');
row.className = 'guide-row';
row.style.position = 'absolute';
row.style.top = (idx * ROW_H) + 'px';
row.style.left = '0'; row.style.right = '0';
row.setAttribute('role', 'row');
const cell = document.createElement('div');
cell.className = 'guide-chcell';
cell.setAttribute('role', 'rowheader');
const num = document.createElement('span'); num.className = 'num'; num.textContent = ch.number;
const nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = ch.name; // safe
cell.append(num, nm);
if (this._health.get(ch.id) === false) {
const sk = document.createElement('span'); sk.className = 'skull'; sk.textContent = '☠';
sk.title = 'no signal'; cell.appendChild(sk);
}
// pin (Oracle's Memory) — click the rune to inscribe/banish a channel
const pin = document.createElement('button');
pin.className = 'pin' + (memory.isPinned(ch.id) ? ' on' : '');
pin.textContent = memory.isPinned(ch.id) ? '★' : '☆';
pin.setAttribute('aria-label', memory.isPinned(ch.id) ? 'Unpin channel' : 'Pin channel');
pin.addEventListener('click', (e) => {
e.stopPropagation();
const on = memory.togglePin(ch.id);
pin.classList.toggle('on', on);
pin.textContent = on ? '★' : '☆';
pin.setAttribute('aria-label', on ? 'Unpin channel' : 'Pin channel');
});
cell.appendChild(pin);
cell.addEventListener('click', () => { this.onTune(ch); this.hide(); });
const progs = document.createElement('div');
progs.className = 'guide-progs';
const cached = this._programCache.get(ch.id);
if (cached) this._layoutPrograms(progs, cached, ch);
row.append(cell, progs);
return row;
}
_layoutPrograms(container, programs, ch) {
const now = Date.now();
const winEnd = this._baseT + WINDOW_HRS * 3600 * 1000;
for (const p of programs) {
if (p.stop <= this._baseT || p.start >= winEnd) continue;
const left = ((Math.max(p.start, this._baseT) - this._baseT) / 3600000) * HOUR_W;
const right = ((Math.min(p.stop, winEnd) - this._baseT) / 3600000) * HOUR_W;
const el = document.createElement('div');
el.className = 'guide-prog' + (p.start <= now && now < p.stop ? ' airing' : '');
el.style.left = left + 'px';
el.style.width = Math.max(8, right - left - 2) + 'px';
el.setAttribute('role', 'gridcell');
el.tabIndex = -1;
const tEl = document.createElement('span'); tEl.className = 't';
const s = new Date(p.start);
tEl.textContent = `${String(s.getHours()).padStart(2,'0')}:${String(s.getMinutes()).padStart(2,'0')} `;
el.appendChild(tEl);
el.appendChild(document.createTextNode(p.title)); // safe
el.addEventListener('click', () => { this.onTune(ch); this.hide(); });
container.appendChild(el);
}
}
async _ensurePrograms(ch) {
if (this._programCache.has(ch.id)) return;
this._programCache.set(ch.id, []); // mark in-flight
const progs = await this.agg.getPrograms(ch.id, {
from: this._baseT - 3600 * 1000,
to: this._baseT + WINDOW_HRS * 3600 * 1000,
});
this._programCache.set(ch.id, progs);
if (this.open) this._renderViewport();
}
_onKey(e) {
const list = this.agg.channels;
switch (e.key) {
case 'ArrowDown': e.preventDefault(); this.focusRow = Math.min(list.length-1, this.focusRow+1); this._scrollToFocus(); break;
case 'ArrowUp': e.preventDefault(); this.focusRow = Math.max(0, this.focusRow-1); this._scrollToFocus(); break;
case 'Enter': e.preventDefault(); { const ch = list[this.focusRow]; if (ch){ this.onTune(ch); this.hide(); } } break;
case 'Escape': e.preventDefault(); this.hide(); break;
}
}
_scrollToFocus() {
const y = this.focusRow * ROW_H;
if (y < this.scroll.scrollTop) this.scroll.scrollTop = y;
else if (y + ROW_H > this.scroll.scrollTop + this.scroll.clientHeight)
this.scroll.scrollTop = y + ROW_H - this.scroll.clientHeight;
}
}