Chandima Prabhath commited on
Commit
beeb302
·
1 Parent(s): 29f13f9
frontend/netlify.toml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ [build]
2
+ command = "npm run build"
3
+ publish = "dist"
4
+
5
+ [dev]
6
+ command = "npm run dev"
frontend/src/components/ContentCard.tsx CHANGED
@@ -292,11 +292,11 @@ const ContentCard: React.FC<ContentCardProps> = ({
292
 
293
  return (
294
  <div
295
- className="relative flex-shrink-0 w-[240px] md:w-[280px] card-hover group"
296
  onMouseEnter={() => setIsHovered(true)}
297
  onMouseLeave={() => setIsHovered(false)}
298
  >
299
- <div className="relative rounded-md overflow-hidden shadow-xl bg-theme-card h-[140px] md:h-[160px]">
300
  {/* Base card image */}
301
  <Link to={path} className="block h-full">
302
  {loading ? (
@@ -330,7 +330,7 @@ const ContentCard: React.FC<ContentCardProps> = ({
330
 
331
  {/* Title overlay (simple version when not hovered) */}
332
  <div className={`absolute inset-x-0 bottom-0 p-3 ${isHovered ? 'opacity-0' : 'opacity-100'}
333
- transition-opacity duration-300 bg-gradient-to-t from-black/90 to-transparent`}>
334
  <div className="flex items-center">
335
  <h3 className="font-bold text-sm line-clamp-1 flex-1">{title}</h3>
336
  {progress?.completed && (
@@ -355,9 +355,9 @@ const ContentCard: React.FC<ContentCardProps> = ({
355
 
356
  {/* Expanded hover overlay with detailed info and buttons */}
357
  <div
358
- className={`fixed group-hover:absolute inset-0 z-20 bg-gradient-to-b from-black/90 to-theme-background-dark
359
- transition-opacity duration-300 flex flex-col justify-between p-3 w-[240px] md:w-[280px] h-[140px] md:h-[160px]
360
- ${isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
361
  >
362
  {/* Top section - title and info */}
363
  <div>
 
292
 
293
  return (
294
  <div
295
+ className="relative flex-shrink-0 w-[240px] md:w-[280px] h-full card-hover group"
296
  onMouseEnter={() => setIsHovered(true)}
297
  onMouseLeave={() => setIsHovered(false)}
298
  >
299
+ <div className="relative rounded-md overflow-hidden shadow-xl bg-theme-card h-[170px] md:h-[170px]">
300
  {/* Base card image */}
301
  <Link to={path} className="block h-full">
302
  {loading ? (
 
330
 
331
  {/* Title overlay (simple version when not hovered) */}
332
  <div className={`absolute inset-x-0 bottom-0 p-3 ${isHovered ? 'opacity-0' : 'opacity-100'}
333
+ transition-opacity duration-300 bg-gradient-to-t from-black to-transparent`}>
334
  <div className="flex items-center">
335
  <h3 className="font-bold text-sm line-clamp-1 flex-1">{title}</h3>
336
  {progress?.completed && (
 
355
 
356
  {/* Expanded hover overlay with detailed info and buttons */}
357
  <div
358
+ className={`fixed group-hover:absolute inset-0 z-20 bg-gradient-to-b from-black/40 to-theme-background-dark
359
+ transition-all duration-300 flex flex-col justify-between p-3 w-full h-full
360
+ ${isHovered ? 'opacity-100 backdrop-blur-md' : 'opacity-0 pointer-events-none backdrop-blur-none'}`}
361
  >
362
  {/* Top section - title and info */}
363
  <div>
frontend/src/components/HeroSection.tsx CHANGED
@@ -88,8 +88,8 @@ const DynamicHeroSlideshow: React.FC<DynamicHeroSlideshowProps> = ({
88
  target.src = '/placeholder.svg';
89
  }}
90
  />
91
- <div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-transparent" />
92
- <div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/40 to-transparent" />
93
  </motion.div>
94
  </AnimatePresence>
95
 
@@ -138,7 +138,7 @@ const DynamicHeroSlideshow: React.FC<DynamicHeroSlideshowProps> = ({
138
  <div className="flex space-x-3">
139
  <Link
140
  to={`${path}/watch`}
141
- className="flex items-center px-6 py-2 rounded bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition"
142
  >
143
  <Play className="w-5 h-5 mr-2" /> Play
144
  </Link>
@@ -161,7 +161,7 @@ const DynamicHeroSlideshow: React.FC<DynamicHeroSlideshowProps> = ({
161
  onClick={() => handleDotClick(index)}
162
  className={`w-2.5 h-2.5 rounded-full transition-all ${
163
  index === currentIndex
164
- ? 'bg-indigo-500 w-6'
165
  : 'bg-gray-500/50 hover:bg-gray-400/70'
166
  }`}
167
  aria-label={`Go to slide ${index + 1}`}
 
88
  target.src = '/placeholder.svg';
89
  }}
90
  />
91
+ <div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" />
92
+ <div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-netflix-black/40 to-transparent" />
93
  </motion.div>
94
  </AnimatePresence>
95
 
 
138
  <div className="flex space-x-3">
139
  <Link
140
  to={`${path}/watch`}
141
+ className="flex items-center px-6 py-2 rounded bg-theme-primary text-white font-semibold hover:bg-theme-primary-hover transition"
142
  >
143
  <Play className="w-5 h-5 mr-2" /> Play
144
  </Link>
 
161
  onClick={() => handleDotClick(index)}
162
  className={`w-2.5 h-2.5 rounded-full transition-all ${
163
  index === currentIndex
164
+ ? 'bg-theme-primary-light w-5'
165
  : 'bg-gray-500/50 hover:bg-gray-400/70'
166
  }`}
167
  aria-label={`Go to slide ${index + 1}`}
frontend/src/components/MoviePlayer.tsx CHANGED
@@ -4,6 +4,7 @@ import { useToast } from '@/hooks/use-toast';
4
  import VideoPlayer from './VideoPlayer';
5
  import VideoPlayerControls from './VideoPlayerControls';
6
  import { Loader2, Play } from 'lucide-react';
 
7
 
8
  interface ProgressData {
9
  status: string;
@@ -16,7 +17,6 @@ interface MoviePlayerProps {
16
  movieTitle: string;
17
  videoUrl?: string;
18
  contentRatings?: any[];
19
- thumbnail?: string;
20
  poster?: string;
21
  startTime?: number;
22
  onClosePlayer?: () => void;
@@ -29,7 +29,6 @@ const MoviePlayer: React.FC<MoviePlayerProps> = ({
29
  movieTitle,
30
  videoUrl,
31
  contentRatings,
32
- thumbnail,
33
  poster,
34
  startTime = 0,
35
  onClosePlayer,
@@ -42,23 +41,31 @@ const MoviePlayer: React.FC<MoviePlayerProps> = ({
42
  const [error, setError] = useState<string | null>(null);
43
  const [progress, setProgress] = useState<ProgressData | null>(null);
44
  const [videoFetched, setVideoFetched] = useState(!!videoUrl);
 
 
 
45
  const { toast } = useToast();
46
-
47
  const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
48
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
49
  const videoFetchedRef = useRef(!!videoUrl);
50
- const [ratingInfo, setRatingInfo] = useState<{ rating: string, description: string } | null>(null);
51
  const [currentTime, setCurrentTime] = useState(startTime);
52
  const containerRef = useRef<HTMLDivElement>(null);
53
  const videoRef = useRef<HTMLVideoElement>(null);
54
-
55
- // Update the onProgressUpdate handler to also update currentTime
 
 
 
 
 
56
  const handleProgressUpdate = (time: number, duration: number) => {
57
  setCurrentTime(time);
58
  onProgressUpdate?.(time, duration);
59
  };
60
 
61
- // Handler for seeking from WatchTogether
62
  const handleSeek = (time: number) => {
63
  if (videoRef.current) {
64
  videoRef.current.currentTime = time;
@@ -66,129 +73,105 @@ const MoviePlayer: React.FC<MoviePlayerProps> = ({
66
  }
67
  };
68
 
69
- // --- Link Fetching & Polling ---
 
 
 
 
 
 
 
 
 
 
 
 
70
  const fetchMovieLink = async () => {
71
  if (videoFetchedRef.current || videoUrlState) return;
72
-
73
  try {
74
  const response = await getMovieLinkByTitle(movieTitle);
75
-
76
- if (response && response.url) {
77
- // Stop any polling if running
78
- if (pollingIntervalRef.current) {
79
- clearInterval(pollingIntervalRef.current);
80
- pollingIntervalRef.current = null;
81
- }
82
-
83
  setVideoUrlState(response.url);
84
  setVideoFetched(true);
85
  videoFetchedRef.current = true;
86
- setLoading(false); // Ensure loading is set to false when URL is fetched
87
- console.log('Video URL fetched:', response.url);
88
- } else if (response && response.progress_url) {
89
- startPolling(response.progress_url);
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  } else {
91
- console.error('No video URL or progress URL found in response:', response);
92
- setError('Video URL not available');
93
  }
94
- } catch (error) {
95
- console.error('Error fetching movie link:', error);
96
  setError('Failed to load video');
97
- toast({
98
- title: "Error",
99
- description: "Could not load the video",
100
- variant: "destructive"
101
- });
102
- } finally {
103
- // Only set loading to false if we don't have a video yet
104
- if (!videoFetchedRef.current && !videoUrlState) {
105
- setLoading(false);
106
- }
107
  }
108
  };
109
 
110
- // Fetch content ratings if not provided
111
  useEffect(() => {
112
- const fetchRatingInfo = async () => {
113
- if (contentRatings && contentRatings.length > 0) {
114
- const usRating = contentRatings.find(r => r.country === 'usa') || contentRatings[0];
115
- setRatingInfo({
116
- rating: usRating.name || 'NR',
117
- description: usRating.description || ''
118
- });
119
- return;
120
- }
121
-
122
  try {
123
  const movieData = await getMovieCard(movieTitle);
124
- if (movieData && movieData.content_ratings) {
125
- const ratings = movieData.content_ratings;
126
- const usRating = ratings.find((r: any) => r.country === 'US') || ratings[0];
127
- setRatingInfo({
128
- rating: usRating?.name || 'NR',
129
- description: usRating?.description || ''
130
- });
131
- }
132
- } catch (error) {
133
- console.error('Failed to fetch movie ratings:', error);
134
- }
135
- };
136
-
137
- fetchRatingInfo();
138
- }, [movieTitle, contentRatings]);
139
-
140
- const pollProgress = async (progressUrl: string) => {
141
- try {
142
- const res = await fetch(progressUrl);
143
- const data = await res.json();
144
-
145
- setProgress(data.progress);
146
-
147
- if (data.progress.progress >= 100) {
148
- if (pollingIntervalRef.current) {
149
- clearInterval(pollingIntervalRef.current);
150
- pollingIntervalRef.current = null;
151
  }
152
-
153
- if (!videoFetchedRef.current) {
154
- timeoutRef.current = setTimeout(fetchMovieLink, 5000);
 
 
 
 
155
  }
 
 
156
  }
157
- } catch (error) {
158
- console.error('Error polling progress:', error);
159
- }
160
- };
161
-
162
- const startPolling = (progressUrl: string) => {
163
- if (!pollingIntervalRef.current) {
164
- const interval = setInterval(() => pollProgress(progressUrl), 2000);
165
- pollingIntervalRef.current = interval;
166
- }
167
- };
168
 
169
- // Cleanup on unmount and when dependencies change
170
  useEffect(() => {
171
  if (!videoUrlState) {
172
  fetchMovieLink();
173
  } else {
174
  setVideoFetched(true);
175
  videoFetchedRef.current = true;
176
- setLoading(false); // Make sure loading is false when we have a URL
177
  }
178
-
179
  return () => {
180
- if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
181
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
182
  };
183
- }, [movieTitle, videoUrl]);
184
 
185
- // Add effect to update loading state when videoUrlState changes
186
  useEffect(() => {
187
- if (videoUrlState) {
188
- setLoading(false);
189
- }
190
  }, [videoUrlState]);
191
 
 
192
  if (error) {
193
  return (
194
  <div className="fixed inset-0 z-50 bg-black flex flex-col items-center justify-center">
@@ -205,62 +188,80 @@ const MoviePlayer: React.FC<MoviePlayerProps> = ({
205
  );
206
  }
207
 
 
208
  if (loading || !videoFetched || !videoUrlState) {
209
  return (
210
- <div className="fixed inset-0 z-50 bg-gradient-to-br from-theme-background-dark to-black flex flex-col items-center justify-center">
211
- <div className="text-center max-w-md px-6">
212
- <div className="mb-6 flex justify-center">
213
- {poster ? (
214
- <img src={poster} alt={movieTitle} className="h-auto w-24 rounded-lg shadow-lg" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  ) : (
216
- <div className="flex items-center justify-center h-24 w-24 bg-theme-primary/20 rounded-lg">
217
- <Play className="h-12 w-12 text-theme-primary" />
218
  </div>
219
  )}
220
  </div>
221
-
222
- <h2 className="text-2xl md:text-3xl font-bold text-white mb-4">
223
- {progress && progress.progress < 100
224
- ? `Preparing "${movieTitle}"`
225
- : `Loading "${movieTitle}"`
226
- }
227
- </h2>
228
-
229
- {progress ? (
230
- <>
231
- <p className="text-gray-300 mb-4">
232
- {progress.progress < 5
233
- ? 'Initializing your stream...'
234
- : progress.progress < 100
235
- ? 'Your stream is being prepared.'
236
- : 'Almost ready! Starting playback soon...'}
237
- </p>
238
- <div className="relative w-full h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
239
- <div
240
- className="absolute top-0 left-0 h-full bg-gradient-to-r from-theme-primary to-theme-primary-light transition-all duration-300"
241
- style={{ width: `${Math.min(100, Math.max(0, progress.progress))}%` }}
242
- />
243
- </div>
244
- <p className="text-sm text-gray-400">
245
- {Math.round(progress.progress)}% complete
246
- </p>
247
- </>
248
- ) : (
249
- <div className="flex justify-center">
250
- <Loader2 className="h-8 w-8 animate-spin text-theme-primary" />
251
- </div>
252
- )}
253
  </div>
254
- </div>
255
  );
256
  }
257
 
 
258
  return (
259
  <div ref={containerRef} className="fixed inset-0 h-screen w-screen overflow-hidden">
260
  <VideoPlayer
261
  url={videoUrlState}
262
  title={movieTitle}
263
- poster={poster || thumbnail}
264
  startTime={startTime}
265
  onClose={onClosePlayer}
266
  onProgressUpdate={handleProgressUpdate}
@@ -270,8 +271,7 @@ const MoviePlayer: React.FC<MoviePlayerProps> = ({
270
  containerRef={containerRef}
271
  videoRef={videoRef}
272
  />
273
-
274
- <VideoPlayerControls
275
  title={movieTitle}
276
  currentTime={currentTime}
277
  duration={videoRef.current?.duration || 0}
 
4
  import VideoPlayer from './VideoPlayer';
5
  import VideoPlayerControls from './VideoPlayerControls';
6
  import { Loader2, Play } from 'lucide-react';
7
+ import { MovieCardData } from './ContentCard';
8
 
9
  interface ProgressData {
10
  status: string;
 
17
  movieTitle: string;
18
  videoUrl?: string;
19
  contentRatings?: any[];
 
20
  poster?: string;
21
  startTime?: number;
22
  onClosePlayer?: () => void;
 
29
  movieTitle,
30
  videoUrl,
31
  contentRatings,
 
32
  poster,
33
  startTime = 0,
34
  onClosePlayer,
 
41
  const [error, setError] = useState<string | null>(null);
42
  const [progress, setProgress] = useState<ProgressData | null>(null);
43
  const [videoFetched, setVideoFetched] = useState(!!videoUrl);
44
+ const [cardData, setCardData] = useState<MovieCardData | null>(null);
45
+ const [selectedImage, setSelectedImage] = useState<string | null>(null);
46
+ const [imageLoaded, setImageLoaded] = useState(false);
47
  const { toast } = useToast();
48
+
49
  const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
50
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
51
  const videoFetchedRef = useRef(!!videoUrl);
52
+ const [ratingInfo, setRatingInfo] = useState<{ rating: string; description: string } | null>(null);
53
  const [currentTime, setCurrentTime] = useState(startTime);
54
  const containerRef = useRef<HTMLDivElement>(null);
55
  const videoRef = useRef<HTMLVideoElement>(null);
56
+
57
+ // Reset fade state when image changes
58
+ useEffect(() => {
59
+ setImageLoaded(false);
60
+ }, [selectedImage]);
61
+
62
+ // Update progress and propagate up
63
  const handleProgressUpdate = (time: number, duration: number) => {
64
  setCurrentTime(time);
65
  onProgressUpdate?.(time, duration);
66
  };
67
 
68
+ // Seek handler
69
  const handleSeek = (time: number) => {
70
  if (videoRef.current) {
71
  videoRef.current.currentTime = time;
 
73
  }
74
  };
75
 
76
+ // Random image selector
77
+ const selectRandomImage = (card: MovieCardData | null) => {
78
+ if (!card) return null;
79
+ if (card.banner && card.banner.length > 0) {
80
+ return card.banner[Math.floor(Math.random() * card.banner.length)].image;
81
+ }
82
+ if (card.portrait && card.portrait.length > 0) {
83
+ return card.portrait[Math.floor(Math.random() * card.portrait.length)].image;
84
+ }
85
+ return card.image;
86
+ };
87
+
88
+ // Fetch movie link or start polling
89
  const fetchMovieLink = async () => {
90
  if (videoFetchedRef.current || videoUrlState) return;
91
+
92
  try {
93
  const response = await getMovieLinkByTitle(movieTitle);
94
+ if (response.url) {
95
+ pollingIntervalRef.current && clearInterval(pollingIntervalRef.current);
 
 
 
 
 
 
96
  setVideoUrlState(response.url);
97
  setVideoFetched(true);
98
  videoFetchedRef.current = true;
99
+ setLoading(false);
100
+ } else if (response.progress_url) {
101
+ if (!pollingIntervalRef.current) {
102
+ pollingIntervalRef.current = setInterval(async () => {
103
+ try {
104
+ const res = await fetch(response.progress_url!);
105
+ const data = await res.json();
106
+ setProgress(data.progress);
107
+ if (data.progress.progress >= 100) {
108
+ clearInterval(pollingIntervalRef.current!);
109
+ timeoutRef.current = setTimeout(fetchMovieLink, 5000);
110
+ }
111
+ } catch (e) {
112
+ console.error(e);
113
+ }
114
+ }, 2000);
115
+ }
116
  } else {
117
+ throw new Error('No URL or progress URL');
 
118
  }
119
+ } catch (e) {
120
+ console.error('Error fetching movie link:', e);
121
  setError('Failed to load video');
122
+ toast({ title: 'Error', description: 'Could not load the video', variant: 'destructive' });
123
+ setLoading(false);
 
 
 
 
 
 
 
 
124
  }
125
  };
126
 
127
+ // Fetch card data & ratings
128
  useEffect(() => {
129
+ const fetchCard = async () => {
 
 
 
 
 
 
 
 
 
130
  try {
131
  const movieData = await getMovieCard(movieTitle);
132
+ setCardData(movieData);
133
+ const img = selectRandomImage(movieData);
134
+ setSelectedImage(img);
135
+ // Poster fallback
136
+ if (!poster) {
137
+ poster = movieData.image || poster;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  }
139
+ // Ratings
140
+ const ratings = contentRatings && contentRatings.length > 0
141
+ ? contentRatings
142
+ : movieData.content_ratings || [];
143
+ if (ratings.length) {
144
+ const us = ratings.find((r: any) => r.country === 'usa') || ratings[0];
145
+ setRatingInfo({ rating: us.name || 'NR', description: us.description || '' });
146
  }
147
+ } catch (e) {
148
+ console.error('Failed to fetch movie card:', e);
149
  }
150
+ };
151
+ fetchCard();
152
+ }, [movieTitle, contentRatings, poster]);
 
 
 
 
 
 
 
 
153
 
154
+ // Initial link fetch / cleanup
155
  useEffect(() => {
156
  if (!videoUrlState) {
157
  fetchMovieLink();
158
  } else {
159
  setVideoFetched(true);
160
  videoFetchedRef.current = true;
161
+ setLoading(false);
162
  }
 
163
  return () => {
164
+ pollingIntervalRef.current && clearInterval(pollingIntervalRef.current);
165
+ timeoutRef.current && clearTimeout(timeoutRef.current);
166
  };
167
+ }, [movieTitle, videoUrlState]);
168
 
169
+ // Sync loading state
170
  useEffect(() => {
171
+ if (videoUrlState) setLoading(false);
 
 
172
  }, [videoUrlState]);
173
 
174
+ // Error UI
175
  if (error) {
176
  return (
177
  <div className="fixed inset-0 z-50 bg-black flex flex-col items-center justify-center">
 
188
  );
189
  }
190
 
191
+ // Loading / preparing UI with fade‑in backdrop
192
  if (loading || !videoFetched || !videoUrlState) {
193
  return (
194
+ <>
195
+ <div className="relative w-full h-full">
196
+ <div className="absolute inset-0">
197
+ <img
198
+ src={selectedImage}
199
+ onLoad={() => setImageLoaded(true)}
200
+ onError={(e) => {
201
+ (e.target as HTMLImageElement).src = '/placeholder.svg';
202
+ }}
203
+ className={`w-full h-full object-cover transition-opacity duration-700 ease-in-out ${
204
+ imageLoaded ? 'opacity-100' : 'opacity-0'
205
+ }`}
206
+ />
207
+ <div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/50 to-transparent" />
208
+ <div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-netflix-black/40 to-transparent" />
209
+ </div>
210
+ </div>
211
+ <div className="fixed inset-0 z-50 flex flex-col items-center backdrop-blur-sm justify-center">
212
+ <div className="text-center max-w-md px-6">
213
+ <div className="mb-6 flex justify-center">
214
+ {poster ? (
215
+ <img src={poster} alt={movieTitle} className="h-auto w-24 rounded-lg shadow-lg" />
216
+ ) : (
217
+ <div className="flex items-center justify-center h-24 w-24 bg-theme-primary/20 rounded-lg">
218
+ <Play className="h-12 w-12 text-theme-primary" />
219
+ </div>
220
+ )}
221
+ </div>
222
+ <h2 className="text-2xl md:text-3xl font-bold text-white mb-4">
223
+ {progress && progress.progress < 100
224
+ ? `Preparing "${movieTitle}"`
225
+ : `Loading "${movieTitle}"`
226
+ }
227
+ </h2>
228
+ {progress ? (
229
+ <>
230
+ <p className="text-gray-300 mb-4">
231
+ {progress.progress < 5
232
+ ? 'Initializing your stream...'
233
+ : progress.progress < 100
234
+ ? 'Your stream is being prepared.'
235
+ : 'Almost ready! Starting playback soon...'}
236
+ </p>
237
+ <div className="relative w-full h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
238
+ <div
239
+ className="absolute top-0 left-0 h-full bg-gradient-to-r from-theme-primary to-theme-primary-light transition-all duration-300"
240
+ style={{ width: `${Math.min(100, Math.max(0, progress.progress))}%` }}
241
+ />
242
+ </div>
243
+ <p className="text-sm text-gray-400">
244
+ {Math.round(progress.progress)}% complete
245
+ </p>
246
+ </>
247
  ) : (
248
+ <div className="flex justify-center">
249
+ <Loader2 className="h-8 w-8 animate-spin text-theme-primary" />
250
  </div>
251
  )}
252
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  </div>
254
+ </>
255
  );
256
  }
257
 
258
+ // Playback UI
259
  return (
260
  <div ref={containerRef} className="fixed inset-0 h-screen w-screen overflow-hidden">
261
  <VideoPlayer
262
  url={videoUrlState}
263
  title={movieTitle}
264
+ poster={selectedImage || undefined}
265
  startTime={startTime}
266
  onClose={onClosePlayer}
267
  onProgressUpdate={handleProgressUpdate}
 
271
  containerRef={containerRef}
272
  videoRef={videoRef}
273
  />
274
+ <VideoPlayerControls
 
275
  title={movieTitle}
276
  currentTime={currentTime}
277
  duration={videoRef.current?.duration || 0}
frontend/src/components/TVShowPlayer.tsx CHANGED
@@ -3,6 +3,7 @@ import { getEpisodeLinkByTitle, getTvShowCard } from '../lib/api';
3
  import { useToast } from '@/hooks/use-toast';
4
  import { Film, GalleryVerticalEnd, Loader2, Play } from 'lucide-react';
5
  import VideoPlayer from './VideoPlayer';
 
6
 
7
  interface ProgressData {
8
  status: string;
@@ -23,7 +24,6 @@ interface TVShowPlayerProps {
23
  episode: string;
24
  movieTitle: string;
25
  contentRatings?: ContentRating[];
26
- thumbnail?: string;
27
  poster?: string;
28
  startTime?: number;
29
  onClosePlayer?: () => void;
@@ -38,7 +38,6 @@ const TVShowPlayer: React.FC<TVShowPlayerProps> = ({
38
  episode,
39
  movieTitle,
40
  contentRatings,
41
- thumbnail,
42
  poster,
43
  startTime = 0,
44
  onClosePlayer,
@@ -51,157 +50,128 @@ const TVShowPlayer: React.FC<TVShowPlayerProps> = ({
51
  const [error, setError] = useState<string | null>(null);
52
  const [progress, setProgress] = useState<ProgressData | null>(null);
53
  const [videoFetched, setVideoFetched] = useState(false);
 
 
 
 
 
 
 
54
  const { toast } = useToast();
55
-
56
  const pollingInterval = useRef<NodeJS.Timeout | null>(null);
57
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
58
  const videoFetchedRef = useRef(false);
59
- const [ratingInfo, setRatingInfo] = useState<{ rating: string, description: string } | null>(null);
60
- const containerRef = useRef<HTMLDivElement>(null);
61
- const [isFullscreen, setIsFullscreen] = useState(false);
62
- const videoRef = useRef<HTMLVideoElement>(null);
 
63
 
64
  // Parse episode info
65
  const getEpisodeInfo = () => {
66
  if (!episode) return { number: '1', title: 'Unknown Episode' };
67
-
68
- const episodeMatch = episode.match(/E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i);
69
- const number = episodeMatch ? episodeMatch[1] : '1';
70
- const title = episodeMatch ? episodeMatch[2].trim() : 'Unknown Episode';
71
-
72
- return { number, title };
73
  };
74
-
75
  const { number: episodeNumber, title: episodeTitle } = getEpisodeInfo();
76
 
77
- // --- Link Fetching & Polling ---
 
 
 
 
 
 
 
 
 
 
 
78
  const fetchMovieLink = async () => {
79
  if (videoFetchedRef.current) return;
80
-
81
  try {
82
  const response = await getEpisodeLinkByTitle(videoTitle, season, episode);
83
-
84
- if (response && response.url) {
85
- // Stop any polling if running
86
- if (pollingInterval.current) {
87
- clearInterval(pollingInterval.current);
88
- pollingInterval.current = null;
89
- }
90
-
91
  setVideoUrl(response.url);
92
  setVideoFetched(true);
93
  videoFetchedRef.current = true;
94
  setLoading(false);
95
- console.log('Video URL fetched:', response.url);
96
- } else if (response && response.progress_url) {
97
- startPolling(response.progress_url);
 
 
 
 
 
 
 
 
 
 
 
 
98
  } else {
99
- console.error('No video URL or progress URL found in response:', response);
100
- setError('Video URL not available');
101
  }
102
- } catch (error) {
103
- console.error('Error fetching episode link:', error);
104
  setError('Failed to load episode');
105
- toast({
106
- title: "Error",
107
- description: "Could not load the episode",
108
- variant: "destructive"
109
- });
110
- } finally {
111
- if (!videoFetchedRef.current && !videoUrl) {
112
- setLoading(false);
113
- }
114
- }
115
- };
116
-
117
- // Fetch content ratings if not provided
118
- useEffect(() => {
119
- const fetchRatingInfo = async () => {
120
- if (contentRatings && contentRatings.length > 0) {
121
- const usRating = contentRatings.find(r => r.country === 'usa') || contentRatings[0];
122
- setRatingInfo({
123
- rating: usRating.name || 'NR',
124
- description: usRating.description || ''
125
- });
126
- return;
127
- }
128
-
129
- try {
130
- const showData = await getTvShowCard(videoTitle);
131
- if (showData && showData.data && showData.data.contentRatings) {
132
- const ratings = showData.data.contentRatings;
133
- const usRating = ratings.find((r: any) => r.country === 'US') || ratings[0];
134
- setRatingInfo({
135
- rating: usRating?.name || 'TV-14',
136
- description: usRating?.description || ''
137
- });
138
- }
139
- } catch (error) {
140
- console.error('Failed to fetch show ratings:', error);
141
- }
142
- };
143
-
144
- fetchRatingInfo();
145
- }, [videoTitle, contentRatings]);
146
-
147
- const pollProgress = async (progressUrl: string) => {
148
- try {
149
- const res = await fetch(progressUrl);
150
- const data = await res.json();
151
-
152
- setProgress(data.progress);
153
-
154
- if (data.progress.progress >= 100) {
155
- if (pollingInterval.current) {
156
- clearInterval(pollingInterval.current);
157
- pollingInterval.current = null;
158
- }
159
-
160
- if (!videoFetchedRef.current) {
161
- timeoutRef.current = setTimeout(fetchMovieLink, 5000);
162
- }
163
- }
164
- } catch (error) {
165
- console.error('Error polling progress:', error);
166
- }
167
- };
168
-
169
- const startPolling = (progressUrl: string) => {
170
- if (!pollingInterval.current) {
171
- const interval = setInterval(() => pollProgress(progressUrl), 2000);
172
- pollingInterval.current = interval;
173
  }
174
  };
175
 
 
176
  useEffect(() => {
177
  if (!videoTitle || !season || !episode) {
178
  setError('Missing required video information');
179
  setLoading(false);
180
  return;
181
  }
182
-
183
- // Reset state for new episode
 
184
  setVideoUrl(null);
185
  setVideoFetched(false);
186
  videoFetchedRef.current = false;
187
- setLoading(true);
188
  setProgress(null);
189
- setError(null);
190
-
191
- // Start fetching
192
- fetchMovieLink();
193
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  return () => {
195
- if (pollingInterval.current) clearInterval(pollingInterval.current);
196
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
197
  };
198
  }, [videoTitle, season, episode]);
199
 
200
- // Add effect to update loading state when videoUrl changes
201
  useEffect(() => {
202
- if (videoUrl) {
203
- setLoading(false);
204
- }
205
  }, [videoUrl]);
206
 
207
  if (error) {
@@ -212,7 +182,7 @@ const TVShowPlayer: React.FC<TVShowPlayerProps> = ({
212
  <p className="text-gray-400 mb-6">{error}</p>
213
  <button
214
  onClick={onClosePlayer}
215
- className="px-6 py-2 bg-theme-primary hover:bg-theme-primary-hover rounded font-medium transition-colors"
216
  >
217
  Back to Show
218
  </button>
@@ -222,58 +192,76 @@ const TVShowPlayer: React.FC<TVShowPlayerProps> = ({
222
 
223
  if (loading || !videoFetched || !videoUrl) {
224
  return (
225
- <div className="fixed inset-0 z-50 bg-gradient-to-br from-theme-background-dark to-black flex flex-col items-center justify-center">
226
- <div className="text-center max-w-md px-6">
227
- <div className="mb-6 flex justify-center">
228
- {poster ? (
229
- <img src={poster} alt={movieTitle} className="h-auto w-24 rounded-lg shadow-lg" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  ) : (
231
- <div className="flex items-center justify-center h-24 w-24 bg-theme-primary/20 rounded-lg">
232
- <Play className="h-12 w-12 text-theme-primary" />
233
  </div>
234
  )}
235
  </div>
236
-
237
- <h2 className="text-2xl md:text-3xl font-bold text-white mb-4">
238
- {progress && progress.progress < 100
239
- ? `Preparing "${episodeTitle}"`
240
- : `Loading "${episodeTitle}"`
241
- }
242
- </h2>
243
-
244
- {progress ? (
245
- <>
246
- <p className="text-gray-300 mb-4">
247
- {progress.progress < 5
248
- ? 'Initializing your stream...'
249
- : progress.progress < 100
250
- ? 'Your stream is being prepared.'
251
- : 'Almost ready! Starting playback soon...'}
252
- </p>
253
- <div className="relative w-full h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
254
- <div
255
- className="absolute top-0 left-0 h-full bg-gradient-to-r from-theme-primary to-theme-primary-light transition-all duration-300"
256
- style={{ width: `${Math.min(100, Math.max(0, progress.progress))}%` }}
257
- />
258
- </div>
259
- <p className="text-sm text-gray-400">
260
- {Math.round(progress.progress)}% complete
261
- </p>
262
- </>
263
- ) : (
264
- <div className="flex justify-center">
265
- <Loader2 className="h-8 w-8 animate-spin text-theme-primary" />
266
- </div>
267
- )}
268
  </div>
269
- </div>
270
  );
271
  }
272
 
273
- // TV Show specific overlay elements that will be passed to VideoPlayer
274
  const tvShowOverlay = (
275
  <>
276
- {/* Top info bar */}
277
  <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">
278
  <div>
279
  <div className="flex items-center">
@@ -289,8 +277,7 @@ const TVShowPlayer: React.FC<TVShowPlayerProps> = ({
289
  <h1 className="text-white text-lg font-bold">{episodeTitle}</h1>
290
  </div>
291
  </div>
292
-
293
- {/* Episodes button */}
294
  <div className="absolute top-4 right-16 z-20">
295
  <button
296
  onClick={onShowEpisodes}
@@ -308,7 +295,7 @@ const TVShowPlayer: React.FC<TVShowPlayerProps> = ({
308
  <VideoPlayer
309
  url={videoUrl}
310
  title={`${videoTitle} - ${season}E${episodeNumber}`}
311
- poster={poster || thumbnail}
312
  startTime={startTime}
313
  onClose={onClosePlayer}
314
  onProgressUpdate={onProgressUpdate}
 
3
  import { useToast } from '@/hooks/use-toast';
4
  import { Film, GalleryVerticalEnd, Loader2, Play } from 'lucide-react';
5
  import VideoPlayer from './VideoPlayer';
6
+ import { TvShowCardData } from './ContentCard';
7
 
8
  interface ProgressData {
9
  status: string;
 
24
  episode: string;
25
  movieTitle: string;
26
  contentRatings?: ContentRating[];
 
27
  poster?: string;
28
  startTime?: number;
29
  onClosePlayer?: () => void;
 
38
  episode,
39
  movieTitle,
40
  contentRatings,
 
41
  poster,
42
  startTime = 0,
43
  onClosePlayer,
 
50
  const [error, setError] = useState<string | null>(null);
51
  const [progress, setProgress] = useState<ProgressData | null>(null);
52
  const [videoFetched, setVideoFetched] = useState(false);
53
+ const [showData, setShowData] = useState<TvShowCardData | null>(null);
54
+ const [selectedImage, setSelectedImage] = useState<string>();
55
+ const [imageLoaded, setImageLoaded] = useState(false);
56
+ const [ratingInfo, setRatingInfo] = useState<{ rating: string; description: string } | null>(null);
57
+ const containerRef = useRef<HTMLDivElement>(null);
58
+ const videoRef = useRef<HTMLVideoElement>(null);
59
+
60
  const { toast } = useToast();
 
61
  const pollingInterval = useRef<NodeJS.Timeout | null>(null);
62
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
63
  const videoFetchedRef = useRef(false);
64
+
65
+ // Reset imageLoaded whenever we pick a new image
66
+ useEffect(() => {
67
+ setImageLoaded(false);
68
+ }, [selectedImage]);
69
 
70
  // Parse episode info
71
  const getEpisodeInfo = () => {
72
  if (!episode) return { number: '1', title: 'Unknown Episode' };
73
+ const match = episode.match(/E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i);
74
+ return {
75
+ number: match ? match[1] : '1',
76
+ title: match ? match[2].trim() : 'Unknown Episode'
77
+ };
 
78
  };
 
79
  const { number: episodeNumber, title: episodeTitle } = getEpisodeInfo();
80
 
81
+ // Random image selector with fallback
82
+ const selectRandomImage = (cardData: TvShowCardData) => {
83
+ if (cardData.banner?.length) {
84
+ return cardData.banner[Math.floor(Math.random() * cardData.banner.length)].image;
85
+ }
86
+ if (cardData.portrait?.length) {
87
+ return cardData.portrait[Math.floor(Math.random() * cardData.portrait.length)].image;
88
+ }
89
+ return cardData.image;
90
+ };
91
+
92
+ // Fetch or poll for the video URL
93
  const fetchMovieLink = async () => {
94
  if (videoFetchedRef.current) return;
 
95
  try {
96
  const response = await getEpisodeLinkByTitle(videoTitle, season, episode);
97
+ if (response.url) {
98
+ pollingInterval.current && clearInterval(pollingInterval.current);
 
 
 
 
 
 
99
  setVideoUrl(response.url);
100
  setVideoFetched(true);
101
  videoFetchedRef.current = true;
102
  setLoading(false);
103
+ } else if (response.progress_url) {
104
+ const poll = async () => {
105
+ try {
106
+ const res = await fetch(response.progress_url);
107
+ const data = await res.json();
108
+ setProgress(data.progress);
109
+ if (data.progress.progress >= 100) {
110
+ pollingInterval.current && clearInterval(pollingInterval.current);
111
+ timeoutRef.current = setTimeout(fetchMovieLink, 5000);
112
+ }
113
+ } catch (e) {
114
+ console.error(e);
115
+ }
116
+ };
117
+ pollingInterval.current = setInterval(poll, 2000);
118
  } else {
119
+ throw new Error('No URL or progress URL');
 
120
  }
121
+ } catch (e) {
122
+ console.error(e);
123
  setError('Failed to load episode');
124
+ toast({ title: 'Error', description: 'Could not load the episode', variant: 'destructive' });
125
+ setLoading(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  }
127
  };
128
 
129
+ // Main init: fetch TV show card, then fetch link
130
  useEffect(() => {
131
  if (!videoTitle || !season || !episode) {
132
  setError('Missing required video information');
133
  setLoading(false);
134
  return;
135
  }
136
+
137
+ setLoading(true);
138
+ setError(null);
139
  setVideoUrl(null);
140
  setVideoFetched(false);
141
  videoFetchedRef.current = false;
 
142
  setProgress(null);
143
+
144
+ const init = async () => {
145
+ try {
146
+ const data = await getTvShowCard(videoTitle);
147
+ setShowData(data);
148
+ const img = selectRandomImage(data);
149
+ setSelectedImage(img);
150
+ const ratings = data.data?.contentRatings || contentRatings || [];
151
+ if (ratings.length) {
152
+ const us = ratings.find(r => r.country === 'usa') || ratings[0];
153
+ setRatingInfo({ rating: us.name || 'NR', description: us.description || '' });
154
+ }
155
+ } catch (e) {
156
+ console.error('Show card fetch error:', e);
157
+ setError('Failed to load show data');
158
+ toast({ title: 'Error', description: 'Could not load show data', variant: 'destructive' });
159
+ setLoading(false);
160
+ return;
161
+ }
162
+ await fetchMovieLink();
163
+ };
164
+
165
+ init();
166
+
167
  return () => {
168
+ pollingInterval.current && clearInterval(pollingInterval.current);
169
+ timeoutRef.current && clearTimeout(timeoutRef.current);
170
  };
171
  }, [videoTitle, season, episode]);
172
 
 
173
  useEffect(() => {
174
+ if (videoUrl) setLoading(false);
 
 
175
  }, [videoUrl]);
176
 
177
  if (error) {
 
182
  <p className="text-gray-400 mb-6">{error}</p>
183
  <button
184
  onClick={onClosePlayer}
185
+ className="px-6 py-2 bg-theme-primary hover:bg-theme-primary-hover rounded font-medium"
186
  >
187
  Back to Show
188
  </button>
 
192
 
193
  if (loading || !videoFetched || !videoUrl) {
194
  return (
195
+ <>
196
+ {/* Hero backdrop with fade-in */}
197
+ <div className="absolute top-0 left-0 w-full h-full z-50">
198
+ <div className="absolute inset-0">
199
+ <img
200
+ src={selectedImage}
201
+ onLoad={() => setImageLoaded(true)}
202
+ onError={(e) => {
203
+ const target = e.target as HTMLImageElement;
204
+ target.src = '/placeholder.svg';
205
+ }}
206
+ className={`w-full h-full object-cover transition-opacity duration-700 ease-in-out ${
207
+ imageLoaded ? 'opacity-100' : 'opacity-0'
208
+ }`}
209
+ />
210
+ <div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/50 to-transparent" />
211
+ <div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-netflix-black/40 to-transparent" />
212
+ </div>
213
+ </div>
214
+ <div className="fixed inset-0 z-50 flex flex-col items-center backdrop-blur-sm justify-center">
215
+ <div className="text-center max-w-md px-6">
216
+ <div className="mb-6 flex justify-center">
217
+ {poster ? (
218
+ <img src={poster} alt={movieTitle} className="h-auto w-24 rounded-lg shadow-lg" />
219
+ ) : (
220
+ <div className="flex items-center justify-center h-24 w-24 bg-theme-primary/20 rounded-lg">
221
+ <Play className="h-12 w-12 text-theme-primary" />
222
+ </div>
223
+ )}
224
+ </div>
225
+
226
+ <h2 className="text-2xl md:text-3xl font-bold text-white mb-4">
227
+ {progress && progress.progress < 100
228
+ ? `Preparing "${episodeTitle}"`
229
+ : `Loading "${episodeTitle}"`
230
+ }
231
+ </h2>
232
+
233
+ {progress ? (
234
+ <>
235
+ <p className="text-gray-300 mb-4">
236
+ {progress.progress < 5
237
+ ? 'Initializing your stream...'
238
+ : progress.progress < 100
239
+ ? 'Your stream is being prepared.'
240
+ : 'Almost ready! Starting playback soon...'}
241
+ </p>
242
+ <div className="relative w-full h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
243
+ <div
244
+ className="absolute top-0 left-0 h-full bg-gradient-to-r from-theme-primary to-theme-primary-light transition-all duration-300"
245
+ style={{ width: `${Math.min(100, Math.max(0, progress.progress))}%` }}
246
+ />
247
+ </div>
248
+ <p className="text-sm text-gray-400">
249
+ {Math.round(progress.progress)}% complete
250
+ </p>
251
+ </>
252
  ) : (
253
+ <div className="flex justify-center">
254
+ <Loader2 className="h-8 w-8 animate-spin text-theme-primary" />
255
  </div>
256
  )}
257
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  </div>
259
+ </>
260
  );
261
  }
262
 
 
263
  const tvShowOverlay = (
264
  <>
 
265
  <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">
266
  <div>
267
  <div className="flex items-center">
 
277
  <h1 className="text-white text-lg font-bold">{episodeTitle}</h1>
278
  </div>
279
  </div>
280
+
 
281
  <div className="absolute top-4 right-16 z-20">
282
  <button
283
  onClick={onShowEpisodes}
 
295
  <VideoPlayer
296
  url={videoUrl}
297
  title={`${videoTitle} - ${season}E${episodeNumber}`}
298
+ poster={selectedImage}
299
  startTime={startTime}
300
  onClose={onClosePlayer}
301
  onProgressUpdate={onProgressUpdate}
frontend/src/lib/storage.ts CHANGED
@@ -11,7 +11,6 @@ export interface WatchProgress {
11
 
12
  // Type for my list items
13
  export interface MyListItem {
14
- id: string;
15
  title: string;
16
  type: 'movie' | 'tvshow';
17
  addedAt: string;
 
11
 
12
  // Type for my list items
13
  export interface MyListItem {
 
14
  title: string;
15
  type: 'movie' | 'tvshow';
16
  addedAt: string;
frontend/src/pages/TvShowPlayerPage.tsx CHANGED
@@ -410,8 +410,6 @@ const TvShowPlayerPage = () => {
410
  episode={selectedEpisode}
411
  movieTitle={title || ''}
412
  contentRatings={showInfo?.data?.contentRatings || []}
413
- thumbnail={showInfo?.data?.image || showInfo?.data?.poster_path}
414
- poster={showInfo?.data?.poster_path}
415
  startTime={getStartTime()}
416
  onClosePlayer={handleBack}
417
  onProgressUpdate={handleProgressUpdate}
 
410
  episode={selectedEpisode}
411
  movieTitle={title || ''}
412
  contentRatings={showInfo?.data?.contentRatings || []}
 
 
413
  startTime={getStartTime()}
414
  onClosePlayer={handleBack}
415
  onProgressUpdate={handleProgressUpdate}