// player.js — HLS.js wiring, live-aware ghost controls, dead-stream handling. import { memory } from './memory.js'; const HLS_CONFIG = { enableWorker: true, lowLatencyMode: false, maxBufferLength: 30, maxMaxBufferLength: 60, manifestLoadingMaxRetry: 4, manifestLoadingRetryDelay: 800, fragLoadingMaxRetry: 4, }; export class Player { /** * @param {object} els DOM refs * @param {(msg:string)=>void} [onFatal] called when a stream is unplayable */ constructor(els, onFatal) { this.els = els; this.video = els.video; this.hls = null; this.onFatal = onFatal || (() => {}); this.onFirstFrag = null; // set by zapper to clear scrying static this._fallbacks = []; this._fallbackIdx = 0; this._wireControls(); this._wireActivity(); } /** * Play a StreamHandle (with optional fallback URLs for resilience). * @param {{url:string}} handle * @param {string[]} [fallbackUrls] */ async play(handle, fallbackUrls = []) { this._fallbacks = [handle.url, ...fallbackUrls.filter((u) => u !== handle.url)]; this._fallbackIdx = 0; this._hideNoSignal(); this._loadCurrent(); } _loadCurrent() { const url = this._fallbacks[this._fallbackIdx]; if (!url) { this._showNoSignal(); this.onFatal('no playable source'); return; } this._teardown(); const canNative = this.video.canPlayType('application/vnd.apple.mpegurl'); if (window.Hls && window.Hls.isSupported()) { const hls = new window.Hls(HLS_CONFIG); this.hls = hls; hls.loadSource(url); hls.attachMedia(this.video); hls.on(window.Hls.Events.MANIFEST_PARSED, () => { this.video.play().catch(() => {}); this._refreshLiveChrome(); this._buildQualityMenu(); }); hls.on(window.Hls.Events.FRAG_BUFFERED, () => { if (this.onFirstFrag) { this.onFirstFrag(); } }); hls.on(window.Hls.Events.ERROR, (_e, data) => { if (!data || !data.fatal) return; this._tryFallbackOrFail(`hls fatal: ${data.type}/${data.details}`); }); } else if (canNative) { this.video.src = url; this.video.addEventListener('loadedmetadata', () => { this.video.play().catch(() => {}); this._refreshLiveChrome(); }, { once: true }); this.video.addEventListener('error', () => this._tryFallbackOrFail('native hls error'), { once: true }); } else { this._showNoSignal(); this.onFatal('HLS unsupported in this browser'); } } _tryFallbackOrFail(reason) { this._fallbackIdx++; if (this._fallbackIdx < this._fallbacks.length) { console.warn(`[player] ${reason} — trying fallback ${this._fallbackIdx}`); this._loadCurrent(); } else { console.warn(`[player] ${reason} — no fallbacks left`); this._showNoSignal(); this.onFatal(reason); } } _teardown() { if (this.hls) { try { this.hls.destroy(); } catch {} this.hls = null; } this.video.removeAttribute('src'); try { this.video.load(); } catch {} } destroy() { this._teardown(); } // ---------- dead-stream "the orb clouds over" ---------- _showNoSignal() { this.els.nosignal.hidden = false; } _hideNoSignal() { this.els.nosignal.hidden = true; } // ---------- live-aware chrome ---------- _refreshLiveChrome() { const live = this._isLive(); this.els.osdLive.hidden = !live; this.els.ctlLiveWrap.hidden = !live; } _isLive() { if (this.hls && this.hls.levels && this.hls.levels.length) { const d = this.hls.levels[this.hls.currentLevel]?.details; if (d) return d.live === true; } return !Number.isFinite(this.video.duration); } _buildQualityMenu() { const menu = this.els.qualMenu; menu.innerHTML = ''; if (!this.hls || !this.hls.levels || this.hls.levels.length < 2) { this.els.btnQual.hidden = true; return; } this.els.btnQual.hidden = false; const mk = (label, levelIdx) => { const b = document.createElement('button'); b.setAttribute('role', 'menuitemradio'); b.textContent = label; b.setAttribute('aria-checked', String(this.hls.autoLevelEnabled ? levelIdx === -1 : this.hls.currentLevel === levelIdx)); b.addEventListener('click', () => { this.hls.currentLevel = levelIdx; // -1 = auto this.els.btnQual.textContent = levelIdx === -1 ? 'AUTO' : label; menu.hidden = true; }); return b; }; menu.appendChild(mk('Auto', -1)); // de-dup by height, descending const seen = new Set(); this.hls.levels .map((l, i) => ({ h: l.height, i })) .filter((x) => x.h && !seen.has(x.h) && seen.add(x.h)) .sort((a, b) => b.h - a.h) .forEach((x) => menu.appendChild(mk(`${x.h}p`, x.i))); } // ---------- controls ---------- _wireControls() { const e = this.els; e.btnPlay.addEventListener('click', () => this.togglePlay()); e.btnMute.addEventListener('click', () => this.toggleMute()); e.vol.addEventListener('input', () => { this.video.volume = e.vol.value / 100; this.video.muted = this.video.volume === 0; this._syncMute(); }); e.btnFull.addEventListener('click', () => this.toggleFullscreen()); e.btnCc.addEventListener('click', () => this.toggleCaptions()); e.btnQual.addEventListener('click', () => { e.qualMenu.hidden = !e.qualMenu.hidden; }); if (document.pictureInPictureEnabled) { e.btnPip.hidden = false; e.btnPip.addEventListener('click', () => this.togglePip()); } this.video.addEventListener('play', () => { e.btnPlay.textContent = '❚❚'; e.btnPlay.setAttribute('aria-pressed','true'); }); this.video.addEventListener('pause', () => { e.btnPlay.textContent = '▶'; e.btnPlay.setAttribute('aria-pressed','false'); }); // wall-clock for live setInterval(() => { const t = new Date(); e.ctlClock.textContent = `${String(t.getHours()).padStart(2,'0')}:${String(t.getMinutes()).padStart(2,'0')}`; }, 1000); } togglePlay() { this.video.paused ? this.video.play().catch(()=>{}) : this.video.pause(); } toggleMute() { this.video.muted = !this.video.muted; this._syncMute(); } _syncMute() { const m = this.video.muted || this.video.volume === 0; this.els.btnMute.textContent = m ? '🔇' : '🔊'; this.els.btnMute.setAttribute('aria-pressed', String(m)); memory.rememberVolume(this.video.volume, this.video.muted); } setVolume(delta) { this.video.muted = false; this.video.volume = Math.min(1, Math.max(0, this.video.volume + delta)); this.els.vol.value = Math.round(this.video.volume * 100); this._syncMute(); } async toggleFullscreen() { try { if (document.fullscreenElement) await document.exitFullscreen(); else await this.els.root.requestFullscreen(); } catch {} } async togglePip() { try { if (document.pictureInPictureElement) await document.exitPictureInPicture(); else await this.video.requestPictureInPicture(); } catch {} } toggleCaptions() { const tracks = this.video.textTracks; if (!tracks || !tracks.length) return; const on = tracks[0].mode === 'showing'; for (const tr of tracks) tr.mode = on ? 'disabled' : 'showing'; this.els.btnCc.setAttribute('aria-pressed', String(!on)); } // ---------- ghost-control auto-hide ---------- _wireActivity() { const show = () => { this.els.screen.classList.add('controls-visible'); clearTimeout(this._hideT); this._hideT = setTimeout(() => this.els.screen.classList.remove('controls-visible'), 3000); }; ['mousemove','touchstart','keydown'].forEach((ev) => this.els.screen.addEventListener(ev, show, { passive: true })); show(); } }