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