|
|
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); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
setImageLoaded(false); |
|
|
}, [selectedImage]); |
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
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; |
|
|
}; |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
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; |
|
|
|