Files
Diablo_Rain 0b761f684b harden: relay timeouts, DNS resolver reuse, cache hygiene + frontend polish
Server (relay/SSRF):
- Bound connect+TLS+request under CONNECT_TIMEOUT and body download under
  BODY_TIMEOUT so a stalling upstream can't pin a relay permit indefinitely
- Add inbound header_read_timeout to defeat slow-loris connections
- Allowlist relayed Content-Type (fall back to octet-stream) to block
  content-confusion from attacker-controlled stream origins
- Reuse one DNS resolver process-wide instead of rebuilding it per request
  (restores hickory's DNS cache; drops per-segment resolver setup)
- Probe cache: random per-process hash seed (DefaultHasher was deterministic)
  + bounded growth with expired-entry eviction
- Real Ctrl-C handler for a clean shutdown (honours the banner)
- Collapse the wrap_raw no-op alias into wrap

Frontend:
- Guide: visible focus ring tracking arrow-key navigation
- Player: buffering/tuning indicator while <video> is stalled
- prefers-contrast: more pass (lifts --ink-dim to meet WCAG AA)
- Service worker: date-stamped cache names so shell updates propagate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:20:06 -04:00

211 lines
8.1 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' + (idx === this.focusRow ? ' focused' : '');
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._moveFocus(); break;
case 'ArrowUp': e.preventDefault(); this.focusRow = Math.max(0, this.focusRow-1); this._moveFocus(); 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;
}
}
// Scroll the focused row into view, then repaint so the .focused highlight
// tracks the cursor even when no scroll was needed (scroll event wouldn't fire).
_moveFocus() {
this._scrollToFocus();
this._renderViewport();
}
_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;
}
}