web / frontend /src /components /TVShowPlayer.tsx
Chandima Prabhath
update
beeb302
raw
history blame
10.9 kB
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;