'use client'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import Hls from 'hls.js'; import { Loader2, Pause, Play, Volume2, VolumeX } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; interface BunnyPreviewPlayerProps { providerVideoId: string | null; isProcessing: boolean; } export interface BunnyPreviewPlayerHandle { togglePlayPause: () => void; seekBy: (seconds: number) => void; toggleMute: () => void; } const DEFAULT_BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net'; function resolveBunnyCdnHostname(): string { const configured = process.env.NEXT_PUBLIC_BUNNY_CDN_URL; if (!configured) return DEFAULT_BUNNY_PULL_ZONE_HOSTNAME; try { const parsed = new URL(configured); return parsed.hostname || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME; } catch { return configured.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME; } } function formatTime(value: number): string { if (!Number.isFinite(value) || value < 0) return '0:00'; const total = Math.floor(value); const minutes = Math.floor(total / 60); const seconds = total % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; } export const BunnyPreviewPlayer = forwardRef(function BunnyPreviewPlayer({ providerVideoId, isProcessing }, ref) { const videoRef = useRef(null); const hlsRef = useRef(null); const retryTimerRef = useRef | null>(null); const retryAttemptRef = useRef(0); const [isReady, setIsReady] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [isMuted, setIsMuted] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [loadError, setLoadError] = useState(false); const playlistUrl = useMemo(() => { if (!providerVideoId) return null; return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`; }, [providerVideoId]); useEffect(() => { const video = videoRef.current; if (!video || !playlistUrl) return; let destroyed = false; let usingHlsJs = false; const clearRetry = () => { if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } }; const scheduleRetry = (retryFn: () => void) => { clearRetry(); retryTimerRef.current = setTimeout(() => { if (!destroyed) retryFn(); }, 3000); }; const getRetryUrl = () => { retryAttemptRef.current += 1; const separator = playlistUrl.includes('?') ? '&' : '?'; return `${playlistUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`; }; const onLoadedMetadata = () => { if (destroyed) return; setIsReady(true); setLoadError(false); setDuration(Number.isFinite(video.duration) ? video.duration : 0); clearRetry(); }; const onPlay = () => setIsPlaying(true); const onPause = () => setIsPlaying(false); const onEnded = () => setIsPlaying(false); const onTimeUpdate = () => setCurrentTime(video.currentTime || 0); const onError = () => { if (destroyed) return; if (video.readyState >= HTMLMediaElement.HAVE_METADATA) { setLoadError(true); return; } setLoadError(false); scheduleRetry(() => { if (usingHlsJs && hlsRef.current) { hlsRef.current.loadSource(getRetryUrl()); hlsRef.current.startLoad(-1); } else { video.src = getRetryUrl(); video.load(); } }); }; video.addEventListener('loadedmetadata', onLoadedMetadata); video.addEventListener('play', onPlay); video.addEventListener('pause', onPause); video.addEventListener('ended', onEnded); video.addEventListener('timeupdate', onTimeUpdate); video.addEventListener('error', onError); const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl'); if (Hls.isSupported()) { const hls = new Hls(); hlsRef.current = hls; usingHlsJs = true; hls.attachMedia(video); hls.on(Hls.Events.MEDIA_ATTACHED, () => { if (!destroyed) hls.loadSource(playlistUrl); }); hls.on(Hls.Events.ERROR, (_event, data) => { if (destroyed) return; if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) { scheduleRetry(() => hls.loadSource(getRetryUrl())); } }); } else if (canPlayNativeHls) { video.src = playlistUrl; video.load(); } else { // Defer state update to avoid sync setState directly in effect body. window.setTimeout(() => { if (!destroyed) setLoadError(true); }, 0); } return () => { destroyed = true; clearRetry(); video.removeEventListener('loadedmetadata', onLoadedMetadata); video.removeEventListener('play', onPlay); video.removeEventListener('pause', onPause); video.removeEventListener('ended', onEnded); video.removeEventListener('timeupdate', onTimeUpdate); video.removeEventListener('error', onError); if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } video.removeAttribute('src'); video.load(); setIsPlaying(false); setCurrentTime(0); setDuration(0); setIsReady(false); }; }, [playlistUrl]); const seekTo = (event: React.MouseEvent) => { const video = videoRef.current; if (!video || !duration) return; const rect = event.currentTarget.getBoundingClientRect(); const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)); video.currentTime = ratio * duration; setCurrentTime(video.currentTime); }; const togglePlayPause = useCallback(() => { const video = videoRef.current; if (!video || !isReady || isProcessing) return; if (video.paused) void video.play(); else video.pause(); }, [isProcessing, isReady]); const seekBy = useCallback((seconds: number) => { const video = videoRef.current; if (!video || !isReady || isProcessing || !duration) return; video.currentTime = Math.min(duration, Math.max(0, (video.currentTime || 0) + seconds)); setCurrentTime(video.currentTime); }, [duration, isProcessing, isReady]); const toggleMute = useCallback(() => { const video = videoRef.current; if (!video) return; const nextMuted = !video.muted; video.muted = nextMuted; setIsMuted(nextMuted); }, []); useImperativeHandle(ref, () => ({ togglePlayPause, seekBy, toggleMute, }), [seekBy, toggleMute, togglePlayPause]); return (
{formatTime(currentTime)} / {formatTime(duration)}
0 ? (currentTime / duration) * 100 : 0}%` }} />
0 ? (currentTime / duration) * 100 : 0}% - 2px)` }} />
); });