Spaces:
Running
Running
import React, { useEffect, useState, useRef } from 'react'; | |
import { getEpisodeLinkByTitle, getTvShowCard } from '../lib/api'; | |
import { useToast } from '@/hooks/use-toast'; | |
import { Film, GalleryVerticalEnd, Loader2, Play } from 'lucide-react'; | |
import VideoPlayer from './VideoPlayer'; | |
import { TvShowCardData } from './ContentCard'; | |
interface ProgressData { | |
status: string; | |
progress: number; | |
downloaded: number; | |
total: number; | |
} | |
interface ContentRating { | |
country: string; | |
name: string; | |
description: string; | |
} | |
interface TVShowPlayerProps { | |
videoTitle: string; | |
season: string; | |
episode: string; | |
movieTitle: string; | |
contentRatings?: ContentRating[]; | |
poster?: string; | |
startTime?: number; | |
onClosePlayer?: () => void; | |
onProgressUpdate?: (currentTime: number, duration: number) => void; | |
onVideoEnded?: () => void; | |
onShowEpisodes?: () => void; | |
} | |
const TVShowPlayer: React.FC<TVShowPlayerProps> = ({ | |
videoTitle, | |
season, | |
episode, | |
movieTitle, | |
contentRatings, | |
poster, | |
startTime = 0, | |
onClosePlayer, | |
onProgressUpdate, | |
onVideoEnded, | |
onShowEpisodes | |
}) => { | |
const [videoUrl, setVideoUrl] = useState<string | null>(null); | |
const [loading, setLoading] = useState(true); | |
const [error, setError] = useState<string | null>(null); | |
const [progress, setProgress] = useState<ProgressData | null>(null); | |
const [videoFetched, setVideoFetched] = useState(false); | |
const [showData, setShowData] = useState<TvShowCardData | null>(null); | |
const [selectedImage, setSelectedImage] = useState<string>(); | |
const [imageLoaded, setImageLoaded] = useState(false); | |
const [ratingInfo, setRatingInfo] = useState<{ rating: string; description: string } | null>(null); | |
const containerRef = useRef<HTMLDivElement>(null); | |
const videoRef = useRef<HTMLVideoElement>(null); | |
const { toast } = useToast(); | |
const pollingInterval = useRef<NodeJS.Timeout | null>(null); | |
const timeoutRef = useRef<NodeJS.Timeout | null>(null); | |
const videoFetchedRef = useRef(false); | |
// Reset imageLoaded whenever we pick a new image | |
useEffect(() => { | |
setImageLoaded(false); | |
}, [selectedImage]); | |
// Parse episode info | |
const getEpisodeInfo = () => { | |
if (!episode) return { number: '1', title: 'Unknown Episode' }; | |
const match = episode.match(/E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i); | |
return { | |
number: match ? match[1] : '1', | |
title: match ? match[2].trim() : 'Unknown Episode' | |
}; | |
}; | |
const { number: episodeNumber, title: episodeTitle } = getEpisodeInfo(); | |
// Random image selector with fallback | |
const selectRandomImage = (cardData: TvShowCardData) => { | |
if (cardData.banner?.length) { | |
return cardData.banner[Math.floor(Math.random() * cardData.banner.length)].image; | |
} | |
if (cardData.portrait?.length) { | |
return cardData.portrait[Math.floor(Math.random() * cardData.portrait.length)].image; | |
} | |
return cardData.image; | |
}; | |
// Fetch or poll for the video URL | |
const fetchMovieLink = async () => { | |
if (videoFetchedRef.current) return; | |
try { | |
const response = await getEpisodeLinkByTitle(videoTitle, season, episode); | |
if (response.url) { | |
pollingInterval.current && clearInterval(pollingInterval.current); | |
setVideoUrl(response.url); | |
setVideoFetched(true); | |
videoFetchedRef.current = true; | |
setLoading(false); | |
} else if (response.progress_url) { | |
const poll = async () => { | |
try { | |
const res = await fetch(response.progress_url); | |
const data = await res.json(); | |
setProgress(data.progress); | |
if (data.progress.progress >= 100) { | |
pollingInterval.current && clearInterval(pollingInterval.current); | |
timeoutRef.current = setTimeout(fetchMovieLink, 5000); | |
} | |
} catch (e) { | |
console.error(e); | |
} | |
}; | |
pollingInterval.current = setInterval(poll, 2000); | |
} else { | |
throw new Error('No URL or progress URL'); | |
} | |
} catch (e) { | |
console.error(e); | |
setError('Failed to load episode'); | |
toast({ title: 'Error', description: 'Could not load the episode', variant: 'destructive' }); | |
setLoading(false); | |
} | |
}; | |
// Main init: fetch TV show card, then fetch link | |
useEffect(() => { | |
if (!videoTitle || !season || !episode) { | |
setError('Missing required video information'); | |
setLoading(false); | |
return; | |
} | |
setLoading(true); | |
setError(null); | |
setVideoUrl(null); | |
setVideoFetched(false); | |
videoFetchedRef.current = false; | |
setProgress(null); | |
const init = async () => { | |
try { | |
const data = await getTvShowCard(videoTitle); | |
setShowData(data); | |
const img = selectRandomImage(data); | |
setSelectedImage(img); | |
const ratings = data.data?.contentRatings || contentRatings || []; | |
if (ratings.length) { | |
const us = ratings.find(r => r.country === 'usa') || ratings[0]; | |
setRatingInfo({ rating: us.name || 'NR', description: us.description || '' }); | |
} | |
} catch (e) { | |
console.error('Show card fetch error:', e); | |
setError('Failed to load show data'); | |
toast({ title: 'Error', description: 'Could not load show data', variant: 'destructive' }); | |
setLoading(false); | |
return; | |
} | |
await fetchMovieLink(); | |
}; | |
init(); | |
return () => { | |
pollingInterval.current && clearInterval(pollingInterval.current); | |
timeoutRef.current && clearTimeout(timeoutRef.current); | |
}; | |
}, [videoTitle, season, episode]); | |
useEffect(() => { | |
if (videoUrl) setLoading(false); | |
}, [videoUrl]); | |
if (error) { | |
return ( | |
<div className="flex flex-col items-center justify-center min-h-screen bg-black text-white"> | |
<div className="text-4xl mb-4 text-theme-error">😢</div> | |
<h2 className="text-2xl font-bold mb-2">Error Playing Episode</h2> | |
<p className="text-gray-400 mb-6">{error}</p> | |
<button | |
onClick={onClosePlayer} | |
className="px-6 py-2 bg-theme-primary hover:bg-theme-primary-hover rounded font-medium" | |
> | |
Back to Show | |
</button> | |
</div> | |
); | |
} | |
if (loading || !videoFetched || !videoUrl) { | |
return ( | |
<> | |
{/* Hero backdrop with fade-in */} | |
<div className="absolute top-0 left-0 w-full h-full z-50"> | |
<div className="absolute inset-0"> | |
<img | |
src={selectedImage} | |
onLoad={() => setImageLoaded(true)} | |
onError={(e) => { | |
const target = e.target as HTMLImageElement; | |
target.src = '/placeholder.svg'; | |
}} | |
className={`w-full h-full object-cover transition-opacity duration-700 ease-in-out ${ | |
imageLoaded ? 'opacity-100' : 'opacity-0' | |
}`} | |
/> | |
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/50 to-transparent" /> | |
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-netflix-black/40 to-transparent" /> | |
</div> | |
</div> | |
<div className="fixed inset-0 z-50 flex flex-col items-center backdrop-blur-sm justify-center"> | |
<div className="text-center max-w-md px-6"> | |
<div className="mb-6 flex justify-center"> | |
{poster ? ( | |
<img src={poster} alt={movieTitle} className="h-auto w-24 rounded-lg shadow-lg" /> | |
) : ( | |
<div className="flex items-center justify-center h-24 w-24 bg-theme-primary/20 rounded-lg"> | |
<Play className="h-12 w-12 text-theme-primary" /> | |
</div> | |
)} | |
</div> | |
<h2 className="text-2xl md:text-3xl font-bold text-white mb-4"> | |
{progress && progress.progress < 100 | |
? `Preparing "${episodeTitle}"` | |
: `Loading "${episodeTitle}"` | |
} | |
</h2> | |
{progress ? ( | |
<> | |
<p className="text-gray-300 mb-4"> | |
{progress.progress < 5 | |
? 'Initializing your stream...' | |
: progress.progress < 100 | |
? 'Your stream is being prepared.' | |
: 'Almost ready! Starting playback soon...'} | |
</p> | |
<div className="relative w-full h-2 bg-gray-800 rounded-full overflow-hidden mb-2"> | |
<div | |
className="absolute top-0 left-0 h-full bg-gradient-to-r from-theme-primary to-theme-primary-light transition-all duration-300" | |
style={{ width: `${Math.min(100, Math.max(0, progress.progress))}%` }} | |
/> | |
</div> | |
<p className="text-sm text-gray-400"> | |
{Math.round(progress.progress)}% complete | |
</p> | |
</> | |
) : ( | |
<div className="flex justify-center"> | |
<Loader2 className="h-8 w-8 animate-spin text-theme-primary" /> | |
</div> | |
)} | |
</div> | |
</div> | |
</> | |
); | |
} | |
const tvShowOverlay = ( | |
<> | |
<div className="absolute top-0 left-0 right-0 z-10 flex items-center p-4 bg-gradient-to-b from-black/80 to-transparent"> | |
<div> | |
<div className="flex items-center"> | |
<Film className="text-primary mr-2" size={20} /> | |
<span className="text-white text-sm font-medium truncate"> | |
{videoTitle} | |
</span> | |
<span className="mx-2 text-gray-400">•</span> | |
<span className="text-white text-sm"> | |
{season} • Episode {episodeNumber} | |
</span> | |
</div> | |
<h1 className="text-white text-lg font-bold">{episodeTitle}</h1> | |
</div> | |
</div> | |
<div className="absolute top-4 right-16 z-20"> | |
<button | |
onClick={onShowEpisodes} | |
className="bg-gray-800/80 hover:bg-gray-700/80 p-2 rounded-full transition-colors" | |
title="Show Episodes" | |
> | |
<GalleryVerticalEnd className="text-white" size={20} /> | |
</button> | |
</div> | |
</> | |
); | |
return ( | |
<div ref={containerRef} className="fixed inset-0 w-screen h-screen overflow-hidden"> | |
<VideoPlayer | |
url={videoUrl} | |
title={`${videoTitle} - ${season}E${episodeNumber}`} | |
poster={selectedImage} | |
startTime={startTime} | |
onClose={onClosePlayer} | |
onProgressUpdate={onProgressUpdate} | |
onVideoEnded={onVideoEnded} | |
showNextButton={true} | |
contentRating={ratingInfo} | |
hideTitleInPlayer={true} | |
customOverlay={tvShowOverlay} | |
containerRef={containerRef} | |
videoRef={videoRef} | |
/> | |
</div> | |
); | |
}; | |
export default TVShowPlayer; | |