web / frontend /src /components /MoviePlayer.tsx
Chandima Prabhath
update
beeb302
raw
history blame
9.97 kB
import React, { useEffect, useState, useRef } from 'react';
import { getMovieLinkByTitle, getMovieCard } from '../lib/api';
import { useToast } from '@/hooks/use-toast';
import VideoPlayer from './VideoPlayer';
import VideoPlayerControls from './VideoPlayerControls';
import { Loader2, Play } from 'lucide-react';
import { MovieCardData } from './ContentCard';
interface ProgressData {
status: string;
progress: number;
downloaded: number;
total: number;
}
interface MoviePlayerProps {
movieTitle: string;
videoUrl?: string;
contentRatings?: any[];
poster?: string;
startTime?: number;
onClosePlayer?: () => void;
onProgressUpdate?: (currentTime: number, duration: number) => void;
onVideoEnded?: () => void;
showNextButton?: boolean;
}
const MoviePlayer: React.FC<MoviePlayerProps> = ({
movieTitle,
videoUrl,
contentRatings,
poster,
startTime = 0,
onClosePlayer,
onProgressUpdate,
onVideoEnded,
showNextButton = false
}) => {
const [videoUrlState, setVideoUrlState] = useState<string | null>(videoUrl || null);
const [loading, setLoading] = useState(!videoUrl);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<ProgressData | null>(null);
const [videoFetched, setVideoFetched] = useState(!!videoUrl);
const [cardData, setCardData] = useState<MovieCardData | null>(null);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [imageLoaded, setImageLoaded] = useState(false);
const { toast } = useToast();
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const videoFetchedRef = useRef(!!videoUrl);
const [ratingInfo, setRatingInfo] = useState<{ rating: string; description: string } | null>(null);
const [currentTime, setCurrentTime] = useState(startTime);
const containerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
// Reset fade state when image changes
useEffect(() => {
setImageLoaded(false);
}, [selectedImage]);
// Update progress and propagate up
const handleProgressUpdate = (time: number, duration: number) => {
setCurrentTime(time);
onProgressUpdate?.(time, duration);
};
// Seek handler
const handleSeek = (time: number) => {
if (videoRef.current) {
videoRef.current.currentTime = time;
setCurrentTime(time);
}
};
// Random image selector
const selectRandomImage = (card: MovieCardData | null) => {
if (!card) return null;
if (card.banner && card.banner.length > 0) {
return card.banner[Math.floor(Math.random() * card.banner.length)].image;
}
if (card.portrait && card.portrait.length > 0) {
return card.portrait[Math.floor(Math.random() * card.portrait.length)].image;
}
return card.image;
};
// Fetch movie link or start polling
const fetchMovieLink = async () => {
if (videoFetchedRef.current || videoUrlState) return;
try {
const response = await getMovieLinkByTitle(movieTitle);
if (response.url) {
pollingIntervalRef.current && clearInterval(pollingIntervalRef.current);
setVideoUrlState(response.url);
setVideoFetched(true);
videoFetchedRef.current = true;
setLoading(false);
} else if (response.progress_url) {
if (!pollingIntervalRef.current) {
pollingIntervalRef.current = setInterval(async () => {
try {
const res = await fetch(response.progress_url!);
const data = await res.json();
setProgress(data.progress);
if (data.progress.progress >= 100) {
clearInterval(pollingIntervalRef.current!);
timeoutRef.current = setTimeout(fetchMovieLink, 5000);
}
} catch (e) {
console.error(e);
}
}, 2000);
}
} else {
throw new Error('No URL or progress URL');
}
} catch (e) {
console.error('Error fetching movie link:', e);
setError('Failed to load video');
toast({ title: 'Error', description: 'Could not load the video', variant: 'destructive' });
setLoading(false);
}
};
// Fetch card data & ratings
useEffect(() => {
const fetchCard = async () => {
try {
const movieData = await getMovieCard(movieTitle);
setCardData(movieData);
const img = selectRandomImage(movieData);
setSelectedImage(img);
// Poster fallback
if (!poster) {
poster = movieData.image || poster;
}
// Ratings
const ratings = contentRatings && contentRatings.length > 0
? contentRatings
: movieData.content_ratings || [];
if (ratings.length) {
const us = ratings.find((r: any) => r.country === 'usa') || ratings[0];
setRatingInfo({ rating: us.name || 'NR', description: us.description || '' });
}
} catch (e) {
console.error('Failed to fetch movie card:', e);
}
};
fetchCard();
}, [movieTitle, contentRatings, poster]);
// Initial link fetch / cleanup
useEffect(() => {
if (!videoUrlState) {
fetchMovieLink();
} else {
setVideoFetched(true);
videoFetchedRef.current = true;
setLoading(false);
}
return () => {
pollingIntervalRef.current && clearInterval(pollingIntervalRef.current);
timeoutRef.current && clearTimeout(timeoutRef.current);
};
}, [movieTitle, videoUrlState]);
// Sync loading state
useEffect(() => {
if (videoUrlState) setLoading(false);
}, [videoUrlState]);
// Error UI
if (error) {
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col items-center justify-center">
<div className="text-4xl mb-4 text-theme-error">😢</div>
<h2 className="text-2xl font-bold mb-2 text-white">Error Playing Movie</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 text-white"
>
Back to Browse
</button>
</div>
);
}
// Loading / preparing UI with fade‑in backdrop
if (loading || !videoFetched || !videoUrlState) {
return (
<>
<div className="relative w-full h-full">
<div className="absolute inset-0">
<img
src={selectedImage}
onLoad={() => setImageLoaded(true)}
onError={(e) => {
(e.target as HTMLImageElement).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 "${movieTitle}"`
: `Loading "${movieTitle}"`
}
</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>
</>
);
}
// Playback UI
return (
<div ref={containerRef} className="fixed inset-0 h-screen w-screen overflow-hidden">
<VideoPlayer
url={videoUrlState}
title={movieTitle}
poster={selectedImage || undefined}
startTime={startTime}
onClose={onClosePlayer}
onProgressUpdate={handleProgressUpdate}
onVideoEnded={onVideoEnded}
showNextButton={showNextButton}
contentRating={ratingInfo}
containerRef={containerRef}
videoRef={videoRef}
/>
<VideoPlayerControls
title={movieTitle}
currentTime={currentTime}
duration={videoRef.current?.duration || 0}
onSeek={handleSeek}
/>
</div>
);
};
export default MoviePlayer;