web / frontend /src /components /TVShowPlayer.tsx
Chandima Prabhath
Track bun.lockb with Git LFS
cc2caf9
raw
history blame
10.6 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';
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[];
thumbnail?: string;
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,
thumbnail,
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 { toast } = useToast();
const pollingInterval = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const videoFetchedRef = useRef(false);
const [ratingInfo, setRatingInfo] = useState<{ rating: string, description: string } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
// Parse episode info
const getEpisodeInfo = () => {
if (!episode) return { number: '1', title: 'Unknown Episode' };
const episodeMatch = episode.match(/E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i);
const number = episodeMatch ? episodeMatch[1] : '1';
const title = episodeMatch ? episodeMatch[2].trim() : 'Unknown Episode';
return { number, title };
};
const { number: episodeNumber, title: episodeTitle } = getEpisodeInfo();
// --- Link Fetching & Polling ---
const fetchMovieLink = async () => {
if (videoFetchedRef.current) return;
try {
const response = await getEpisodeLinkByTitle(videoTitle, season, episode);
if (response && response.url) {
// Stop any polling if running
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
pollingInterval.current = null;
}
setVideoUrl(response.url);
setVideoFetched(true);
videoFetchedRef.current = true;
setLoading(false);
console.log('Video URL fetched:', response.url);
} else if (response && response.progress_url) {
startPolling(response.progress_url);
} else {
console.error('No video URL or progress URL found in response:', response);
setError('Video URL not available');
}
} catch (error) {
console.error('Error fetching episode link:', error);
setError('Failed to load episode');
toast({
title: "Error",
description: "Could not load the episode",
variant: "destructive"
});
} finally {
if (!videoFetchedRef.current && !videoUrl) {
setLoading(false);
}
}
};
// Fetch content ratings if not provided
useEffect(() => {
const fetchRatingInfo = async () => {
if (contentRatings && contentRatings.length > 0) {
const usRating = contentRatings.find(r => r.country === 'usa') || contentRatings[0];
setRatingInfo({
rating: usRating.name || 'NR',
description: usRating.description || ''
});
return;
}
try {
const showData = await getTvShowCard(videoTitle);
if (showData && showData.data && showData.data.contentRatings) {
const ratings = showData.data.contentRatings;
const usRating = ratings.find((r: any) => r.country === 'US') || ratings[0];
setRatingInfo({
rating: usRating?.name || 'TV-14',
description: usRating?.description || ''
});
}
} catch (error) {
console.error('Failed to fetch show ratings:', error);
}
};
fetchRatingInfo();
}, [videoTitle, contentRatings]);
const pollProgress = async (progressUrl: string) => {
try {
const res = await fetch(progressUrl);
const data = await res.json();
setProgress(data.progress);
if (data.progress.progress >= 100) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
pollingInterval.current = null;
}
if (!videoFetchedRef.current) {
timeoutRef.current = setTimeout(fetchMovieLink, 5000);
}
}
} catch (error) {
console.error('Error polling progress:', error);
}
};
const startPolling = (progressUrl: string) => {
if (!pollingInterval.current) {
const interval = setInterval(() => pollProgress(progressUrl), 2000);
pollingInterval.current = interval;
}
};
useEffect(() => {
if (!videoTitle || !season || !episode) {
setError('Missing required video information');
setLoading(false);
return;
}
// Reset state for new episode
setVideoUrl(null);
setVideoFetched(false);
videoFetchedRef.current = false;
setLoading(true);
setProgress(null);
setError(null);
// Start fetching
fetchMovieLink();
return () => {
if (pollingInterval.current) clearInterval(pollingInterval.current);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [videoTitle, season, episode]);
// Add effect to update loading state when videoUrl changes
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 transition-colors"
>
Back to Show
</button>
</div>
);
}
if (loading || !videoFetched || !videoUrl) {
return (
<div className="fixed inset-0 z-50 bg-gradient-to-br from-theme-background-dark to-black flex flex-col items-center 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>
);
}
// TV Show specific overlay elements that will be passed to VideoPlayer
const tvShowOverlay = (
<>
{/* Top info bar */}
<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>
{/* Episodes button */}
<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={poster || thumbnail}
startTime={startTime}
onClose={onClosePlayer}
onProgressUpdate={onProgressUpdate}
onVideoEnded={onVideoEnded}
showNextButton={true}
contentRating={ratingInfo}
hideTitleInPlayer={true}
customOverlay={tvShowOverlay}
containerRef={containerRef}
videoRef={videoRef}
/>
</div>
);
};
export default TVShowPlayer;