// zapper.js — channel tuning: number entry, OSD banner, up/down, last-channel, // and the "Scrying Static" channel-change transition. const ENTRY_DEBOUNCE = 1200; // ms cable-box number accumulation const BANNER_MS = 4000; const STATIC_MIN = 300; // never flash shorter than this const STATIC_MAX = 3000; // clear regardless after this export class Zapper { /** * @param {object} els * @param {import('./player.js').Player} player * @param {import('./providers/source-provider.js').ChannelAggregator} agg * @param {{memory?:any, health?:import('./health.js').Health}} [opts] */ constructor(els, player, agg, opts = {}) { this.els = els; this.player = player; this.agg = agg; this.memory = opts.memory || null; this.health = opts.health || null; this.current = null; // current Channel this.previous = null; // for last-channel toggle this._entry = ''; this._entryT = 0; this._bannerT = 0; this._staticFrames = []; this._reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; this._prepStatic(); // player calls this when first fragment buffers → clear the static this.player.onFirstFrag = () => this._clearStatic(); } // ---------- public tuning API ---------- async tuneToNumber(n) { const ch = this.agg.getChannelByNumber(n); if (!ch) { this._flashEntry(String(n), true); return; } return this.tune(ch); } async tuneToIndexOffset(delta) { const list = this.agg.channels; if (!list.length) return; const start = this.current ? list.findIndex((c) => c.id === this.current.id) : -1; let idx = start; // Step in the requested direction, skipping channels the Oracle knows are dead. for (let n = 0; n < list.length; n++) { idx = (idx + delta + list.length) % list.length; const ch = list[idx]; if (!this.health || !this.health.isKnownDead(ch.id)) return this.tune(ch); } // everything known-dead → just tune the immediate neighbour return this.tune(list[(start + delta + list.length) % list.length]); } async tuneLast() { if (this.previous) return this.tune(this.previous); } /** @param {import('./providers/source-provider.js').Channel} ch */ async tune(ch) { if (!ch) return; if (this.current && this.current.id !== ch.id) this.previous = this.current; this.current = ch; if (this.memory) this.memory.rememberChannel(ch.id); if (this.health) this.health.probe(ch); // refresh this channel's reliability this._showStatic(); this._safetyClear = setTimeout(() => this._clearStatic(), STATIC_MAX); const handle = await this.agg.getStreamHandle(ch.id); if (!handle) { this.player._showNoSignal?.(); this._clearStatic(); } else { const fallbacks = (ch.streamUrls || []).slice(1); await this.player.play(handle, fallbacks); } this._showBanner(ch); this._announce(ch); return ch; } // ---------- number entry (incantation) ---------- pushDigit(d) { this._entry = (this._entry + d).slice(-4); this._flashEntry(this._entry, false); clearTimeout(this._entryT); this._entryT = setTimeout(() => { const n = parseInt(this._entry, 10); this._entry = ''; this.els.osdNumber.hidden = true; if (Number.isFinite(n)) this.tuneToNumber(n); }, ENTRY_DEBOUNCE); } _flashEntry(text, invalid) { this.els.osdNumber.hidden = false; this.els.osdNumberVal.textContent = (text + '___').slice(0, 3); this.els.osdNumberVal.style.color = invalid ? 'var(--ink-dim)' : ''; if (invalid) setTimeout(() => { this.els.osdNumber.hidden = true; }, 900); } // ---------- channel banner OSD ---------- async _showBanner(ch) { const e = this.els; e.osdNum.textContent = String(ch.number ?? '--'); e.osdName.textContent = ch.name || ''; // textContent: untrusted-safe e.osdLogo.src = ch.logo || ''; e.osdLogo.alt = ''; e.osdNow.textContent = ''; e.osdProgFill.style.width = '0%'; e.osdChannel.hidden = false; requestAnimationFrame(() => e.osdChannel.classList.add('show')); clearTimeout(this._bannerT); this._bannerT = setTimeout(() => { e.osdChannel.classList.remove('show'); setTimeout(() => { e.osdChannel.hidden = true; }, 350); }, BANNER_MS); // now-playing (best effort) try { const { now } = await this.agg.nowNext(ch.id); if (now && this.current && this.current.id === ch.id) { e.osdNow.textContent = now.title; const pct = Math.min(100, Math.max(0, ((Date.now() - now.start) / (now.stop - now.start)) * 100)); e.osdProgFill.style.width = pct.toFixed(1) + '%'; } } catch {} } _announce(ch) { // aria-live region already on osd-channel; ensure SR gets a concise message this.els.osdChannel.setAttribute('aria-label', `Channel ${ch.number}, ${ch.name}`); } // ---------- scrying static ---------- _prepStatic() { if (this._reduced) return; const c = this.els.staticCanvas; const w = c.width = 160, h = c.height = 90; const ctx = c.getContext('2d'); for (let f = 0; f < 8; f++) { const img = ctx.createImageData(w, h); for (let i = 0; i < img.data.length; i += 4) { const v = (Math.random() * 90) | 0; img.data[i] = v + (Math.random() * 80 | 0); // red-tinged img.data[i+1] = v * 0.3; img.data[i+2] = v * 0.3; img.data[i+3] = 255; } this._staticFrames.push(img); } } _showStatic() { const c = this.els.staticCanvas; this._staticShownAt = performance.now(); if (this._reduced) { // reduced-motion: brief red flash instead of noise c.classList.add('show'); c.style.background = 'var(--red-deep)'; return; } c.style.background = ''; c.classList.add('show'); const ctx = c.getContext('2d'); let i = 0; clearInterval(this._staticTimer); this._staticTimer = setInterval(() => { ctx.putImageData(this._staticFrames[i++ % this._staticFrames.length], 0, 0); }, 1000 / 18); } _clearStatic() { clearTimeout(this._safetyClear); const elapsed = performance.now() - (this._staticShownAt || 0); const wait = Math.max(0, STATIC_MIN - elapsed); setTimeout(() => { clearInterval(this._staticTimer); this.els.staticCanvas.classList.remove('show'); }, wait); } }