feat: initial release of The Pondering Orb
A retro CRT-themed web TV that channel-surfs live HLS streams through a security-hardened, same-origin Rust relay. Front end (vanilla JS + HLS.js, no framework/build step): - SourceProvider abstraction + canonical Channel/Program model so public streams and future self-hosted media share one UI (MockProvider built first to keep the UI provably source-agnostic; IptvOrgProvider for live data) - Red/black phosphor CRT theme, virtualized 1994-style guide (The Almanac), cable-box zapping with 7-segment OSD, scrying-static transitions - Theater/Dim/Ambient immersion modes, dead-stream "orb clouds over" handling - The Lexicon in-world naming with mundane-mode; full a11y + reduced-motion - HLS.js pinned + Subresource-Integrity verified Rust relay (hyper 1.x + tokio-rustls/ring + hickory-resolver): - Serves the SPA and proxies HLS from one origin (no CORS, untainted canvas) - SSRF defense: resolve -> reject private/loopback/link-local/metadata IPs -> connect to the validated IP (no re-resolve, defeats DNS-rebind), fail-closed - HTTPS-only egress, re-validated redirects, manifest URL rewrite, size/ concurrency caps, strict CSP + security headers, path-traversal guard - /metrics observability + structured logs (host only, never raw URLs) Verified: all SSRF probes rejected, CSP clean, real Apple HLS manifest rewritten, ~39k iptv-org channels fetched end-to-end, tests green, zero warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+189
@@ -0,0 +1,189 @@
|
||||
// guide.js — "The Almanac": 1994-style scrolling EPG overlay.
|
||||
// Virtualized rows (fixed height) so thousands of channels stay smooth.
|
||||
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user