Football / src /components /VideoPlayer.js
Prataykarali's picture
chore: update Unsplash image fallbacks and refactor sustainability mode aliases for HF Space
51cbd57
Raw
History Blame Contribute Delete
15.3 kB
/**
* 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, `
<div class="video-player" role="region" aria-label="Match video player">
<iframe
id="match-video"
class="video-player__video"
src="${escapeHTML(embedUrl)}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
style="width: 100%; height: 100%; object-fit: cover;"
></iframe>
<div class="video-player__scan-reticle" id="video-scan-reticle" hidden>
<span></span>
</div>
<div class="video-player__live-badge" id="video-live-badge" ${options.isLive ? '' : 'hidden'}>
<span class="video-player__live-dot"></span> LIVE
</div>
${overlay ? `
<div class="video-player__overlay" id="video-overlay">
<div class="video-player__score" id="video-score-overlay"></div>
</div>
` : ''}
</div>
`);
} 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, `
<div class="video-player" role="region" aria-label="Match video player">
<iframe
id="match-video"
class="video-player__video"
src="${escapeHTML(embedUrl)}"
frameborder="0"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
style="width: 100%; height: 100%; object-fit: cover;"
></iframe>
<div class="video-player__scan-reticle" id="video-scan-reticle" hidden>
<span></span>
</div>
<div class="video-player__live-badge" id="video-live-badge" ${options.isLive ? '' : 'hidden'}>
<span class="video-player__live-dot"></span> LIVE
</div>
${overlay ? `
<div class="video-player__overlay" id="video-overlay">
<div class="video-player__score" id="video-score-overlay"></div>
</div>
` : ''}
</div>
`);
} else {
setHTML(this.containerEl, `
<div class="video-player" role="region" aria-label="Match video player">
<video
id="match-video"
class="video-player__video"
${autoplay ? 'autoplay' : ''}
${loop ? 'loop' : ''}
${muted ? 'muted' : ''}
${controls ? 'controls' : ''}
playsinline
preload="metadata"
aria-label="Match footage"
poster="${escapeHTML(resolveImage('metlife'))}"
>
<source src="${escapeHTML(safeSrc)}" type="video/mp4" />
Your browser does not support video.
</video>
<div class="video-player__scan-reticle" id="video-scan-reticle" hidden>
<span></span>
</div>
<div class="video-player__live-badge" id="video-live-badge" ${options.isLive ? '' : 'hidden'}>
<span class="video-player__live-dot"></span> LIVE
</div>
${overlay ? `
<div class="video-player__overlay" id="video-overlay">
<div class="video-player__score" id="video-score-overlay"></div>
</div>
` : ''}
<div class="video-player__controls-bar" id="video-controls-bar">
<button class="video-ctrl-btn" id="btn-video-play" aria-label="Play/Pause">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
<div class="video-progress" id="video-progress">
<div class="video-progress__bar" id="video-progress-bar"></div>
</div>
<span class="video-time" id="video-time">0:00</span>
<button class="video-ctrl-btn" id="btn-video-zoom-out" aria-label="Zoom out">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M8 11h6"/><path d="m21 21-4.3-4.3"/></svg>
</button>
<span class="video-zoom-label" id="video-zoom-label">1.0x</span>
<button class="video-ctrl-btn" id="btn-video-zoom-in" aria-label="Zoom in">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M11 8v6"/><path d="M8 11h6"/><path d="m21 21-4.3-4.3"/></svg>
</button>
<button class="video-ctrl-btn" id="btn-video-pip" aria-label="Picture in picture">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><rect x="12" y="9" width="8" height="7" rx="1" fill="currentColor" opacity="0.5"/></svg>
</button>
<button class="video-ctrl-btn" id="btn-video-fullscreen" aria-label="Fullscreen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
</button>
</div>
</div>
`);
}
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, '<div style="background:rgba(0,0,0,0.7);padding:16px 24px;border-radius:12px;font-family:var(--font-heading);font-size:1.2rem;color:#fff;">⚽ LIVE MATCH FEED</div><div style="color:var(--text-muted);font-size:0.8rem;">Stream connecting...</div>');
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, '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>');
});
video.addEventListener('pause', () => {
this.isPlaying = false;
if (playBtn) setHTML(playBtn, '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>');
});
// 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, `
<span class="video-score__team">${escapeHTML(homeTeam)}</span>
<span class="video-score__num">${escapeHTML(homeScore)}${escapeHTML(awayScore)}</span>
<span class="video-score__team">${escapeHTML(awayTeam)}</span>
`);
}
}
_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;
}
}