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
// 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;
|
|
}
|
|
}
|