/** * Video Player Component — plays match stream or highlight clips * Supports overlay for Player ID, picture-in-picture, and keyboard shortcuts. */ import { escapeHTML, setHTML } from '../utils/dom.js'; import { resolveImage, resolveVideo, VIDEO_BASE } from '../utils/media.js'; export class VideoPlayer { constructor(containerEl) { this.containerEl = containerEl; this.videoEl = null; this.isPlaying = false; this.currentSrc = ''; this.zoom = 1; this._onTimeUpdate = null; this._onEnded = null; } render(src, options = {}) { if (!this.containerEl) return; const { autoplay = true, loop = false, muted = true, controls = true, overlay = false } = options; const safeSrc = this._safeMediaSrc(src); const isYouTube = safeSrc.includes('youtube.com') || safeSrc.includes('youtu.be') || safeSrc.includes('embed/'); const isTwitch = safeSrc.includes('twitch.tv'); const existingFallback = this.containerEl.querySelector('.video-fallback'); if (existingFallback) existingFallback.remove(); if (isYouTube) { let embedUrl = safeSrc; if (safeSrc.includes('watch?v=')) { const id = safeSrc.split('v=')[1]?.split('&')[0]; embedUrl = `https://www.youtube.com/embed/${id}`; } else if (safeSrc.includes('youtu.be/')) { const id = safeSrc.split('youtu.be/')[1]?.split('?')[0]; embedUrl = `https://www.youtube.com/embed/${id}`; } // Add parameters const connector = embedUrl.includes('?') ? '&' : '?'; embedUrl = `${embedUrl}${connector}autoplay=${autoplay ? 1 : 0}&mute=${muted ? 1 : 0}&controls=${controls ? 1 : 0}`; setHTML(this.containerEl, `
LIVE
${overlay ? `
` : ''}
`); } else if (isTwitch) { let channelName = 'esl_sc2'; const parts = safeSrc.split('twitch.tv/'); if (parts[1]) { channelName = parts[1].split('/').shift().split('?').shift().trim().replace(/[^\w]/g, '') || channelName; } const parentDomain = (window.location.hostname || 'localhost').replace(/[^\w.-]/g, ''); const embedUrl = `https://player.twitch.tv/?channel=${channelName}&parent=${parentDomain}&autoplay=${autoplay ? 'true' : 'false'}&muted=${muted ? 'true' : 'false'}`; setHTML(this.containerEl, `
LIVE
${overlay ? `
` : ''}
`); } else { setHTML(this.containerEl, `
LIVE
${overlay ? `
` : ''}
0:00 1.0x
`); } this.videoEl = this.containerEl.querySelector('#match-video'); this.currentSrc = safeSrc; this.setZoom(1); if (this.videoEl && this.videoEl.tagName === 'VIDEO') { this.videoEl.src = safeSrc; this.videoEl.load(); this._bindControls(); this.videoEl.addEventListener('error', () => { if (this.videoEl && this.videoEl.parentElement) { this.videoEl.style.display = 'none'; let fallback = this.containerEl.querySelector('.video-fallback'); if (!fallback) { fallback = document.createElement('div'); fallback.className = 'video-fallback'; fallback.style.cssText = `position:absolute;inset:0;background:url(${resolveImage('metlife')}) center/cover;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;`; setHTML(fallback, '
⚽ LIVE MATCH FEED
Stream connecting...
'); this.videoEl.parentElement.appendChild(fallback); } } }); } } _bindControls() { const video = this.videoEl; if (!video || video.tagName !== 'VIDEO') return; const playBtn = this.containerEl.querySelector('#btn-video-play'); const progress = this.containerEl.querySelector('#video-progress'); const progressBar = this.containerEl.querySelector('#video-progress-bar'); const timeDisplay = this.containerEl.querySelector('#video-time'); const zoomOutBtn = this.containerEl.querySelector('#btn-video-zoom-out'); const zoomInBtn = this.containerEl.querySelector('#btn-video-zoom-in'); const pipBtn = this.containerEl.querySelector('#btn-video-pip'); const fsBtn = this.containerEl.querySelector('#btn-video-fullscreen'); // Play/Pause const togglePlay = () => { if (video.paused) { video.play(); } else { video.pause(); } }; playBtn?.addEventListener('click', togglePlay); video.addEventListener('click', togglePlay); video.addEventListener('play', () => { this.isPlaying = true; if (playBtn) setHTML(playBtn, ''); }); video.addEventListener('pause', () => { this.isPlaying = false; if (playBtn) setHTML(playBtn, ''); }); // Progress bar video.addEventListener('timeupdate', () => { if (!video.duration) return; const pct = (video.currentTime / video.duration) * 100; if (progressBar) progressBar.style.width = `${pct}%`; if (timeDisplay) timeDisplay.textContent = this._formatTime(video.currentTime); if (this._onTimeUpdate) this._onTimeUpdate(video.currentTime, video.duration); }); // Seek progress?.addEventListener('click', (e) => { const rect = progress.getBoundingClientRect(); const pct = (e.clientX - rect.left) / rect.width; video.currentTime = pct * video.duration; }); zoomOutBtn?.addEventListener('click', () => this.setZoom(this.zoom - 0.25)); zoomInBtn?.addEventListener('click', () => this.setZoom(this.zoom + 0.25)); // Ended video.addEventListener('ended', () => { this.isPlaying = false; if (this._onEnded) this._onEnded(); }); // PiP pipBtn?.addEventListener('click', async () => { try { if (document.pictureInPictureElement) { await document.exitPictureInPicture(); } else { await video.requestPictureInPicture(); } } catch (e) { console.warn('PiP not supported:', e); } }); // Fullscreen fsBtn?.addEventListener('click', () => { const player = this.containerEl.querySelector('.video-player'); if (document.fullscreenElement) { document.exitFullscreen(); } else { player?.requestFullscreen?.(); } }); // Keyboard shortcuts video.addEventListener('keydown', (e) => { switch (e.key) { case ' ': case 'k': e.preventDefault(); togglePlay(); break; case 'ArrowLeft': video.currentTime = Math.max(0, video.currentTime - 5); break; case 'ArrowRight': video.currentTime = Math.min(video.duration, video.currentTime + 5); break; case '+': case '=': this.setZoom(this.zoom + 0.25); break; case '-': this.setZoom(this.zoom - 0.25); break; case 'f': fsBtn?.click(); break; case 'm': video.muted = !video.muted; break; } }); } play(src) { const safeSrc = this._safeMediaSrc(src || this.currentSrc); if (safeSrc && safeSrc !== this.currentSrc) { this.render(safeSrc, { autoplay: true, loop: true, muted: true, controls: true, overlay: true }); } else if (this.videoEl && this.videoEl.tagName === 'VIDEO') { this.videoEl.play(); } } setZoom(nextZoom = 1) { this.zoom = Math.min(5, Math.max(1, Number(nextZoom) || 1)); if (this.videoEl) { this.videoEl.style.transform = `scale(${this.zoom})`; this.videoEl.style.transformOrigin = 'center center'; this.videoEl.style.transition = 'transform 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'; } const label = this.containerEl?.querySelector('#video-zoom-label'); if (label) label.textContent = `${this.zoom.toFixed(1)}x`; const player = this.containerEl?.querySelector('.video-player'); if (player) player.classList.toggle('video-player--zoomed', this.zoom > 1); } pulseScanReticle() { const reticle = this.containerEl?.querySelector('#video-scan-reticle'); if (!reticle) return; reticle.hidden = false; reticle.classList.remove('video-player__scan-reticle--pulse'); void reticle.offsetWidth; reticle.classList.add('video-player__scan-reticle--pulse'); setTimeout(() => { reticle.hidden = true; reticle.classList.remove('video-player__scan-reticle--pulse'); }, 1600); } setOverlayScore(homeTeam, awayTeam, homeScore, awayScore) { const el = this.containerEl?.querySelector('#video-score-overlay'); if (el) { setHTML(el, ` ${escapeHTML(homeTeam)} ${escapeHTML(homeScore)} — ${escapeHTML(awayScore)} ${escapeHTML(awayTeam)} `); } } _formatTime(seconds) { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, '0')}`; } _safeMediaSrc(src = '') { const text = String(src || '').trim(); try { const url = new URL(text); const host = url.hostname.toLowerCase(); const allowedVideoHosts = new Set([ 'youtube.com', 'www.youtube.com', 'youtube-nocookie.com', 'www.youtube-nocookie.com', 'youtu.be', 'twitch.tv', 'www.twitch.tv', 'player.twitch.tv', ]); if (url.protocol === 'https:' && allowedVideoHosts.has(host)) return url.href; } catch { // Non-URL asset references are handled by resolveVideo below. } // Already-resolved release URLs (e.g. a re-render) pass through unchanged. if (text.startsWith(`${VIDEO_BASE}/`) && /\.mp4($|\?)/i.test(text)) return text; // Otherwise only accept known media asset references; anything else falls // back to the release-hosted demo clip. return resolveVideo(text) || `${VIDEO_BASE}/football-goal-1.mp4`; } destroy() { if (this.videoEl && this.videoEl.tagName === 'VIDEO') { this.videoEl.pause(); } this.videoEl = null; } }