ChandimaPrabath commited on
Commit
5a8b2b7
·
1 Parent(s): 7d1bb2e

v0.2.4 beta - TvShow Player Updated

Browse files
frontend/app/browse/movies/page.tsx CHANGED
@@ -176,7 +176,7 @@ export default function MoviesPage() {
176
  const [filterActive, setFilterActive] = useState(false);
177
 
178
  return (
179
- <div className="page min-h-screen pt-20 pb-4">
180
  <div className="container mx-auto portrait:px-3 px-4">
181
  {/* Header Section */}
182
  <div className="mb-8 space-y-2">
 
176
  const [filterActive, setFilterActive] = useState(false);
177
 
178
  return (
179
+ <div className="page min-h-screen animate-fade pt-20 pb-4">
180
  <div className="container mx-auto portrait:px-3 px-4">
181
  {/* Header Section */}
182
  <div className="mb-8 space-y-2">
frontend/app/browse/tvshows/page.tsx CHANGED
@@ -176,7 +176,7 @@ export default function TvShowsPage() {
176
  const [filterActive, setFilterActive] = useState(false);
177
 
178
  return (
179
- <div className="page min-h-screen pt-20 pb-4">
180
  <div className="container mx-auto portrait:px-3 px-4">
181
  {/* Header Section */}
182
  <div className="mb-8 space-y-2">
 
176
  const [filterActive, setFilterActive] = useState(false);
177
 
178
  return (
179
+ <div className="page min-h-screen animate-fade pt-20 pb-4">
180
  <div className="container mx-auto portrait:px-3 px-4">
181
  {/* Header Section */}
182
  <div className="mb-8 space-y-2">
frontend/app/details/movie/[title]/page.tsx CHANGED
@@ -44,7 +44,7 @@ export default function MovieTitlePage() {
44
 
45
  return (
46
  <div
47
- className="page relative w-full min-h-screen h-full flex items-start landscape:pt-24 pt-28 text-white"
48
  style={{
49
  backgroundImage: `url(${movie.artworks?.[0]?.image || movie.image})`,
50
  backgroundSize: 'cover',
@@ -64,119 +64,105 @@ export default function MovieTitlePage() {
64
  <div className="lg:col-span-2 space-y-6">
65
  <div className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50">
66
  {/* Title and Year */}
67
- <div className="space-y-2">
68
- <h1 className="text-4xl font-bold text-white">
69
- {movie.translations?.nameTranslations?.find(
70
- (t: { language: string; name: string }) =>
71
- t.language === 'eng',
72
- )?.name ||
73
- movie.translations?.nameTranslations?.find(
74
- (t: {
75
- isAlias: boolean;
76
- language: string;
77
- name: string;
78
- }) => t.isAlias && t.language === 'eng',
79
  )?.name ||
80
- movie?.name ||
81
- 'Unknown Title'}
82
- </h1>
83
- <div className="flex items-center gap-3 text-gray-300">
84
- <span className="text-lg">{movie.year}</span>
85
- <span>•</span>
86
- <span className="bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded text-sm">
87
- Movie
88
- </span>
89
- </div>
90
- </div>
91
- {/* Genres and Score */}
92
- <div className="flex flex-wrap items-center gap-4 mt-4">
93
- <div className="flex items-center gap-2">
94
- <span className="bg-purple-500 text-white px-3 py-1 rounded-full text-sm font-medium">
95
- ⭐ {movie.score ? (movie.score / 1000).toFixed(1) : 'N/A'}
96
- </span>
97
- </div>
98
- <div className="flex flex-wrap gap-2">
99
- {Array.isArray(movie.genres)
100
- ? movie.genres.map((genre) => (
101
- <Link
102
- key={genre.id}
103
- href={`/genre/${encodeURIComponent(genre.slug)}`}
104
- className="bg-gray-700/50 hover:bg-gray-600/50 text-gray-200 px-3 py-1 rounded-full text-sm transition-colors"
105
- >
106
- {genre.name}
107
- </Link>
108
- ))
109
- : null}
110
- </div>
111
- </div>
112
- {/* Content Ratings */}
113
- {Array.isArray(movie.contentRatings) &&
114
- movie.contentRatings.length > 0 ? (
115
- <div className="mt-6">
116
- <h3 className="text-lg font-semibold text-gray-200 mb-3">
117
- Content Ratings
118
- </h3>
119
- <ul className="flex flex-wrap gap-3">
120
- {movie.contentRatings.map((rating, index) => {
121
- const countryFlags = {
122
- AUS: '🇦🇺',
123
- USA: '🇺🇸',
124
- GBR: '🇬🇧',
125
- EU: '🇪🇺',
126
- JPN: '🇯🇵',
127
- KOR: '🇰🇷',
128
- CAN: '🇨🇦',
129
- DEU: '🇩🇪',
130
- FRA: '🇫🇷',
131
- ESP: '🇪🇸',
132
- ITA: '🇮🇹',
133
- };
134
-
135
- const flag =
136
- countryFlags[
137
- rating.country.toUpperCase() as keyof typeof countryFlags
138
- ] || '🌍';
139
 
140
- return (
141
- <li
142
- key={index}
143
- className="flex items-center bg-gray-800/50 px-2 py-1 rounded-md text-xs"
144
- >
145
- <span className="mr-2">{flag}</span>
146
- <span className="text-white font-semibold">
147
- {rating.name}
148
- </span>
149
- <span className="text-gray-400 ml-1">
150
- {' '}
151
- - {rating.description || 'N/A'}
152
- </span>
153
- </li>
154
- );
155
- })}
156
- </ul>
157
- </div>
158
- ) : (
159
- <p className="text-gray-400 text-sm">
160
- No content ratings available.
161
- </p>
162
- )}
163
-
164
- {/* Action Buttons */}
165
- <div className="flex justify-start landscape:gap-2 mt-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  <Link
167
  href={`/watch/movie/${encodeURIComponent(decodedTitle)}`}
168
- className="bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-4 py-2 md:px-8 landscape:rounded-3xl rounded-s-2xl flex items-center gap-1 transition-colors text-sm md:text-base"
169
  >
170
  <PlayIcon className="size-5" />
171
  Play Now
172
  </Link>
173
  <Link
174
  href="#"
175
- className="bg-gray-800/80 hover:bg-gray-700/80 px-4 py-2 md:px-8 landscape:rounded-3xl rounded-e-2xl transition-colors text-sm md:text-base"
176
  >
177
  Add to My List
178
  </Link>
179
  </div>
 
 
 
 
 
 
 
 
180
  </div>
181
  {/* Overview Section */}
182
  <div className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50">
 
44
 
45
  return (
46
  <div
47
+ className="page relative animate-fade w-full min-h-screen h-full flex items-start landscape:pt-24 pt-28 text-white"
48
  style={{
49
  backgroundImage: `url(${movie.artworks?.[0]?.image || movie.image})`,
50
  backgroundSize: 'cover',
 
64
  <div className="lg:col-span-2 space-y-6">
65
  <div className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50">
66
  {/* Title and Year */}
67
+ <div className="flex gap-6 justify-between">
68
+ <div className="space-y-2 flex flex-col justify-around">
69
+ <h1 className="text-4xl portrait:text-2xl font-bold text-white">
70
+ {movie.translations?.nameTranslations?.find(
71
+ (t: { language: string; name: string }) =>
72
+ t.language === 'eng',
 
 
 
 
 
 
73
  )?.name ||
74
+ movie.translations?.nameTranslations?.find(
75
+ (t: {
76
+ isAlias: boolean;
77
+ language: string;
78
+ name: string;
79
+ }) => t.isAlias && t.language === 'eng',
80
+ )?.name ||
81
+ movie?.name ||
82
+ 'Unknown Title'}
83
+ </h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
+ <div className="flex items-center gap-3 text-gray-300">
86
+ <span className="text-lg">{movie.year}</span>
87
+ <span>•</span>
88
+ <span className="bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded text-sm">
89
+ Movie
90
+ </span>
91
+ </div>
92
+ {/* Genres and Score */}
93
+ <div className="flex flex-wrap items-center gap-4 mt-4">
94
+ <div className="flex items-center gap-2">
95
+ <span className="bg-purple-500 text-white px-3 py-1 rounded-full text-sm font-medium">
96
+ {' '}
97
+ {movie.score
98
+ ? (movie.score / 1000).toFixed(1)
99
+ : 'N/A'}
100
+ </span>
101
+ </div>
102
+ <div className="flex flex-wrap gap-2">
103
+ {Array.isArray(movie.genres)
104
+ ? movie.genres.map((genre) => (
105
+ <Link
106
+ key={genre.id}
107
+ href={`/genre/${encodeURIComponent(genre.slug)}`}
108
+ className="bg-gray-700/50 hover:bg-gray-600/50 text-gray-200 px-3 py-1 rounded-full text-sm transition-colors"
109
+ >
110
+ {genre.name}
111
+ </Link>
112
+ ))
113
+ : null}
114
+ </div>
115
+ </div>
116
+ {/* Content Ratings */}
117
+ {Array.isArray(movie.contentRatings) &&
118
+ movie.contentRatings.length > 0 ? (
119
+ <div className="mt-6">
120
+ <ul className="flex flex-wrap gap-3">
121
+ {movie.contentRatings.slice(0, 1).map((rating, index) => {
122
+ return (
123
+ <li
124
+ key={index}
125
+ className="flex items-center bg-gray-800/50 px-2 py-1 rounded-md text-xs"
126
+ >
127
+ <span className="text-white font-semibold">
128
+ {rating.name}
129
+ </span>
130
+ <span className="text-gray-400 ml-1">
131
+ {' '}
132
+ - {rating.description || 'N/A'}
133
+ </span>
134
+ </li>
135
+ );
136
+ })}
137
+ </ul>
138
+ </div>
139
+ ) : (
140
+ ''
141
+ )}
142
+ {/* Action Buttons */}
143
+ <div className="flex portrait:flex-col justify-start landscape:gap-2 mt-4">
144
  <Link
145
  href={`/watch/movie/${encodeURIComponent(decodedTitle)}`}
146
+ className="bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-4 py-1 md:px-8 landscape:rounded-3xl rounded-s-2xl flex items-center gap-1 transition-colors text-sm md:text-base"
147
  >
148
  <PlayIcon className="size-5" />
149
  Play Now
150
  </Link>
151
  <Link
152
  href="#"
153
+ className="bg-gray-800/80 hover:bg-gray-700/80 px-8 py-1 md:py-2-8 landscape:rounded-3xl rounded-e-2xl transition-colors text-sm md:text-base"
154
  >
155
  Add to My List
156
  </Link>
157
  </div>
158
+ </div>
159
+ <img
160
+ src={movie.image}
161
+ alt={movie.name}
162
+ className="rounded-2xl h-64 w-auto"
163
+ />
164
+ </div>
165
+
166
  </div>
167
  {/* Overview Section */}
168
  <div className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50">
frontend/app/details/tvshow/[title]/page.tsx CHANGED
@@ -52,7 +52,7 @@ export default function TvShowTitlePage() {
52
 
53
  return (
54
  <div
55
- className="page relative w-full min-h-screen h-full flex items-start landscape:pt-24 pt-28 text-white"
56
  style={{
57
  backgroundImage: `url(${tvshow.artworks?.[0]?.image || tvshow.image})`,
58
  backgroundSize: 'cover',
 
52
 
53
  return (
54
  <div
55
+ className="page relative animate-fade w-full min-h-screen h-full flex items-start landscape:pt-24 pt-28 text-white"
56
  style={{
57
  backgroundImage: `url(${tvshow.artworks?.[0]?.image || tvshow.image})`,
58
  backgroundSize: 'cover',
frontend/app/mylist/page.tsx CHANGED
@@ -12,5 +12,5 @@ export default function MyListPage() {
12
  if (spinnerLoading === false) {
13
  setSpinnerLoading(true);
14
  }
15
- return <div></div>;
16
  }
 
12
  if (spinnerLoading === false) {
13
  setSpinnerLoading(true);
14
  }
15
+ return <div className='animate-fade'></div>;
16
  }
frontend/app/page.tsx CHANGED
@@ -6,6 +6,7 @@ import { getRecentItems } from '@lib/lb';
6
  import Link from 'next/link';
7
  import { useLoading } from '@/components/loading/SplashScreen';
8
  import { InformationCircleIcon, PlayIcon } from '@heroicons/react/24/outline';
 
9
 
10
  interface Slide {
11
  image: string;
@@ -48,7 +49,7 @@ export default function Page() {
48
  }, [slides]);
49
 
50
  return (
51
- <div className="page">
52
  {/* Hero Slideshow */}
53
  <div className="relative landscape:h-[100vh] portrait:h-[80vh]">
54
  {/* Loading Skeleton */}
@@ -116,8 +117,8 @@ export default function Page() {
116
  <Link
117
  href={
118
  slide.type === 'movie'
119
- ? `/watch/movie/${slide.title}`
120
- : `/watch/tvshow/${slide.title}`
121
  }
122
  className="bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-4 landscape:py-2 py-2 md:px-8 md:py-3 landscape:rounded-3xl rounded-s-2xl flex flex-row gap-1 items-center transition-colors text-sm md:text-base"
123
  >
@@ -127,8 +128,8 @@ export default function Page() {
127
  <Link
128
  href={
129
  slide.type === 'movie'
130
- ? `/details/movie/${slide.title}`
131
- : `/details/tvshow/${slide.title}`
132
  }
133
  className="bg-gray-800/80 hover:bg-gray-700/80 px-4 landscape:py-2 py-2 md:px-8 md:py-3 landscape:rounded-3xl rounded-e-2xl transition-colors text-sm md:text-base flex flex-row gap-1"
134
  >
 
6
  import Link from 'next/link';
7
  import { useLoading } from '@/components/loading/SplashScreen';
8
  import { InformationCircleIcon, PlayIcon } from '@heroicons/react/24/outline';
9
+ import { MOVIE_WATCH_RUOTE, TVSHOW_WATCH_ROUTE, MOVIE_DETAILS_ROUTE, TVSHOW_DETAILS_ROUTE } from '@/lib/config';
10
 
11
  interface Slide {
12
  image: string;
 
49
  }, [slides]);
50
 
51
  return (
52
+ <div className="page animate-fade">
53
  {/* Hero Slideshow */}
54
  <div className="relative landscape:h-[100vh] portrait:h-[80vh]">
55
  {/* Loading Skeleton */}
 
117
  <Link
118
  href={
119
  slide.type === 'movie'
120
+ ? `${MOVIE_WATCH_RUOTE}/${slide.title}`
121
+ : `${TVSHOW_WATCH_ROUTE}${slide.title}`
122
  }
123
  className="bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-4 landscape:py-2 py-2 md:px-8 md:py-3 landscape:rounded-3xl rounded-s-2xl flex flex-row gap-1 items-center transition-colors text-sm md:text-base"
124
  >
 
128
  <Link
129
  href={
130
  slide.type === 'movie'
131
+ ? `${MOVIE_DETAILS_ROUTE}/${slide.title}`
132
+ : `${TVSHOW_DETAILS_ROUTE}/${slide.title}`
133
  }
134
  className="bg-gray-800/80 hover:bg-gray-700/80 px-4 landscape:py-2 py-2 md:px-8 md:py-3 landscape:rounded-3xl rounded-e-2xl transition-colors text-sm md:text-base flex flex-row gap-1"
135
  >
frontend/app/search/page.tsx CHANGED
@@ -8,6 +8,7 @@ import { TvShowCard } from '@/components/tvshow/TvShowCard';
8
  import ScrollSection from '@/components/sections/ScrollSection';
9
  import { getSeasonMetadata } from '@/lib/lb';
10
  import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
 
11
 
12
  type EpisodeMetadata = {
13
  id: number;
@@ -180,11 +181,11 @@ export default function SearchPage() {
180
  const flattenedEpisodes = results?.episodes ?? [];
181
 
182
  return (
183
- <div className="page min-h-screen pt-20 pb-4">
184
  <div className="container mx-auto portrait:px-3 px-4">
185
  {/* Header Section */}
186
  <div className="mb-8 space-y-2">
187
- <h2 className="text-4xl font-bold bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent">
188
  Search
189
  </h2>
190
  <p className="text-gray-400 max-w-3xl">
@@ -230,7 +231,7 @@ export default function SearchPage() {
230
  key={type}
231
  type="button"
232
  onClick={() => toggleType(type)}
233
- className={`px-5 py-2 rounded-full transition-all duration-300 font-medium
234
  ${
235
  isActive
236
  ? 'bg-gradient-to-r from-violet-600 to-purple-500 hover:from-violet-500 hover:to-purple-400 text-white shadow-lg shadow-purple-500/20'
@@ -307,7 +308,7 @@ export default function SearchPage() {
307
  <ScrollSection title="Episodes" link="/browse/tvshows">
308
  {flattenedEpisodes.map((episode) => (
309
  <Link
310
- href={`/watch/tvshow/${episode.series}/${episode.season}/${episode.title}`}
311
  key={episode.path}
312
  className="w-[180px] flex-shrink-0 rounded-md overflow-hidden bg-gray-800/60 backdrop-blur-sm
313
  border border-gray-700/50 hover:border-purple-500/50 transition-all duration-300
 
8
  import ScrollSection from '@/components/sections/ScrollSection';
9
  import { getSeasonMetadata } from '@/lib/lb';
10
  import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
11
+ import { TVSHOW_WATCH_ROUTE } from '@/lib/config';
12
 
13
  type EpisodeMetadata = {
14
  id: number;
 
181
  const flattenedEpisodes = results?.episodes ?? [];
182
 
183
  return (
184
+ <div className="page animate-fade min-h-screen pt-20 pb-4">
185
  <div className="container mx-auto portrait:px-3 px-4">
186
  {/* Header Section */}
187
  <div className="mb-8 space-y-2">
188
+ <h2 className="text-4xl font-bold text-gray-200">
189
  Search
190
  </h2>
191
  <p className="text-gray-400 max-w-3xl">
 
231
  key={type}
232
  type="button"
233
  onClick={() => toggleType(type)}
234
+ className={`px-5 py-1 font-extralight rounded-full transition-all duration-300 font-medium
235
  ${
236
  isActive
237
  ? 'bg-gradient-to-r from-violet-600 to-purple-500 hover:from-violet-500 hover:to-purple-400 text-white shadow-lg shadow-purple-500/20'
 
308
  <ScrollSection title="Episodes" link="/browse/tvshows">
309
  {flattenedEpisodes.map((episode) => (
310
  <Link
311
+ href={`${TVSHOW_WATCH_ROUTE}/${episode.series}/${episode.season}/${episode.title}`}
312
  key={episode.path}
313
  className="w-[180px] flex-shrink-0 rounded-md overflow-hidden bg-gray-800/60 backdrop-blur-sm
314
  border border-gray-700/50 hover:border-purple-500/50 transition-all duration-300
frontend/app/watch/movie/[title]/page.tsx CHANGED
@@ -37,8 +37,15 @@ const MoviePlayerPage = () => {
37
  const videoTitle = Array.isArray(title) ? title[0] : title;
38
 
39
  const handleClose = () => {
40
- router.back();
 
 
 
 
 
 
41
  };
 
42
 
43
  const [movie, setMovie] = useState<Movie | null>(null);
44
 
 
37
  const videoTitle = Array.isArray(title) ? title[0] : title;
38
 
39
  const handleClose = () => {
40
+ if (window.history.length > 1) {
41
+ // If the browser history length is greater than 1, the user has navigated from another page
42
+ router.back();
43
+ } else {
44
+ // Otherwise, push to the homepage
45
+ router.push('/');
46
+ }
47
  };
48
+
49
 
50
  const [movie, setMovie] = useState<Movie | null>(null);
51
 
frontend/app/watch/tvshow/[title]/[season]/[episode]/page.tsx CHANGED
@@ -1,14 +1,16 @@
1
  'use client';
2
 
3
  import { useRouter } from 'next/navigation';
4
- import TvShowPlayer from '@/components/tvshow/TvshowPlayer';
5
  import { useParams } from 'next/navigation';
 
 
6
  const TvShowPlayerPage = () => {
7
  // Assuming you get the movie title from the route params
8
  const router = useRouter();
9
  const { title, season, episode } = useParams();
10
  const videoTitle = Array.isArray(title) ? title[0] : title;
11
-
12
  const handleClose = () => {
13
  router.back();
14
  };
@@ -16,11 +18,44 @@ const TvShowPlayerPage = () => {
16
  if (!title || !season || !episode) {
17
  return <div>tvshow title, season or episode is missing.</div>;
18
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  return (
20
  <TvShowPlayer
21
  videoTitle={decodeURIComponent(videoTitle) as string}
22
  season={season as string}
23
  episode={episode as string}
 
24
  onClosePlayer={handleClose}
25
  />
26
  );
 
1
  'use client';
2
 
3
  import { useRouter } from 'next/navigation';
4
+ import TvShowPlayer from '@/components/tvshow/TvShowPlayerv2';
5
  import { useParams } from 'next/navigation';
6
+ import { useEffect, useState } from 'react';
7
+ import { getTvShowMetadata } from '@/lib/lb';
8
  const TvShowPlayerPage = () => {
9
  // Assuming you get the movie title from the route params
10
  const router = useRouter();
11
  const { title, season, episode } = useParams();
12
  const videoTitle = Array.isArray(title) ? title[0] : title;
13
+ const decodedTitle = decodeURIComponent(videoTitle);
14
  const handleClose = () => {
15
  router.back();
16
  };
 
18
  if (!title || !season || !episode) {
19
  return <div>tvshow title, season or episode is missing.</div>;
20
  }
21
+ interface TvShow {
22
+ name: string;
23
+ image: string;
24
+ translations?: any;
25
+ year?: number;
26
+ genres?: any[];
27
+ score?: number;
28
+ contentRatings?: any[];
29
+ characters?: any;
30
+ artworks?: any[];
31
+ file_structure?: any;
32
+ }
33
+
34
+ interface FileStructure {
35
+ contents?: any[];
36
+ }
37
+
38
+ const [tvshow, setTvShow] = useState<TvShow | null>(null);
39
+ const [fileStructure, setFileStructure] = useState<FileStructure>({});
40
+
41
+ useEffect(() => {
42
+ async function fetchMovie() {
43
+ try {
44
+ const data = await getTvShowMetadata(decodedTitle);
45
+ setTvShow(data.data);
46
+ setFileStructure(data.file_structure);
47
+ } catch (error) {
48
+ console.error('Failed to fetch movie details', error);
49
+ }
50
+ }
51
+ fetchMovie();
52
+ }, [decodedTitle]);
53
  return (
54
  <TvShowPlayer
55
  videoTitle={decodeURIComponent(videoTitle) as string}
56
  season={season as string}
57
  episode={episode as string}
58
+ fileStructure={fileStructure}
59
  onClosePlayer={handleClose}
60
  />
61
  );
frontend/components/movie/MovieCard.tsx CHANGED
@@ -12,6 +12,7 @@ import {
12
  import Link from 'next/link';
13
  import { StarIcon } from '@heroicons/react/24/solid';
14
  import TrailersComp from '@/components/shared/Trailers';
 
15
 
16
  interface Artwork {
17
  id: number;
@@ -393,7 +394,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ title }) => {
393
  {card.overview || 'No overview available.'}
394
  </div>
395
  <div className="flex flex-row items-center" data-oid="-3dgeqz">
396
- <Link href={`/details/movie/${title}`} data-oid=":xrx9h3">
397
  <button
398
  className="text-white bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-4 py-2 rounded-3xl flex items-center text-sm md:text-base transition-all duration-750 ease-in-out gap-0.5"
399
  data-oid="x3ov0_d"
@@ -413,7 +414,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ title }) => {
413
  <>
414
  <div
415
  ref={cardRef}
416
- className="relative block w-[fit-content] cursor-pointer"
417
  onMouseEnter={handleCardMouseEnter}
418
  onMouseLeave={handleCardMouseLeave}
419
  onClick={handleCardClick}
@@ -437,9 +438,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ title }) => {
437
  <img
438
  src={cardImage}
439
  alt={card?.title || title}
440
- className={`w-full h-full object-cover transition-opacity ease-in-out duration-300 ${
441
- imageLoaded ? 'opacity-100' : 'opacity-0'
442
- }`}
443
  onLoad={() => setImageLoaded(true)}
444
  data-oid="xg_ip5e"
445
  />
 
12
  import Link from 'next/link';
13
  import { StarIcon } from '@heroicons/react/24/solid';
14
  import TrailersComp from '@/components/shared/Trailers';
15
+ import { MOVIE_DETAILS_ROUTE } from '@/lib/config';
16
 
17
  interface Artwork {
18
  id: number;
 
394
  {card.overview || 'No overview available.'}
395
  </div>
396
  <div className="flex flex-row items-center" data-oid="-3dgeqz">
397
+ <Link href={`${MOVIE_DETAILS_ROUTE}/${title}`} data-oid=":xrx9h3">
398
  <button
399
  className="text-white bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-4 py-2 rounded-3xl flex items-center text-sm md:text-base transition-all duration-750 ease-in-out gap-0.5"
400
  data-oid="x3ov0_d"
 
414
  <>
415
  <div
416
  ref={cardRef}
417
+ className="relative block w-[fit-content] cursor-pointer animate-fade"
418
  onMouseEnter={handleCardMouseEnter}
419
  onMouseLeave={handleCardMouseLeave}
420
  onClick={handleCardClick}
 
438
  <img
439
  src={cardImage}
440
  alt={card?.title || title}
441
+ className={`w-full h-full animate-fade object-cover transition-opacity ease-in-out duration-300 `}
 
 
442
  onLoad={() => setImageLoaded(true)}
443
  data-oid="xg_ip5e"
444
  />
frontend/components/sections/EpisodesSection.tsx CHANGED
@@ -71,6 +71,11 @@ export default function EpisodesSection({
71
  <div className="space-y-2">
72
  {activeSeasonContent.contents?.map(
73
  (episode: any, episodeIdx: number) => {
 
 
 
 
 
74
  const match = episode.path.match(
75
  /[S](\d+)[E](\d+) - (.+?)\./,
76
  );
 
71
  <div className="space-y-2">
72
  {activeSeasonContent.contents?.map(
73
  (episode: any, episodeIdx: number) => {
74
+ // Skip subtitle files
75
+ if (episode.path.endsWith('.srt') || episode.path.endsWith('.vtt')) {
76
+ return null;
77
+ }
78
+
79
  const match = episode.path.match(
80
  /[S](\d+)[E](\d+) - (.+?)\./,
81
  );
frontend/components/tvshow/EpisodeQueueIcon.tsx ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import Link from 'next/link';
3
+ import { XCircleIcon } from '@heroicons/react/24/outline';
4
+ import { AnimatePresence, motion } from 'framer-motion';
5
+ import { RectangleStackIcon } from '@heroicons/react/16/solid';
6
+
7
+ interface Episode {
8
+ episodeNum: string;
9
+ episodeTitle: string;
10
+ filePath: string;
11
+ isSpecial: boolean;
12
+ }
13
+
14
+ interface SeasonWithEpisodes {
15
+ seasonName: string;
16
+ episodes: Episode[];
17
+ }
18
+
19
+ interface PlayQueueModalProps {
20
+ seasons: SeasonWithEpisodes[];
21
+ currentlyPlaying: string; // The filePath or episodeNum of the currently playing episode
22
+ onClose: () => void;
23
+ }
24
+
25
+ const PlayQueueModal: React.FC<PlayQueueModalProps> = ({ seasons, currentlyPlaying, onClose }) => {
26
+ const handleExitAnimationComplete = () => {
27
+ onClose(); // Close modal after exit animation completes
28
+ };
29
+
30
+ const getEpisodeClass = (episodeFilePath: string) => {
31
+ // Compare the current episode filePath with the playing one
32
+ return episodeFilePath === currentlyPlaying
33
+ ? 'bg-purple-600/50 text-white' // Highlight style for currently playing episode
34
+ : 'hover:bg-gray-800/50'; // Default style for other episodes
35
+ };
36
+
37
+ return (
38
+ <AnimatePresence>
39
+ <motion.div
40
+ className="absolute top-5 right-0 backdrop-blur-md bg-gray-900/80 text-white rounded-2xl md:w-[28rem] max-h-[80vh] flex flex-col"
41
+ initial={{ opacity: 0, scale: 0.9 }}
42
+ animate={{ opacity: 1, scale: 1 }}
43
+ exit={{ opacity: 0, scale: 0.9 }}
44
+ transition={{ duration: 0.3 }}
45
+ onAnimationComplete={(definition) => {
46
+ if (definition === 'exit') {
47
+ handleExitAnimationComplete(); // Close modal after exit animation completes
48
+ }
49
+ }}
50
+ >
51
+ {/* Header Section */}
52
+ <div className="p-4 bg-gray-900/50 rounded-2xl">
53
+ <div className="flex justify-between items-center mb-2">
54
+ <h3 className="text-2xl font-bold">Episodes</h3>
55
+ <button onClick={onClose}>
56
+ <XCircleIcon className="w-6 h-6 text-gray-300 hover:text-white transition-all" />
57
+ </button>
58
+ </div>
59
+ </div>
60
+
61
+ {/* Episodes Section (Scrollable) */}
62
+ <motion.div
63
+ className="space-y-4 overflow-y-scroll flex-grow p-4"
64
+ initial={{ opacity: 0 }}
65
+ animate={{ opacity: 1 }}
66
+ exit={{ opacity: 0 }}
67
+ transition={{ duration: 0.3, delay: 0.1 }}
68
+ >
69
+ {seasons.map((season) => (
70
+ <div key={season.seasonName}>
71
+ <h4 className="text-lg font-semibold text-gray-300">{season.seasonName}</h4>
72
+ {season.episodes.map((episode) => (
73
+ <Link
74
+ href={`/watch/tvshow/${episode.filePath}`}
75
+ key={episode.filePath}
76
+ passHref
77
+ >
78
+ <motion.div
79
+ className={`flex group items-center justify-between py-3 px-4 rounded-lg transition-colors ${getEpisodeClass(episode.filePath)}`}
80
+ whileHover={{ scale: 1.05 }}
81
+ transition={{ duration: 0.2 }}
82
+ >
83
+ {/* Episode Number and Title */}
84
+ <div className="flex items-center">
85
+ <span className="text-sm font-semibold text-gray-300 border-2 p-2 rounded-lg border-gray-600 group-hover:border-purple-700">
86
+ {episode.episodeNum}
87
+ </span>
88
+ <span className="ml-3 text-md font-medium text-gray-200 truncate w-80">
89
+ {episode.episodeTitle.replace(/_/g, ' ')}
90
+ </span>
91
+ </div>
92
+ </motion.div>
93
+ </Link>
94
+ ))}
95
+ </div>
96
+ ))}
97
+ </motion.div>
98
+ </motion.div>
99
+ </AnimatePresence>
100
+ );
101
+ };
102
+
103
+ interface EpisodeQueueIconProps {
104
+ episodes: any[];
105
+ currentlyPlaying: string; // Pass the filePath or episodeNum for the currently playing episode
106
+ }
107
+
108
+ const EpisodeQueueIcon: React.FC<EpisodeQueueIconProps> = ({ episodes, currentlyPlaying }) => {
109
+ const [isModalOpen, setIsModalOpen] = useState(false);
110
+
111
+ const openModal = () => {
112
+ setIsModalOpen(true);
113
+ };
114
+
115
+ const closeModal = () => {
116
+ setIsModalOpen(false);
117
+ };
118
+
119
+ const toggleModal = () => {
120
+ setIsModalOpen(!isModalOpen);
121
+ };
122
+
123
+ return (
124
+ <div className="flex items-center">
125
+ {episodes.length > 0 && (
126
+ <button onClick={toggleModal} className="text-white hover:text-purple-300 transition-colors p-2 rounded-full hover:bg-gray-900/30">
127
+ <RectangleStackIcon className="size-8" />
128
+ </button>
129
+ )}
130
+
131
+ {/* AnimatePresence ensures the modal exits smoothly */}
132
+ <AnimatePresence>
133
+ {isModalOpen && episodes.length > 0 && (
134
+ <PlayQueueModal
135
+ seasons={episodes} // Pass grouped seasons and episodes
136
+ onClose={closeModal}
137
+ currentlyPlaying={currentlyPlaying} // Pass the currently playing episode to highlight it
138
+ />
139
+ )}
140
+ </AnimatePresence>
141
+ </div>
142
+ );
143
+ };
144
+
145
+ export default EpisodeQueueIcon;
frontend/components/tvshow/TvShowCard.tsx CHANGED
@@ -12,6 +12,7 @@ import {
12
  import { StarIcon } from '@heroicons/react/24/solid';
13
  import Link from 'next/link';
14
  import TrailersComp from '@/components/shared/Trailers';
 
15
 
16
  interface Artwork {
17
  id: number;
@@ -389,7 +390,7 @@ export const TvShowCard: React.FC<TvShowCardProps> = ({ title, episodesCount = n
389
  </div>
390
  {/* View Details Button */}
391
  <div className="flex flex-row">
392
- <Link href={`/details/tvshow/${title}`}>
393
  <button className="text-white bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-4 py-2 rounded-3xl flex items-center text-sm md:text-base transition-all duration-750 ease-in-out gap-0.5">
394
  View Details
395
  <ChevronRightIcon className="size-4" />
 
12
  import { StarIcon } from '@heroicons/react/24/solid';
13
  import Link from 'next/link';
14
  import TrailersComp from '@/components/shared/Trailers';
15
+ import { TVSHOW_DETAILS_ROUTE } from '@/lib/config';
16
 
17
  interface Artwork {
18
  id: number;
 
390
  </div>
391
  {/* View Details Button */}
392
  <div className="flex flex-row">
393
+ <Link href={`${TVSHOW_DETAILS_ROUTE}/${title}`}>
394
  <button className="text-white bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-4 py-2 rounded-3xl flex items-center text-sm md:text-base transition-all duration-750 ease-in-out gap-0.5">
395
  View Details
396
  <ChevronRightIcon className="size-4" />
frontend/components/tvshow/TvShowPlayerv2.tsx ADDED
@@ -0,0 +1,918 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import {
3
+ PlayIcon,
4
+ PauseIcon,
5
+ SpeakerWaveIcon,
6
+ SpeakerXMarkIcon,
7
+ ArrowsPointingOutIcon,
8
+ ArrowsPointingInIcon,
9
+ ForwardIcon,
10
+ BackwardIcon,
11
+ QuestionMarkCircleIcon,
12
+ } from '@heroicons/react/24/solid';
13
+ import { XCircleIcon, FilmIcon, ArrowLeftStartOnRectangleIcon } from '@heroicons/react/24/outline';
14
+ import { getEpisodeLinkByTitle } from '@/lib/lb';
15
+ import { motion, AnimatePresence } from 'framer-motion';
16
+ import EpisodeQueueIcon from './EpisodeQueueIcon';
17
+ import { createPlayQueue } from './utils';
18
+ import { title } from 'process';
19
+
20
+ interface ContentRating {
21
+ country: string;
22
+ name: string;
23
+ description: string;
24
+ }
25
+
26
+ interface FileStructure {
27
+ contents?: any[];
28
+ }
29
+
30
+ interface MoviePlayerProps {
31
+ videoTitle: string;
32
+ season: string;
33
+ episode: string;
34
+ movieTitle?: string;
35
+ contentRatings?: ContentRating[];
36
+ thumbnail?: string;
37
+ fileStructure?: FileStructure;
38
+ poster?: string;
39
+ onClosePlayer?: () => void; // Optional close handler for page context
40
+ }
41
+
42
+ interface ProgressData {
43
+ status: string;
44
+ progress: number;
45
+ downloaded: number;
46
+ total: number;
47
+ }
48
+
49
+ // Keyboard shortcuts help component
50
+ const KeyboardShortcutsHelp: React.FC = () => {
51
+ const [showHelp, setShowHelp] = useState(false);
52
+ const shortcuts = [
53
+ { key: 'Space / K', action: 'Play/Pause' },
54
+ { key: 'F', action: 'Toggle Fullscreen' },
55
+ { key: 'M', action: 'Mute/Unmute' },
56
+ { key: '←', action: 'Rewind 10s' },
57
+ { key: '→', action: 'Forward 10s' },
58
+ { key: '↑', action: 'Volume Up' },
59
+ { key: '↓', action: 'Volume Down' },
60
+ { key: '0-9', action: 'Seek to 0-90%' },
61
+ ];
62
+
63
+ return (
64
+ <div className="relative">
65
+ <button
66
+ onClick={() => setShowHelp(!showHelp)}
67
+ className="text-white hover:text-purple-300 transition-colors p-2 rounded-full hover:bg-gray-900/30 text-xs"
68
+ title="Keyboard Shortcuts"
69
+ >
70
+ <QuestionMarkCircleIcon className="size-6" />
71
+ </button>
72
+ <AnimatePresence>
73
+ {showHelp && (
74
+ <motion.div
75
+ initial={{ opacity: 0, y: 10 }}
76
+ animate={{ opacity: 1, y: 0 }}
77
+ exit={{ opacity: 0, y: 10 }}
78
+ className="absolute bottom-full left-0 mb-2 p-3 bg-gray-900/90 backdrop-blur-sm rounded-lg text-white text-xs w-64 shadow-lg z-50"
79
+ >
80
+ <h4 className="font-bold mb-2 text-purple-400">Keyboard Shortcuts</h4>
81
+ <div className="grid grid-cols-2 gap-y-1.5">
82
+ {shortcuts.map((shortcut, index) => (
83
+ <React.Fragment key={index}>
84
+ <div className="font-medium">{shortcut.key}</div>
85
+ <div className="text-gray-300">{shortcut.action}</div>
86
+ </React.Fragment>
87
+ ))}
88
+ </div>
89
+ </motion.div>
90
+ )}
91
+ </AnimatePresence>
92
+ </div>
93
+ );
94
+ };
95
+
96
+ // Time preview component for the progress bar
97
+ const TimePreview: React.FC<{
98
+ duration: number;
99
+ progressBarRef: React.RefObject<HTMLDivElement>;
100
+ }> = ({ duration, progressBarRef }) => {
101
+ const [previewTime, setPreviewTime] = useState(0);
102
+ const [showPreview, setShowPreview] = useState(false);
103
+ const [previewPosition, setPreviewPosition] = useState(0);
104
+
105
+ // Listen for mousemove events on the document
106
+ useEffect(() => {
107
+ const handleMouseMove = (e: MouseEvent) => {
108
+ if (!progressBarRef.current || !duration) return;
109
+ const rect = progressBarRef.current.getBoundingClientRect();
110
+ // Check if mouse is over the progress bar
111
+ if (
112
+ e.clientX >= rect.left &&
113
+ e.clientX <= rect.right &&
114
+ e.clientY >= rect.top - 30 &&
115
+ e.clientY <= rect.bottom + 30
116
+ ) {
117
+ // Calculate position percentage
118
+ const position = (e.clientX - rect.left) / rect.width;
119
+ const time = position * duration;
120
+ setPreviewTime(time);
121
+ setPreviewPosition(position * 100);
122
+ setShowPreview(true);
123
+ } else {
124
+ setShowPreview(false);
125
+ }
126
+ };
127
+ document.addEventListener('mousemove', handleMouseMove);
128
+ return () => document.removeEventListener('mousemove', handleMouseMove);
129
+ }, [duration, progressBarRef]);
130
+
131
+ // Format the preview time
132
+ const formatTime = (time: number): string => {
133
+ const hours = Math.floor(time / 3600);
134
+ const minutes = Math.floor((time % 3600) / 60);
135
+ const seconds = Math.floor(time % 60);
136
+ const minutesStr = minutes.toString().padStart(2, '0');
137
+ const secondsStr = seconds.toString().padStart(2, '0');
138
+ return hours > 0 ? `${hours}:${minutesStr}:${secondsStr}` : `${minutesStr}:${secondsStr}`;
139
+ };
140
+
141
+ return (
142
+ <AnimatePresence>
143
+ {showPreview && (
144
+ <motion.div
145
+ initial={{ opacity: 0, y: 10 }}
146
+ animate={{ opacity: 1, y: 0 }}
147
+ exit={{ opacity: 0 }}
148
+ transition={{ duration: 0.15 }}
149
+ className="absolute bottom-full mb-2 px-2 py-1 bg-gray-900/80 backdrop-blur-sm text-white text-xs rounded pointer-events-none z-50"
150
+ style={{ left: `${previewPosition}%`, transform: 'translateX(-50%)' }}
151
+ >
152
+ {formatTime(previewTime)}
153
+ </motion.div>
154
+ )}
155
+ </AnimatePresence>
156
+ );
157
+ };
158
+
159
+ const IntegratedPlayerControls: React.FC<{
160
+ isPlaying: boolean;
161
+ loading: boolean;
162
+ togglePlay: () => void;
163
+ }> = ({ isPlaying, loading, togglePlay }) => (
164
+ <AnimatePresence mode="wait">
165
+ <motion.button
166
+ key="play-pause-button"
167
+ initial={{ scale: 0.8, opacity: 0 }}
168
+ animate={{ scale: 1, opacity: 1 }}
169
+ exit={{ scale: 1.2, opacity: 0 }}
170
+ transition={{ duration: 0.2 }}
171
+ onClick={togglePlay}
172
+ className="text-white bg-gray-900/30 backdrop-blur-sm p-4 rounded-full hover:bg-purple-500/20 transition-all pointer-events-auto relative w-20 h-20 flex items-center justify-center"
173
+ >
174
+ {loading && (
175
+ <motion.div
176
+ className="absolute inset-0 flex items-center justify-center"
177
+ style={{ zIndex: 1 }} // Ensure spinner stays behind the button
178
+ >
179
+ <motion.div
180
+ animate={{ rotate: 360 }}
181
+ transition={{ repeat: Infinity, duration: 1.2, ease: 'linear' }}
182
+ className="absolute inset-0 border-4 border-transparent border-t-purple-600 border-l-purple-600 rounded-full"
183
+ />
184
+ </motion.div>
185
+ )}
186
+
187
+ {/* Play/Pause Icon always visible */}
188
+ {isPlaying ? (
189
+ <PauseIcon className="w-20 h-20 z-10" />
190
+ ) : (
191
+ <PlayIcon className="w-20 h-20 z-10" />
192
+ )}
193
+ </motion.button>
194
+ </AnimatePresence>
195
+ );
196
+
197
+ // Ad component to show while fetching link with real progress
198
+ const AdPlaceholder: React.FC<{
199
+ title: string;
200
+ progress: ProgressData | null;
201
+ poster: string | undefined;
202
+ }> = ({ title, progress, poster }) => {
203
+ const progressPercent = progress ? Math.min(100, Math.max(0, progress.progress)) : 0;
204
+ return (
205
+ <div className="relative w-full h-full bg-gradient-to-br from-gray-900 to-gray-900 flex flex-col items-center justify-center overflow-hidden">
206
+ <div className="absolute inset-0 opacity-10">
207
+ <motion.div
208
+ className="absolute w-[500px] h-[500px] rounded-full bg-purple-500 filter blur-3xl"
209
+ animate={{ x: ['-20%', '120%'], y: ['30%', '60%'] }}
210
+ transition={{
211
+ repeat: Infinity,
212
+ repeatType: 'reverse',
213
+ duration: 15,
214
+ ease: 'easeInOut',
215
+ }}
216
+ />
217
+
218
+ <motion.div
219
+ className="absolute w-[300px] h-[300px] rounded-full bg-blue-500 filter blur-3xl"
220
+ animate={{ x: ['120%', '-20%'], y: ['60%', '20%'] }}
221
+ transition={{
222
+ repeat: Infinity,
223
+ repeatType: 'reverse',
224
+ duration: 18,
225
+ ease: 'easeInOut',
226
+ }}
227
+ />
228
+ </div>
229
+ <div className="z-10 text-center max-w-2xl px-6 py-10">
230
+ <motion.div
231
+ initial={{ scale: 0.9, opacity: 0 }}
232
+ animate={{ scale: 1, opacity: 1 }}
233
+ transition={{ duration: 0.5 }}
234
+ className="mb-6 flex justify-center"
235
+ >
236
+ {!poster ? (
237
+ <FilmIcon className="h-20 w-20 text-purple-500" />
238
+ ) : (
239
+ <img src={poster} alt={title} className="h-auto w-20 rounded-lg" />
240
+ )}
241
+ </motion.div>
242
+ <motion.h2
243
+ initial={{ y: 20, opacity: 0 }}
244
+ animate={{ y: 0, opacity: 1 }}
245
+ transition={{ delay: 0.2, duration: 0.6 }}
246
+ className="text-3xl md:text-4xl font-bold text-white mb-4"
247
+ >
248
+ Preparing &quot;{title}&quot;
249
+ </motion.h2>
250
+ <motion.div
251
+ initial={{ y: 20, opacity: 0 }}
252
+ animate={{ y: 0, opacity: 1 }}
253
+ transition={{ delay: 0.4, duration: 0.6 }}
254
+ >
255
+ <p className="text-gray-300 text-lg mb-8">
256
+ {progressPercent < 5
257
+ ? 'Initializing your stream...'
258
+ : progressPercent < 100
259
+ ? 'Your movie is being prepared for streaming.'
260
+ : 'Almost ready! Starting playback soon...'}
261
+ </p>
262
+ <div className="relative w-full h-3 bg-gray-800 rounded-full overflow-hidden">
263
+ <div
264
+ className="absolute top-0 left-0 h-full bg-gradient-to-r from-purple-600 to-blue-500 transition-all duration-300 ease-out"
265
+ style={{ width: `${progressPercent}%` }}
266
+ />
267
+
268
+ <div
269
+ className="absolute top-0 h-full w-8 bg-white/20 blur-sm transform -skew-x-12"
270
+ style={{
271
+ left: `calc(${progressPercent}% - 8px)`,
272
+ opacity: progressPercent > 0 && progressPercent < 100 ? 1 : 0,
273
+ }}
274
+ />
275
+ </div>
276
+ <div className="flex justify-between mt-2 text-sm text-gray-400">
277
+ <span>{progressPercent.toFixed(0)}% complete</span>
278
+ {progress && progress.downloaded && progress.total && (
279
+ <span className="text-gray-500">
280
+ {(progress.downloaded / (1024 * 1024)).toFixed(1)} MB /{' '}
281
+ {(progress.total / (1024 * 1024)).toFixed(1)} MB
282
+ </span>
283
+ )}
284
+ </div>
285
+ <p className="mt-6 text-purple-400 font-medium">
286
+ {' '}
287
+ Premium members enjoy faster streaming preparation{' '}
288
+ </p>
289
+ </motion.div>
290
+ </div>
291
+ </div>
292
+ );
293
+ };
294
+
295
+ const MoviePlayer: React.FC<MoviePlayerProps> = ({
296
+ videoTitle,
297
+ season,
298
+ episode,
299
+ movieTitle = videoTitle,
300
+ contentRatings = [],
301
+ thumbnail,
302
+ poster,
303
+ fileStructure,
304
+ onClosePlayer,
305
+ }) => {
306
+ // Extract season and episode info using regex
307
+ const episodeRegex = /.*S(\d+)E(\d+).*?-\s*(.*?)(?=\..*|$)/i;
308
+ const episodeMatch = episode.match(episodeRegex);
309
+ const seasonNumber = episodeMatch ? episodeMatch[1] : season;
310
+ const episodeNumber = episodeMatch ? episodeMatch[2] : '';
311
+ const episodeTitleClean = episodeMatch
312
+ ? decodeURIComponent(episodeMatch[3])
313
+ : decodeURIComponent(episode);
314
+ // Use local spinner state inside the player
315
+ const [localSpinnerLoading, setLocalSpinnerLoading] = useState(false);
316
+ // Refs
317
+ const containerRef = useRef<HTMLDivElement>(null);
318
+ const videoRef = useRef<HTMLVideoElement>(null);
319
+ const progressBarRef = useRef<HTMLDivElement>(null);
320
+ const inactivityRef = useRef<NodeJS.Timeout | null>(null);
321
+ // Video URL & blob state
322
+ const [videoUrl, setVideoUrl] = useState<string | null>(null);
323
+ const [videoBlobUrl, setVideoBlobUrl] = useState<string>('');
324
+ // Player UI states
325
+ const [showControls, setShowControls] = useState(true);
326
+ const [isPlaying, setIsPlaying] = useState(false);
327
+ const [isSeeking, setIsSeeking] = useState(false);
328
+ const [duration, setDuration] = useState(0);
329
+ const [currentTime, setCurrentTime] = useState(0);
330
+ const [volume, setVolume] = useState(1);
331
+ const [isFullscreen, setIsFullscreen] = useState(false);
332
+ const isMuted = volume === 0;
333
+ const [buffered, setBuffered] = useState(0);
334
+ // Link fetching states
335
+ const [progress, setProgress] = useState<ProgressData | null>(null);
336
+ const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(null);
337
+ const [videoFetched, setVideoFetched] = useState(false);
338
+ const videoFetchedRef = useRef(videoFetched);
339
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null);
340
+ const [episodes, setEpisodes] = useState<any[]>([]);
341
+ // Keep ref in sync
342
+ useEffect(() => {
343
+ videoFetchedRef.current = videoFetched;
344
+ }, [videoFetched]);
345
+
346
+ useEffect(() => {
347
+ if (fileStructure && season) {
348
+ console.log('current season', season);
349
+ const episodes = createPlayQueue(fileStructure);
350
+ setEpisodes(episodes);
351
+ console.log('Episodes:', episodes);
352
+ }
353
+ }, [fileStructure, season]);
354
+ // --- Link Fetching & Polling ---
355
+ const fetchMovieLink = async () => {
356
+ if (videoFetchedRef.current) return;
357
+ try {
358
+ const response = await getEpisodeLinkByTitle(videoTitle, season, episode);
359
+ if (response.url) {
360
+ // Stop any polling if running
361
+ if (pollingInterval) {
362
+ clearInterval(pollingInterval);
363
+ setPollingInterval(null);
364
+ }
365
+ setVideoUrl(response.url);
366
+ setVideoFetched(true);
367
+ console.log('Video URL fetched:', response.url);
368
+ } else if (response.progress_url) {
369
+ startPolling(response.progress_url);
370
+ }
371
+ } catch (error) {
372
+ console.error('Error fetching movie link:', error);
373
+ }
374
+ };
375
+
376
+ const pollProgress = async (progressUrl: string) => {
377
+ try {
378
+ const res = await fetch(progressUrl);
379
+ const data: { progress: ProgressData } = await res.json();
380
+ setProgress(data.progress);
381
+ if (data.progress.progress >= 100) {
382
+ if (pollingInterval) {
383
+ clearInterval(pollingInterval);
384
+ setPollingInterval(null);
385
+ }
386
+ if (!videoFetchedRef.current) {
387
+ timeoutRef.current = setTimeout(fetchMovieLink, 5000);
388
+ }
389
+ }
390
+ } catch (error) {
391
+ console.error('Error polling progress:', error);
392
+ }
393
+ };
394
+
395
+ const startPolling = (progressUrl: string) => {
396
+ if (!pollingInterval) {
397
+ const interval = setInterval(() => pollProgress(progressUrl), 2000);
398
+ setPollingInterval(interval);
399
+ }
400
+ };
401
+
402
+ useEffect(() => {
403
+ fetchMovieLink();
404
+ return () => {
405
+ if (pollingInterval) clearInterval(pollingInterval);
406
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
407
+ };
408
+ }, [videoTitle]);
409
+
410
+ // --- Player Control Helpers ---
411
+ const resetInactivityTimer = () => {
412
+ setShowControls(true);
413
+ if (inactivityRef.current) clearTimeout(inactivityRef.current);
414
+ if (!localSpinnerLoading && isPlaying) {
415
+ inactivityRef.current = setTimeout(() => setShowControls(false), 3000);
416
+ }
417
+ };
418
+
419
+ const updateBuffered = () => {
420
+ if (videoRef.current) {
421
+ const bufferedTime =
422
+ videoRef.current.buffered.length > 0
423
+ ? videoRef.current.buffered.end(videoRef.current.buffered.length - 1)
424
+ : 0;
425
+ setBuffered(bufferedTime);
426
+ }
427
+ };
428
+
429
+ useEffect(() => {
430
+ const interval = setInterval(updateBuffered, 1000);
431
+ return () => clearInterval(interval);
432
+ }, []);
433
+
434
+ // --- Fullscreen Listener ---
435
+ useEffect(() => {
436
+ const handleFullScreenChange = () => {
437
+ setIsFullscreen(document.fullscreenElement === containerRef.current);
438
+ };
439
+ document.addEventListener('fullscreenchange', handleFullScreenChange);
440
+ return () => document.removeEventListener('fullscreenchange', handleFullScreenChange);
441
+ }, []);
442
+
443
+ // --- Blob Fetching ---
444
+ useEffect(() => {
445
+ if (videoUrl) {
446
+ setLocalSpinnerLoading(true);
447
+ const abortController = new AbortController();
448
+ const fetchBlob = async () => {
449
+ try {
450
+ const response = await fetch(videoUrl, {
451
+ signal: abortController.signal,
452
+ mode: 'cors',
453
+ });
454
+ if (!response.ok) {
455
+ throw new Error(`HTTP error! status: ${response.status}`);
456
+ }
457
+ const blob = await response.blob();
458
+ const blobUrl = URL.createObjectURL(blob);
459
+ setVideoBlobUrl(blobUrl);
460
+ console.log('Blob URL created:', blobUrl);
461
+ setLocalSpinnerLoading(false);
462
+ } catch (error: any) {
463
+ if (error.name === 'AbortError') {
464
+ console.log('Blob fetch aborted.');
465
+ } else {
466
+ console.error('Error fetching video blob:', error);
467
+ console.error('Falling back to direct video URL.');
468
+ setVideoBlobUrl('');
469
+ setLocalSpinnerLoading(false);
470
+ }
471
+ }
472
+ };
473
+ fetchBlob();
474
+ return () => {
475
+ abortController.abort();
476
+ if (videoBlobUrl) {
477
+ URL.revokeObjectURL(videoBlobUrl);
478
+ console.log('Blob URL revoked:', videoBlobUrl);
479
+ }
480
+ setVideoBlobUrl('');
481
+ };
482
+ }
483
+ }, [videoUrl]);
484
+
485
+ // --- Reset Player on New URL ---
486
+ useEffect(() => {
487
+ setIsPlaying(false);
488
+ setCurrentTime(0);
489
+ setDuration(0);
490
+ setVolume(1);
491
+ setIsFullscreen(false);
492
+ resetInactivityTimer();
493
+ console.log(
494
+ 'Video player loaded using',
495
+ videoBlobUrl ? 'blob' : 'direct URL',
496
+ videoBlobUrl,
497
+ );
498
+ return () => {
499
+ if (inactivityRef.current) clearTimeout(inactivityRef.current);
500
+ };
501
+ }, [videoUrl]);
502
+
503
+ // --- Video Event Handlers ---
504
+ const handleLoadedMetadata = () => {
505
+ if (videoRef.current) {
506
+ setDuration(videoRef.current.duration);
507
+ }
508
+ };
509
+
510
+ const handleTimeUpdate = () => {
511
+ if (videoRef.current && !isSeeking) {
512
+ setCurrentTime(videoRef.current.currentTime);
513
+ updateBuffered();
514
+ }
515
+ };
516
+
517
+ const handleProgress = () => {
518
+ updateBuffered();
519
+ };
520
+
521
+ // Enhanced content rating overlay with Netflix-like animation - only shown once at start
522
+ const [showRatingOverlay, setShowRatingOverlay] = useState(false);
523
+ const [ratingShown, setRatingShown] = useState(false);
524
+
525
+ const triggerRatingOverlay = () => {
526
+ if (contentRatings.length > 0 && !ratingShown) {
527
+ setRatingShown(true);
528
+ setTimeout(() => {
529
+ setShowRatingOverlay(true);
530
+ setTimeout(() => setShowRatingOverlay(false), 8000);
531
+ }, 800);
532
+ }
533
+ };
534
+
535
+ const handlePlay = () => {
536
+ setIsPlaying(true);
537
+ setLocalSpinnerLoading(false);
538
+ triggerRatingOverlay();
539
+ };
540
+
541
+ const handlePause = () => {
542
+ setIsPlaying(false);
543
+ };
544
+
545
+ const togglePlay = () => {
546
+ if (!videoRef.current) return;
547
+ videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause();
548
+ };
549
+
550
+ const handleSeekStart = () => setIsSeeking(true);
551
+ const handleSeekEnd = (e: React.ChangeEvent<HTMLInputElement>) => {
552
+ const newTime = Number(e.target.value);
553
+ if (videoRef.current) videoRef.current.currentTime = newTime;
554
+ setCurrentTime(newTime);
555
+ setIsSeeking(false);
556
+ };
557
+
558
+ // Handle hover over the progress bar for time preview
559
+ const handleSeekHover = (e: React.MouseEvent<HTMLInputElement>) => {
560
+ // This is just to ensure the event handler exists
561
+ // The actual preview is handled by the TimePreview component
562
+ };
563
+ const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
564
+ const newVolume = Number(e.target.value);
565
+ setVolume(newVolume);
566
+ if (videoRef.current) videoRef.current.volume = newVolume;
567
+ };
568
+
569
+ const toggleMute = () => {
570
+ if (!videoRef.current) return;
571
+ if (volume > 0) {
572
+ setVolume(0);
573
+ videoRef.current.volume = 0;
574
+ } else {
575
+ setVolume(1);
576
+ videoRef.current.volume = 1;
577
+ }
578
+ };
579
+
580
+ const toggleFullscreen = async () => {
581
+ if (!containerRef.current) return;
582
+ if (!isFullscreen) {
583
+ await containerRef.current.requestFullscreen?.();
584
+ setIsFullscreen(true);
585
+ } else {
586
+ await document.exitFullscreen?.();
587
+ setIsFullscreen(false);
588
+ }
589
+ };
590
+
591
+ const handleClose = () => {
592
+ onClosePlayer && onClosePlayer();
593
+ };
594
+
595
+ // Keyboard controls
596
+ useEffect(() => {
597
+ const handleKeyDown = (e: KeyboardEvent) => {
598
+ if (!videoRef.current) return;
599
+ switch (e.key.toLowerCase()) {
600
+ case ' ':
601
+ case 'k':
602
+ e.preventDefault();
603
+ togglePlay();
604
+ break;
605
+ case 'f':
606
+ e.preventDefault();
607
+ toggleFullscreen();
608
+ break;
609
+ case 'm':
610
+ e.preventDefault();
611
+ toggleMute();
612
+ break;
613
+ case 'arrowright':
614
+ e.preventDefault();
615
+ videoRef.current.currentTime = Math.min(duration, currentTime + 10);
616
+ break;
617
+ case 'arrowleft':
618
+ e.preventDefault();
619
+ videoRef.current.currentTime = Math.max(0, currentTime - 10);
620
+ break;
621
+ case 'arrowup':
622
+ e.preventDefault();
623
+ const newVolUp = Math.min(1, volume + 0.1);
624
+ setVolume(newVolUp);
625
+ videoRef.current.volume = newVolUp;
626
+ break;
627
+ case 'arrowdown':
628
+ e.preventDefault();
629
+ const newVolDown = Math.max(0, volume - 0.1);
630
+ setVolume(newVolDown);
631
+ videoRef.current.volume = newVolDown;
632
+ break;
633
+ case '0':
634
+ case '1':
635
+ case '2':
636
+ case '3':
637
+ case '4':
638
+ case '5':
639
+ case '6':
640
+ case '7':
641
+ case '8':
642
+ case '9':
643
+ const percent = parseInt(e.key) * 10;
644
+ videoRef.current.currentTime = (percent / 100) * duration;
645
+ break;
646
+ }
647
+ };
648
+
649
+ if (videoUrl) {
650
+ window.addEventListener('keydown', handleKeyDown);
651
+ return () => window.removeEventListener('keydown', handleKeyDown);
652
+ }
653
+ }, [videoUrl, togglePlay, toggleFullscreen, toggleMute, duration, currentTime, volume]);
654
+
655
+ const formatTime = (time: number): string => {
656
+ const hours = Math.floor(time / 3600);
657
+ const minutes = Math.floor((time % 3600) / 60);
658
+ const seconds = Math.floor(time % 60);
659
+ const minutesStr = minutes.toString().padStart(2, '0');
660
+ const secondsStr = seconds.toString().padStart(2, '0');
661
+ return hours > 0 ? `${hours}:${minutesStr}:${secondsStr}` : `${minutesStr}:${secondsStr}`;
662
+ };
663
+
664
+ // --- Progress Bar Calculations ---
665
+ const playedPercent = duration ? (currentTime / duration) * 100 : 0;
666
+ const bufferedPercent = duration ? (buffered / duration) * 100 : 0;
667
+
668
+ return (
669
+ <div
670
+ className="z-30 absolute flex w-full h-full"
671
+ ref={containerRef}
672
+ onMouseMove={resetInactivityTimer}
673
+ >
674
+ {videoUrl ? (
675
+ <>
676
+ <video
677
+ ref={videoRef}
678
+ crossOrigin="anonymous"
679
+ className="w-full h-full bg-gray-900 object-contain"
680
+ style={{ pointerEvents: 'none' }}
681
+ onLoadedMetadata={handleLoadedMetadata}
682
+ onTimeUpdate={handleTimeUpdate}
683
+ onProgress={handleProgress}
684
+ onPlay={handlePlay}
685
+ onPause={handlePause}
686
+ onWaiting={() => setLocalSpinnerLoading(true)}
687
+ onCanPlay={() => setLocalSpinnerLoading(false)}
688
+ onPlaying={() => setLocalSpinnerLoading(false)}
689
+ autoPlay
690
+ poster={thumbnail}
691
+ >
692
+ <source src={videoBlobUrl || videoUrl} type="video/webm" />
693
+ </video>
694
+ <AnimatePresence>
695
+ {showRatingOverlay && contentRatings.length > 0 && (
696
+ <motion.div
697
+ initial={{ opacity: 0, y: 20 }}
698
+ animate={{ opacity: 1, y: 0 }}
699
+ exit={{ opacity: 0, y: -20 }}
700
+ transition={{ duration: 0.5 }}
701
+ className="fixed top-[5rem] ml-4 inline-flex flex-col items-start bg-gray-900/60 backdrop-blur-sm text-white text-sm px-3 py-1 rounded-md border border-gray-700/50"
702
+ >
703
+ {/* Rating Name */}
704
+ <motion.div className="flex items-center text-xl">
705
+ <span>{contentRatings[0].name}</span>
706
+ {contentRatings[0].country && (
707
+ <span className="w-1 h-6 bg-purple-500 rounded-sm ml-2 mr-2"></span>
708
+ )}
709
+ {/* Description */}
710
+ {contentRatings[0].description && (
711
+ <motion.p
712
+ className="text-white/90 text-xs text-center max-w-md"
713
+ initial={{ opacity: 0 }}
714
+ animate={{ opacity: 1 }}
715
+ transition={{ delay: 0.3, duration: 0.5 }}
716
+ >
717
+ {contentRatings[0].description}
718
+ </motion.p>
719
+ )}
720
+ </motion.div>
721
+ </motion.div>
722
+ )}
723
+ </AnimatePresence>
724
+ {/* Controls Overlay */}
725
+ <div
726
+ className={`absolute inset-0 flex flex-col justify-between transition-opacity duration-300 z-[1000] ${showControls || localSpinnerLoading ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
727
+ >
728
+ {/* Top Controls */}
729
+ <div className="flex items-center justify-between p-6 bg-gradient-to-b from-gray-900/90 via-gray-900/60 to-transparent">
730
+ <div className="flex flex-col">
731
+ <h1 className="text-white text-3xl font-extrabold bg-clip-text text-transparent bg-gradient-to-r from-white to-gray-300">
732
+ {movieTitle || 'Video Player'}
733
+ </h1>
734
+ {episodeMatch && (
735
+ <p className="text-white font-extralight text-xl mt-2">
736
+ Season {seasonNumber} - Episode {episodeNumber}:{' '}
737
+ {episodeTitleClean.replace(/_/g, ' ')}
738
+ </p>
739
+ )}
740
+ </div>
741
+
742
+ {onClosePlayer && (
743
+ <button
744
+ onClick={handleClose}
745
+ className="text-white hover:text-red-400 transition-colors p-2 rounded-full hover:bg-gray-900/30"
746
+ >
747
+ <ArrowLeftStartOnRectangleIcon className="size-6" />
748
+ </button>
749
+ )}
750
+ </div>
751
+ {/* Center Play/Pause Button with integrated loading spinner and fast-forward/rewind controls */}
752
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
753
+ {/* Skip backward 10s */}
754
+ <button
755
+ onClick={() => {
756
+ if (videoRef.current) {
757
+ videoRef.current.currentTime = Math.max(
758
+ 0,
759
+ currentTime - 10,
760
+ );
761
+ }
762
+ }}
763
+ className="z-10 relative pointer-events-auto text-white hover:text-purple-300 transition-colors p-2 rounded-full hover:bg-gray-900/30 mx-4"
764
+ >
765
+ <BackwardIcon className="w-10 h-10" />
766
+ </button>
767
+
768
+ <IntegratedPlayerControls
769
+ isPlaying={isPlaying}
770
+ loading={localSpinnerLoading}
771
+ togglePlay={togglePlay}
772
+ />
773
+
774
+ {/* Skip forward 10s */}
775
+ <button
776
+ onClick={() => {
777
+ if (videoRef.current) {
778
+ videoRef.current.currentTime = Math.min(
779
+ duration,
780
+ currentTime + 10,
781
+ );
782
+ }
783
+ }}
784
+ className="z-10 relative pointer-events-auto text-white hover:text-purple-300 transition-colors p-2 rounded-full hover:bg-gray-900/30 mx-4"
785
+ >
786
+ <ForwardIcon className="w-10 h-10" />
787
+ </button>
788
+ </div>
789
+ {/* Bottom Controls */}
790
+ <div className="flex flex-col p-6 bg-gradient-to-t from-gray-900/90 via-gray-900/60 to-transparent">
791
+ {/* Progress Bar */}
792
+ <div className="flex items-center justify-between mb-4">
793
+ <span className="text-white text-sm font-medium">
794
+ {formatTime(currentTime)}
795
+ </span>
796
+ {/* Enhanced Progress Bar with time preview */}
797
+ <div className="relative w-full mx-4 group">
798
+ {/* Time preview tooltip */}
799
+ <TimePreview
800
+ duration={duration}
801
+ progressBarRef={progressBarRef}
802
+ />
803
+
804
+ {/* Progress bar */}
805
+ <div
806
+ ref={progressBarRef}
807
+ className="h-1.5 rounded-full bg-gray-700/70 group-hover:h-2.5 transition-all"
808
+ >
809
+ {/* Buffered progress */}
810
+ <div
811
+ className="h-full rounded-full bg-gray-400/50"
812
+ style={{ width: `${bufferedPercent}%` }}
813
+ ></div>
814
+ {/* Played progress */}
815
+ <div
816
+ className="h-full rounded-full bg-gradient-to-r from-purple-600 to-purple-400 absolute top-0 left-0 group-hover:shadow-glow"
817
+ style={{ width: `${playedPercent}%` }}
818
+ >
819
+ {/* Thumb indicator */}
820
+ <div className="absolute right-0 top-1/2 transform -translate-y-1/2 w-3 h-3 bg-white rounded-full scale-0 group-hover:scale-100 transition-transform"></div>
821
+ </div>
822
+ </div>
823
+ {/* Invisible range input for seeking */}
824
+ <input
825
+ type="range"
826
+ min="0"
827
+ max={duration}
828
+ value={
829
+ isSeeking
830
+ ? currentTime
831
+ : videoRef.current?.currentTime || 0
832
+ }
833
+ onMouseDown={handleSeekStart}
834
+ onTouchStart={handleSeekStart}
835
+ onChange={handleSeekEnd}
836
+ onMouseMove={handleSeekHover}
837
+ className="absolute top-0 left-0 w-full h-8 opacity-0 cursor-pointer"
838
+ />
839
+ </div>
840
+ <span className="text-white text-sm font-medium">
841
+ {formatTime(duration)}
842
+ </span>
843
+ </div>
844
+ {/* Control Buttons */}
845
+ <div className="flex items-center justify-between">
846
+ <div className="flex items-center space-x-6">
847
+
848
+ {/* Volume */}
849
+ <div className="flex items-center space-x-2 group">
850
+ <button
851
+ onClick={toggleMute}
852
+ className="text-white hover:text-purple-300 transition-colors p-2 rounded-full hover:bg-gray-900/30"
853
+ >
854
+ {isMuted ? (
855
+ <SpeakerXMarkIcon className="size-6" />
856
+ ) : (
857
+ <SpeakerWaveIcon className="size-6" />
858
+ )}
859
+ </button>
860
+ <input
861
+ type="range"
862
+ min={0}
863
+ max={1}
864
+ step={0.01}
865
+ value={volume}
866
+ onChange={handleVolumeChange}
867
+ className="w-0 md:w-24 opacity-0 md:opacity-100 group-hover:w-24 group-hover:opacity-100 transition-all duration-300 accent-purple-500"
868
+ />
869
+ </div>
870
+ {/* Keyboard shortcuts help button */}
871
+ <KeyboardShortcutsHelp />
872
+ </div>
873
+ <div className="flex flex-row items-center space-x-4">
874
+ <EpisodeQueueIcon episodes={episodes} currentlyPlaying={`${decodeURIComponent(videoTitle)}/${decodeURIComponent(season)}/${decodeURIComponent(episode)}`}/>
875
+ {/* Fullscreen */}
876
+ <button
877
+ onClick={toggleFullscreen}
878
+ className="text-white hover:text-purple-300 transition-colors p-2 rounded-full hover:bg-gray-900/30"
879
+ >
880
+ {!isFullscreen ? (
881
+ <ArrowsPointingOutIcon className="size-6" />
882
+ ) : (
883
+ <ArrowsPointingInIcon className="size-6" />
884
+ )}
885
+ </button>
886
+ </div>
887
+ </div>
888
+ </div>
889
+ </div>
890
+ </>
891
+ ) : (
892
+ // Link Fetcher UI with Ad Placeholder and real progress
893
+ <AdPlaceholder title={movieTitle} progress={progress} poster={poster} />
894
+ )}
895
+ <style jsx>{`
896
+ @keyframes fade-out {
897
+ 0% {
898
+ opacity: 1;
899
+ }
900
+ 80% {
901
+ opacity: 1;
902
+ }
903
+ 100% {
904
+ opacity: 0;
905
+ }
906
+ }
907
+ .animate-fade-out {
908
+ animation: fade-out 4s forwards;
909
+ }
910
+ .shadow-glow {
911
+ box-shadow: 0 0 8px 2px rgba(168, 85, 247, 0.4);
912
+ }
913
+ `}</style>
914
+ </div>
915
+ );
916
+ };
917
+
918
+ export default MoviePlayer;
frontend/components/tvshow/TvshowPlayer.tsx CHANGED
@@ -8,12 +8,13 @@ import {
8
  SpeakerXMarkIcon,
9
  ArrowsPointingOutIcon,
10
  ArrowsPointingInIcon,
11
- RectangleStackIcon,
12
  } from '@heroicons/react/24/solid';
13
  import { XCircleIcon } from '@heroicons/react/24/outline';
14
  import { getEpisodeLinkByTitle } from '@/lib/lb';
15
  import Hls from 'hls.js';
16
  import { motion, AnimatePresence } from 'framer-motion';
 
 
17
 
18
  interface ContentRating {
19
  country: string;
@@ -21,11 +22,16 @@ interface ContentRating {
21
  description: string;
22
  }
23
 
 
 
 
 
24
  interface TvShowPlayerProps {
25
  videoTitle: string;
26
  season: string;
27
  episode: string;
28
  contentRatings?: ContentRating[];
 
29
  onClosePlayer?: () => void;
30
  }
31
 
@@ -65,6 +71,7 @@ const TvShowPlayer: React.FC<TvShowPlayerProps> = ({
65
  season,
66
  episode,
67
  contentRatings = [],
 
68
  onClosePlayer,
69
  }) => {
70
  // Extract season and episode info using regex
@@ -112,12 +119,24 @@ const TvShowPlayer: React.FC<TvShowPlayerProps> = ({
112
  const [videoFetched, setVideoFetched] = useState(false);
113
  const videoFetchedRef = useRef(videoFetched);
114
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
 
115
 
116
  // Keep ref in sync
117
  useEffect(() => {
118
  videoFetchedRef.current = videoFetched;
119
  }, [videoFetched]);
120
 
 
 
 
 
 
 
 
 
 
 
 
121
  // --- Link Fetching & Polling ---
122
  const fetchMovieLink = async () => {
123
  if (videoFetchedRef.current) return;
@@ -382,10 +401,6 @@ const TvShowPlayer: React.FC<TvShowPlayerProps> = ({
382
  }
383
  };
384
 
385
- const toggleEpisodesContainer = () => {
386
- console.log('Episodes container toggled');
387
- };
388
-
389
  const handleClose = () => {
390
  onClosePlayer && onClosePlayer();
391
  };
@@ -585,12 +600,8 @@ const TvShowPlayer: React.FC<TvShowPlayerProps> = ({
585
  />
586
  </div>
587
  <div className="flex items-center space-x-6">
588
- <button
589
- onClick={toggleEpisodesContainer}
590
- className="text-white hover:text-purple-300 transition-colors"
591
- >
592
- <RectangleStackIcon className="w-10 h-10" />
593
- </button>
594
  <button
595
  onClick={toggleFullscreen}
596
  className="text-white hover:text-purple-300 transition-colors"
 
8
  SpeakerXMarkIcon,
9
  ArrowsPointingOutIcon,
10
  ArrowsPointingInIcon,
 
11
  } from '@heroicons/react/24/solid';
12
  import { XCircleIcon } from '@heroicons/react/24/outline';
13
  import { getEpisodeLinkByTitle } from '@/lib/lb';
14
  import Hls from 'hls.js';
15
  import { motion, AnimatePresence } from 'framer-motion';
16
+ import EpisodeQueueIcon from './EpisodeQueueIcon';
17
+ import { createPlayQueue } from './utils';
18
 
19
  interface ContentRating {
20
  country: string;
 
22
  description: string;
23
  }
24
 
25
+ interface FileStructure {
26
+ contents?: any[];
27
+ }
28
+
29
  interface TvShowPlayerProps {
30
  videoTitle: string;
31
  season: string;
32
  episode: string;
33
  contentRatings?: ContentRating[];
34
+ fileStructure?: FileStructure;
35
  onClosePlayer?: () => void;
36
  }
37
 
 
71
  season,
72
  episode,
73
  contentRatings = [],
74
+ fileStructure,
75
  onClosePlayer,
76
  }) => {
77
  // Extract season and episode info using regex
 
119
  const [videoFetched, setVideoFetched] = useState(false);
120
  const videoFetchedRef = useRef(videoFetched);
121
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
122
+ const [episodes, setEpisodes] = useState<any[]>([]);
123
 
124
  // Keep ref in sync
125
  useEffect(() => {
126
  videoFetchedRef.current = videoFetched;
127
  }, [videoFetched]);
128
 
129
+ useEffect(() => {
130
+ if (fileStructure && seasonNumber) {
131
+ console.log('current season',season)
132
+ const episodes = createPlayQueue(fileStructure, decodeURIComponent(season));
133
+ setEpisodes(episodes);
134
+ console.log('Episodes:', episodes);
135
+ }
136
+ }
137
+ , [fileStructure, seasonNumber]);
138
+
139
+
140
  // --- Link Fetching & Polling ---
141
  const fetchMovieLink = async () => {
142
  if (videoFetchedRef.current) return;
 
401
  }
402
  };
403
 
 
 
 
 
404
  const handleClose = () => {
405
  onClosePlayer && onClosePlayer();
406
  };
 
600
  />
601
  </div>
602
  <div className="flex items-center space-x-6">
603
+ <EpisodeQueueIcon episodes={episodes}/>
604
+
 
 
 
 
605
  <button
606
  onClick={toggleFullscreen}
607
  className="text-white hover:text-purple-300 transition-colors"
frontend/components/tvshow/utils.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface FileStructure {
2
+ contents?: any[];
3
+ }
4
+
5
+ interface Episode {
6
+ episodeNum: string;
7
+ episodeTitle: string;
8
+ filePath: string;
9
+ isSpecial: boolean;
10
+ }
11
+
12
+ interface SeasonWithEpisodes {
13
+ seasonName: string;
14
+ episodes: Episode[];
15
+ }
16
+
17
+ export function createPlayQueue(fileStructure: FileStructure): SeasonWithEpisodes[] {
18
+ const seasonsWithEpisodes: SeasonWithEpisodes[] = [];
19
+
20
+ // Loop through each season in the file structure
21
+ fileStructure.contents?.forEach((seasonContent) => {
22
+ const seasonName = seasonContent.path.split('/').pop() || '';
23
+
24
+ const episodes: Episode[] = [];
25
+
26
+ // Process each episode in the season
27
+ seasonContent.contents?.forEach((episode: any) => {
28
+ // Skip subtitle files
29
+ if (episode.path.endsWith('.srt') || episode.path.endsWith('.vtt')) {
30
+ return;
31
+ }
32
+
33
+ // Match the episode name format
34
+ const match = episode.path.match(/[S](\d+)[E](\d+) - (.+?)\./);
35
+ if (match) {
36
+ const [, , episodeNum, episodeTitle] = match;
37
+ const isSpecial = seasonContent.path.includes('Specials');
38
+ let filePath = episode.path;
39
+
40
+ // Remove "tv/" from the file path
41
+ if (filePath.startsWith('tv/')) {
42
+ filePath = filePath.slice(3);
43
+ }
44
+
45
+ episodes.push({
46
+ episodeNum,
47
+ episodeTitle,
48
+ filePath: filePath,
49
+ isSpecial,
50
+ });
51
+ }
52
+ });
53
+
54
+ // Sort episodes by their episode number
55
+ episodes.sort((a, b) => parseInt(a.episodeNum) - parseInt(b.episodeNum));
56
+
57
+ // Add the season with its episodes
58
+ if (episodes.length > 0) {
59
+ seasonsWithEpisodes.push({
60
+ seasonName,
61
+ episodes,
62
+ });
63
+ }
64
+ });
65
+
66
+ return seasonsWithEpisodes;
67
+ }
frontend/lib/config.ts CHANGED
@@ -1,2 +1,6 @@
1
- export const WEB_VERSION = 'v0.2.3 beta';
2
- export const SEARCH_API_URL = 'https://hans-den-search.hf.space';
 
 
 
 
 
1
+ export const WEB_VERSION = 'v0.2.4 beta';
2
+ export const SEARCH_API_URL = 'https://hans-den-search.hf.space';
3
+ export const MOVIE_WATCH_RUOTE = '/watch/movie';
4
+ export const TVSHOW_WATCH_ROUTE = '/watch/tvshow';
5
+ export const MOVIE_DETAILS_ROUTE = '/details/movie';
6
+ export const TVSHOW_DETAILS_ROUTE = '/details/tvshow';
frontend/lib/utils.ts CHANGED
@@ -10,251 +10,3 @@ export function convertMinutesToHM(minutes:number) {
10
  const mins = minutes % 60;
11
  return `${hours}h${mins.toString().padStart(2, '0')}mins`;
12
  }
13
-
14
- const countryNames: { [key: string]: string } = {
15
- AFG: 'Afghanistan',
16
- ALB: 'Albania',
17
- DZA: 'Algeria',
18
- ASM: 'American Samoa',
19
- AND: 'Andorra',
20
- AGO: 'Angola',
21
- AIA: 'Anguilla',
22
- ATA: 'Antarctica',
23
- ATG: 'Antigua and Barbuda',
24
- ARG: 'Argentina',
25
- ARM: 'Armenia',
26
- ABW: 'Aruba',
27
- AUS: 'Australia',
28
- AUT: 'Austria',
29
- AZE: 'Azerbaijan',
30
- BHS: 'Bahamas',
31
- BHR: 'Bahrain',
32
- BGD: 'Bangladesh',
33
- BRB: 'Barbados',
34
- BLR: 'Belarus',
35
- BEL: 'Belgium',
36
- BLZ: 'Belize',
37
- BEN: 'Benin',
38
- BMU: 'Bermuda',
39
- BTN: 'Bhutan',
40
- BOL: 'Bolivia',
41
- BIH: 'Bosnia and Herzegovina',
42
- BWA: 'Botswana',
43
- BRA: 'Brazil',
44
- IOT: 'British Indian Ocean Territory',
45
- VGB: 'British Virgin Islands',
46
- BRN: 'Brunei',
47
- BGR: 'Bulgaria',
48
- BFA: 'Burkina Faso',
49
- BDI: 'Burundi',
50
- KHM: 'Cambodia',
51
- CMR: 'Cameroon',
52
- CAN: 'Canada',
53
- CPV: 'Cape Verde',
54
- CYM: 'Cayman Islands',
55
- CAF: 'Central African Republic',
56
- TCD: 'Chad',
57
- CHL: 'Chile',
58
- CHN: 'China',
59
- CXR: 'Christmas Island',
60
- CCK: 'Cocos Islands',
61
- COL: 'Colombia',
62
- COM: 'Comoros',
63
- COK: 'Cook Islands',
64
- CRI: 'Costa Rica',
65
- HRV: 'Croatia',
66
- CUB: 'Cuba',
67
- CUW: 'Curacao',
68
- CYP: 'Cyprus',
69
- CZE: 'Czech Republic',
70
- COD: 'Democratic Republic of the Congo',
71
- DNK: 'Denmark',
72
- DJI: 'Djibouti',
73
- DMA: 'Dominica',
74
- DOM: 'Dominican Republic',
75
- TLS: 'East Timor',
76
- ECU: 'Ecuador',
77
- EGY: 'Egypt',
78
- SLV: 'El Salvador',
79
- GNQ: 'Equatorial Guinea',
80
- ERI: 'Eritrea',
81
- EST: 'Estonia',
82
- ETH: 'Ethiopia',
83
- FLK: 'Falkland Islands',
84
- FRO: 'Faroe Islands',
85
- FJI: 'Fiji',
86
- FIN: 'Finland',
87
- FRA: 'France',
88
- PYF: 'French Polynesia',
89
- GAB: 'Gabon',
90
- GMB: 'Gambia',
91
- GEO: 'Georgia',
92
- DEU: 'Germany',
93
- GHA: 'Ghana',
94
- GIB: 'Gibraltar',
95
- GRC: 'Greece',
96
- GRL: 'Greenland',
97
- GRD: 'Grenada',
98
- GUM: 'Guam',
99
- GTM: 'Guatemala',
100
- GGY: 'Guernsey',
101
- GIN: 'Guinea',
102
- GNB: 'Guinea-Bissau',
103
- GUY: 'Guyana',
104
- HTI: 'Haiti',
105
- HND: 'Honduras',
106
- HKG: 'Hong Kong',
107
- HUN: 'Hungary',
108
- ISL: 'Iceland',
109
- IND: 'India',
110
- IDN: 'Indonesia',
111
- IRN: 'Iran',
112
- IRQ: 'Iraq',
113
- IRL: 'Ireland',
114
- IMN: 'Isle of Man',
115
- ISR: 'Israel',
116
- ITA: 'Italy',
117
- CIV: 'Ivory Coast',
118
- JAM: 'Jamaica',
119
- JPN: 'Japan',
120
- JEY: 'Jersey',
121
- JOR: 'Jordan',
122
- KAZ: 'Kazakhstan',
123
- KEN: 'Kenya',
124
- KIR: 'Kiribati',
125
- XKX: 'Kosovo',
126
- KWT: 'Kuwait',
127
- KGZ: 'Kyrgyzstan',
128
- LAO: 'Laos',
129
- LVA: 'Latvia',
130
- LBN: 'Lebanon',
131
- LSO: 'Lesotho',
132
- LBR: 'Liberia',
133
- LBY: 'Libya',
134
- LIE: 'Liechtenstein',
135
- LTU: 'Lithuania',
136
- LUX: 'Luxembourg',
137
- MAC: 'Macau',
138
- MKD: 'Macedonia',
139
- MDG: 'Madagascar',
140
- MWN: 'Malawi',
141
- MYS: 'Malaysia',
142
- MDV: 'Maldives',
143
- MLI: 'Mali',
144
- MLT: 'Malta',
145
- MHL: 'Marshall Islands',
146
- MRT: 'Mauritania',
147
- MUS: 'Mauritius',
148
- MYT: 'Mayotte',
149
- MEX: 'Mexico',
150
- FSM: 'Micronesia',
151
- MDA: 'Moldova',
152
- MCO: 'Monaco',
153
- MNG: 'Mongolia',
154
- MNE: 'Montenegro',
155
- MSR: 'Montserrat',
156
- MAR: 'Morocco',
157
- MOZ: 'Mozambique',
158
- MMR: 'Myanmar',
159
- NAM: 'Namibia',
160
- NRU: 'Nauru',
161
- NPL: 'Nepal',
162
- NLD: 'Netherlands',
163
- ANT: 'Netherlands Antilles',
164
- NCL: 'New Caledonia',
165
- NZL: 'New Zealand',
166
- NIC: 'Nicaragua',
167
- NER: 'Niger',
168
- NGA: 'Nigeria',
169
- NIU: 'Niue',
170
- PRK: 'North Korea',
171
- MNP: 'Northern Mariana Islands',
172
- NOR: 'Norway',
173
- OMN: 'Oman',
174
- PAK: 'Pakistan',
175
- PLW: 'Palau',
176
- PSE: 'Palestine',
177
- PAN: 'Panama',
178
- PNG: 'Papua New Guinea',
179
- PRY: 'Paraguay',
180
- PER: 'Peru',
181
- PHL: 'Philippines',
182
- PCN: 'Pitcairn',
183
- POL: 'Poland',
184
- PRT: 'Portugal',
185
- PRI: 'Puerto Rico',
186
- QAT: 'Qatar',
187
- COG: 'Republic of the Congo',
188
- REU: 'Reunion',
189
- ROU: 'Romania',
190
- RUS: 'Russia',
191
- RWA: 'Rwanda',
192
- BLM: 'Saint Barthelemy',
193
- SHN: 'Saint Helena',
194
- KNA: 'Saint Kitts and Nevis',
195
- LCA: 'Saint Lucia',
196
- MAO: 'Saint Martin',
197
- SPM: 'Saint Pierre and Miquelon',
198
- VCT: 'Saint Vincent and the Grenadines',
199
- WSM: 'Samoa',
200
- SMR: 'San Marino',
201
- STP: 'Sao Tome and Principe',
202
- SAU: 'Saudi Arabia',
203
- SEN: 'Senegal',
204
- SRB: 'Serbia',
205
- SYC: 'Seychelles',
206
- SLE: 'Sierra Leone',
207
- SGP: 'Singapore',
208
- SXM: 'Sint Maarten',
209
- SVK: 'Slovakia',
210
- SVN: 'Slovenia',
211
- SLB: 'Solomon Islands',
212
- SOM: 'Somalia',
213
- ZAF: 'South Africa',
214
- KOR: 'South Korea',
215
- SSD: 'South Sudan',
216
- ESP: 'Spain',
217
- LKA: 'Sri Lanka',
218
- SDN: 'Sudan',
219
- SUR: 'Suriname',
220
- SJM: 'Svalbard and Jan Mayen',
221
- SWZ: 'Swaziland',
222
- SWE: 'Sweden',
223
- CHE: 'Switzerland',
224
- SYR: 'Syria',
225
- TWN: 'Taiwan',
226
- TJK: 'Tajikistan',
227
- TZA: 'Tanzania',
228
- THA: 'Thailand',
229
- TGO: 'Togo',
230
- TKL: 'Tokelau',
231
- TON: 'Tonga',
232
- TTO: 'Trinidad and Tobago',
233
- TUN: 'Tunisia',
234
- TUR: 'Turkey',
235
- TKM: 'Turkmenistan',
236
- TCA: 'Turks and Caicos Islands',
237
- TUV: 'Tuvalu',
238
- VIR: 'U.S. Virgin Islands',
239
- UGA: 'Uganda',
240
- UKR: 'Ukraine',
241
- ARE: 'United Arab Emirates',
242
- GBR: 'United Kingdom',
243
- USA: 'United States',
244
- URY: 'Uruguay',
245
- UZB: 'Uzbekistan',
246
- VUT: 'Vanuatu',
247
- VAT: 'Vatican',
248
- VEN: 'Venezuela',
249
- VNM: 'Vietnam',
250
- WLF: 'Wallis and Futuna',
251
- ESH: 'Western Sahara',
252
- YEM: 'Yemen',
253
- ZMB: 'Zambia',
254
- ZWE: 'Zimbabwe',
255
- };
256
-
257
- export function getCountryName(countryCode: string): string {
258
- const upperCaseCode = countryCode.toUpperCase();
259
- return countryNames[upperCaseCode] || countryCode;
260
- }
 
10
  const mins = minutes % 60;
11
  return `${hours}h${mins.toString().padStart(2, '0')}mins`;
12
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/tailwind.config.ts CHANGED
@@ -9,6 +9,16 @@ const config: Config = {
9
  ],
10
  theme: {
11
  extend: {
 
 
 
 
 
 
 
 
 
 
12
  backgroundImage: {
13
  'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
14
  'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))'
 
9
  ],
10
  theme: {
11
  extend: {
12
+ animation: {
13
+ fade: 'fadeIn .5s ease-in-out',
14
+ },
15
+
16
+ keyframes: {
17
+ fadeIn: {
18
+ from: { opacity: '0' },
19
+ to: { opacity: '1' },
20
+ },
21
+ },
22
  backgroundImage: {
23
  'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
24
  'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))'