ChandimaPrabath commited on
Commit
77b8fea
·
1 Parent(s): 3635b0f

v0.2.1 beta. search page, tvshow player and movie player

Browse files
Files changed (37) hide show
  1. frontend/TODO.md +1 -3
  2. frontend/app/{movies → browse/movies}/page.tsx +58 -19
  3. frontend/app/{tvshows → browse/tvshows}/page.tsx +62 -19
  4. frontend/app/layout.tsx +7 -6
  5. frontend/app/movie/[title]/LoadingSkeleton.tsx +43 -19
  6. frontend/app/movie/[title]/page.tsx +84 -74
  7. frontend/app/mylist/page.tsx +1 -1
  8. frontend/app/not-found.tsx +47 -22
  9. frontend/app/page.tsx +61 -18
  10. frontend/app/search/page.tsx +301 -0
  11. frontend/app/tvshow/[title]/LoadingSkeleton.tsx +43 -19
  12. frontend/app/tvshow/[title]/page.tsx +90 -178
  13. frontend/app/watch/movie/[title]/page.tsx +28 -0
  14. frontend/app/watch/tvshow/[title]/[season]/[episode]/page.tsx +30 -0
  15. frontend/components/loading/Spinner.tsx +13 -5
  16. frontend/components/loading/SplashScreen.tsx +28 -10
  17. frontend/components/movie/MovieCard.tsx +421 -340
  18. frontend/components/movie/MovieLinkFetcher.tsx +121 -0
  19. frontend/components/movie/MovieLinkFetcherModal.tsx +32 -11
  20. frontend/components/movie/MoviePlayer.tsx +566 -0
  21. frontend/components/movie/MoviePlayerModal.tsx +167 -67
  22. frontend/components/navigation/DesktopMenu.tsx +17 -5
  23. frontend/components/navigation/MobileMenu.tsx +30 -7
  24. frontend/components/navigation/Navbar.tsx +20 -12
  25. frontend/components/sections/CastSection.tsx +26 -8
  26. frontend/components/sections/EpisodesSection.tsx +181 -0
  27. frontend/components/sections/NewContentSection.tsx +10 -5
  28. frontend/components/sections/ScrollSection.tsx +22 -8
  29. frontend/components/shared/GenresFilter.tsx +48 -14
  30. frontend/components/shared/Trailers.tsx +76 -0
  31. frontend/components/tvshow/TvShowCard.tsx +215 -96
  32. frontend/components/tvshow/TvshowPlayer.tsx +695 -0
  33. frontend/lib/LoadbalancerAPI.js +4 -0
  34. frontend/lib/config.ts +2 -1
  35. frontend/lib/lb.js +11 -0
  36. frontend/package.json +3 -1
  37. frontend/yarn.lock +90 -41
frontend/TODO.md CHANGED
@@ -10,6 +10,4 @@ Project Description
10
 
11
  ### In Progress
12
 
13
- ### Done ✓
14
-
15
-
 
10
 
11
  ### In Progress
12
 
13
+ ### Done ✓
 
 
frontend/app/{movies → browse/movies}/page.tsx RENAMED
@@ -54,10 +54,13 @@ function MoviesContent() {
54
  };
55
 
56
  return (
57
- <div className="portrait:p-2 p-4">
58
  {loading ? (
59
- <div className="flex items-center justify-center min-h-[60vh]">
60
- <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"></div>
 
 
 
61
  </div>
62
  ) : (
63
  <>
@@ -65,36 +68,44 @@ function MoviesContent() {
65
  <div
66
  key={currentPage}
67
  className="flex flex-wrap justify-center items-center portrait:gap-2 gap-10"
 
68
  >
69
  {movies.map((movie, index) => (
70
  <div
71
  key={`${movie.title}-${index}`}
72
  className="transform transition-transform duration-300 hover:scale-105 w-[fit-content]"
 
73
  >
74
- <MovieCard title={movie.title} />
75
  </div>
76
  ))}
77
  </div>
78
 
79
  {/* Pagination */}
80
- <div className="mt-12 flex flex-row items-center justify-center gap-2">
81
- <div className="flex items-center gap-2">
 
 
 
82
  <button
83
  onClick={() => handlePageChange(1)}
84
  disabled={currentPage <= 1}
85
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
 
86
  >
87
  <svg
88
  className="w-5 h-5"
89
  fill="none"
90
  stroke="currentColor"
91
  viewBox="0 0 24 24"
 
92
  >
93
  <path
94
  strokeLinecap="round"
95
  strokeLinejoin="round"
96
  strokeWidth={2}
97
  d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
 
98
  />
99
  </svg>
100
  </button>
@@ -102,46 +113,55 @@ function MoviesContent() {
102
  onClick={() => handlePageChange(currentPage - 1)}
103
  disabled={currentPage <= 1}
104
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
 
105
  >
106
  <svg
107
  className="w-5 h-5"
108
  fill="none"
109
  stroke="currentColor"
110
  viewBox="0 0 24 24"
 
111
  >
112
  <path
113
  strokeLinecap="round"
114
  strokeLinejoin="round"
115
  strokeWidth={2}
116
  d="M15 19l-7-7 7-7"
 
117
  />
118
  </svg>
119
  </button>
120
  </div>
121
 
122
- <div className="flex items-center gap-2">
123
- <span className="px-4 py-2 rounded-lg bg-gray-800 text-white font-medium">
 
 
 
124
  Page {currentPage} of {totalPages}
125
  </span>
126
  </div>
127
 
128
- <div className="flex items-center gap-2">
129
  <button
130
  onClick={() => handlePageChange(currentPage + 1)}
131
  disabled={currentPage >= totalPages}
132
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
 
133
  >
134
  <svg
135
  className="w-5 h-5"
136
  fill="none"
137
  stroke="currentColor"
138
  viewBox="0 0 24 24"
 
139
  >
140
  <path
141
  strokeLinecap="round"
142
  strokeLinejoin="round"
143
  strokeWidth={2}
144
  d="M9 5l7 7-7 7"
 
145
  />
146
  </svg>
147
  </button>
@@ -149,18 +169,21 @@ function MoviesContent() {
149
  onClick={() => handlePageChange(totalPages)}
150
  disabled={currentPage >= totalPages}
151
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
 
152
  >
153
  <svg
154
  className="w-5 h-5"
155
  fill="none"
156
  stroke="currentColor"
157
  viewBox="0 0 24 24"
 
158
  >
159
  <path
160
  strokeLinecap="round"
161
  strokeLinejoin="round"
162
  strokeWidth={2}
163
  d="M13 5l7 7-7 7M5 5l7 7-7 7"
 
164
  />
165
  </svg>
166
  </button>
@@ -176,29 +199,45 @@ 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">
183
- <h2 className="text-4xl font-bold text-white">Movies</h2>
184
- <p className="text-gray-400 max-w-3xl">
 
 
185
  Explore our collection of movies from various genres. From action to
186
  romance, find your next movie night selection here.
187
  </p>
188
- <GenresFilter mediaType="movie" onFilterChange={setFilterActive} />
 
 
 
 
189
  </div>
190
 
191
  {/* Content Section */}
192
  {!filterActive && (
193
- <div className="bg-gray-800/30 rounded-3xl backdrop-blur-sm border border-gray-700/50">
 
 
 
194
  <Suspense
195
  fallback={
196
- <div className="flex items-center justify-center min-h-[50vh]">
197
- <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"></div>
 
 
 
 
 
 
198
  </div>
199
  }
 
200
  >
201
- <MoviesContent />
202
  </Suspense>
203
  </div>
204
  )}
 
54
  };
55
 
56
  return (
57
+ <div className="portrait:p-2 p-4" data-oid="fvshohr">
58
  {loading ? (
59
+ <div className="flex items-center justify-center min-h-[60vh]" data-oid="bvikmt-">
60
+ <div
61
+ className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"
62
+ data-oid="rtqwuek"
63
+ ></div>
64
  </div>
65
  ) : (
66
  <>
 
68
  <div
69
  key={currentPage}
70
  className="flex flex-wrap justify-center items-center portrait:gap-2 gap-10"
71
+ data-oid="aln__mo"
72
  >
73
  {movies.map((movie, index) => (
74
  <div
75
  key={`${movie.title}-${index}`}
76
  className="transform transition-transform duration-300 hover:scale-105 w-[fit-content]"
77
+ data-oid="769vh_3"
78
  >
79
+ <MovieCard title={movie.title} data-oid="ypnr91j" />
80
  </div>
81
  ))}
82
  </div>
83
 
84
  {/* Pagination */}
85
+ <div
86
+ className="mt-12 flex flex-row items-center justify-center gap-2"
87
+ data-oid="y9gjnx6"
88
+ >
89
+ <div className="flex items-center gap-2" data-oid="khk:95c">
90
  <button
91
  onClick={() => handlePageChange(1)}
92
  disabled={currentPage <= 1}
93
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
94
+ data-oid="h_956lx"
95
  >
96
  <svg
97
  className="w-5 h-5"
98
  fill="none"
99
  stroke="currentColor"
100
  viewBox="0 0 24 24"
101
+ data-oid="jdlqnep"
102
  >
103
  <path
104
  strokeLinecap="round"
105
  strokeLinejoin="round"
106
  strokeWidth={2}
107
  d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
108
+ data-oid="d2gu_mw"
109
  />
110
  </svg>
111
  </button>
 
113
  onClick={() => handlePageChange(currentPage - 1)}
114
  disabled={currentPage <= 1}
115
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
116
+ data-oid="5yi3mm4"
117
  >
118
  <svg
119
  className="w-5 h-5"
120
  fill="none"
121
  stroke="currentColor"
122
  viewBox="0 0 24 24"
123
+ data-oid="gk-zc44"
124
  >
125
  <path
126
  strokeLinecap="round"
127
  strokeLinejoin="round"
128
  strokeWidth={2}
129
  d="M15 19l-7-7 7-7"
130
+ data-oid="_jaw6sg"
131
  />
132
  </svg>
133
  </button>
134
  </div>
135
 
136
+ <div className="flex items-center gap-2" data-oid="d.gjt6x">
137
+ <span
138
+ className="px-4 py-2 rounded-lg bg-gray-800 text-white font-medium"
139
+ data-oid="wir:k4e"
140
+ >
141
  Page {currentPage} of {totalPages}
142
  </span>
143
  </div>
144
 
145
+ <div className="flex items-center gap-2" data-oid="bs-wdt.">
146
  <button
147
  onClick={() => handlePageChange(currentPage + 1)}
148
  disabled={currentPage >= totalPages}
149
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
150
+ data-oid="zr44.g5"
151
  >
152
  <svg
153
  className="w-5 h-5"
154
  fill="none"
155
  stroke="currentColor"
156
  viewBox="0 0 24 24"
157
+ data-oid="v3y5gev"
158
  >
159
  <path
160
  strokeLinecap="round"
161
  strokeLinejoin="round"
162
  strokeWidth={2}
163
  d="M9 5l7 7-7 7"
164
+ data-oid="r9qw8a8"
165
  />
166
  </svg>
167
  </button>
 
169
  onClick={() => handlePageChange(totalPages)}
170
  disabled={currentPage >= totalPages}
171
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
172
+ data-oid="v0wgcaw"
173
  >
174
  <svg
175
  className="w-5 h-5"
176
  fill="none"
177
  stroke="currentColor"
178
  viewBox="0 0 24 24"
179
+ data-oid="b__v.hd"
180
  >
181
  <path
182
  strokeLinecap="round"
183
  strokeLinejoin="round"
184
  strokeWidth={2}
185
  d="M13 5l7 7-7 7M5 5l7 7-7 7"
186
+ data-oid="ha_gdi9"
187
  />
188
  </svg>
189
  </button>
 
199
  const [filterActive, setFilterActive] = useState(false);
200
 
201
  return (
202
+ <div className="page min-h-screen pt-20 pb-4" data-oid="c30dpdf">
203
+ <div className="container mx-auto portrait:px-3 px-4" data-oid="qc8wc5o">
204
  {/* Header Section */}
205
+ <div className="mb-8 space-y-2" data-oid="l_h1wg-">
206
+ <h2 className="text-4xl font-bold text-white" data-oid="q5f:fz6">
207
+ Movies
208
+ </h2>
209
+ <p className="text-gray-400 max-w-3xl" data-oid="087lc-o">
210
  Explore our collection of movies from various genres. From action to
211
  romance, find your next movie night selection here.
212
  </p>
213
+ <GenresFilter
214
+ mediaType="movie"
215
+ onFilterChange={setFilterActive}
216
+ data-oid="7v966l9"
217
+ />
218
  </div>
219
 
220
  {/* Content Section */}
221
  {!filterActive && (
222
+ <div
223
+ className="bg-gray-800/30 rounded-3xl backdrop-blur-sm border border-gray-700/50"
224
+ data-oid="go9ax7."
225
+ >
226
  <Suspense
227
  fallback={
228
+ <div
229
+ className="flex items-center justify-center min-h-[50vh]"
230
+ data-oid=":rp_h5l"
231
+ >
232
+ <div
233
+ className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"
234
+ data-oid="w:3x912"
235
+ ></div>
236
  </div>
237
  }
238
+ data-oid="fvt3emu"
239
  >
240
+ <MoviesContent data-oid="tgt2kr2" />
241
  </Suspense>
242
  </div>
243
  )}
frontend/app/{tvshows → browse/tvshows}/page.tsx RENAMED
@@ -54,10 +54,13 @@ function TvShowsContent() {
54
  };
55
 
56
  return (
57
- <div className="portrait:p-2 p-4">
58
  {loading ? (
59
- <div className="flex items-center justify-center min-h-[60vh]">
60
- <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"></div>
 
 
 
61
  </div>
62
  ) : (
63
  <>
@@ -65,36 +68,48 @@ function TvShowsContent() {
65
  <div
66
  key={currentPage}
67
  className="flex flex-wrap justify-center items-center portrait:gap-2 gap-10"
 
68
  >
69
  {tvshows.map((show, index) => (
70
  <div
71
  key={`${show.title}-${index}`}
72
  className="transform transition-transform duration-300 hover:scale-105 w-[fit-content]"
 
73
  >
74
- <TvShowCard title={show.title} episodesCount={show.episodeCount} />
 
 
 
 
75
  </div>
76
  ))}
77
  </div>
78
 
79
  {/* Pagination */}
80
- <div className="mt-12 flex flex-row items-center justify-center gap-2">
81
- <div className="flex items-center gap-2">
 
 
 
82
  <button
83
  onClick={() => handlePageChange(1)}
84
  disabled={currentPage <= 1}
85
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
 
86
  >
87
  <svg
88
  className="w-5 h-5"
89
  fill="none"
90
  stroke="currentColor"
91
  viewBox="0 0 24 24"
 
92
  >
93
  <path
94
  strokeLinecap="round"
95
  strokeLinejoin="round"
96
  strokeWidth={2}
97
  d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
 
98
  />
99
  </svg>
100
  </button>
@@ -102,46 +117,55 @@ function TvShowsContent() {
102
  onClick={() => handlePageChange(currentPage - 1)}
103
  disabled={currentPage <= 1}
104
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
 
105
  >
106
  <svg
107
  className="w-5 h-5"
108
  fill="none"
109
  stroke="currentColor"
110
  viewBox="0 0 24 24"
 
111
  >
112
  <path
113
  strokeLinecap="round"
114
  strokeLinejoin="round"
115
  strokeWidth={2}
116
  d="M15 19l-7-7 7-7"
 
117
  />
118
  </svg>
119
  </button>
120
  </div>
121
 
122
- <div className="flex items-center gap-2">
123
- <span className="px-4 py-2 rounded-lg bg-gray-800 text-white font-medium">
 
 
 
124
  Page {currentPage} of {totalPages}
125
  </span>
126
  </div>
127
 
128
- <div className="flex items-center gap-2">
129
  <button
130
  onClick={() => handlePageChange(currentPage + 1)}
131
  disabled={currentPage >= totalPages}
132
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
 
133
  >
134
  <svg
135
  className="w-5 h-5"
136
  fill="none"
137
  stroke="currentColor"
138
  viewBox="0 0 24 24"
 
139
  >
140
  <path
141
  strokeLinecap="round"
142
  strokeLinejoin="round"
143
  strokeWidth={2}
144
  d="M9 5l7 7-7 7"
 
145
  />
146
  </svg>
147
  </button>
@@ -149,18 +173,21 @@ function TvShowsContent() {
149
  onClick={() => handlePageChange(totalPages)}
150
  disabled={currentPage >= totalPages}
151
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
 
152
  >
153
  <svg
154
  className="w-5 h-5"
155
  fill="none"
156
  stroke="currentColor"
157
  viewBox="0 0 24 24"
 
158
  >
159
  <path
160
  strokeLinecap="round"
161
  strokeLinejoin="round"
162
  strokeWidth={2}
163
  d="M13 5l7 7-7 7M5 5l7 7-7 7"
 
164
  />
165
  </svg>
166
  </button>
@@ -176,29 +203,45 @@ 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">
183
- <h2 className="text-4xl font-bold text-white">TV Shows</h2>
184
- <p className="text-gray-400 max-w-3xl">
 
 
185
  Explore our collection of TV series from various genres. From drama to
186
  comedy, find your next binge-worthy show here.
187
  </p>
188
- <GenresFilter mediaType="series" onFilterChange={setFilterActive} />
 
 
 
 
189
  </div>
190
 
191
  {/* Content Section */}
192
  {!filterActive && (
193
- <div className="bg-gray-800/30 rounded-3xl backdrop-blur-sm border border-gray-700/50">
 
 
 
194
  <Suspense
195
  fallback={
196
- <div className="flex items-center justify-center min-h-[50vh]">
197
- <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"></div>
 
 
 
 
 
 
198
  </div>
199
  }
 
200
  >
201
- <TvShowsContent />
202
  </Suspense>
203
  </div>
204
  )}
 
54
  };
55
 
56
  return (
57
+ <div className="portrait:p-2 p-4" data-oid="n2:_pgl">
58
  {loading ? (
59
+ <div className="flex items-center justify-center min-h-[60vh]" data-oid="ae6-.3e">
60
+ <div
61
+ className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"
62
+ data-oid="nmkri01"
63
+ ></div>
64
  </div>
65
  ) : (
66
  <>
 
68
  <div
69
  key={currentPage}
70
  className="flex flex-wrap justify-center items-center portrait:gap-2 gap-10"
71
+ data-oid="lzyd1tg"
72
  >
73
  {tvshows.map((show, index) => (
74
  <div
75
  key={`${show.title}-${index}`}
76
  className="transform transition-transform duration-300 hover:scale-105 w-[fit-content]"
77
+ data-oid="rct63vr"
78
  >
79
+ <TvShowCard
80
+ title={show.title}
81
+ episodesCount={show.episodeCount}
82
+ data-oid="k259d8k"
83
+ />
84
  </div>
85
  ))}
86
  </div>
87
 
88
  {/* Pagination */}
89
+ <div
90
+ className="mt-12 flex flex-row items-center justify-center gap-2"
91
+ data-oid="tv8c_oq"
92
+ >
93
+ <div className="flex items-center gap-2" data-oid="djdzja-">
94
  <button
95
  onClick={() => handlePageChange(1)}
96
  disabled={currentPage <= 1}
97
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
98
+ data-oid="vy3:pbt"
99
  >
100
  <svg
101
  className="w-5 h-5"
102
  fill="none"
103
  stroke="currentColor"
104
  viewBox="0 0 24 24"
105
+ data-oid="ithecvc"
106
  >
107
  <path
108
  strokeLinecap="round"
109
  strokeLinejoin="round"
110
  strokeWidth={2}
111
  d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
112
+ data-oid="s6-ahz3"
113
  />
114
  </svg>
115
  </button>
 
117
  onClick={() => handlePageChange(currentPage - 1)}
118
  disabled={currentPage <= 1}
119
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
120
+ data-oid="3o:hlwp"
121
  >
122
  <svg
123
  className="w-5 h-5"
124
  fill="none"
125
  stroke="currentColor"
126
  viewBox="0 0 24 24"
127
+ data-oid="hshz1fz"
128
  >
129
  <path
130
  strokeLinecap="round"
131
  strokeLinejoin="round"
132
  strokeWidth={2}
133
  d="M15 19l-7-7 7-7"
134
+ data-oid="ur0xm_r"
135
  />
136
  </svg>
137
  </button>
138
  </div>
139
 
140
+ <div className="flex items-center gap-2" data-oid="flleluo">
141
+ <span
142
+ className="px-4 py-2 rounded-lg bg-gray-800 text-white font-medium"
143
+ data-oid="o6jdc8l"
144
+ >
145
  Page {currentPage} of {totalPages}
146
  </span>
147
  </div>
148
 
149
+ <div className="flex items-center gap-2" data-oid="mvf4as4">
150
  <button
151
  onClick={() => handlePageChange(currentPage + 1)}
152
  disabled={currentPage >= totalPages}
153
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
154
+ data-oid="urckp54"
155
  >
156
  <svg
157
  className="w-5 h-5"
158
  fill="none"
159
  stroke="currentColor"
160
  viewBox="0 0 24 24"
161
+ data-oid="ic4kb.e"
162
  >
163
  <path
164
  strokeLinecap="round"
165
  strokeLinejoin="round"
166
  strokeWidth={2}
167
  d="M9 5l7 7-7 7"
168
+ data-oid="k3a5i-q"
169
  />
170
  </svg>
171
  </button>
 
173
  onClick={() => handlePageChange(totalPages)}
174
  disabled={currentPage >= totalPages}
175
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 disabled:hover:bg-gray-800 disabled:hover:text-gray-400 transition-colors"
176
+ data-oid="r.gov:g"
177
  >
178
  <svg
179
  className="w-5 h-5"
180
  fill="none"
181
  stroke="currentColor"
182
  viewBox="0 0 24 24"
183
+ data-oid="stt:nzr"
184
  >
185
  <path
186
  strokeLinecap="round"
187
  strokeLinejoin="round"
188
  strokeWidth={2}
189
  d="M13 5l7 7-7 7M5 5l7 7-7 7"
190
+ data-oid="qf14v4d"
191
  />
192
  </svg>
193
  </button>
 
203
  const [filterActive, setFilterActive] = useState(false);
204
 
205
  return (
206
+ <div className="page min-h-screen pt-20 pb-4" data-oid="g:6lbe-">
207
+ <div className="container mx-auto portrait:px-3 px-4" data-oid="rfwhupx">
208
  {/* Header Section */}
209
+ <div className="mb-8 space-y-2" data-oid="s_fx0hn">
210
+ <h2 className="text-4xl font-bold text-white" data-oid="i2lnnbw">
211
+ TV Shows
212
+ </h2>
213
+ <p className="text-gray-400 max-w-3xl" data-oid="_-_30bb">
214
  Explore our collection of TV series from various genres. From drama to
215
  comedy, find your next binge-worthy show here.
216
  </p>
217
+ <GenresFilter
218
+ mediaType="series"
219
+ onFilterChange={setFilterActive}
220
+ data-oid="rm:i33j"
221
+ />
222
  </div>
223
 
224
  {/* Content Section */}
225
  {!filterActive && (
226
+ <div
227
+ className="bg-gray-800/30 rounded-3xl backdrop-blur-sm border border-gray-700/50"
228
+ data-oid="qbe.vm6"
229
+ >
230
  <Suspense
231
  fallback={
232
+ <div
233
+ className="flex items-center justify-center min-h-[50vh]"
234
+ data-oid="y_wp-ye"
235
+ >
236
+ <div
237
+ className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"
238
+ data-oid="12i7scm"
239
+ ></div>
240
  </div>
241
  }
242
+ data-oid="ocs:-_m"
243
  >
244
+ <TvShowsContent data-oid="0vw1_jx" />
245
  </Suspense>
246
  </div>
247
  )}
frontend/app/layout.tsx CHANGED
@@ -10,18 +10,19 @@ const inter = Inter({ subsets: ['latin'] });
10
 
11
  export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
12
  return (
13
- <html lang="en">
14
- <body className={inter.className}>
15
- <LoadingProvider>
16
- <SpinnerLoadingProvider>
17
- <div className="min-h-screen bg-gray-900 text-white">
18
- <Navbar />
19
  {children}
20
  <ProgressBar
21
  height="5px"
22
  color="#8927e9"
23
  options={{ showSpinner: false }}
24
  shallowRouting
 
25
  />
26
  </div>
27
  </SpinnerLoadingProvider>
 
10
 
11
  export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
12
  return (
13
+ <html lang="en" data-oid="1_gq4u4">
14
+ <body className={inter.className} data-oid="9kys4r7">
15
+ <LoadingProvider data-oid=".vw1l-d">
16
+ <SpinnerLoadingProvider data-oid="coli-8u">
17
+ <div className="min-h-screen bg-gray-900 text-white" data-oid="lqjkae6">
18
+ <Navbar data-oid="5q86ab0" />
19
  {children}
20
  <ProgressBar
21
  height="5px"
22
  color="#8927e9"
23
  options={{ showSpinner: false }}
24
  shallowRouting
25
+ data-oid="egg_77i"
26
  />
27
  </div>
28
  </SpinnerLoadingProvider>
frontend/app/movie/[title]/LoadingSkeleton.tsx CHANGED
@@ -1,32 +1,56 @@
1
  export function LoadingSkeleton() {
2
  return (
3
- <div className="w-full min-h-screen bg-gray-900 animate-pulse">
4
  {/* Hero Section Skeleton */}
5
- <div className="relative w-full h-[60vh] bg-gray-800">
6
- <div className="absolute inset-0 flex items-center justify-center">
7
- <div className="w-full max-w-7xl px-6 space-y-4">
8
- <div className="h-12 bg-gray-700 rounded-lg w-3/4 max-w-2xl"></div>
9
- <div className="h-6 bg-gray-700 rounded-lg w-1/4 max-w-xs"></div>
10
- <div className="flex gap-4">
11
- <div className="h-12 bg-gray-700 rounded-3xl w-32"></div>
12
- <div className="h-12 bg-gray-700 rounded-3xl w-32"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  </div>
14
  </div>
15
  </div>
16
  </div>
17
 
18
  {/* Details Section Skeleton */}
19
- <div className="max-w-7xl mx-auto px-6 py-12 space-y-8">
20
- <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
21
- <div className="md:col-span-2 space-y-4">
22
- <div className="h-8 bg-gray-800 rounded-lg w-1/2"></div>
23
- <div className="h-32 bg-gray-800 rounded-lg w-full"></div>
24
- <div className="h-8 bg-gray-800 rounded-lg w-1/3"></div>
25
- <div className="h-24 bg-gray-800 rounded-lg w-full"></div>
 
 
 
 
 
 
26
  </div>
27
- <div className="space-y-4">
28
- <div className="h-8 bg-gray-800 rounded-lg w-full"></div>
29
- <div className="h-40 bg-gray-800 rounded-lg w-full"></div>
 
 
 
30
  </div>
31
  </div>
32
  </div>
 
1
  export function LoadingSkeleton() {
2
  return (
3
+ <div className="w-full min-h-screen bg-gray-900 animate-pulse" data-oid="73f9oui">
4
  {/* Hero Section Skeleton */}
5
+ <div className="relative w-full h-[60vh] bg-gray-800" data-oid="2lssp0g">
6
+ <div
7
+ className="absolute inset-0 flex items-center justify-center"
8
+ data-oid="e:k_3l2"
9
+ >
10
+ <div className="w-full max-w-7xl px-6 space-y-4" data-oid=":8p5gdk">
11
+ <div
12
+ className="h-12 bg-gray-700 rounded-lg w-3/4 max-w-2xl"
13
+ data-oid="bdjs9p4"
14
+ ></div>
15
+ <div
16
+ className="h-6 bg-gray-700 rounded-lg w-1/4 max-w-xs"
17
+ data-oid="6hifqk3"
18
+ ></div>
19
+ <div className="flex gap-4" data-oid="p3p8-k4">
20
+ <div
21
+ className="h-12 bg-gray-700 rounded-3xl w-32"
22
+ data-oid="i_.99tb"
23
+ ></div>
24
+ <div
25
+ className="h-12 bg-gray-700 rounded-3xl w-32"
26
+ data-oid="qda.fb7"
27
+ ></div>
28
  </div>
29
  </div>
30
  </div>
31
  </div>
32
 
33
  {/* Details Section Skeleton */}
34
+ <div className="max-w-7xl mx-auto px-6 py-12 space-y-8" data-oid="v_::bzo">
35
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-8" data-oid="n5y-f2a">
36
+ <div className="md:col-span-2 space-y-4" data-oid="9u4ksz9">
37
+ <div className="h-8 bg-gray-800 rounded-lg w-1/2" data-oid="t7dke8s"></div>
38
+ <div
39
+ className="h-32 bg-gray-800 rounded-lg w-full"
40
+ data-oid="f9jr15m"
41
+ ></div>
42
+ <div className="h-8 bg-gray-800 rounded-lg w-1/3" data-oid="zmv0iah"></div>
43
+ <div
44
+ className="h-24 bg-gray-800 rounded-lg w-full"
45
+ data-oid="xg:0sg7"
46
+ ></div>
47
  </div>
48
+ <div className="space-y-4" data-oid="22h1s57">
49
+ <div className="h-8 bg-gray-800 rounded-lg w-full" data-oid=":85tapy"></div>
50
+ <div
51
+ className="h-40 bg-gray-800 rounded-lg w-full"
52
+ data-oid="p29b82g"
53
+ ></div>
54
  </div>
55
  </div>
56
  </div>
frontend/app/movie/[title]/page.tsx CHANGED
@@ -8,12 +8,9 @@ import { useLoading } from '@/components/loading/SplashScreen';
8
  import Link from 'next/link';
9
  import CastSection from '@/components/sections/CastSection';
10
  import { PlayIcon } from '@heroicons/react/24/outline';
11
- import MovieLinkFetcherModal from '@/components/movie/MovieLinkFetcherModal';
12
- import MoviePlayerModal from '@/components/movie/MoviePlayerModal';
13
 
14
  export default function MovieTitlePage() {
15
  const { title } = useParams();
16
- const { loading, setLoading } = useLoading();
17
  const decodedTitle = decodeURIComponent(Array.isArray(title) ? title[0] : title);
18
 
19
  interface Movie {
@@ -30,9 +27,6 @@ export default function MovieTitlePage() {
30
  }
31
 
32
  const [movie, setMovie] = useState<Movie | null>(null);
33
- const [isFetchLinkModalOpen, setIsFetchLinkModalOpen] = useState(false);
34
- const [isPlayerModalOpen, setIsPlayerModalOpen] = useState(false);
35
- const [videoLink, setVideoLink] = useState<string | null>(null);
36
 
37
  useEffect(() => {
38
  async function fetchMovie() {
@@ -47,23 +41,7 @@ export default function MovieTitlePage() {
47
  }, [decodedTitle]);
48
 
49
  if (!movie) {
50
- return <LoadingSkeleton />;
51
- }
52
-
53
- // When the Play button is clicked, open the link fetcher modal.
54
- const handlePlayClick = () => {
55
- setIsFetchLinkModalOpen(true);
56
- };
57
-
58
- // Callback when the video link is fetched
59
- const handleVideoLinkFetched = (link: string) => {
60
- setVideoLink(link);
61
- setIsFetchLinkModalOpen(false);
62
- setIsPlayerModalOpen(true);
63
- };
64
-
65
- if (loading) {
66
- setLoading(false);
67
  }
68
 
69
  return (
@@ -75,21 +53,31 @@ export default function MovieTitlePage() {
75
  backgroundPosition: 'top',
76
  backgroundAttachment: 'fixed',
77
  }}
 
78
  >
79
  {/* Gradient Overlays */}
80
- <div className="h-screen fixed inset-0">
81
- <div className="h-full bg-gradient-to-b from-gray-900/90 via-gray-900/50 to-gray-900/90"></div>
 
 
 
82
  </div>
83
 
84
  {/* Main Content Container */}
85
- <div className="relative z-10 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
86
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
 
 
 
87
  {/* Left Column - Main Info */}
88
- <div className="lg:col-span-2 space-y-6">
89
- <div className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50">
 
 
 
90
  {/* Title and Year */}
91
- <div className="space-y-2">
92
- <h1 className="text-4xl font-bold text-white">
93
  {movie.translations?.nameTranslations?.find(
94
  (t: { language: string; name: string }) =>
95
  t.language === 'eng',
@@ -104,28 +92,43 @@ export default function MovieTitlePage() {
104
  movie?.name ||
105
  'Unknown Title'}
106
  </h1>
107
- <div className="flex items-center gap-3 text-gray-300">
108
- <span className="text-lg">{movie.year}</span>
109
- <span>•</span>
110
- <span className="bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded text-sm">
 
 
 
 
 
 
 
 
111
  Movie
112
  </span>
113
  </div>
114
  </div>
115
  {/* Genres and Score */}
116
- <div className="flex flex-wrap items-center gap-4 mt-4">
117
- <div className="flex items-center gap-2">
118
- <span className="bg-purple-500 text-white px-3 py-1 rounded-full text-sm font-medium">
 
 
 
 
 
 
119
  ⭐ {movie.score ? (movie.score / 1000).toFixed(1) : 'N/A'}
120
  </span>
121
  </div>
122
- <div className="flex flex-wrap gap-2">
123
  {Array.isArray(movie.genres)
124
  ? movie.genres.map((genre) => (
125
  <Link
126
  key={genre.id}
127
  href={`/genre/${encodeURIComponent(genre.slug)}`}
128
  className="bg-gray-700/50 hover:bg-gray-600/50 text-gray-200 px-3 py-1 rounded-full text-sm transition-colors"
 
129
  >
130
  {genre.name}
131
  </Link>
@@ -136,11 +139,14 @@ export default function MovieTitlePage() {
136
  {/* Content Ratings */}
137
  {Array.isArray(movie.contentRatings) &&
138
  movie.contentRatings.length > 0 ? (
139
- <div className="mt-6">
140
- <h3 className="text-lg font-semibold text-gray-200 mb-3">
 
 
 
141
  Content Ratings
142
  </h3>
143
- <ul className="flex flex-wrap gap-3">
144
  {movie.contentRatings.map((rating, index) => {
145
  const countryFlags = {
146
  AUS: '🇦🇺',
@@ -165,12 +171,21 @@ export default function MovieTitlePage() {
165
  <li
166
  key={index}
167
  className="flex items-center bg-gray-800/50 px-2 py-1 rounded-md text-xs"
 
168
  >
169
- <span className="mr-2">{flag}</span>
170
- <span className="text-white font-semibold">
 
 
 
 
 
171
  {rating.name}
172
  </span>
173
- <span className="text-gray-400 ml-1">
 
 
 
174
  {' '}
175
  - {rating.description || 'N/A'}
176
  </span>
@@ -180,32 +195,45 @@ export default function MovieTitlePage() {
180
  </ul>
181
  </div>
182
  ) : (
183
- <p className="text-gray-400 text-sm">
184
  No content ratings available.
185
  </p>
186
  )}
187
 
188
  {/* Action Buttons */}
189
- <div className="flex justify-start landscape:gap-2 mt-4">
190
- <button
191
- onClick={handlePlayClick}
 
 
 
192
  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"
 
193
  >
194
- <PlayIcon className="size-5" />
195
  Play Now
196
- </button>
197
  <Link
198
  href="#"
199
  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"
 
200
  >
201
  Add to My List
202
  </Link>
203
  </div>
204
  </div>
205
  {/* Overview Section */}
206
- <div className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50">
207
- <h3 className="text-lg font-semibold text-gray-200 mb-3">Overview</h3>
208
- <p className="text-gray-300 leading-relaxed">
 
 
 
 
 
 
 
 
209
  {movie.translations?.overviewTranslations?.find(
210
  (t: { language: string; overview: string }) =>
211
  t.language === 'eng',
@@ -217,29 +245,11 @@ export default function MovieTitlePage() {
217
  </div>
218
 
219
  {/* Right Column - Cast */}
220
- <div className="lg:col-span-1">
221
- <CastSection movie={movie} />
222
  </div>
223
  </div>
224
  </div>
225
- {/* Modals */}
226
- <MovieLinkFetcherModal
227
- title={decodedTitle}
228
- isOpen={isFetchLinkModalOpen}
229
- onClose={() => setIsFetchLinkModalOpen(false)}
230
- onVideoLinkFetched={handleVideoLinkFetched}
231
- />
232
-
233
- {/* Movie Player Modal */}
234
- {videoLink && (
235
- <MoviePlayerModal
236
- videoUrl={videoLink}
237
- videoTitle={decodedTitle}
238
- isOpen={isPlayerModalOpen}
239
- onClose={() => setIsPlayerModalOpen(false)}
240
- contentRatings={movie.contentRatings || []} // or pass a single rating if you prefer
241
- />
242
- )}
243
  </div>
244
  );
245
  }
 
8
  import Link from 'next/link';
9
  import CastSection from '@/components/sections/CastSection';
10
  import { PlayIcon } from '@heroicons/react/24/outline';
 
 
11
 
12
  export default function MovieTitlePage() {
13
  const { title } = useParams();
 
14
  const decodedTitle = decodeURIComponent(Array.isArray(title) ? title[0] : title);
15
 
16
  interface Movie {
 
27
  }
28
 
29
  const [movie, setMovie] = useState<Movie | null>(null);
 
 
 
30
 
31
  useEffect(() => {
32
  async function fetchMovie() {
 
41
  }, [decodedTitle]);
42
 
43
  if (!movie) {
44
+ return <LoadingSkeleton data-oid="jqmlniy" />;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
46
 
47
  return (
 
53
  backgroundPosition: 'top',
54
  backgroundAttachment: 'fixed',
55
  }}
56
+ data-oid="oo5okx8"
57
  >
58
  {/* Gradient Overlays */}
59
+ <div className="h-screen fixed inset-0" data-oid="sxu9dm_">
60
+ <div
61
+ className="h-full bg-gradient-to-b from-gray-900/90 via-gray-900/50 to-gray-900/90"
62
+ data-oid="ifbddio"
63
+ ></div>
64
  </div>
65
 
66
  {/* Main Content Container */}
67
+ <div
68
+ className="relative z-10 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"
69
+ data-oid="igsokki"
70
+ >
71
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8" data-oid="6kseqcc">
72
  {/* Left Column - Main Info */}
73
+ <div className="lg:col-span-2 space-y-6" data-oid="2dark0v">
74
+ <div
75
+ className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50"
76
+ data-oid="xftrcvj"
77
+ >
78
  {/* Title and Year */}
79
+ <div className="space-y-2" data-oid="54-fa7v">
80
+ <h1 className="text-4xl font-bold text-white" data-oid="ko:o3._">
81
  {movie.translations?.nameTranslations?.find(
82
  (t: { language: string; name: string }) =>
83
  t.language === 'eng',
 
92
  movie?.name ||
93
  'Unknown Title'}
94
  </h1>
95
+ <div
96
+ className="flex items-center gap-3 text-gray-300"
97
+ data-oid="ioyb9wh"
98
+ >
99
+ <span className="text-lg" data-oid=":9_szf2">
100
+ {movie.year}
101
+ </span>
102
+ <span data-oid="o6sjf8l">•</span>
103
+ <span
104
+ className="bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded text-sm"
105
+ data-oid="p08w2ui"
106
+ >
107
  Movie
108
  </span>
109
  </div>
110
  </div>
111
  {/* Genres and Score */}
112
+ <div
113
+ className="flex flex-wrap items-center gap-4 mt-4"
114
+ data-oid="jd_:e49"
115
+ >
116
+ <div className="flex items-center gap-2" data-oid="d:ehbw8">
117
+ <span
118
+ className="bg-purple-500 text-white px-3 py-1 rounded-full text-sm font-medium"
119
+ data-oid="cgw5de_"
120
+ >
121
  ⭐ {movie.score ? (movie.score / 1000).toFixed(1) : 'N/A'}
122
  </span>
123
  </div>
124
+ <div className="flex flex-wrap gap-2" data-oid="t6r1p.e">
125
  {Array.isArray(movie.genres)
126
  ? movie.genres.map((genre) => (
127
  <Link
128
  key={genre.id}
129
  href={`/genre/${encodeURIComponent(genre.slug)}`}
130
  className="bg-gray-700/50 hover:bg-gray-600/50 text-gray-200 px-3 py-1 rounded-full text-sm transition-colors"
131
+ data-oid="g.wawra"
132
  >
133
  {genre.name}
134
  </Link>
 
139
  {/* Content Ratings */}
140
  {Array.isArray(movie.contentRatings) &&
141
  movie.contentRatings.length > 0 ? (
142
+ <div className="mt-6" data-oid="beabj1.">
143
+ <h3
144
+ className="text-lg font-semibold text-gray-200 mb-3"
145
+ data-oid="ypcu8ls"
146
+ >
147
  Content Ratings
148
  </h3>
149
+ <ul className="flex flex-wrap gap-3" data-oid="qtdbmg:">
150
  {movie.contentRatings.map((rating, index) => {
151
  const countryFlags = {
152
  AUS: '🇦🇺',
 
171
  <li
172
  key={index}
173
  className="flex items-center bg-gray-800/50 px-2 py-1 rounded-md text-xs"
174
+ data-oid="1gf7ys3"
175
  >
176
+ <span className="mr-2" data-oid="5m:gb43">
177
+ {flag}
178
+ </span>
179
+ <span
180
+ className="text-white font-semibold"
181
+ data-oid="wk7za_2"
182
+ >
183
  {rating.name}
184
  </span>
185
+ <span
186
+ className="text-gray-400 ml-1"
187
+ data-oid="6_0lvvu"
188
+ >
189
  {' '}
190
  - {rating.description || 'N/A'}
191
  </span>
 
195
  </ul>
196
  </div>
197
  ) : (
198
+ <p className="text-gray-400 text-sm" data-oid="_uhsf1e">
199
  No content ratings available.
200
  </p>
201
  )}
202
 
203
  {/* Action Buttons */}
204
+ <div
205
+ className="flex justify-start landscape:gap-2 mt-4"
206
+ data-oid="_uwb90y"
207
+ >
208
+ <Link
209
+ href={`/watch/movie/${encodeURIComponent(decodedTitle)}`}
210
  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"
211
+ data-oid="k:mbnk4"
212
  >
213
+ <PlayIcon className="size-5" data-oid="urpd3kx" />
214
  Play Now
215
+ </Link>
216
  <Link
217
  href="#"
218
  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"
219
+ data-oid="v0s61bn"
220
  >
221
  Add to My List
222
  </Link>
223
  </div>
224
  </div>
225
  {/* Overview Section */}
226
+ <div
227
+ className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50"
228
+ data-oid="ouop9w0"
229
+ >
230
+ <h3
231
+ className="text-lg font-semibold text-gray-200 mb-3"
232
+ data-oid="7a6ky4n"
233
+ >
234
+ Overview
235
+ </h3>
236
+ <p className="text-gray-300 leading-relaxed" data-oid="nlr586l">
237
  {movie.translations?.overviewTranslations?.find(
238
  (t: { language: string; overview: string }) =>
239
  t.language === 'eng',
 
245
  </div>
246
 
247
  {/* Right Column - Cast */}
248
+ <div className="lg:col-span-1" data-oid="75iz6fx">
249
+ <CastSection movie={movie} data-oid="h-0mzb7" />
250
  </div>
251
  </div>
252
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  </div>
254
  );
255
  }
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 data-oid="55x4xe6"></div>;
16
  }
frontend/app/not-found.tsx CHANGED
@@ -14,11 +14,15 @@ export default function NotFound() {
14
  }, []);
15
 
16
  return (
17
- <div className="flex flex-col items-center justify-center min-h-screen text-white">
 
 
 
18
  <h1
19
  className={`text-6xl font-bold text-gray-300 transition-transform duration-500 ${
20
  floating ? 'translate-y-1' : '-translate-y-1'
21
  }`}
 
22
  >
23
  <svg
24
  fill="#b8b8b8"
@@ -28,67 +32,88 @@ export default function NotFound() {
28
  id="Layer_1"
29
  viewBox="0 0 512 512"
30
  stroke="#b8b8b8"
 
31
  >
32
- <g id="SVGRepo_bgCarrier" stroke-width="0"></g>
33
  <g
34
  id="SVGRepo_tracerCarrier"
35
  stroke-linecap="round"
36
  stroke-linejoin="round"
 
37
  ></g>
38
- <g id="SVGRepo_iconCarrier">
39
  {' '}
40
- <g>
41
  {' '}
42
- <g>
43
  {' '}
44
- <path d="M170.667,320.007V320c0-11.782-9.551-21.333-21.333-21.333C137.552,298.667,128,308.218,128,320v-56.89 c0-11.782-9.551-21.333-21.333-21.333s-21.333,9.551-21.333,21.333v78.229c-0.001,11.783,9.551,21.334,21.333,21.334H128v42.66 c0,11.782,9.551,21.333,21.333,21.333s21.333-9.551,21.333-21.333v-42.66c11.782,0,21.333-9.551,21.333-21.333 C192,329.558,182.449,320.007,170.667,320.007z"></path>{' '}
 
 
 
45
  </g>{' '}
46
  </g>{' '}
47
- <g>
48
  {' '}
49
- <g>
50
  {' '}
51
- <path d="M426.667,320.007V320c0-11.782-9.551-21.333-21.333-21.333C393.552,298.667,384,308.218,384,320v-56.89 c0-11.782-9.551-21.333-21.333-21.333c-11.782,0-21.333,9.551-21.333,21.333v78.229c-0.001,11.783,9.551,21.334,21.333,21.334H384 v42.66c0,11.782,9.551,21.333,21.333,21.333c11.782,0,21.333-9.551,21.333-21.333v-42.66c11.782,0,21.333-9.551,21.333-21.333 C448,329.558,438.449,320.007,426.667,320.007z"></path>{' '}
 
 
 
52
  </g>{' '}
53
  </g>{' '}
54
- <g>
55
  {' '}
56
- <g>
57
  {' '}
58
- <path d="M266.667,256c-29.446,0-53.333,23.887-53.333,53.333v64c-0.001,29.446,23.887,53.334,53.333,53.334 S320,402.78,320,373.334v-64C320,279.887,296.114,256,266.667,256z M277.334,373.333c0,5.882-4.785,10.667-10.667,10.667 c-5.882,0-10.667-4.785-10.667-10.667v-64c0-5.882,4.785-10.667,10.667-10.667c5.882,0,10.667,4.785,10.667,10.667V373.333z"></path>{' '}
 
 
 
59
  </g>{' '}
60
  </g>{' '}
61
- <g>
62
  {' '}
63
- <g>
64
  {' '}
65
- <path d="M490.667,0H21.333C9.552,0,0,9.551,0,21.333V192v298.667C0,502.449,9.552,512,21.333,512h469.333 c11.782,0,21.333-9.551,21.333-21.333V192V21.333C512,9.551,502.45,0,490.667,0z M469.334,469.333L469.334,469.333H42.667v-256 h426.667V469.333z M469.334,170.667H42.667v-128h426.667V170.667z"></path>{' '}
 
 
 
66
  </g>{' '}
67
  </g>{' '}
68
- <g>
69
  {' '}
70
- <g>
71
  {' '}
72
- <path d="M435.505,106.666l6.248-6.248c8.331-8.331,8.331-21.838,0-30.17c-8.331-8.331-21.839-8.331-30.17,0l-6.248,6.248 l-6.248-6.248c-8.331-8.331-21.839-8.331-30.17,0c-8.331,8.331-8.331,21.839,0,30.17l6.248,6.248l-6.248,6.248 c-8.331,8.331-8.331,21.839,0,30.17s21.839,8.331,30.17,0l6.248-6.248l6.248,6.248c8.331,8.331,21.839,8.331,30.17,0 s8.331-21.839,0-30.17L435.505,106.666z"></path>{' '}
 
 
 
73
  </g>{' '}
74
  </g>{' '}
75
- <g>
76
  {' '}
77
- <g>
78
  {' '}
79
- <path d="M256,85.333H85.333C73.552,85.333,64,94.885,64,106.667S73.552,128,85.333,128H256c11.782,0,21.333-9.551,21.333-21.333 S267.783,85.333,256,85.333z"></path>{' '}
 
 
 
80
  </g>{' '}
81
  </g>{' '}
82
  </g>
83
  </svg>
84
  </h1>
85
 
86
- <p className="text-xl text-gray-400 mt-4">
87
  Oops! The page you’re looking for doesn’t exist.
88
  </p>
89
  <Link
90
  href="/"
91
  className="mt-5 px-6 py-2 rounded-3xl bg-purple-600 hover:bg-purple-500 transition-all text-white text-lg font-semibold shadow-lg shadow-purple-500/30"
 
92
  >
93
  Go Home
94
  </Link>
 
14
  }, []);
15
 
16
  return (
17
+ <div
18
+ className="flex flex-col items-center justify-center min-h-screen text-white"
19
+ data-oid="7fn1qz:"
20
+ >
21
  <h1
22
  className={`text-6xl font-bold text-gray-300 transition-transform duration-500 ${
23
  floating ? 'translate-y-1' : '-translate-y-1'
24
  }`}
25
+ data-oid="t:boz3."
26
  >
27
  <svg
28
  fill="#b8b8b8"
 
32
  id="Layer_1"
33
  viewBox="0 0 512 512"
34
  stroke="#b8b8b8"
35
+ data-oid="8vjll3o"
36
  >
37
+ <g id="SVGRepo_bgCarrier" stroke-width="0" data-oid="sg7bur1"></g>
38
  <g
39
  id="SVGRepo_tracerCarrier"
40
  stroke-linecap="round"
41
  stroke-linejoin="round"
42
+ data-oid="ijsnjgi"
43
  ></g>
44
+ <g id="SVGRepo_iconCarrier" data-oid="yhgbs62">
45
  {' '}
46
+ <g data-oid="348e0g9">
47
  {' '}
48
+ <g data-oid="tqzhz4f">
49
  {' '}
50
+ <path
51
+ d="M170.667,320.007V320c0-11.782-9.551-21.333-21.333-21.333C137.552,298.667,128,308.218,128,320v-56.89 c0-11.782-9.551-21.333-21.333-21.333s-21.333,9.551-21.333,21.333v78.229c-0.001,11.783,9.551,21.334,21.333,21.334H128v42.66 c0,11.782,9.551,21.333,21.333,21.333s21.333-9.551,21.333-21.333v-42.66c11.782,0,21.333-9.551,21.333-21.333 C192,329.558,182.449,320.007,170.667,320.007z"
52
+ data-oid="gpy308i"
53
+ ></path>{' '}
54
  </g>{' '}
55
  </g>{' '}
56
+ <g data-oid="kmi5rgh">
57
  {' '}
58
+ <g data-oid="pprspeg">
59
  {' '}
60
+ <path
61
+ d="M426.667,320.007V320c0-11.782-9.551-21.333-21.333-21.333C393.552,298.667,384,308.218,384,320v-56.89 c0-11.782-9.551-21.333-21.333-21.333c-11.782,0-21.333,9.551-21.333,21.333v78.229c-0.001,11.783,9.551,21.334,21.333,21.334H384 v42.66c0,11.782,9.551,21.333,21.333,21.333c11.782,0,21.333-9.551,21.333-21.333v-42.66c11.782,0,21.333-9.551,21.333-21.333 C448,329.558,438.449,320.007,426.667,320.007z"
62
+ data-oid="zz.o2js"
63
+ ></path>{' '}
64
  </g>{' '}
65
  </g>{' '}
66
+ <g data-oid="tcnsy4o">
67
  {' '}
68
+ <g data-oid="5d7:28a">
69
  {' '}
70
+ <path
71
+ d="M266.667,256c-29.446,0-53.333,23.887-53.333,53.333v64c-0.001,29.446,23.887,53.334,53.333,53.334 S320,402.78,320,373.334v-64C320,279.887,296.114,256,266.667,256z M277.334,373.333c0,5.882-4.785,10.667-10.667,10.667 c-5.882,0-10.667-4.785-10.667-10.667v-64c0-5.882,4.785-10.667,10.667-10.667c5.882,0,10.667,4.785,10.667,10.667V373.333z"
72
+ data-oid="u2n36p_"
73
+ ></path>{' '}
74
  </g>{' '}
75
  </g>{' '}
76
+ <g data-oid="fifqx49">
77
  {' '}
78
+ <g data-oid=":7qgb9o">
79
  {' '}
80
+ <path
81
+ d="M490.667,0H21.333C9.552,0,0,9.551,0,21.333V192v298.667C0,502.449,9.552,512,21.333,512h469.333 c11.782,0,21.333-9.551,21.333-21.333V192V21.333C512,9.551,502.45,0,490.667,0z M469.334,469.333L469.334,469.333H42.667v-256 h426.667V469.333z M469.334,170.667H42.667v-128h426.667V170.667z"
82
+ data-oid="5givur:"
83
+ ></path>{' '}
84
  </g>{' '}
85
  </g>{' '}
86
+ <g data-oid="1x0ldr6">
87
  {' '}
88
+ <g data-oid=".3-a8sy">
89
  {' '}
90
+ <path
91
+ d="M435.505,106.666l6.248-6.248c8.331-8.331,8.331-21.838,0-30.17c-8.331-8.331-21.839-8.331-30.17,0l-6.248,6.248 l-6.248-6.248c-8.331-8.331-21.839-8.331-30.17,0c-8.331,8.331-8.331,21.839,0,30.17l6.248,6.248l-6.248,6.248 c-8.331,8.331-8.331,21.839,0,30.17s21.839,8.331,30.17,0l6.248-6.248l6.248,6.248c8.331,8.331,21.839,8.331,30.17,0 s8.331-21.839,0-30.17L435.505,106.666z"
92
+ data-oid="g1ou.9n"
93
+ ></path>{' '}
94
  </g>{' '}
95
  </g>{' '}
96
+ <g data-oid="la4toiq">
97
  {' '}
98
+ <g data-oid=".:vwvk4">
99
  {' '}
100
+ <path
101
+ d="M256,85.333H85.333C73.552,85.333,64,94.885,64,106.667S73.552,128,85.333,128H256c11.782,0,21.333-9.551,21.333-21.333 S267.783,85.333,256,85.333z"
102
+ data-oid="o8q57y9"
103
+ ></path>{' '}
104
  </g>{' '}
105
  </g>{' '}
106
  </g>
107
  </svg>
108
  </h1>
109
 
110
+ <p className="text-xl text-gray-400 mt-4" data-oid=":m00ggz">
111
  Oops! The page you’re looking for doesn’t exist.
112
  </p>
113
  <Link
114
  href="/"
115
  className="mt-5 px-6 py-2 rounded-3xl bg-purple-600 hover:bg-purple-500 transition-all text-white text-lg font-semibold shadow-lg shadow-purple-500/30"
116
+ data-oid="6mc7yrw"
117
  >
118
  Go Home
119
  </Link>
frontend/app/page.tsx CHANGED
@@ -48,19 +48,32 @@ 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 */}
55
  <div
56
  className={`absolute inset-0 flex items-center justify-center bg-gray-900 transition-opacity duration-1000 ${
57
  loading ? 'opacity-100' : 'opacity-0 pointer-events-none'
58
  }`}
 
59
  >
60
- <div className="w-full h-full flex flex-col justify-end bg-gray-800 rounded-lg animate-pulse">
61
- <div className="w-2/4 h-5 landscape:w-1/4 ml-10 landscape:ml-20 mb-4 bg-gray-700 rounded-lg"></div>
62
- <div className="w-3/4 h-14 landscape:w-2/4 ml-10 landscape:ml-20 mb-4 bg-gray-700 rounded-lg"></div>
63
- <div className="w-3/4 h-1/4 landscape:w-2/4 ml-10 landscape:ml-20 mb-20 bg-gray-700 rounded-lg"></div>
 
 
 
 
 
 
 
 
 
 
 
 
64
  </div>
65
  </div>
66
 
@@ -69,6 +82,7 @@ export default function Page() {
69
  className={`absolute inset-0 transition-opacity duration-1000 ${
70
  loaded ? 'opacity-100' : 'opacity-0'
71
  }`}
 
72
  >
73
  {slides.map((slide, index) => (
74
  <div
@@ -76,24 +90,36 @@ export default function Page() {
76
  className={`absolute inset-0 transition-opacity duration-1000 ${
77
  index === currentSlide ? 'opacity-100' : 'opacity-0'
78
  }`}
 
79
  >
80
- <div className="absolute inset-0 bg-black/50 z-10" />
81
  <div
82
  className="w-full h-full bg-center pan-animation transition-transform duration-1000 transform-gpu"
83
  style={{ backgroundImage: `url(${slide.image})` }}
 
84
  ></div>
85
- <div className="absolute bottom-0 left-0 right-0 z-20 p-10 md:p-20 bg-gradient-to-t from-gray-900">
86
- <div className="container mx-auto">
87
- <h1 className="text-4xl md:text-5xl font-sans font-medium mt-2 mb-4">
 
 
 
 
 
 
88
  {slide.title}
89
  </h1>
90
- <span className="text-purple-400 text-base font-semibold flex flex-wrap gap-2">
 
 
 
91
  {Array.isArray(slide.genre) ? (
92
  slide.genre.map((genre, index) => (
93
- <span key={index}>
94
  <Link
95
  href={`/genre/${encodeURIComponent(genre)}`}
96
  className="bg-purple-700/50 hover:bg-gray-600/50 text-gray-200 px-3 py-1 rounded-full text-sm transition-colors"
 
97
  >
98
  {genre}
99
  </Link>
@@ -103,21 +129,33 @@ export default function Page() {
103
  <Link
104
  href={`/genre/${encodeURIComponent(slide.genre)}`}
105
  className="bg-purple-700/50 hover:bg-gray-600/50 text-gray-200 px-3 py-1 rounded-full text-sm transition-colors"
 
106
  >
107
  {slide.genre}
108
  </Link>
109
  )}
110
  </span>
111
 
112
- <p className="text-gray-300 text-lg font-sans max-w-xl overflow-hidden line-clamp-5">
 
 
 
113
  {slide.description}
114
  </p>
115
- <div className="flex justify-start landscape:gap-4 mt-8">
 
 
 
116
  <Link
117
- href={'#play'}
 
 
 
 
118
  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"
 
119
  >
120
- <PlayIcon className="size-5" />
121
  Play Now
122
  </Link>
123
  <Link
@@ -127,8 +165,13 @@ export default function Page() {
127
  : `/tvshow/${slide.title}`
128
  }
129
  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"
 
130
  >
131
- More Info <InformationCircleIcon className="size-5" />
 
 
 
 
132
  </Link>
133
  </div>
134
  </div>
@@ -138,7 +181,7 @@ export default function Page() {
138
  </div>
139
  </div>
140
 
141
- <NewContentSection />
142
  </div>
143
  );
144
  }
 
48
  }, [slides]);
49
 
50
  return (
51
+ <div className="page" data-oid="ch.sdg3">
52
  {/* Hero Slideshow */}
53
+ <div className="relative landscape:h-[100vh] portrait:h-[80vh]" data-oid="hqn:4x0">
54
  {/* Loading Skeleton */}
55
  <div
56
  className={`absolute inset-0 flex items-center justify-center bg-gray-900 transition-opacity duration-1000 ${
57
  loading ? 'opacity-100' : 'opacity-0 pointer-events-none'
58
  }`}
59
+ data-oid="-2:w7k3"
60
  >
61
+ <div
62
+ className="w-full h-full flex flex-col justify-end bg-gray-800 rounded-lg animate-pulse"
63
+ data-oid="n6-k2i1"
64
+ >
65
+ <div
66
+ className="w-2/4 h-5 landscape:w-1/4 ml-10 landscape:ml-20 mb-4 bg-gray-700 rounded-lg"
67
+ data-oid="xe8lt-i"
68
+ ></div>
69
+ <div
70
+ className="w-3/4 h-14 landscape:w-2/4 ml-10 landscape:ml-20 mb-4 bg-gray-700 rounded-lg"
71
+ data-oid="5aultfi"
72
+ ></div>
73
+ <div
74
+ className="w-3/4 h-1/4 landscape:w-2/4 ml-10 landscape:ml-20 mb-20 bg-gray-700 rounded-lg"
75
+ data-oid="zepou.t"
76
+ ></div>
77
  </div>
78
  </div>
79
 
 
82
  className={`absolute inset-0 transition-opacity duration-1000 ${
83
  loaded ? 'opacity-100' : 'opacity-0'
84
  }`}
85
+ data-oid="uz.mhtp"
86
  >
87
  {slides.map((slide, index) => (
88
  <div
 
90
  className={`absolute inset-0 transition-opacity duration-1000 ${
91
  index === currentSlide ? 'opacity-100' : 'opacity-0'
92
  }`}
93
+ data-oid="4woww41"
94
  >
95
+ <div className="absolute inset-0 bg-black/50 z-10" data-oid="kbvkdme" />
96
  <div
97
  className="w-full h-full bg-center pan-animation transition-transform duration-1000 transform-gpu"
98
  style={{ backgroundImage: `url(${slide.image})` }}
99
+ data-oid="t82igp8"
100
  ></div>
101
+ <div
102
+ className="absolute bottom-0 left-0 right-0 z-20 p-10 md:p-20 bg-gradient-to-t from-gray-900"
103
+ data-oid="wxk6yyx"
104
+ >
105
+ <div className="container mx-auto" data-oid="4sd-e6x">
106
+ <h1
107
+ className="text-4xl md:text-5xl font-sans font-medium mt-2 mb-4"
108
+ data-oid="u8_4-sy"
109
+ >
110
  {slide.title}
111
  </h1>
112
+ <span
113
+ className="text-purple-400 text-base font-semibold flex flex-wrap gap-2"
114
+ data-oid="qauzuhb"
115
+ >
116
  {Array.isArray(slide.genre) ? (
117
  slide.genre.map((genre, index) => (
118
+ <span key={index} data-oid="vi:_x28">
119
  <Link
120
  href={`/genre/${encodeURIComponent(genre)}`}
121
  className="bg-purple-700/50 hover:bg-gray-600/50 text-gray-200 px-3 py-1 rounded-full text-sm transition-colors"
122
+ data-oid="t0d8h7x"
123
  >
124
  {genre}
125
  </Link>
 
129
  <Link
130
  href={`/genre/${encodeURIComponent(slide.genre)}`}
131
  className="bg-purple-700/50 hover:bg-gray-600/50 text-gray-200 px-3 py-1 rounded-full text-sm transition-colors"
132
+ data-oid="fka3qge"
133
  >
134
  {slide.genre}
135
  </Link>
136
  )}
137
  </span>
138
 
139
+ <p
140
+ className="text-gray-300 text-lg font-sans max-w-xl overflow-hidden line-clamp-5"
141
+ data-oid="yjgs9an"
142
+ >
143
  {slide.description}
144
  </p>
145
+ <div
146
+ className="flex justify-start landscape:gap-4 mt-8"
147
+ data-oid="a05syev"
148
+ >
149
  <Link
150
+ href={
151
+ slide.type === 'movie'
152
+ ? `/watch/movie/${slide.title}`
153
+ : `/watch/tvshow/${slide.title}`
154
+ }
155
  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"
156
+ data-oid="7no3hf3"
157
  >
158
+ <PlayIcon className="size-5" data-oid="a8d24n_" />
159
  Play Now
160
  </Link>
161
  <Link
 
165
  : `/tvshow/${slide.title}`
166
  }
167
  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"
168
+ data-oid="51qqotn"
169
  >
170
+ More Info{' '}
171
+ <InformationCircleIcon
172
+ className="size-5"
173
+ data-oid="_bm0usz"
174
+ />
175
  </Link>
176
  </div>
177
  </div>
 
181
  </div>
182
  </div>
183
 
184
+ <NewContentSection data-oid="v5_ktqn" />
185
  </div>
186
  );
187
  }
frontend/app/search/page.tsx ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, FormEvent, useMemo } from 'react';
4
+ import Link from 'next/link';
5
+ import { SEARCH_API_URL } from '@/lib/config';
6
+ import { useSpinnerLoading } from '@/components/loading/Spinner';
7
+ import { MovieCard } from '@/components/movie/MovieCard';
8
+ import { TvShowCard } from '@/components/tvshow/TvShowCard';
9
+ import ScrollSection from '@/components/sections/ScrollSection';
10
+ import { getSeasonMetadata } from '@/lib/lb';
11
+ // ^ assumed signature: getSeasonMetadata(seriesName: string, seasonNum: number) => Promise<Metadata[]>
12
+
13
+ type EpisodeMetadata = {
14
+ id: number;
15
+ seriesId: number;
16
+ name: string;
17
+ aired: string;
18
+ runtime: number;
19
+ overview: string;
20
+ image: string; // e.g. "/banners/episodes/343913/6684232.jpg"
21
+ number: number; // Episode number in the season
22
+ seasonNumber: number;
23
+ year?: string;
24
+ };
25
+
26
+ type Episode = {
27
+ series: string;
28
+ title: string;
29
+ path: string;
30
+ season: string;
31
+ metadata?: EpisodeMetadata; // Attach the matched metadata here
32
+ };
33
+
34
+ type SearchResults = {
35
+ films: string[];
36
+ series: string[];
37
+ episodes: Episode[];
38
+ };
39
+
40
+ export default function SearchPage() {
41
+ const [query, setQuery] = useState('');
42
+ const [results, setResults] = useState<SearchResults | null>(null);
43
+
44
+ const { spinnerLoading, setSpinnerLoading } = useSpinnerLoading();
45
+ const [error, setError] = useState('');
46
+
47
+ // By default, all types (films, series, episodes) are selected
48
+ const [selectedTypes, setSelectedTypes] = useState<string[]>(['films', 'series', 'episodes']);
49
+
50
+ // Toggle which types are displayed (using "pills" instead of checkboxes)
51
+ const toggleType = (type: string) => {
52
+ setSelectedTypes((prev) =>
53
+ prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type],
54
+ );
55
+ };
56
+
57
+ // Helper: parse season number from something like "Season 1" or "S01"
58
+ function parseSeasonNumber(seasonName: string): number | null {
59
+ const match = seasonName.match(/(\d+)/);
60
+ return match ? parseInt(match[1], 10) : null;
61
+ }
62
+
63
+ // Helper: parse episode number from something like "S01E03" or "E03"
64
+ function parseEpisodeNumber(title: string): number | null {
65
+ // Try the standard SxxExx first
66
+ let match = title.match(/S(\d+)E(\d+)/i);
67
+ if (match) {
68
+ return parseInt(match[2], 10);
69
+ }
70
+ // Fallback: just Exx
71
+ match = title.match(/E(\d+)/i);
72
+ if (match) {
73
+ return parseInt(match[1], 10);
74
+ }
75
+ return null;
76
+ }
77
+
78
+ // Debounced search whenever 'query' changes
79
+ useEffect(() => {
80
+ const handler = setTimeout(() => {
81
+ if (query.trim() === '') {
82
+ setResults(null);
83
+ return;
84
+ }
85
+ doSearch(query);
86
+ }, 500);
87
+
88
+ return () => clearTimeout(handler);
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ }, [query]);
91
+
92
+ // For manual searching if the user presses Enter
93
+ const handleSubmit = (e: FormEvent) => {
94
+ e.preventDefault();
95
+ if (query.trim() !== '') {
96
+ doSearch(query);
97
+ }
98
+ };
99
+
100
+ // Core search function
101
+ const doSearch = async (searchQuery: string) => {
102
+ setSpinnerLoading(true);
103
+ setError('');
104
+ try {
105
+ const res = await fetch(`${SEARCH_API_URL}/api/search`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ query: searchQuery }),
109
+ });
110
+ if (!res.ok) {
111
+ throw new Error('Network response was not ok');
112
+ }
113
+ const data: SearchResults = await res.json();
114
+
115
+ // Now we have data. Next, fetch & attach metadata for episodes.
116
+ const grouped = groupEpisodesBySeriesAndSeason(data.episodes);
117
+ await attachSeasonMetadata(grouped);
118
+ // Once done, flatten them back for display
119
+ const flattened = flattenGroupedEpisodes(grouped);
120
+
121
+ // Put the updated episodes & everything else in state
122
+ setResults({
123
+ ...data,
124
+ episodes: flattened,
125
+ });
126
+ } catch (err) {
127
+ setError('Error fetching search results');
128
+ }
129
+ setSpinnerLoading(false);
130
+ };
131
+
132
+ // Group episodes by series -> season
133
+ function groupEpisodesBySeriesAndSeason(episodes: Episode[]) {
134
+ const grouped: Record<string, Record<string, Episode[]>> = {};
135
+ episodes.forEach((ep) => {
136
+ if (!grouped[ep.series]) {
137
+ grouped[ep.series] = {};
138
+ }
139
+ if (!grouped[ep.series][ep.season]) {
140
+ grouped[ep.series][ep.season] = [];
141
+ }
142
+ grouped[ep.series][ep.season].push(ep);
143
+ });
144
+ return grouped;
145
+ }
146
+
147
+ // For each (series + season) group, fetch getSeasonMetadata once,
148
+ // then match local episodes by episode number.
149
+ async function attachSeasonMetadata(grouped: Record<string, Record<string, Episode[]>>) {
150
+ const promises: Promise<void>[] = [];
151
+
152
+ for (const [seriesName, seasons] of Object.entries(grouped)) {
153
+ for (const [seasonName, episodesArr] of Object.entries(seasons)) {
154
+ // We'll fetch metadata for that (seriesName, seasonName)
155
+ const promise = getSeasonMetadata(seriesName, seasonName).then(
156
+ (seasonMeta: EpisodeMetadata[]) => {
157
+ // For each local ep, parse its episode number & find matching metadata
158
+ episodesArr.forEach((ep) => {
159
+ const epNum = parseEpisodeNumber(ep.title);
160
+ if (!epNum) return;
161
+ const matchedMeta = seasonMeta.find((m) => m.number === epNum);
162
+ if (matchedMeta) {
163
+ ep.metadata = matchedMeta;
164
+ }
165
+ });
166
+ },
167
+ );
168
+ promises.push(promise);
169
+ }
170
+ }
171
+ await Promise.all(promises);
172
+ }
173
+
174
+ // Flatten grouped episodes back into a single array
175
+ function flattenGroupedEpisodes(grouped: Record<string, Record<string, Episode[]>>): Episode[] {
176
+ return Object.values(grouped).flatMap((seasons) =>
177
+ Object.values(seasons).flatMap((eps) => eps),
178
+ );
179
+ }
180
+
181
+ // Once everything is attached, we can just rely on `results.episodes` for final display
182
+ const flattenedEpisodes = results?.episodes ?? [];
183
+
184
+ return (
185
+ <div className="page min-h-screen pt-20 pb-4" data-oid="c30dpdf">
186
+ <div className="container mx-auto portrait:px-3 px-4" data-oid="qc8wc5o">
187
+ {/* Header Section */}
188
+ <div className="mb-8 space-y-2" data-oid="l_h1wg-">
189
+ <h2 className="text-4xl font-bold text-white" data-oid="q5f:fz6">
190
+ Search
191
+ </h2>
192
+ <p className="text-gray-400 max-w-3xl" data-oid="087lc-o">
193
+ Search for movies, TV shows, and episodes.
194
+ </p>
195
+ </div>
196
+ {/* Search + Filters */}
197
+ <form onSubmit={handleSubmit} className="mb-8">
198
+ <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
199
+ {/* Search Input */}
200
+ <div className="flex flex-col">
201
+ <input
202
+ id="search"
203
+ type="text"
204
+ value={query}
205
+ onChange={(e) => setQuery(e.target.value)}
206
+ placeholder="Type to search..."
207
+ className="p-2 w-72 rounded border border-gray-700 bg-[#1E1E1E] text-white
208
+ focus:outline-none focus:ring-2 focus:ring-blue-600"
209
+ />
210
+ </div>
211
+
212
+ {/* Filter Pills */}
213
+ <div className="flex flex-wrap gap-2 items-center">
214
+ {['films', 'series', 'episodes'].map((type) => {
215
+ const isActive = selectedTypes.includes(type);
216
+ // Make the label more user-friendly
217
+ const label =
218
+ type === 'films'
219
+ ? 'Movies'
220
+ : type === 'series'
221
+ ? 'TV Shows'
222
+ : 'Episodes';
223
+
224
+ return (
225
+ <button
226
+ key={type}
227
+ type="button"
228
+ onClick={() => toggleType(type)}
229
+ className={`px-4 py-1 rounded-full transition-colors
230
+ ${isActive ? 'bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 text-white' : 'bg-gray-700 text-gray-300'}
231
+ hover:bg-gray-600`}
232
+ >
233
+ {label}
234
+ </button>
235
+ );
236
+ })}
237
+ </div>
238
+ </div>
239
+ </form>
240
+
241
+ {/* Error message */}
242
+ {error && <p className="text-red-500 mb-4">{error}</p>}
243
+
244
+ {/* Results */}
245
+ {results && (
246
+ <div className="mt-4 space-y-8">
247
+ {/* Movies Section */}
248
+ {selectedTypes.includes('films') && results.films?.length > 0 && (
249
+ <ScrollSection title="Movies" link="/browse/movies">
250
+ {results.films.map((film, index) => (
251
+ <MovieCard key={index} title={film.replace('films/', '')} />
252
+ ))}
253
+ </ScrollSection>
254
+ )}
255
+
256
+ {/* TV Shows Section */}
257
+ {selectedTypes.includes('series') && results.series?.length > 0 && (
258
+ <ScrollSection title="TV Shows" link="/browse/tvshows">
259
+ {results.series.map((serie, index) => (
260
+ <TvShowCard key={index} title={serie} episodesCount={null} />
261
+ ))}
262
+ </ScrollSection>
263
+ )}
264
+
265
+ {/* Episodes Section (flattened, with metadata) */}
266
+ {selectedTypes.includes('episodes') && flattenedEpisodes.length > 0 && (
267
+ <ScrollSection title="Episodes" link="/browse/tvshows">
268
+ {flattenedEpisodes.map((episode) => (
269
+ <Link
270
+ href={`/watch/tvshow/${episode.series}/${episode.season}/${episode.title}`}
271
+ key={episode.path}
272
+ className="w-[160px] flex-shrink-0 rounded-md bg-[#1E1E1E] border border-gray-700
273
+ p-2 flex flex-col text-center"
274
+ >
275
+ {/* If you want to show the metadata image */}
276
+ {episode.metadata?.image && (
277
+ <img
278
+ src={`https://artworks.thetvdb.com${episode.metadata.image}`}
279
+ alt={episode.metadata.name || episode.title}
280
+ className="h-[90px] w-full object-contain rounded mb-2"
281
+ />
282
+ )}
283
+ <h4 className="text-sm font-semibold line-clamp-2 mb-1">
284
+ {episode.metadata?.name ?? episode.title}
285
+ </h4>
286
+ {/* Display short overview, or the local series-season text */}
287
+ <p className="text-xs text-gray-300 line-clamp-3">
288
+ {episode.metadata?.overview
289
+ ? episode.metadata.overview
290
+ : `${episode.series} - ${episode.season}`}
291
+ </p>
292
+ </Link>
293
+ ))}
294
+ </ScrollSection>
295
+ )}
296
+ </div>
297
+ )}
298
+ </div>
299
+ </div>
300
+ );
301
+ }
frontend/app/tvshow/[title]/LoadingSkeleton.tsx CHANGED
@@ -1,32 +1,56 @@
1
  export function LoadingSkeleton() {
2
  return (
3
- <div className="w-full min-h-screen bg-gray-900 animate-pulse">
4
  {/* Hero Section Skeleton */}
5
- <div className="relative w-full h-[60vh] bg-gray-800">
6
- <div className="absolute inset-0 flex items-center justify-center">
7
- <div className="w-full max-w-7xl px-6 space-y-4">
8
- <div className="h-12 bg-gray-700 rounded-lg w-3/4 max-w-2xl"></div>
9
- <div className="h-6 bg-gray-700 rounded-lg w-1/4 max-w-xs"></div>
10
- <div className="flex gap-4">
11
- <div className="h-12 bg-gray-700 rounded-3xl w-32"></div>
12
- <div className="h-12 bg-gray-700 rounded-3xl w-32"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  </div>
14
  </div>
15
  </div>
16
  </div>
17
 
18
  {/* Details Section Skeleton */}
19
- <div className="max-w-7xl mx-auto px-6 py-12 space-y-8">
20
- <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
21
- <div className="md:col-span-2 space-y-4">
22
- <div className="h-8 bg-gray-800 rounded-lg w-1/2"></div>
23
- <div className="h-32 bg-gray-800 rounded-lg w-full"></div>
24
- <div className="h-8 bg-gray-800 rounded-lg w-1/3"></div>
25
- <div className="h-24 bg-gray-800 rounded-lg w-full"></div>
 
 
 
 
 
 
26
  </div>
27
- <div className="space-y-4">
28
- <div className="h-8 bg-gray-800 rounded-lg w-full"></div>
29
- <div className="h-40 bg-gray-800 rounded-lg w-full"></div>
 
 
 
30
  </div>
31
  </div>
32
  </div>
 
1
  export function LoadingSkeleton() {
2
  return (
3
+ <div className="w-full min-h-screen bg-gray-900 animate-pulse" data-oid="v0dd7s5">
4
  {/* Hero Section Skeleton */}
5
+ <div className="relative w-full h-[60vh] bg-gray-800" data-oid="h:37-w6">
6
+ <div
7
+ className="absolute inset-0 flex items-center justify-center"
8
+ data-oid="t4nb.n8"
9
+ >
10
+ <div className="w-full max-w-7xl px-6 space-y-4" data-oid="p1jl.eg">
11
+ <div
12
+ className="h-12 bg-gray-700 rounded-lg w-3/4 max-w-2xl"
13
+ data-oid="wpv9e.8"
14
+ ></div>
15
+ <div
16
+ className="h-6 bg-gray-700 rounded-lg w-1/4 max-w-xs"
17
+ data-oid="7jd5zcs"
18
+ ></div>
19
+ <div className="flex gap-4" data-oid=":nu-56g">
20
+ <div
21
+ className="h-12 bg-gray-700 rounded-3xl w-32"
22
+ data-oid=".8c06ec"
23
+ ></div>
24
+ <div
25
+ className="h-12 bg-gray-700 rounded-3xl w-32"
26
+ data-oid=".5c0u6o"
27
+ ></div>
28
  </div>
29
  </div>
30
  </div>
31
  </div>
32
 
33
  {/* Details Section Skeleton */}
34
+ <div className="max-w-7xl mx-auto px-6 py-12 space-y-8" data-oid="v7i4ygf">
35
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-8" data-oid="icit7ao">
36
+ <div className="md:col-span-2 space-y-4" data-oid="2bv0cox">
37
+ <div className="h-8 bg-gray-800 rounded-lg w-1/2" data-oid="ju1av99"></div>
38
+ <div
39
+ className="h-32 bg-gray-800 rounded-lg w-full"
40
+ data-oid="_4-_cmg"
41
+ ></div>
42
+ <div className="h-8 bg-gray-800 rounded-lg w-1/3" data-oid="x.08-8o"></div>
43
+ <div
44
+ className="h-24 bg-gray-800 rounded-lg w-full"
45
+ data-oid="ktjj29j"
46
+ ></div>
47
  </div>
48
+ <div className="space-y-4" data-oid="zs83v6-">
49
+ <div className="h-8 bg-gray-800 rounded-lg w-full" data-oid="zilvhpx"></div>
50
+ <div
51
+ className="h-40 bg-gray-800 rounded-lg w-full"
52
+ data-oid="9sr_zfs"
53
+ ></div>
54
  </div>
55
  </div>
56
  </div>
frontend/app/tvshow/[title]/page.tsx CHANGED
@@ -7,148 +7,7 @@ import { LoadingSkeleton } from './LoadingSkeleton';
7
  import { convertMinutesToHM } from '@lib/utils';
8
  import Link from 'next/link';
9
  import CastSection from '@components/sections/CastSection';
10
-
11
- interface FileStructure {
12
- contents?: any[];
13
- }
14
-
15
- const EpisodesSection = ({ fileStructure }: { fileStructure: FileStructure }) => {
16
- const [activeSeason, setActiveSeason] = useState(0);
17
-
18
- return (
19
- <div className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50">
20
- <div className="flex flex-col space-y-4">
21
- <div className="flex items-center justify-between">
22
- <h3 className="text-lg font-semibold text-gray-200">Episodes</h3>
23
- </div>
24
-
25
- {/* Season Buttons */}
26
- <div className="overflow-x-auto whitespace-nowrap flex gap-2 scrollbar-hide snap-x snap-mandatory pb-2">
27
- {fileStructure.contents?.map((season, idx) => {
28
- const seasonName = season.path.split('/').pop();
29
- const isSpecials = seasonName === 'Specials';
30
-
31
- return (
32
- <button
33
- key={idx}
34
- onClick={() => setActiveSeason(idx)}
35
- className={`px-4 py-2 min-w-fit rounded-full text-sm font-medium transition-colors snap-start
36
- ${
37
- activeSeason === idx
38
- ? 'bg-purple-500 text-white'
39
- : isSpecials
40
- ? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
41
- : 'bg-purple-500/20 text-purple-300 hover:bg-purple-500/30'
42
- }`}
43
- >
44
- {seasonName}
45
- </button>
46
- );
47
- })}
48
- </div>
49
-
50
- {/* Episodes List */}
51
- <div className="space-y-8 mt-4">
52
- {fileStructure.contents?.[activeSeason] && (
53
- <div key={activeSeason} className="space-y-4">
54
- <h4
55
- className={`text-base font-medium ${
56
- fileStructure.contents[activeSeason].path.includes('Specials')
57
- ? 'text-amber-300'
58
- : 'text-purple-300'
59
- }`}
60
- >
61
- {fileStructure.contents[activeSeason].path.split('/').pop()}
62
- </h4>
63
- <div className="space-y-2">
64
- {fileStructure.contents[activeSeason].contents?.map(
65
- (episode: any, episodeIdx: number) => {
66
- const match = episode.path.match(
67
- /[S](\d+)[E](\d+) - (.+?)\./,
68
- );
69
- if (!match) return null;
70
-
71
- const [, , episodeNum, episodeTitle] = match;
72
- const isSpecials =
73
- fileStructure.contents &&
74
- fileStructure.contents[activeSeason]?.path.includes(
75
- 'Specials',
76
- );
77
-
78
- return (
79
- <div
80
- key={episodeIdx}
81
- className="group flex items-center gap-4 p-3 rounded-xl transition-colors hover:bg-gray-700/50 cursor-pointer"
82
- >
83
- {/* Episode Number */}
84
- <div
85
- className={`flex-shrink-0 w-12 h-12 flex items-center justify-center rounded-xl bg-gray-700/50
86
- ${
87
- isSpecials
88
- ? 'group-hover:bg-amber-500/20'
89
- : 'group-hover:bg-purple-500/20'
90
- }`}
91
- >
92
- <span
93
- className={`text-lg font-semibold text-gray-300
94
- ${
95
- isSpecials
96
- ? 'group-hover:text-amber-300'
97
- : 'group-hover:text-purple-300'
98
- }`}
99
- >
100
- {episodeNum}
101
- </span>
102
- </div>
103
-
104
- {/* Episode Info */}
105
- <div className="flex-grow">
106
- <h4 className="text-gray-200 font-medium">
107
- {episodeTitle.replace(/_/g, ' ')}
108
- </h4>
109
- <div className="flex items-center gap-3 text-sm text-gray-400">
110
- <span>
111
- {Math.round(episode.size / 1024 / 1024)}{' '}
112
- MB
113
- </span>
114
- <span>•</span>
115
- <span>
116
- {episode.path.includes('720p')
117
- ? 'HD'
118
- : 'SD'}
119
- </span>
120
- </div>
121
- </div>
122
-
123
- {/* Play Button */}
124
- <button
125
- className={`flex-shrink-0 p-2 rounded-full text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity
126
- ${
127
- isSpecials
128
- ? 'bg-amber-500/20 hover:bg-amber-500/30'
129
- : 'bg-purple-500/20 hover:bg-purple-500/30'
130
- }`}
131
- >
132
- <svg
133
- className="w-5 h-5"
134
- fill="currentColor"
135
- viewBox="0 0 20 20"
136
- >
137
- <path d="M4 4l12 6-12 6V4z" />
138
- </svg>
139
- </button>
140
- </div>
141
- );
142
- },
143
- )}
144
- </div>
145
- </div>
146
- )}
147
- </div>
148
- </div>
149
- </div>
150
- );
151
- };
152
 
153
  export default function TvShowTitlePage() {
154
  const { title } = useParams();
@@ -188,7 +47,7 @@ export default function TvShowTitlePage() {
188
  }, [decodedTitle]);
189
 
190
  if (!tvshow) {
191
- return <LoadingSkeleton />;
192
  }
193
 
194
  return (
@@ -200,21 +59,31 @@ export default function TvShowTitlePage() {
200
  backgroundPosition: 'top',
201
  backgroundAttachment: 'fixed',
202
  }}
 
203
  >
204
  {/* Gradient Overlays */}
205
- <div className="h-screen fixed inset-0">
206
- <div className="h-full bg-gradient-to-b from-gray-900/90 via-gray-900/50 to-gray-900/90"></div>
 
 
 
207
  </div>
208
 
209
  {/* Main Content Container */}
210
- <div className="relative z-10 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
211
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
 
 
 
212
  {/* Left Column - Main Info */}
213
- <div className="lg:col-span-2 space-y-6">
214
- <div className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50">
 
 
 
215
  {/* Title and Year */}
216
- <div className="space-y-2">
217
- <h1 className="text-4xl font-bold text-white">
218
  {tvshow.translations?.nameTranslations?.find(
219
  (t: { language: string; name: string }) =>
220
  t.language === 'eng',
@@ -229,28 +98,43 @@ export default function TvShowTitlePage() {
229
  tvshow?.name ||
230
  'Unknown Title'}
231
  </h1>
232
- <div className="flex items-center gap-3 text-gray-300">
233
- <span className="text-lg">{tvshow.year}</span>
234
- <span>•</span>
235
- <span className="bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded text-sm">
 
 
 
 
 
 
 
 
236
  TV Series
237
  </span>
238
  </div>
239
  </div>
240
  {/* Genres and Score */}
241
- <div className="flex flex-wrap items-center gap-4 mt-4">
242
- <div className="flex items-center gap-2">
243
- <span className="bg-purple-500 text-white px-3 py-1 rounded-full text-sm font-medium">
 
 
 
 
 
 
244
  ⭐ {tvshow.score ? (tvshow.score / 1000).toFixed(1) : 'N/A'}
245
  </span>
246
  </div>
247
- <div className="flex flex-wrap gap-2">
248
  {Array.isArray(tvshow.genres)
249
  ? tvshow.genres.map((genre) => (
250
  <Link
251
  key={genre.id}
252
  href={`/genre/${encodeURIComponent(genre.slug)}`}
253
  className="bg-gray-700/50 hover:bg-gray-600/50 text-gray-200 px-3 py-1 rounded-full text-sm transition-colors"
 
254
  >
255
  {genre.name}
256
  </Link>
@@ -261,13 +145,15 @@ export default function TvShowTitlePage() {
261
  {/* Content Ratings */}
262
  {Array.isArray(tvshow.contentRatings) &&
263
  tvshow.contentRatings.length > 0 ? (
264
- <div className="mt-6">
265
- <h3 className="text-lg font-semibold text-gray-200 mb-3">
 
 
 
266
  Content Ratings
267
  </h3>
268
- <ul className="flex flex-wrap gap-3">
269
  {tvshow.contentRatings.map((rating, index) => {
270
- // Map country codes to corresponding flags
271
  const countryFlags = {
272
  AUS: '🇦🇺',
273
  USA: '🇺🇸',
@@ -285,18 +171,27 @@ export default function TvShowTitlePage() {
285
  const flag =
286
  countryFlags[
287
  rating.country.toUpperCase() as keyof typeof countryFlags
288
- ] || '🌍'; // Default to globe if no match
289
 
290
  return (
291
  <li
292
  key={index}
293
  className="flex items-center bg-gray-800/50 px-2 py-1 rounded-md text-xs"
 
294
  >
295
- <span className="mr-2">{flag}</span>
296
- <span className="text-white font-semibold">
 
 
 
 
 
297
  {rating.name}
298
  </span>
299
- <span className="text-gray-400 ml-1">
 
 
 
300
  {' '}
301
  - {rating.description || 'N/A'}
302
  </span>
@@ -306,37 +201,51 @@ export default function TvShowTitlePage() {
306
  </ul>
307
  </div>
308
  ) : (
309
- <p className="text-gray-400 text-sm">
310
  No content ratings available.
311
  </p>
312
  )}
313
- {/* Action Buttons */}{' '}
314
- <div className="flex justify-start landscape:gap-2 mt-4">
 
 
 
315
  <Link
316
  href={'#play'}
317
  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 transition-colors text-sm md:text-base"
 
318
  >
319
  <svg
320
  className="w-4 h-4 md:w-5 md:h-5 mr-2"
321
  fill="currentColor"
322
  viewBox="0 0 20 20"
 
323
  >
324
- <path d="M4 4l12 6-12 6V4z" />
325
  </svg>
326
  Play Now
327
  </Link>
328
  <Link
329
  href={'#'}
330
  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"
 
331
  >
332
  Add to My List
333
  </Link>
334
  </div>
335
  </div>
336
  {/* Overview Section */}
337
- <div className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50">
338
- <h3 className="text-lg font-semibold text-gray-200 mb-3">Overview</h3>
339
- <p className="text-gray-300 leading-relaxed">
 
 
 
 
 
 
 
 
340
  {tvshow.translations?.overviewTranslations?.find(
341
  (t: { language: string; overview: string }) =>
342
  t.language === 'eng',
@@ -346,13 +255,16 @@ export default function TvShowTitlePage() {
346
  </p>
347
  </div>
348
  {/* Episodes Section */}
349
- <EpisodesSection fileStructure={fileStructure} />
350
- {/* end of episodes section*/}
 
 
 
351
  </div>
352
 
353
  {/* Right Column - Cast */}
354
- <div className="lg:col-span-1">
355
- <CastSection movie={tvshow} />
356
  </div>
357
  </div>
358
  </div>
 
7
  import { convertMinutesToHM } from '@lib/utils';
8
  import Link from 'next/link';
9
  import CastSection from '@components/sections/CastSection';
10
+ import EpisodesSection from '@/components/sections/EpisodesSection';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  export default function TvShowTitlePage() {
13
  const { title } = useParams();
 
47
  }, [decodedTitle]);
48
 
49
  if (!tvshow) {
50
+ return <LoadingSkeleton data-oid="wt9w2fo" />;
51
  }
52
 
53
  return (
 
59
  backgroundPosition: 'top',
60
  backgroundAttachment: 'fixed',
61
  }}
62
+ data-oid="4.jurse"
63
  >
64
  {/* Gradient Overlays */}
65
+ <div className="h-screen fixed inset-0" data-oid=":7b37p7">
66
+ <div
67
+ className="h-full bg-gradient-to-b from-gray-900/90 via-gray-900/50 to-gray-900/90"
68
+ data-oid="srrv8s_"
69
+ ></div>
70
  </div>
71
 
72
  {/* Main Content Container */}
73
+ <div
74
+ className="relative z-10 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"
75
+ data-oid="u-stmqh"
76
+ >
77
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8" data-oid="6r8ultd">
78
  {/* Left Column - Main Info */}
79
+ <div className="lg:col-span-2 space-y-6" data-oid="xpvg.4g">
80
+ <div
81
+ className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50"
82
+ data-oid="5:3lc_h"
83
+ >
84
  {/* Title and Year */}
85
+ <div className="space-y-2" data-oid="bijg9ok">
86
+ <h1 className="text-4xl font-bold text-white" data-oid="g.g.h9q">
87
  {tvshow.translations?.nameTranslations?.find(
88
  (t: { language: string; name: string }) =>
89
  t.language === 'eng',
 
98
  tvshow?.name ||
99
  'Unknown Title'}
100
  </h1>
101
+ <div
102
+ className="flex items-center gap-3 text-gray-300"
103
+ data-oid="bgr3b5u"
104
+ >
105
+ <span className="text-lg" data-oid="aiifu47">
106
+ {tvshow.year}
107
+ </span>
108
+ <span data-oid="waw327_">•</span>
109
+ <span
110
+ className="bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded text-sm"
111
+ data-oid="da8mf4b"
112
+ >
113
  TV Series
114
  </span>
115
  </div>
116
  </div>
117
  {/* Genres and Score */}
118
+ <div
119
+ className="flex flex-wrap items-center gap-4 mt-4"
120
+ data-oid="n::tuqe"
121
+ >
122
+ <div className="flex items-center gap-2" data-oid="zodeb7x">
123
+ <span
124
+ className="bg-purple-500 text-white px-3 py-1 rounded-full text-sm font-medium"
125
+ data-oid="_51g95r"
126
+ >
127
  ⭐ {tvshow.score ? (tvshow.score / 1000).toFixed(1) : 'N/A'}
128
  </span>
129
  </div>
130
+ <div className="flex flex-wrap gap-2" data-oid="dz_1gae">
131
  {Array.isArray(tvshow.genres)
132
  ? tvshow.genres.map((genre) => (
133
  <Link
134
  key={genre.id}
135
  href={`/genre/${encodeURIComponent(genre.slug)}`}
136
  className="bg-gray-700/50 hover:bg-gray-600/50 text-gray-200 px-3 py-1 rounded-full text-sm transition-colors"
137
+ data-oid="i4eoln4"
138
  >
139
  {genre.name}
140
  </Link>
 
145
  {/* Content Ratings */}
146
  {Array.isArray(tvshow.contentRatings) &&
147
  tvshow.contentRatings.length > 0 ? (
148
+ <div className="mt-6" data-oid="54siwr_">
149
+ <h3
150
+ className="text-lg font-semibold text-gray-200 mb-3"
151
+ data-oid="nsy-_00"
152
+ >
153
  Content Ratings
154
  </h3>
155
+ <ul className="flex flex-wrap gap-3" data-oid="2:qr8cp">
156
  {tvshow.contentRatings.map((rating, index) => {
 
157
  const countryFlags = {
158
  AUS: '🇦🇺',
159
  USA: '🇺🇸',
 
171
  const flag =
172
  countryFlags[
173
  rating.country.toUpperCase() as keyof typeof countryFlags
174
+ ] || '🌍';
175
 
176
  return (
177
  <li
178
  key={index}
179
  className="flex items-center bg-gray-800/50 px-2 py-1 rounded-md text-xs"
180
+ data-oid="-u:voln"
181
  >
182
+ <span className="mr-2" data-oid="62_8hg5">
183
+ {flag}
184
+ </span>
185
+ <span
186
+ className="text-white font-semibold"
187
+ data-oid="_xxbhea"
188
+ >
189
  {rating.name}
190
  </span>
191
+ <span
192
+ className="text-gray-400 ml-1"
193
+ data-oid="qvcr4tn"
194
+ >
195
  {' '}
196
  - {rating.description || 'N/A'}
197
  </span>
 
201
  </ul>
202
  </div>
203
  ) : (
204
+ <p className="text-gray-400 text-sm" data-oid="tnn4uku">
205
  No content ratings available.
206
  </p>
207
  )}
208
+ {/* Action Buttons */}
209
+ <div
210
+ className="flex justify-start landscape:gap-2 mt-4"
211
+ data-oid="-.:cblk"
212
+ >
213
  <Link
214
  href={'#play'}
215
  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 transition-colors text-sm md:text-base"
216
+ data-oid="kr95utu"
217
  >
218
  <svg
219
  className="w-4 h-4 md:w-5 md:h-5 mr-2"
220
  fill="currentColor"
221
  viewBox="0 0 20 20"
222
+ data-oid="y56_5ga"
223
  >
224
+ <path d="M4 4l12 6-12 6V4z" data-oid=".-7cwlz" />
225
  </svg>
226
  Play Now
227
  </Link>
228
  <Link
229
  href={'#'}
230
  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"
231
+ data-oid="aask6sd"
232
  >
233
  Add to My List
234
  </Link>
235
  </div>
236
  </div>
237
  {/* Overview Section */}
238
+ <div
239
+ className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50"
240
+ data-oid="twvwhom"
241
+ >
242
+ <h3
243
+ className="text-lg font-semibold text-gray-200 mb-3"
244
+ data-oid="s3f.i.p"
245
+ >
246
+ Overview
247
+ </h3>
248
+ <p className="text-gray-300 leading-relaxed" data-oid="exdh8pn">
249
  {tvshow.translations?.overviewTranslations?.find(
250
  (t: { language: string; overview: string }) =>
251
  t.language === 'eng',
 
255
  </p>
256
  </div>
257
  {/* Episodes Section */}
258
+ <EpisodesSection
259
+ fileStructure={fileStructure}
260
+ tvshow={title}
261
+ data-oid="trlojhf"
262
+ />
263
  </div>
264
 
265
  {/* Right Column - Cast */}
266
+ <div className="lg:col-span-1" data-oid="-c328r.">
267
+ <CastSection movie={tvshow} data-oid="p6hxqrq" />
268
  </div>
269
  </div>
270
  </div>
frontend/app/watch/movie/[title]/page.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useRouter } from 'next/navigation';
4
+ import MoviePlayer from '@/components/movie/MoviePlayer';
5
+ import { useParams } from 'next/navigation';
6
+ const MoviePlayerPage = () => {
7
+ // Assuming you get the movie title from the route params
8
+ const router = useRouter();
9
+ const { title } = useParams();
10
+ const videoTitle = Array.isArray(title) ? title[0] : title;
11
+
12
+ const handleClose = () => {
13
+ router.back();
14
+ };
15
+
16
+ if (!title) {
17
+ return <div data-oid="w6._haz">Movie title is missing.</div>;
18
+ }
19
+ return (
20
+ <MoviePlayer
21
+ videoTitle={decodeURIComponent(videoTitle) as string}
22
+ onClosePlayer={handleClose}
23
+ data-oid="i9k-1k4"
24
+ />
25
+ );
26
+ };
27
+
28
+ export default MoviePlayerPage;
frontend/app/watch/tvshow/[title]/[season]/[episode]/page.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ };
15
+
16
+ if (!title || !season || !episode) {
17
+ return <div data-oid="jlx4rr5">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
+ data-oid="g1cs-r4"
26
+ />
27
+ );
28
+ };
29
+
30
+ export default TvShowPlayerPage;
frontend/components/loading/Spinner.tsx CHANGED
@@ -23,26 +23,34 @@ export function SpinnerLoadingProvider({ children }: { children: ReactNode }) {
23
  const [spinnerLoading, setSpinnerLoading] = useState(false);
24
 
25
  return (
26
- <LoadingContext.Provider value={{ spinnerLoading, setSpinnerLoading }}>
27
- <AnimatePresence mode="wait">
28
  {spinnerLoading && (
29
  <motion.div
30
  initial={{ opacity: 1 }}
31
  exit={{ opacity: 0 }}
32
  transition={{ duration: 0.6, ease: 'easeInOut' }}
33
  className="fixed inset-0 z-40 flex items-center justify-center bg-gray-900/50"
 
34
  >
35
- <div className="relative flex flex-col items-center">
36
  {/* Loading Animation */}
37
  <motion.div
38
  animate={{ rotate: 360 }}
39
  transition={{ repeat: Infinity, duration: 1.2, ease: 'linear' }}
40
  className="relative w-16 h-16"
 
41
  >
42
  {/* Outer Ring */}
43
- <div className="absolute inset-0 border-4 border-transparent border-t-purple-500 border-l-purple-500 rounded-full"></div>
 
 
 
44
  {/* Inner Glow Effect */}
45
- <div className="absolute inset-0 w-full h-full animate-ping rounded-full bg-purple-500 opacity-30"></div>
 
 
 
46
  </motion.div>
47
  </div>
48
  </motion.div>
 
23
  const [spinnerLoading, setSpinnerLoading] = useState(false);
24
 
25
  return (
26
+ <LoadingContext.Provider value={{ spinnerLoading, setSpinnerLoading }} data-oid=".8kjy-y">
27
+ <AnimatePresence mode="wait" data-oid="d2x5jij">
28
  {spinnerLoading && (
29
  <motion.div
30
  initial={{ opacity: 1 }}
31
  exit={{ opacity: 0 }}
32
  transition={{ duration: 0.6, ease: 'easeInOut' }}
33
  className="fixed inset-0 z-40 flex items-center justify-center bg-gray-900/50"
34
+ data-oid="8:iqov0"
35
  >
36
+ <div className="relative flex flex-col items-center" data-oid="fs.5iw6">
37
  {/* Loading Animation */}
38
  <motion.div
39
  animate={{ rotate: 360 }}
40
  transition={{ repeat: Infinity, duration: 1.2, ease: 'linear' }}
41
  className="relative w-16 h-16"
42
+ data-oid="7c81ip_"
43
  >
44
  {/* Outer Ring */}
45
+ <div
46
+ className="absolute inset-0 border-4 border-transparent border-t-purple-500 border-l-purple-500 rounded-full"
47
+ data-oid="nch7_v2"
48
+ ></div>
49
  {/* Inner Glow Effect */}
50
+ <div
51
+ className="absolute inset-0 w-full h-full animate-ping rounded-full bg-purple-500 opacity-30"
52
+ data-oid=":eo20gp"
53
+ ></div>
54
  </motion.div>
55
  </div>
56
  </motion.div>
frontend/components/loading/SplashScreen.tsx CHANGED
@@ -21,7 +21,7 @@ export function useLoading() {
21
 
22
  // Provider Component
23
  export function LoadingProvider({ children }: { children: ReactNode }) {
24
- const [loading, setLoading] = useState(true);
25
  const [message, setMessage] = useState('Getting things ready for you');
26
 
27
  useEffect(() => {
@@ -39,35 +39,51 @@ export function LoadingProvider({ children }: { children: ReactNode }) {
39
  }, []);
40
 
41
  return (
42
- <LoadingContext.Provider value={{ loading, setLoading }}>
43
- <AnimatePresence mode="wait">
44
  {loading && (
45
  <motion.div
46
  initial={{ opacity: 1 }}
47
  exit={{ opacity: 0 }}
48
  transition={{ duration: 0.6, ease: 'easeInOut' }}
49
  className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900"
 
50
  >
51
- <div className="relative flex flex-col items-center">
52
  {/* Logo Animation */}
53
  <motion.div
54
  initial={{ scale: 0.5, opacity: 0 }}
55
  animate={{ scale: 1, opacity: 1 }}
56
  transition={{ duration: 0.6, ease: 'easeOut' }}
57
  className="mb-8"
 
58
  >
59
- <div className="w-44 h-44 relative ">
60
- <div className="animate-pulse absolute inset-0 bg-gradient-to-r from-purple-600 to-pink-600 rounded-2xl" />
 
 
 
61
 
62
  {/* Inner Background */}
63
- <div className="animate-pulse absolute inset-1 bg-gray-800 rounded-xl" />
 
 
 
64
 
65
  {/* Text */}
66
- <div className="absolute inset-0 flex-col items-center text-center flex justify-items-center justify-center">
67
- <span className="text-4xl font-bold bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent drop-shadow-lg">
 
 
 
 
 
 
68
  NEXORA
69
  </span>
70
- <p className="text-gray-300 font-mono">{WEB_VERSION}</p>
 
 
71
  </div>
72
  </div>
73
  </motion.div>
@@ -78,6 +94,7 @@ export function LoadingProvider({ children }: { children: ReactNode }) {
78
  animate={{ width: '150px' }}
79
  transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
80
  className="animate-pulse h-1 bg-gradient-to-r m from-purple-500 to-pink-500 rounded-full"
 
81
  />
82
 
83
  {/* Loading Text */}
@@ -86,6 +103,7 @@ export function LoadingProvider({ children }: { children: ReactNode }) {
86
  animate={{ opacity: 1, y: 0 }}
87
  transition={{ delay: 0.5, ease: 'easeOut' }}
88
  className="mt-6 text-gray-300 text-sm font-mono tracking-wide"
 
89
  >
90
  {message}
91
  </motion.p>
 
21
 
22
  // Provider Component
23
  export function LoadingProvider({ children }: { children: ReactNode }) {
24
+ const [loading, setLoading] = useState(false);
25
  const [message, setMessage] = useState('Getting things ready for you');
26
 
27
  useEffect(() => {
 
39
  }, []);
40
 
41
  return (
42
+ <LoadingContext.Provider value={{ loading, setLoading }} data-oid="k5me0da">
43
+ <AnimatePresence mode="wait" data-oid="u49096x">
44
  {loading && (
45
  <motion.div
46
  initial={{ opacity: 1 }}
47
  exit={{ opacity: 0 }}
48
  transition={{ duration: 0.6, ease: 'easeInOut' }}
49
  className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900"
50
+ data-oid="_rjul_i"
51
  >
52
+ <div className="relative flex flex-col items-center" data-oid="zmc8scv">
53
  {/* Logo Animation */}
54
  <motion.div
55
  initial={{ scale: 0.5, opacity: 0 }}
56
  animate={{ scale: 1, opacity: 1 }}
57
  transition={{ duration: 0.6, ease: 'easeOut' }}
58
  className="mb-8"
59
+ data-oid="oik_h9r"
60
  >
61
+ <div className="w-44 h-44 relative " data-oid="3tbwztk">
62
+ <div
63
+ className="animate-pulse absolute inset-0 bg-gradient-to-r from-purple-600 to-pink-600 rounded-2xl"
64
+ data-oid="nswqfs6"
65
+ />
66
 
67
  {/* Inner Background */}
68
+ <div
69
+ className="animate-pulse absolute inset-1 bg-gray-800 rounded-xl"
70
+ data-oid="p7wrz0e"
71
+ />
72
 
73
  {/* Text */}
74
+ <div
75
+ className="absolute inset-0 flex-col items-center text-center flex justify-items-center justify-center"
76
+ data-oid="8w:62dp"
77
+ >
78
+ <span
79
+ className="text-4xl font-bold bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent drop-shadow-lg"
80
+ data-oid="a3y25ex"
81
+ >
82
  NEXORA
83
  </span>
84
+ <p className="text-gray-300 font-mono" data-oid="l5hfcjt">
85
+ {WEB_VERSION}
86
+ </p>
87
  </div>
88
  </div>
89
  </motion.div>
 
94
  animate={{ width: '150px' }}
95
  transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
96
  className="animate-pulse h-1 bg-gradient-to-r m from-purple-500 to-pink-500 rounded-full"
97
+ data-oid="ve7kdif"
98
  />
99
 
100
  {/* Loading Text */}
 
103
  animate={{ opacity: 1, y: 0 }}
104
  transition={{ delay: 0.5, ease: 'easeOut' }}
105
  className="mt-6 text-gray-300 text-sm font-mono tracking-wide"
106
+ data-oid="r.-e_gs"
107
  >
108
  {message}
109
  </motion.p>
frontend/components/movie/MovieCard.tsx CHANGED
@@ -11,387 +11,468 @@ import {
11
  } from '@heroicons/react/24/outline';
12
  import Link from 'next/link';
13
  import { StarIcon } from '@heroicons/react/24/solid';
 
14
 
15
  interface Artwork {
16
- id: number;
17
- image: string;
18
- thumbnail: string;
19
- language: string | null;
20
- type: number;
21
- score: number;
22
- width: number;
23
- height: number;
24
- includesText: boolean;
25
- thumbnailWidth: number;
26
- thumbnailHeight: number;
27
- updatedAt: number;
28
- status: { id: number; name: string | null };
29
- tagOptions: any;
 
 
 
 
 
 
 
 
30
  }
31
 
32
  interface Card {
33
- title: string;
34
- year: string;
35
- image: string;
36
- portrait: Artwork[];
37
- banner: Artwork[];
38
- overview: string;
 
39
  }
40
 
41
  interface MovieCardProps {
42
- title: string;
43
  }
44
 
45
  // BannerImage component: remounts on src change to trigger fade-in.
46
  const BannerImage: React.FC<{ src: string; alt: string }> = ({ src, alt }) => {
47
- const [visible, setVisible] = useState(false);
48
 
49
- useEffect(() => {
50
- // Reset visibility to false on src change then fade in.
51
- setVisible(false);
52
- const timer = setTimeout(() => {
53
- setVisible(true);
54
- }, 100);
55
- return () => clearTimeout(timer);
56
- }, [src]);
57
 
58
- return (
59
- <img
60
- src={src}
61
- alt={alt}
62
- className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ease-in-out ${
63
- visible ? 'opacity-100' : 'opacity-0'
64
- }`}
65
- />
66
- );
 
67
  };
68
 
69
  export const MovieCard: React.FC<MovieCardProps> = ({ title }) => {
70
- const [card, setCard] = useState<Card | null>(null);
71
- const [cardImage, setCardImage] = useState<string>('');
72
- const [imageLoaded, setImageLoaded] = useState(false);
73
- const [showModal, setShowModal] = useState(false);
74
- const [bannerIndex, setBannerIndex] = useState(0);
75
- const [isHovering, setIsHovering] = useState(false);
76
- // modalStyle is used during both opening and closing animations.
77
- const [modalStyle, setModalStyle] = useState<React.CSSProperties | null>(null);
78
- const cardRef = useRef<HTMLDivElement>(null);
79
- const slideshowTimer = useRef<NodeJS.Timeout | null>(null);
80
- const hoverTimer = useRef<NodeJS.Timeout | null>(null);
81
 
82
- useEffect(() => {
83
- async function fetchMovieCard() {
84
- try {
85
- const cardData = await getMovieCard(title);
86
- setCard(cardData);
87
- } catch (error) {
88
- console.error('Error fetching movie card:', error);
89
- }
90
- }
91
- fetchMovieCard();
92
- }, [title]);
93
 
94
- // Set a random card image once when card loads.
95
- useEffect(() => {
96
- if (card) {
97
- if (card.portrait && card.portrait.length > 0) {
98
- setCardImage(
99
- card.portrait[Math.floor(Math.random() * card.portrait.length)].thumbnail,
100
- );
101
- } else {
102
- setCardImage(card.image);
103
- }
104
- }
105
- }, [card]);
106
 
107
- // Compute the final (expanded) modal style dynamically.
108
- const computeFinalModalStyle = (): React.CSSProperties => {
109
- if (!cardRef.current) return {};
110
- const rect = cardRef.current.getBoundingClientRect();
111
- const margin = 20;
112
- const modalWidth = Math.min(600, window.innerWidth * 0.9);
113
- const modalHeight = Math.min(500, window.innerHeight * 0.7);
114
- let modalLeft = rect.left + rect.width / 2 - modalWidth / 2;
115
- if (modalLeft < margin) modalLeft = margin;
116
- if (modalLeft + modalWidth > window.innerWidth - margin) {
117
- modalLeft = window.innerWidth - modalWidth - margin;
118
- }
119
- let modalTop = rect.bottom - modalHeight;
120
- if (modalTop < margin) modalTop = margin;
121
- if (modalTop + modalHeight > window.innerHeight - margin) {
122
- modalTop = window.innerHeight - modalHeight - margin;
123
- }
124
- return {
125
- position: 'fixed',
126
- left: modalLeft,
127
- top: modalTop,
128
- width: modalWidth,
129
- height: modalHeight,
130
- zIndex: 1000,
131
- };
132
  };
 
133
 
134
- // Animate modal opening/closing.
135
- useEffect(() => {
136
- let animationFrame: number;
137
- let timer: NodeJS.Timeout;
138
- const duration = 400; // duration in ms
139
- if (showModal && cardRef.current) {
140
- // OPENING: Start at card's rect.
141
- const rect = cardRef.current.getBoundingClientRect();
142
- const initialStyle: React.CSSProperties = {
143
- position: 'fixed',
144
- left: rect.left,
145
- top: rect.top,
146
- width: rect.width,
147
- height: rect.height,
148
- opacity: 0,
149
- zIndex: 1000,
150
- transition: `all ${duration}ms ease-in-out`,
151
- };
152
- setModalStyle(initialStyle);
153
- // Next frame: animate to final style.
154
- animationFrame = requestAnimationFrame(() => {
155
- const finalStyle = computeFinalModalStyle();
156
- setModalStyle({
157
- ...finalStyle,
158
- opacity: 1,
159
- transition: `all ${duration}ms ease-in-out`,
160
- });
161
- });
162
- } else if (!showModal && modalStyle && cardRef.current) {
163
- // CLOSING: Animate from current style back to card's rect.
164
- const rect = cardRef.current.getBoundingClientRect();
165
- const closingStyle: React.CSSProperties = {
166
- position: 'fixed',
167
- left: rect.left,
168
- top: rect.top,
169
- width: rect.width,
170
- height: rect.height,
171
- opacity: 0,
172
- zIndex: 1000,
173
- transition: `all ${duration}ms ease-in-out`,
174
- };
175
- setModalStyle(closingStyle);
176
- // Unmount after animation.
177
- timer = setTimeout(() => {
178
- setModalStyle(null);
179
- }, duration);
180
- }
181
- return () => {
182
- cancelAnimationFrame(animationFrame);
183
- if (timer) clearTimeout(timer);
184
- };
185
- }, [showModal]);
186
-
187
- // Banner slideshow: update bannerIndex every 1.5 seconds.
188
- useEffect(() => {
189
- if (showModal && card?.banner && card.banner.length > 0) {
190
- slideshowTimer.current = setInterval(() => {
191
- setBannerIndex((prev) => (prev + 1) % card.banner.length);
192
- }, 1500);
193
- }
194
- return () => {
195
- if (slideshowTimer.current) clearInterval(slideshowTimer.current);
196
- };
197
- }, [showModal, card]);
198
-
199
- // Function to reset the slideshow timer when manually controlling the slideshow.
200
- const resetSlideshowTimer = () => {
201
- if (slideshowTimer.current) {
202
- clearInterval(slideshowTimer.current);
203
- }
204
- if (showModal && card?.banner && card.banner.length > 0) {
205
- slideshowTimer.current = setInterval(() => {
206
- setBannerIndex((prev) => (prev + 1) % card.banner.length);
207
- }, 1500);
208
- }
209
  };
 
210
 
211
- // Slideshow control functions.
212
- const handlePrev = () => {
213
- if (card?.banner && card.banner.length > 0) {
214
- setBannerIndex((prev) => (prev - 1 + card.banner.length) % card.banner.length);
215
- resetSlideshowTimer();
216
- }
 
 
 
217
  };
 
218
 
219
- const handleNext = () => {
220
- if (card?.banner && card.banner.length > 0) {
221
- setBannerIndex((prev) => (prev + 1) % card.banner.length);
222
- resetSlideshowTimer();
223
- }
224
- };
 
 
 
 
225
 
226
- // Control modal visibility based on hover (desktop) or click (mobile).
227
- // (Modal will only render if hovered for more than 0.3s.)
228
- useEffect(() => {
229
- setShowModal(isHovering);
230
- }, [isHovering]);
 
231
 
232
- const handleCardMouseEnter = () => {
233
- hoverTimer.current = setTimeout(() => {
234
- setIsHovering(true);
235
- }, 300); // Only show modal if hovered for more than 0.3s.
236
- };
 
237
 
238
- const handleCardMouseLeave = () => {
239
- if (hoverTimer.current) {
240
- clearTimeout(hoverTimer.current);
241
- hoverTimer.current = null;
242
- }
243
- setIsHovering(false);
244
- };
 
 
 
 
 
 
 
 
 
 
245
 
246
- const handleCardClick = (e: React.MouseEvent) => {
247
- e.preventDefault();
248
- // Toggle modal on click (for mobile) immediately.
249
- setShowModal((prev) => !prev);
 
 
 
 
 
250
  };
 
251
 
252
- // Clear hover timer on unmount.
253
- useEffect(() => {
254
- return () => {
255
- if (hoverTimer.current) clearTimeout(hoverTimer.current);
256
- };
257
- }, []);
 
 
258
 
259
- // Adjust brightness of page elements with class "page" based on modal open state.
260
- useEffect(() => {
261
- const pageElements = document.getElementsByClassName('page');
262
- for (let i = 0; i < pageElements.length; i++) {
263
- (pageElements[i] as HTMLElement).style.filter = showModal
264
- ? 'brightness(0.6)'
265
- : 'brightness(1)';
266
- }
267
- }, [showModal]);
268
 
269
- // Render modal via portal when modalStyle is set.
270
- const modal =
271
- card && cardRef.current && modalStyle
272
- ? createPortal(
273
- <div
274
- style={modalStyle}
275
- className="bg-gray-800/60 backdrop-blur-md rounded-lg p-2.5 border border-gray-400/50 transition-all flex flex-col justify-between pt-0"
276
- // Keep modal open on hover (desktop).
277
- onMouseEnter={() => setIsHovering(true)}
278
- onMouseLeave={() => setIsHovering(false)}
279
- >
280
- <button
281
- onClick={() => setShowModal(false)}
282
- className="absolute top-0 right-0 text-white bg-gray-800 hover:bg-gradient-to-r hover:from-violet-500/80 hover:to-purple-400/80 px-3 py-2 rounded-md z-10 border border-gray-400/50"
283
- >
284
- <XMarkIcon className="size-6" />
285
- </button>
286
- <div className="flex flex-col w-full">
287
- <h3 className="pb-2 pl-2 pt-2 bg-gray-700/40 backdrop-blur-md text-md sm:text-lg md:text-xl font-semibold text-white text-clip line-clamp-1">
288
- {card?.title || 'Loading...'}
289
- </h3>
290
 
291
- <div className="flex pb-2 items-center space-x-2 mt-1 text-sm sm:text-md">
292
- <span className="text-yellow-400 flex gap-1 items-center">
293
- <StarIcon className="size-3" /> 0
294
- </span>
295
- <span className="text-gray-300">• {card?.year || '----'}</span>
296
- <span className="text-purple-300 text-sm">•</span>
297
- <span className="bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded text-sm">
298
- Movie
299
- </span>
300
- </div>
301
- </div>
302
- {/* Banner slideshow preview with crossfade effect and controls */}
303
- {card.banner && (
304
- <div className="relative w-full h-56 overflow-hidden rounded-md mb-4">
305
- <BannerImage
306
- key={bannerIndex}
307
- src={
308
- card.banner[bannerIndex]?.thumbnail ||
309
- `https://placehold.co/640x360?text=Preview+Not+Available`
310
- }
311
- alt="Banner Preview"
312
- />
313
 
314
- {/* Slideshow controls */}
315
- {card.banner.length > 1 && (
316
- <>
317
- <button
318
- onClick={handlePrev}
319
- className="absolute left-2 top-1/2 transform -translate-y-1/2 bg-gray-900/70 hover:bg-gray-900/90 text-white rounded-full"
320
- >
321
- <ArrowLeftCircleIcon className="size-10 text-violet-400" />
322
- </button>
323
- <button
324
- onClick={handleNext}
325
- className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-gray-900/70 hover:bg-gray-900/90 text-white rounded-full"
326
- >
327
- <ArrowRightCircleIcon className="size-10 text-violet-400" />
328
- </button>
329
- </>
330
- )}
331
- </div>
332
- )}
333
- {/* Overview text */}
334
- <div className="text-gray-300 text-base sm:text-lg overflow-hidden line-clamp-4 transition-all duration-300 mb-4">
335
- {card.overview || 'No overview available.'}
336
- </div>
337
- {/* View Details Button */}
338
- <div className="flex flex-row items-center">
339
- <Link href={`/movie/${title}`}>
340
- <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">
341
- View Details <ChevronRightIcon className="size-4" />
342
- </button>
343
- </Link>
344
- </div>
345
- </div>,
346
- document.body,
347
- )
348
- : null;
349
 
350
- return (
351
- <>
352
- <div
353
- ref={cardRef}
354
- className="relative block w-[fit-content] cursor-pointer"
355
- onMouseEnter={handleCardMouseEnter}
356
- onMouseLeave={handleCardMouseLeave}
357
- onClick={handleCardClick}
 
 
 
 
 
 
 
358
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  <div
360
- className="rounded-lg border border-gray-400/50 overflow-hidden relative transition-transform duration-300 w-[140px] sm:w-[150px] md:w-[180px] lg:w-[180px] xl:w-[200px]
361
- h-[210px] sm:h-[220px] md:h-[250px] lg:h-[280px] xl:h-[300px]"
362
  >
363
- {/* Skeleton Loader */}
364
- {!imageLoaded && (
365
- <div className="absolute inset-0 bg-gray-700 animate-pulse rounded-lg" />
366
- )}
367
- {/* Card Image (fixed once randomly selected) */}
368
- {cardImage && (
369
- <img
370
- src={cardImage}
371
- alt={card?.title || title}
372
- className={`w-full h-full object-cover transition-opacity ease-in-out duration-300 ${
373
- imageLoaded ? 'opacity-100' : 'opacity-0'
374
- }`}
375
- onLoad={() => setImageLoaded(true)}
 
 
 
 
 
 
376
  />
377
- )}
378
- {/* Overlay */}
379
- <div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent">
380
- <div className="absolute bottom-0 p-4 w-full">
381
- <h3 className="text-sm sm:text-base md:text-lg font-semibold text-white">
382
- {card?.title || 'Loading...'}
383
- </h3>
384
- <div className="flex items-center space-x-2 mt-1 text-xs sm:text-sm">
385
- <span className="text-yellow-400 flex gap-1 items-center">
386
- <StarIcon className="size-3" /> 0
387
- </span>
388
- <span className="text-gray-300">• {card?.year || '----'}</span>
389
- </div>
390
- </div>
391
- </div>
392
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  </div>
394
- {modal}
395
- </>
396
- );
 
 
 
397
  };
 
11
  } from '@heroicons/react/24/outline';
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;
18
+ image: string;
19
+ thumbnail: string;
20
+ language: string | null;
21
+ type: number;
22
+ score: number;
23
+ width: number;
24
+ height: number;
25
+ includesText: boolean;
26
+ thumbnailWidth: number;
27
+ thumbnailHeight: number;
28
+ updatedAt: number;
29
+ status: { id: number; name: string | null };
30
+ tagOptions: any;
31
+ }
32
+
33
+ interface Trailer {
34
+ id: number;
35
+ name: string;
36
+ url: string;
37
+ language: string;
38
+ runtime: number;
39
  }
40
 
41
  interface Card {
42
+ title: string;
43
+ year: string;
44
+ image: string;
45
+ portrait: Artwork[];
46
+ banner: Artwork[];
47
+ overview: string;
48
+ trailers: Trailer[]; // Now trailers is an array of Trailer objects
49
  }
50
 
51
  interface MovieCardProps {
52
+ title: string;
53
  }
54
 
55
  // BannerImage component: remounts on src change to trigger fade-in.
56
  const BannerImage: React.FC<{ src: string; alt: string }> = ({ src, alt }) => {
57
+ const [visible, setVisible] = useState(false);
58
 
59
+ useEffect(() => {
60
+ setVisible(false);
61
+ const timer = setTimeout(() => {
62
+ setVisible(true);
63
+ }, 100);
64
+ return () => clearTimeout(timer);
65
+ }, [src]);
 
66
 
67
+ return (
68
+ <img
69
+ src={src}
70
+ alt={alt}
71
+ className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ease-in-out ${
72
+ visible ? 'opacity-100' : 'opacity-0'
73
+ }`}
74
+ data-oid="b2qav2h"
75
+ />
76
+ );
77
  };
78
 
79
  export const MovieCard: React.FC<MovieCardProps> = ({ title }) => {
80
+ const [card, setCard] = useState<Card | null>(null);
81
+ const [cardImage, setCardImage] = useState<string>('');
82
+ const [imageLoaded, setImageLoaded] = useState(false);
83
+ const [showModal, setShowModal] = useState(false);
84
+ const [bannerIndex, setBannerIndex] = useState(0);
85
+ const [isHovering, setIsHovering] = useState(false);
86
+ const [modalStyle, setModalStyle] = useState<React.CSSProperties | null>(null);
87
+ const cardRef = useRef<HTMLDivElement>(null);
88
+ const slideshowTimer = useRef<NodeJS.Timeout | null>(null);
89
+ const hoverTimer = useRef<NodeJS.Timeout | null>(null);
90
+ const touchTimer = useRef<NodeJS.Timeout | null>(null);
91
 
92
+ useEffect(() => {
93
+ async function fetchMovieCard() {
94
+ try {
95
+ const cardData = await getMovieCard(title);
96
+ setCard(cardData);
97
+ } catch (error) {
98
+ console.error('Error fetching movie card:', error);
99
+ }
100
+ }
101
+ fetchMovieCard();
102
+ }, [title]);
103
 
104
+ // Set a random card image once card loads.
105
+ useEffect(() => {
106
+ if (card) {
107
+ if (card.portrait && card.portrait.length > 0) {
108
+ setCardImage(
109
+ card.portrait[Math.floor(Math.random() * card.portrait.length)].thumbnail
110
+ );
111
+ } else {
112
+ setCardImage(card.image);
113
+ }
114
+ }
115
+ }, [card]);
116
 
117
+ // Compute the final (expanded) modal style dynamically.
118
+ const computeFinalModalStyle = (): React.CSSProperties => {
119
+ if (!cardRef.current) return {};
120
+ const rect = cardRef.current.getBoundingClientRect();
121
+ const margin = 20;
122
+ const modalWidth = Math.min(600, window.innerWidth * 0.9);
123
+ const modalHeight = Math.min(500, window.innerHeight * 0.7);
124
+ let modalLeft = rect.left + rect.width / 2 - modalWidth / 2;
125
+ if (modalLeft < margin) modalLeft = margin;
126
+ if (modalLeft + modalWidth > window.innerWidth - margin) {
127
+ modalLeft = window.innerWidth - modalWidth - margin;
128
+ }
129
+ let modalTop = rect.bottom - modalHeight;
130
+ if (modalTop < margin) modalTop = margin;
131
+ if (modalTop + modalHeight > window.innerHeight - margin) {
132
+ modalTop = window.innerHeight - modalHeight - margin;
133
+ }
134
+ return {
135
+ position: 'fixed',
136
+ left: modalLeft,
137
+ top: modalTop,
138
+ width: modalWidth,
139
+ height: modalHeight,
140
+ zIndex: 1000,
 
141
  };
142
+ };
143
 
144
+ // Animate modal opening/closing.
145
+ useEffect(() => {
146
+ let animationFrame: number;
147
+ let timer: NodeJS.Timeout;
148
+ const duration = 400;
149
+ if (showModal && cardRef.current) {
150
+ const rect = cardRef.current.getBoundingClientRect();
151
+ const initialStyle: React.CSSProperties = {
152
+ position: 'fixed',
153
+ left: rect.left,
154
+ top: rect.top,
155
+ width: rect.width,
156
+ height: rect.height,
157
+ opacity: 0,
158
+ zIndex: 1000,
159
+ transition: `all ${duration}ms ease-in-out`,
160
+ };
161
+ setModalStyle(initialStyle);
162
+ animationFrame = requestAnimationFrame(() => {
163
+ const finalStyle = computeFinalModalStyle();
164
+ setModalStyle({
165
+ ...finalStyle,
166
+ opacity: 1,
167
+ transition: `all ${duration}ms ease-in-out`,
168
+ });
169
+ });
170
+ } else if (!showModal && modalStyle && cardRef.current) {
171
+ const rect = cardRef.current.getBoundingClientRect();
172
+ const closingStyle: React.CSSProperties = {
173
+ position: 'fixed',
174
+ left: rect.left,
175
+ top: rect.top,
176
+ width: rect.width,
177
+ height: rect.height,
178
+ opacity: 0,
179
+ zIndex: 1000,
180
+ transition: `all ${duration}ms ease-in-out`,
181
+ };
182
+ setModalStyle(closingStyle);
183
+ timer = setTimeout(() => {
184
+ setModalStyle(null);
185
+ }, duration);
186
+ }
187
+ return () => {
188
+ cancelAnimationFrame(animationFrame);
189
+ if (timer) clearTimeout(timer);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  };
191
+ }, [showModal]);
192
 
193
+ // Banner slideshow: update bannerIndex every 1.5 seconds.
194
+ useEffect(() => {
195
+ if (showModal && card?.banner && card.banner.length > 0) {
196
+ slideshowTimer.current = setInterval(() => {
197
+ setBannerIndex((prev) => (prev + 1) % card.banner.length);
198
+ }, 1500);
199
+ }
200
+ return () => {
201
+ if (slideshowTimer.current) clearInterval(slideshowTimer.current);
202
  };
203
+ }, [showModal, card]);
204
 
205
+ const resetSlideshowTimer = () => {
206
+ if (slideshowTimer.current) {
207
+ clearInterval(slideshowTimer.current);
208
+ }
209
+ if (showModal && card?.banner && card.banner.length > 0) {
210
+ slideshowTimer.current = setInterval(() => {
211
+ setBannerIndex((prev) => (prev + 1) % card.banner.length);
212
+ }, 1500);
213
+ }
214
+ };
215
 
216
+ const handlePrev = () => {
217
+ if (card?.banner && card.banner.length > 0) {
218
+ setBannerIndex((prev) => (prev - 1 + card.banner.length) % card.banner.length);
219
+ resetSlideshowTimer();
220
+ }
221
+ };
222
 
223
+ const handleNext = () => {
224
+ if (card?.banner && card.banner.length > 0) {
225
+ setBannerIndex((prev) => (prev + 1) % card.banner.length);
226
+ resetSlideshowTimer();
227
+ }
228
+ };
229
 
230
+ useEffect(() => {
231
+ setShowModal(isHovering);
232
+ }, [isHovering]);
233
+
234
+ const handleCardMouseEnter = () => {
235
+ hoverTimer.current = setTimeout(() => {
236
+ setIsHovering(true);
237
+ }, 300);
238
+ };
239
+
240
+ const handleCardMouseLeave = () => {
241
+ if (hoverTimer.current) {
242
+ clearTimeout(hoverTimer.current);
243
+ hoverTimer.current = null;
244
+ }
245
+ setIsHovering(false);
246
+ };
247
 
248
+ const handleCardClick = (e: React.MouseEvent) => {
249
+ e.preventDefault();
250
+ setShowModal((prev) => !prev);
251
+ };
252
+
253
+ useEffect(() => {
254
+ return () => {
255
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
256
+ if (touchTimer.current) clearTimeout(touchTimer.current);
257
  };
258
+ }, []);
259
 
260
+ useEffect(() => {
261
+ const pageElements = document.getElementsByClassName('page');
262
+ for (let i = 0; i < pageElements.length; i++) {
263
+ (pageElements[i] as HTMLElement).style.filter = showModal
264
+ ? 'brightness(0.6)'
265
+ : 'brightness(1)';
266
+ }
267
+ }, [showModal]);
268
 
269
+ const handleTouchStart = () => {
270
+ touchTimer.current = setTimeout(() => {
271
+ setShowModal(true);
272
+ }, 300);
273
+ };
 
 
 
 
274
 
275
+ const handleTouchEnd = () => {
276
+ if (touchTimer.current) {
277
+ clearTimeout(touchTimer.current);
278
+ touchTimer.current = null;
279
+ }
280
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
+ const handleTouchCancel = () => {
283
+ if (touchTimer.current) {
284
+ clearTimeout(touchTimer.current);
285
+ touchTimer.current = null;
286
+ }
287
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
 
289
+ const handleContextMenu = (e: React.MouseEvent) => {
290
+ e.preventDefault();
291
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
 
293
+ // Render modal via portal.
294
+ const modal =
295
+ card && cardRef.current && modalStyle
296
+ ? createPortal(
297
+ <div
298
+ style={modalStyle}
299
+ className="bg-gray-800/60 backdrop-blur-md rounded-lg p-2.5 border border-gray-400/50 transition-all flex flex-col justify-between pt-0"
300
+ onMouseEnter={() => setIsHovering(true)}
301
+ onMouseLeave={() => setIsHovering(false)}
302
+ data-oid="tb8v-uu"
303
+ >
304
+ <button
305
+ onClick={() => setShowModal(false)}
306
+ className="absolute top-0 right-0 text-white bg-gray-800 hover:bg-gradient-to-r hover:from-violet-500/80 hover:to-purple-400/80 px-3 py-2 rounded-md z-10 border border-gray-400/50"
307
+ data-oid="5t0t0w8"
308
  >
309
+ <XMarkIcon className="size-6" data-oid="9l_e-n4" />
310
+ </button>
311
+ <div className="flex flex-col w-full" data-oid="yqltdoi">
312
+ <h3
313
+ className="pb-2 pl-2 pt-2 bg-gray-700/40 backdrop-blur-md text-md sm:text-lg md:text-xl font-semibold text-white text-clip line-clamp-1"
314
+ data-oid="5m8o8:w"
315
+ >
316
+ {card?.title || 'Loading...'}
317
+ </h3>
318
+ <div
319
+ className="flex pb-2 items-center space-x-2 mt-1 text-sm sm:text-md"
320
+ data-oid="c4mccmo"
321
+ >
322
+ <span
323
+ className="text-yellow-400 flex gap-1 items-center"
324
+ data-oid="xicxnyd"
325
+ >
326
+ <StarIcon className="size-3" data-oid="_p6_8g9" /> 0
327
+ </span>
328
+ <span className="text-gray-300" data-oid="jmctmcv">
329
+ • {card?.year || '----'}
330
+ </span>
331
+ <span className="text-purple-300 text-sm" data-oid="eb540.e">
332
+
333
+ </span>
334
+ <span
335
+ className="bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded text-sm"
336
+ data-oid="3ojoz.m"
337
+ >
338
+ Movie
339
+ </span>
340
+ </div>
341
+ </div>
342
+ {/* Conditionally render TrailersComp if trailers exist, otherwise show banner slideshow */}
343
+ {card?.trailers && card.trailers.length > 0 ? (
344
+ <TrailersComp trailers={card.trailers} />
345
+ ) : (
346
+ card.banner && (
347
  <div
348
+ className="relative w-full h-56 overflow-hidden rounded-md mb-4"
349
+ data-oid="h__x1wg"
350
  >
351
+ <BannerImage
352
+ key={bannerIndex}
353
+ src={
354
+ card.banner[bannerIndex]?.thumbnail ||
355
+ `https://placehold.co/640x360?text=Preview+Not+Available`
356
+ }
357
+ alt="Banner Preview"
358
+ data-oid="9kczgk8"
359
+ />
360
+ {card.banner.length > 1 && (
361
+ <>
362
+ <button
363
+ onClick={handlePrev}
364
+ className="absolute left-2 top-1/2 transform -translate-y-1/2 bg-gray-900/70 hover:bg-gray-900/90 text-white rounded-full"
365
+ data-oid="z151qsy"
366
+ >
367
+ <ArrowLeftCircleIcon
368
+ className="size-10 text-violet-400"
369
+ data-oid="fpnh2w9"
370
  />
371
+ </button>
372
+ <button
373
+ onClick={handleNext}
374
+ className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-gray-900/70 hover:bg-gray-900/90 text-white rounded-full"
375
+ data-oid="h.h66si"
376
+ >
377
+ <ArrowRightCircleIcon
378
+ className="size-10 text-violet-400"
379
+ data-oid="l56nlzc"
380
+ />
381
+ </button>
382
+ </>
383
+ )}
 
 
384
  </div>
385
+ )
386
+ )}
387
+ <div>
388
+ <div
389
+ className="text-gray-300 text-base sm:text-lg overflow-hidden line-clamp-4 transition-all duration-300 mb-4"
390
+ data-oid="kv2m21p"
391
+ >
392
+ {card.overview || 'No overview available.'}
393
+ </div>
394
+ <div className="flex flex-row items-center" data-oid="0b-7rk:">
395
+ <Link href={`/movie/${title}`} data-oid="hkbkx6n">
396
+ <button
397
+ 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"
398
+ data-oid="twpk-.0"
399
+ >
400
+ View Details{' '}
401
+ <ChevronRightIcon className="size-4" data-oid="qcfuh6i" />
402
+ </button>
403
+ </Link>
404
+ </div>
405
+ </div>
406
+ </div>,
407
+ document.body
408
+ )
409
+ : null;
410
+
411
+ return (
412
+ <>
413
+ <div
414
+ ref={cardRef}
415
+ className="relative block w-[fit-content] cursor-pointer"
416
+ onMouseEnter={handleCardMouseEnter}
417
+ onMouseLeave={handleCardMouseLeave}
418
+ onClick={handleCardClick}
419
+ onContextMenu={handleContextMenu}
420
+ onTouchStart={handleTouchStart}
421
+ onTouchEnd={handleTouchEnd}
422
+ onTouchCancel={handleTouchCancel}
423
+ data-oid="n5vhem8"
424
+ >
425
+ <div
426
+ className="rounded-lg border border-gray-400/50 overflow-hidden relative transition-transform duration-300 w-[140px] sm:w-[150px] md:w-[180px] lg:w-[180px] xl:w-[200px] h-[210px] sm:h-[220px] md:h-[250px] lg:h-[280px] xl:h-[300px]"
427
+ data-oid="l9pmup."
428
+ >
429
+ {!imageLoaded && (
430
+ <div
431
+ className="absolute inset-0 bg-gray-700 animate-pulse rounded-lg"
432
+ data-oid="2voine3"
433
+ />
434
+ )}
435
+ {cardImage && (
436
+ <img
437
+ src={cardImage}
438
+ alt={card?.title || title}
439
+ className={`w-full h-full object-cover transition-opacity ease-in-out duration-300 ${
440
+ imageLoaded ? 'opacity-100' : 'opacity-0'
441
+ }`}
442
+ onLoad={() => setImageLoaded(true)}
443
+ data-oid="nwn_z5j"
444
+ />
445
+ )}
446
+ <div
447
+ className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent"
448
+ data-oid="r2rej5e"
449
+ >
450
+ <div className="absolute bottom-0 p-4 w-full" data-oid="1eaxbqc">
451
+ <h3
452
+ className="text-sm sm:text-base md:text-lg font-semibold text-white"
453
+ data-oid="hpfobv_"
454
+ >
455
+ {card?.title || 'Loading...'}
456
+ </h3>
457
+ <div
458
+ className="flex items-center space-x-2 mt-1 text-xs sm:text-sm"
459
+ data-oid="ezlhf5b"
460
+ >
461
+ <span
462
+ className="text-yellow-400 flex gap-1 items-center"
463
+ data-oid="y:azqm."
464
+ >
465
+ <StarIcon className="size-3" data-oid="3xs1fgq" /> 0
466
+ </span>
467
+ <span className="text-gray-300" data-oid="xws-0rb">
468
+ • {card?.year || '----'}
469
+ </span>
470
+ </div>
471
  </div>
472
+ </div>
473
+ </div>
474
+ </div>
475
+ {modal}
476
+ </>
477
+ );
478
  };
frontend/components/movie/MovieLinkFetcher.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useRef } from 'react';
2
+ import { getMovieLinkByTitle } from '@/lib/lb';
3
+
4
+ interface MovieLinkFetcherProps {
5
+ title: string;
6
+ onCancel?: () => void;
7
+ onVideoLinkFetched: (url: string) => void;
8
+ }
9
+
10
+ interface ProgressData {
11
+ status: string;
12
+ progress: number;
13
+ downloaded: number;
14
+ total: number;
15
+ }
16
+
17
+ const MovieLinkFetcher: React.FC<MovieLinkFetcherProps> = ({
18
+ title,
19
+ onCancel,
20
+ onVideoLinkFetched,
21
+ }) => {
22
+ const [progress, setProgress] = useState<ProgressData | null>(null);
23
+ const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(null);
24
+ const [videoFetched, setVideoFetched] = useState(false);
25
+ const videoFetchedRef = useRef(videoFetched);
26
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null);
27
+
28
+ useEffect(() => {
29
+ videoFetchedRef.current = videoFetched;
30
+ }, [videoFetched]);
31
+
32
+ const fetchMovieLink = async () => {
33
+ if (videoFetchedRef.current) return;
34
+ try {
35
+ const response = await getMovieLinkByTitle(title);
36
+ if (response.url) {
37
+ setVideoFetched(true);
38
+ onVideoLinkFetched(response.url);
39
+ } else if (response.progress_url) {
40
+ startPolling(response.progress_url);
41
+ }
42
+ } catch (error) {
43
+ console.error('Error fetching movie link:', error);
44
+ }
45
+ };
46
+
47
+ const pollProgress = async (progressUrl: string) => {
48
+ try {
49
+ const res = await fetch(progressUrl);
50
+ const data: { progress: ProgressData } = await res.json();
51
+ setProgress(data.progress);
52
+ if (data.progress && data.progress.progress >= 100) {
53
+ if (pollingInterval) {
54
+ clearInterval(pollingInterval);
55
+ setPollingInterval(null);
56
+ }
57
+ if (!videoFetchedRef.current) {
58
+ timeoutRef.current = setTimeout(() => {
59
+ fetchMovieLink();
60
+ }, 5000);
61
+ }
62
+ }
63
+ } catch (error) {
64
+ console.error('Error polling progress:', error);
65
+ }
66
+ };
67
+
68
+ const startPolling = (progressUrl: string) => {
69
+ if (!pollingInterval) {
70
+ const interval = setInterval(() => {
71
+ pollProgress(progressUrl);
72
+ }, 2000);
73
+ setPollingInterval(interval);
74
+ }
75
+ };
76
+
77
+ useEffect(() => {
78
+ fetchMovieLink();
79
+ return () => {
80
+ if (pollingInterval) clearInterval(pollingInterval);
81
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
82
+ };
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [title]);
85
+
86
+ return (
87
+ <div className="p-6 bg-gray-800 text-gray-200 rounded-lg shadow-md" data-oid="vvphqe1">
88
+ <h2 className="text-xl font-semibold mb-4" data-oid="0tsia7.">
89
+ Fetching Video Link
90
+ </h2>
91
+ {progress ? (
92
+ <div className="space-y-2" data-oid="xxr2e-g">
93
+ <p className="text-sm" data-oid="vbpgm.d">
94
+ Status: {progress.status}
95
+ </p>
96
+ <p className="text-sm" data-oid="dw1pv31">
97
+ Progress: {progress.progress.toFixed(2)}%
98
+ </p>
99
+ <p className="text-sm" data-oid="lna-q-l">
100
+ Downloaded: {progress.downloaded} / {progress.total}
101
+ </p>
102
+ </div>
103
+ ) : (
104
+ <p className="text-sm" data-oid=":dwyu_n">
105
+ Initializing...
106
+ </p>
107
+ )}
108
+ {onCancel && (
109
+ <button
110
+ onClick={onCancel}
111
+ className="mt-6 w-full py-2 bg-purple-600 hover:bg-purple-500 rounded-md text-white font-semibold transition-colors"
112
+ data-oid="zgczbvu"
113
+ >
114
+ Cancel
115
+ </button>
116
+ )}
117
+ </div>
118
+ );
119
+ };
120
+
121
+ export default MovieLinkFetcher;
frontend/components/movie/MovieLinkFetcherModal.tsx CHANGED
@@ -90,30 +90,50 @@ const MovieLinkFetcherModal: React.FC<VideoLinkFetcherModalProps> = ({
90
  if (!isOpen) return null;
91
 
92
  return (
93
- <div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fadeIn">
94
- <div className="bg-gradient-to-b from-gray-900 to-gray-800 text-gray-200 rounded-lg shadow-lg p-6 w-full max-w-md mx-4 relative">
95
- <h2 className="text-xl font-semibold text-center mb-4">Fetching Video Link</h2>
 
 
 
 
 
 
 
 
96
 
97
  {progress ? (
98
- <div className="text-center space-y-2">
99
- <p className="text-sm">Status: {progress.status}</p>
100
- <p className="text-sm">Progress: {progress.progress.toFixed(2)}%</p>
101
- <p className="text-sm">
 
 
 
 
102
  Downloaded: {progress.downloaded} / {progress.total}
103
  </p>
104
  </div>
105
  ) : (
106
- <p className="text-center text-sm">Initializing...</p>
 
 
107
  )}
108
 
109
  {!videoFetched && (
110
- <div className="mt-4 p-2 bg-gray-700 rounded text-center text-sm text-yellow-300">
111
- <p>Advertisement: Your video is caching, please wait...</p>
112
- <div className="mt-2 flex justify-center">
 
 
 
 
 
113
  <img
114
  src="https://www.adspeed.com/placeholder-300x250.gif"
115
  alt="Ad"
116
  className="h-24 rounded"
 
117
  />
118
  </div>
119
  </div>
@@ -122,6 +142,7 @@ const MovieLinkFetcherModal: React.FC<VideoLinkFetcherModalProps> = ({
122
  <button
123
  onClick={onClose}
124
  className="mt-6 w-full py-2 bg-purple-600 hover:bg-purple-500 rounded-md text-white font-semibold transition-colors"
 
125
  >
126
  Cancel
127
  </button>
 
90
  if (!isOpen) return null;
91
 
92
  return (
93
+ <div
94
+ className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fadeIn"
95
+ data-oid="adbm35z"
96
+ >
97
+ <div
98
+ className="bg-gradient-to-b from-gray-900 to-gray-800 text-gray-200 rounded-lg shadow-lg p-6 w-full max-w-md mx-4 relative"
99
+ data-oid=".rz6w-q"
100
+ >
101
+ <h2 className="text-xl font-semibold text-center mb-4" data-oid="4rplmdm">
102
+ Fetching Video Link
103
+ </h2>
104
 
105
  {progress ? (
106
+ <div className="text-center space-y-2" data-oid="036m60o">
107
+ <p className="text-sm" data-oid="n21qhw0">
108
+ Status: {progress.status}
109
+ </p>
110
+ <p className="text-sm" data-oid="9jgpb6y">
111
+ Progress: {progress.progress.toFixed(2)}%
112
+ </p>
113
+ <p className="text-sm" data-oid="ofdd2_.">
114
  Downloaded: {progress.downloaded} / {progress.total}
115
  </p>
116
  </div>
117
  ) : (
118
+ <p className="text-center text-sm" data-oid="y0ktcy:">
119
+ Initializing...
120
+ </p>
121
  )}
122
 
123
  {!videoFetched && (
124
+ <div
125
+ className="mt-4 p-2 bg-gray-700 rounded text-center text-sm text-yellow-300"
126
+ data-oid="s3d5_nv"
127
+ >
128
+ <p data-oid="dcvr:z:">
129
+ Advertisement: Your video is caching, please wait...
130
+ </p>
131
+ <div className="mt-2 flex justify-center" data-oid="76x-kmq">
132
  <img
133
  src="https://www.adspeed.com/placeholder-300x250.gif"
134
  alt="Ad"
135
  className="h-24 rounded"
136
+ data-oid="pmav4wc"
137
  />
138
  </div>
139
  </div>
 
142
  <button
143
  onClick={onClose}
144
  className="mt-6 w-full py-2 bg-purple-600 hover:bg-purple-500 rounded-md text-white font-semibold transition-colors"
145
+ data-oid="2g1v29b"
146
  >
147
  Cancel
148
  </button>
frontend/components/movie/MoviePlayer.tsx ADDED
@@ -0,0 +1,566 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { useSpinnerLoading } from '@/components/loading/Spinner';
3
+ import {
4
+ PlayIcon,
5
+ PauseIcon,
6
+ SpeakerWaveIcon,
7
+ SpeakerXMarkIcon,
8
+ ArrowsPointingOutIcon,
9
+ ArrowsPointingInIcon,
10
+ } from '@heroicons/react/24/solid';
11
+ import { XCircleIcon } from '@heroicons/react/24/outline';
12
+ import { getMovieLinkByTitle } from '@/lib/lb';
13
+
14
+ interface ContentRating {
15
+ country: string;
16
+ name: string;
17
+ description: string;
18
+ }
19
+
20
+ interface MoviePlayerProps {
21
+ videoTitle: string;
22
+ contentRatings?: ContentRating[];
23
+ onClosePlayer?: () => void; // Optional close handler for page context
24
+ }
25
+
26
+ interface ProgressData {
27
+ status: string;
28
+ progress: number;
29
+ downloaded: number;
30
+ total: number;
31
+ }
32
+
33
+ const MoviePlayer: React.FC<MoviePlayerProps> = ({
34
+ videoTitle,
35
+ contentRatings = [],
36
+ onClosePlayer,
37
+ }) => {
38
+ // Spinner
39
+ const { spinnerLoading, setSpinnerLoading } = useSpinnerLoading();
40
+
41
+ // Refs
42
+ const containerRef = useRef<HTMLDivElement>(null);
43
+ const videoRef = useRef<HTMLVideoElement>(null);
44
+ const inactivityRef = useRef<NodeJS.Timeout | null>(null);
45
+
46
+ // Video URL & blob state
47
+ const [videoUrl, setVideoUrl] = useState<string | null>(null);
48
+ const [videoBlobUrl, setVideoBlobUrl] = useState<string>('');
49
+
50
+ // Player UI states
51
+ const [showControls, setShowControls] = useState(true);
52
+ const [isPlaying, setIsPlaying] = useState(false);
53
+ const [isSeeking, setIsSeeking] = useState(false);
54
+ const [duration, setDuration] = useState(0);
55
+ const [currentTime, setCurrentTime] = useState(0);
56
+ const [volume, setVolume] = useState(1);
57
+ const [isFullscreen, setIsFullscreen] = useState(false);
58
+ const isMuted = volume === 0;
59
+ const [buffered, setBuffered] = useState(0);
60
+
61
+ // Link fetching states
62
+ const [progress, setProgress] = useState<ProgressData | null>(null);
63
+ const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(null);
64
+ const [videoFetched, setVideoFetched] = useState(false);
65
+ const videoFetchedRef = useRef(videoFetched);
66
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null);
67
+
68
+ // Keep ref in sync
69
+ useEffect(() => {
70
+ videoFetchedRef.current = videoFetched;
71
+ }, [videoFetched]);
72
+
73
+ // --- Link Fetching & Polling ---
74
+ const fetchMovieLink = async () => {
75
+ if (videoFetchedRef.current) return;
76
+ try {
77
+ const response = await getMovieLinkByTitle(videoTitle);
78
+ if (response.url) {
79
+ // Stop any polling if running
80
+ if (pollingInterval) {
81
+ clearInterval(pollingInterval);
82
+ setPollingInterval(null);
83
+ }
84
+ setVideoUrl(response.url);
85
+ setVideoFetched(true);
86
+ console.log('Video URL fetched:', response.url);
87
+ } else if (response.progress_url) {
88
+ startPolling(response.progress_url);
89
+ }
90
+ } catch (error) {
91
+ console.error('Error fetching movie link:', error);
92
+ }
93
+ };
94
+
95
+ const pollProgress = async (progressUrl: string) => {
96
+ try {
97
+ const res = await fetch(progressUrl);
98
+ const data: { progress: ProgressData } = await res.json();
99
+ setProgress(data.progress);
100
+ if (data.progress.progress >= 100) {
101
+ if (pollingInterval) {
102
+ clearInterval(pollingInterval);
103
+ setPollingInterval(null);
104
+ }
105
+ // If still no video URL, try again after 5 sec
106
+ if (!videoFetchedRef.current) {
107
+ timeoutRef.current = setTimeout(fetchMovieLink, 5000);
108
+ }
109
+ }
110
+ } catch (error) {
111
+ console.error('Error polling progress:', error);
112
+ }
113
+ };
114
+
115
+ const startPolling = (progressUrl: string) => {
116
+ if (!pollingInterval) {
117
+ const interval = setInterval(() => pollProgress(progressUrl), 2000);
118
+ setPollingInterval(interval);
119
+ }
120
+ };
121
+
122
+ useEffect(() => {
123
+ fetchMovieLink();
124
+ return () => {
125
+ if (pollingInterval) clearInterval(pollingInterval);
126
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
127
+ };
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ }, [videoTitle]);
130
+
131
+ // --- Player Control Helpers ---
132
+ const resetInactivityTimer = () => {
133
+ setShowControls(true);
134
+ if (inactivityRef.current) clearTimeout(inactivityRef.current);
135
+ inactivityRef.current = setTimeout(() => setShowControls(false), 3000);
136
+ };
137
+
138
+ const updateBuffered = () => {
139
+ if (videoRef.current) {
140
+ const bufferedTime =
141
+ videoRef.current.buffered.length > 0
142
+ ? videoRef.current.buffered.end(videoRef.current.buffered.length - 1)
143
+ : 0;
144
+ setBuffered(bufferedTime);
145
+ }
146
+ };
147
+
148
+ // Update buffered every second (even when paused)
149
+ useEffect(() => {
150
+ const interval = setInterval(updateBuffered, 1000);
151
+ return () => clearInterval(interval);
152
+ }, []);
153
+
154
+ // --- Fullscreen Listener ---
155
+ useEffect(() => {
156
+ const handleFullScreenChange = () => {
157
+ setIsFullscreen(document.fullscreenElement === containerRef.current);
158
+ };
159
+ document.addEventListener('fullscreenchange', handleFullScreenChange);
160
+ return () => document.removeEventListener('fullscreenchange', handleFullScreenChange);
161
+ }, []);
162
+
163
+ // --- Blob Fetching ---
164
+ useEffect(() => {
165
+ if (videoUrl) {
166
+ setSpinnerLoading(true);
167
+ const abortController = new AbortController();
168
+ const fetchBlob = async () => {
169
+ try {
170
+ const response = await fetch(videoUrl, {
171
+ signal: abortController.signal,
172
+ mode: 'cors',
173
+ });
174
+ if (!response.ok) {
175
+ throw new Error(`HTTP error! status: ${response.status}`);
176
+ }
177
+ const blob = await response.blob();
178
+ const blobUrl = URL.createObjectURL(blob);
179
+ setVideoBlobUrl(blobUrl);
180
+ console.log('Blob URL created:', blobUrl);
181
+ setSpinnerLoading(false);
182
+ } catch (error: any) {
183
+ if (error.name === 'AbortError') {
184
+ console.log('Blob fetch aborted.');
185
+ } else {
186
+ console.error('Error fetching video blob:', error);
187
+ console.error('Falling back to direct video URL.');
188
+ setVideoBlobUrl('');
189
+ setSpinnerLoading(false);
190
+ }
191
+ }
192
+ };
193
+ fetchBlob();
194
+ return () => {
195
+ abortController.abort();
196
+ if (videoBlobUrl) {
197
+ URL.revokeObjectURL(videoBlobUrl);
198
+ console.log('Blob URL revoked:', videoBlobUrl);
199
+ }
200
+ setVideoBlobUrl('');
201
+ };
202
+ }
203
+ // eslint-disable-next-line react-hooks/exhaustive-deps
204
+ }, [videoUrl]);
205
+
206
+ // --- Reset Player on New URL ---
207
+ useEffect(() => {
208
+ setIsPlaying(false);
209
+ setCurrentTime(0);
210
+ setDuration(0);
211
+ setVolume(1);
212
+ setIsFullscreen(false);
213
+ resetInactivityTimer();
214
+ console.log(
215
+ 'Video player loaded using',
216
+ videoBlobUrl ? 'blob' : 'direct URL',
217
+ videoBlobUrl,
218
+ );
219
+ return () => {
220
+ if (inactivityRef.current) clearTimeout(inactivityRef.current);
221
+ };
222
+ // eslint-disable-next-line react-hooks/exhaustive-deps
223
+ }, [videoUrl]);
224
+
225
+ // --- Video Event Handlers ---
226
+ const handleLoadedMetadata = () => {
227
+ if (videoRef.current) {
228
+ setDuration(videoRef.current.duration);
229
+ }
230
+ };
231
+
232
+ const handleTimeUpdate = () => {
233
+ if (videoRef.current && !isSeeking) {
234
+ setCurrentTime(videoRef.current.currentTime);
235
+ updateBuffered();
236
+ }
237
+ };
238
+
239
+ const handleProgress = () => {
240
+ updateBuffered();
241
+ };
242
+
243
+ // Show content rating overlay briefly when playing
244
+ const [showRatingOverlay, setShowRatingOverlay] = useState(false);
245
+ const triggerRatingOverlay = () => {
246
+ if (contentRatings.length > 0) {
247
+ setShowRatingOverlay(true);
248
+ setTimeout(() => setShowRatingOverlay(false), 5000);
249
+ }
250
+ };
251
+
252
+ const handlePlay = () => {
253
+ setIsPlaying(true);
254
+ setSpinnerLoading(false);
255
+ triggerRatingOverlay();
256
+ };
257
+
258
+ const handlePause = () => {
259
+ setIsPlaying(false);
260
+ };
261
+
262
+ const togglePlay = () => {
263
+ if (!videoRef.current) return;
264
+ videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause();
265
+ };
266
+
267
+ const handleSeekStart = () => setIsSeeking(true);
268
+ const handleSeekEnd = (e: React.ChangeEvent<HTMLInputElement>) => {
269
+ const newTime = Number(e.target.value);
270
+ if (videoRef.current) videoRef.current.currentTime = newTime;
271
+ setCurrentTime(newTime);
272
+ setIsSeeking(false);
273
+ };
274
+
275
+ const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
276
+ const newVolume = Number(e.target.value);
277
+ setVolume(newVolume);
278
+ if (videoRef.current) videoRef.current.volume = newVolume;
279
+ };
280
+
281
+ const toggleMute = () => {
282
+ if (!videoRef.current) return;
283
+ if (volume > 0) {
284
+ setVolume(0);
285
+ videoRef.current.volume = 0;
286
+ } else {
287
+ setVolume(1);
288
+ videoRef.current.volume = 1;
289
+ }
290
+ };
291
+
292
+ const toggleFullscreen = async () => {
293
+ if (!containerRef.current) return;
294
+ if (!isFullscreen) {
295
+ await containerRef.current.requestFullscreen?.();
296
+ setIsFullscreen(true);
297
+ } else {
298
+ await document.exitFullscreen?.();
299
+ setIsFullscreen(false);
300
+ }
301
+ };
302
+
303
+ const handleClose = () => {
304
+ onClosePlayer && onClosePlayer();
305
+ };
306
+
307
+ const formatTime = (time: number): string => {
308
+ const hours = Math.floor(time / 3600);
309
+ const minutes = Math.floor((time % 3600) / 60);
310
+ const seconds = Math.floor(time % 60);
311
+ const minutesStr = minutes.toString().padStart(2, '0');
312
+ const secondsStr = seconds.toString().padStart(2, '0');
313
+ return hours > 0 ? `${hours}:${minutesStr}:${secondsStr}` : `${minutesStr}:${secondsStr}`;
314
+ };
315
+
316
+ // --- Progress Bar Calculations ---
317
+ const playedPercent = duration ? (currentTime / duration) * 100 : 0;
318
+ const bufferedPercent = duration ? (buffered / duration) * 100 : 0;
319
+
320
+ return (
321
+ <div
322
+ className="z-30 absolute w-full h-full"
323
+ ref={containerRef}
324
+ onMouseMove={resetInactivityTimer}
325
+ data-oid="r0v5aoy"
326
+ >
327
+ {videoUrl ? (
328
+ <>
329
+ <video
330
+ ref={videoRef}
331
+ crossOrigin="anonymous"
332
+ className="w-full h-full bg-black object-cover"
333
+ style={{ pointerEvents: 'none' }}
334
+ onLoadedMetadata={handleLoadedMetadata}
335
+ onTimeUpdate={handleTimeUpdate}
336
+ onProgress={handleProgress}
337
+ onPlay={handlePlay}
338
+ onPause={handlePause}
339
+ onWaiting={() => setSpinnerLoading(true)}
340
+ onCanPlay={() => setSpinnerLoading(false)}
341
+ onPlaying={() => setSpinnerLoading(false)}
342
+ autoPlay
343
+ data-oid="b1om8sp"
344
+ >
345
+ <source
346
+ src={videoBlobUrl || videoUrl}
347
+ type="video/webm"
348
+ data-oid="fkv995l"
349
+ />
350
+ </video>
351
+
352
+ {showRatingOverlay && contentRatings.length > 0 && (
353
+ <div
354
+ className="absolute z-50 pointer-events-none"
355
+ style={{ top: '80px', left: '20px' }}
356
+ data-oid="ut68anl"
357
+ >
358
+ <div
359
+ className="px-6 py-3 bg-black bg-opacity-70 rounded-lg border border-white text-white text-3xl font-bold animate-fade-out"
360
+ data-oid="kmmj3.7"
361
+ >
362
+ {contentRatings[0].country ? `[${contentRatings[0].country}] ` : ''}
363
+ {contentRatings[0].name}
364
+ </div>
365
+ </div>
366
+ )}
367
+
368
+ {/* Controls Overlay */}
369
+ <div
370
+ className={`absolute inset-0 flex flex-col justify-between transition-opacity duration-300 z-40 ${
371
+ showControls
372
+ ? 'opacity-100 pointer-events-auto'
373
+ : 'opacity-0 pointer-events-none'
374
+ }`}
375
+ data-oid="ie8a4tx"
376
+ >
377
+ <div
378
+ className="flex items-center justify-between p-6 bg-gradient-to-b from-black/90 to-transparent"
379
+ data-oid="sbitr2x"
380
+ >
381
+ <div className="flex flex-col" data-oid="2izgux-">
382
+ <h1
383
+ className="text-white text-3xl font-extrabold"
384
+ data-oid="3jf5av-"
385
+ >
386
+ {videoTitle || 'Video Player'}
387
+ </h1>
388
+ {contentRatings.length > 0 && (
389
+ <div
390
+ className="mt-2 inline-block bg-gray-700 text-white text-sm px-3 py-1 rounded-md"
391
+ data-oid="hih_w29"
392
+ >
393
+ {contentRatings[0].country
394
+ ? `[${contentRatings[0].country}] `
395
+ : ''}
396
+ {contentRatings[0].name}
397
+ </div>
398
+ )}
399
+ </div>
400
+ {onClosePlayer && (
401
+ <button
402
+ onClick={handleClose}
403
+ className="text-white hover:text-red-400 transition-colors"
404
+ data-oid="vw:rhzp"
405
+ >
406
+ <XCircleIcon className="w-10 h-10" data-oid="rr-p16m" />
407
+ </button>
408
+ )}
409
+ </div>
410
+ <div
411
+ className="flex flex-col p-6 bg-gradient-to-t from-black/90 to-transparent"
412
+ data-oid="oqn0s0f"
413
+ >
414
+ <div
415
+ className="flex items-center justify-between mb-4"
416
+ data-oid="qo0gq8w"
417
+ >
418
+ <span className="text-white text-sm" data-oid="h9bzg-k">
419
+ {formatTime(currentTime)}
420
+ </span>
421
+ {/* Custom Progress Bar */}
422
+ <div className="relative w-full mx-4" data-oid="is-s2q_">
423
+ <div className="h-2 rounded bg-gray-700" data-oid="jm10y2t">
424
+ <div
425
+ className="h-2 rounded bg-purple-300"
426
+ style={{ width: `${bufferedPercent}%` }}
427
+ data-oid="t4-00_s"
428
+ ></div>
429
+ <div
430
+ className="h-2 rounded bg-purple-700 absolute top-0 left-0"
431
+ style={{ width: `${playedPercent}%` }}
432
+ data-oid="uq6zt6h"
433
+ ></div>
434
+ </div>
435
+ <input
436
+ type="range"
437
+ min="0"
438
+ max={duration}
439
+ value={
440
+ isSeeking
441
+ ? currentTime
442
+ : videoRef.current?.currentTime || 0
443
+ }
444
+ onMouseDown={handleSeekStart}
445
+ onTouchStart={handleSeekStart}
446
+ onChange={handleSeekEnd}
447
+ className="absolute top-0 left-0 w-full h-2 opacity-0 cursor-pointer"
448
+ data-oid="qlau95d"
449
+ />
450
+ </div>
451
+ <span className="text-white text-sm" data-oid="swiz6uh">
452
+ {formatTime(duration)}
453
+ </span>
454
+ </div>
455
+ <div className="flex items-center justify-between" data-oid="r:rpbrz">
456
+ <div className="flex items-center space-x-6" data-oid="milzceh">
457
+ <button
458
+ onClick={togglePlay}
459
+ className="text-white hover:text-purple-300 transition-colors"
460
+ data-oid="9xeiulf"
461
+ >
462
+ {isPlaying ? (
463
+ <PauseIcon className="w-10 h-10" data-oid="3_2us3v" />
464
+ ) : (
465
+ <PlayIcon className="w-10 h-10" data-oid="8hyyjo_" />
466
+ )}
467
+ </button>
468
+ <button
469
+ onClick={toggleMute}
470
+ className="text-white hover:text-purple-300 transition-colors"
471
+ data-oid="rf1ura4"
472
+ >
473
+ {isMuted ? (
474
+ <SpeakerXMarkIcon
475
+ className="w-10 h-10"
476
+ data-oid="f6mx23l"
477
+ />
478
+ ) : (
479
+ <SpeakerWaveIcon
480
+ className="w-10 h-10"
481
+ data-oid="y2bqchw"
482
+ />
483
+ )}
484
+ </button>
485
+ <input
486
+ type="range"
487
+ min={0}
488
+ max={1}
489
+ step={0.01}
490
+ value={volume}
491
+ onChange={handleVolumeChange}
492
+ className="w-24 accent-purple-500"
493
+ data-oid="4db1hvt"
494
+ />
495
+ </div>
496
+ <button
497
+ onClick={toggleFullscreen}
498
+ className="text-white hover:text-purple-300 transition-colors"
499
+ data-oid="eqb0vgb"
500
+ >
501
+ {!isFullscreen ? (
502
+ <ArrowsPointingOutIcon
503
+ className="w-10 h-10"
504
+ data-oid="oyn8yc6"
505
+ />
506
+ ) : (
507
+ <ArrowsPointingInIcon
508
+ className="w-10 h-10"
509
+ data-oid="t7zqpg9"
510
+ />
511
+ )}
512
+ </button>
513
+ </div>
514
+ </div>
515
+ </div>
516
+ </>
517
+ ) : (
518
+ // Link Fetcher UI while waiting for video URL
519
+ <div
520
+ className="p-6 bg-gray-800 text-gray-200 rounded-lg shadow-md"
521
+ data-oid="x:w-azo"
522
+ >
523
+ <h2 className="text-xl font-semibold mb-4" data-oid="gsa4riq">
524
+ Fetching Video Link
525
+ </h2>
526
+ {progress ? (
527
+ <div className="space-y-2" data-oid="d0ty9:0">
528
+ <p className="text-sm" data-oid="bacm-s6">
529
+ Status: {progress.status}
530
+ </p>
531
+ <p className="text-sm" data-oid="miy4vsg">
532
+ Progress: {progress.progress.toFixed(2)}%
533
+ </p>
534
+ <p className="text-sm" data-oid="_0i74dp">
535
+ Downloaded: {progress.downloaded} / {progress.total}
536
+ </p>
537
+ </div>
538
+ ) : (
539
+ <p className="text-sm" data-oid="05y0gze">
540
+ Initializing link fetching...
541
+ </p>
542
+ )}
543
+ </div>
544
+ )}
545
+
546
+ <style jsx data-oid="45q3vmn">{`
547
+ @keyframes fade-out {
548
+ 0% {
549
+ opacity: 1;
550
+ }
551
+ 80% {
552
+ opacity: 1;
553
+ }
554
+ 100% {
555
+ opacity: 0;
556
+ }
557
+ }
558
+ .animate-fade-out {
559
+ animation: fade-out 4s forwards;
560
+ }
561
+ `}</style>
562
+ </div>
563
+ );
564
+ };
565
+
566
+ export default MoviePlayer;
frontend/components/movie/MoviePlayerModal.tsx CHANGED
@@ -32,11 +32,10 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
32
  contentRatings = [],
33
  }) => {
34
  const { spinnerLoading, setSpinnerLoading } = useSpinnerLoading();
35
- // Refs for the container and video
36
  const containerRef = useRef<HTMLDivElement>(null);
37
  const videoRef = useRef<HTMLVideoElement>(null);
38
 
39
- // Player state
40
  const [showControls, setShowControls] = useState(true);
41
  const [isPlaying, setIsPlaying] = useState(false);
42
  const [isSeeking, setIsSeeking] = useState(false);
@@ -44,12 +43,9 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
44
  const [currentTime, setCurrentTime] = useState(0);
45
  const [volume, setVolume] = useState(1);
46
  const [isFullscreen, setIsFullscreen] = useState(false);
47
-
48
- // Mute toggle – we consider volume 0 as muted
49
  const isMuted = volume === 0;
50
-
51
- // Overlay auto-hide timer
52
  const inactivityRef = useRef<NodeJS.Timeout | null>(null);
 
53
  const resetInactivityTimer = () => {
54
  setShowControls(true);
55
  if (inactivityRef.current) clearTimeout(inactivityRef.current);
@@ -58,21 +54,63 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
58
  }, 3000);
59
  };
60
 
61
- // Top overlay: show content rating briefly; we show only the first rating
62
- const [showRatingOverlay, setShowRatingOverlay] = useState(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  useEffect(() => {
64
- if (contentRatings.length > 0) {
65
  setShowRatingOverlay(true);
66
- const timeout = setTimeout(() => {
67
  setShowRatingOverlay(false);
68
  }, 4000);
69
- return () => clearTimeout(timeout);
70
- } else {
71
- setShowRatingOverlay(false);
72
  }
73
- }, [contentRatings]);
74
 
75
- // Listen for fullscreen changes (e.g. if user presses Esc)
76
  useEffect(() => {
77
  const handleFullScreenChange = () => {
78
  if (document.fullscreenElement === containerRef.current) {
@@ -87,7 +125,6 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
87
  };
88
  }, []);
89
 
90
- // Reset player state when modal opens
91
  useEffect(() => {
92
  if (isOpen) {
93
  setIsPlaying(false);
@@ -96,15 +133,20 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
96
  setVolume(1);
97
  setIsFullscreen(false);
98
  resetInactivityTimer();
 
 
 
 
 
99
  } else {
100
  videoRef.current?.pause();
 
101
  }
102
  return () => {
103
  if (inactivityRef.current) clearTimeout(inactivityRef.current);
104
  };
105
- }, [isOpen]);
106
 
107
- // Video event handlers
108
  const handleLoadedMetadata = () => {
109
  if (videoRef.current) {
110
  setDuration(videoRef.current.duration);
@@ -139,13 +181,7 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
139
  setIsSeeking(true);
140
  };
141
 
142
- interface SeekEvent {
143
- target: {
144
- value: string;
145
- };
146
- }
147
-
148
- const handleSeekEnd = (e: SeekEvent) => {
149
  const newTime = Number(e.target.value);
150
  if (videoRef.current) {
151
  videoRef.current.currentTime = newTime;
@@ -193,30 +229,35 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
193
  onClose();
194
  };
195
 
196
- // Format time (mm:ss)
197
- const formatTime = (time: number) => {
198
- const mins = Math.floor(time / 60)
199
- .toString()
200
- .padStart(2, '0');
201
- const secs = Math.floor(time % 60)
202
- .toString()
203
- .padStart(2, '0');
204
- return `${mins}:${secs}`;
 
 
 
 
 
205
  };
206
 
207
  if (!isOpen) return null;
208
 
209
  return (
210
  <div
211
- className="fixed inset-0 z-[30] bg-black/90 flex items-center justify-center"
212
  onMouseMove={resetInactivityTimer}
 
213
  >
214
- <div className="relative w-full h-full" ref={containerRef}>
215
- {/* Video Element */}
216
  <video
217
  ref={videoRef}
218
- src={videoUrl}
219
- className="w-full h-full bg-black"
220
  onLoadedMetadata={handleLoadedMetadata}
221
  onTimeUpdate={handleTimeUpdate}
222
  onPlay={handlePlay}
@@ -225,20 +266,43 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
225
  onCanPlay={() => setSpinnerLoading(false)}
226
  onPlaying={() => setSpinnerLoading(false)}
227
  autoPlay
 
228
  />
229
 
230
- {/* Single Overlay: Top and Bottom Controls */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  <div
232
- className={`absolute inset-0 flex flex-col justify-between transition-opacity duration-300 z-20 ${
233
  showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'
234
  }`}
 
235
  >
236
- {/* Top Controls */}
237
- <div className="flex items-center justify-between p-4 bg-gradient-to-b from-black/80 to-transparent">
238
- <div className="flex flex-col">
239
- <h1 className="text-white text-2xl font-bold w-full">{videoTitle}</h1>
 
 
 
 
240
  {contentRatings.length > 0 && (
241
- <div className="mt-1 inline-block bg-gray-800 text-white text-xs px-2 py-1 rounded">
 
 
 
242
  {contentRatings[0].country
243
  ? `[${contentRatings[0].country}] `
244
  : ''}
@@ -246,17 +310,22 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
246
  </div>
247
  )}
248
  </div>
249
- <button onClick={handleClose} className="text-white hover:text-red-400">
250
- <XCircleIcon className="w-12 h-12" />
 
 
 
 
251
  </button>
252
  </div>
253
-
254
- {/* Bottom Controls */}
255
- <div className="flex flex-col p-4 bg-gradient-to-t from-black/80 to-transparent">
256
- {/* Progress Row */}
257
- <div className="flex items-center justify-between mb-2">
258
- <span className="text-white text-xs">{formatTime(currentTime)}</span>
259
-
 
260
  <input
261
  type="range"
262
  min="0"
@@ -266,31 +335,38 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
266
  onTouchStart={handleSeekStart}
267
  onChange={handleSeekEnd}
268
  className="w-full mx-4 accent-purple-500 cursor-pointer"
 
269
  />
270
 
271
- <span className="text-white text-xs">{formatTime(duration)}</span>
 
 
272
  </div>
273
- {/* Control Row */}
274
- <div className="flex items-center justify-between">
275
- <div className="flex items-center space-x-4">
276
  <button
277
  onClick={togglePlay}
278
- className="text-white hover:text-purple-300"
 
279
  >
280
  {isPlaying ? (
281
- <PauseIcon className="size-8" />
282
  ) : (
283
- <PlayIcon className="size-8" />
284
  )}
285
  </button>
286
  <button
287
  onClick={toggleMute}
288
- className="text-white hover:text-purple-300"
 
289
  >
290
  {isMuted ? (
291
- <SpeakerXMarkIcon className="w-8 h-8" />
 
 
 
292
  ) : (
293
- <SpeakerWaveIcon className="w-8 h-8" />
294
  )}
295
  </button>
296
  <input
@@ -301,22 +377,46 @@ const MoviePlayerModal: React.FC<MoviePlayerModalProps> = ({
301
  value={volume}
302
  onChange={handleVolume}
303
  className="w-24 accent-purple-500"
 
304
  />
305
  </div>
306
  <button
307
  onClick={toggleFullscreen}
308
  className="text-white hover:text-purple-300 transition-colors"
 
309
  >
310
  {!isFullscreen ? (
311
- <ArrowsPointingOutIcon className="w-8 h-8" />
 
 
 
312
  ) : (
313
- <ArrowsPointingInIcon className="w-8 h-8" />
 
 
 
314
  )}
315
  </button>
316
  </div>
317
  </div>
318
  </div>
319
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  </div>
321
  );
322
  };
 
32
  contentRatings = [],
33
  }) => {
34
  const { spinnerLoading, setSpinnerLoading } = useSpinnerLoading();
 
35
  const containerRef = useRef<HTMLDivElement>(null);
36
  const videoRef = useRef<HTMLVideoElement>(null);
37
 
38
+ const [videoBlobUrl, setVideoBlobUrl] = useState<string>('');
39
  const [showControls, setShowControls] = useState(true);
40
  const [isPlaying, setIsPlaying] = useState(false);
41
  const [isSeeking, setIsSeeking] = useState(false);
 
43
  const [currentTime, setCurrentTime] = useState(0);
44
  const [volume, setVolume] = useState(1);
45
  const [isFullscreen, setIsFullscreen] = useState(false);
 
 
46
  const isMuted = volume === 0;
 
 
47
  const inactivityRef = useRef<NodeJS.Timeout | null>(null);
48
+
49
  const resetInactivityTimer = () => {
50
  setShowControls(true);
51
  if (inactivityRef.current) clearTimeout(inactivityRef.current);
 
54
  }, 3000);
55
  };
56
 
57
+ // State for rating overlay (now positioned at top left below the top overlay)
58
+ const [showRatingOverlay, setShowRatingOverlay] = useState(false);
59
+
60
+ // Fetch the video blob and create a blob URL when modal opens
61
+ useEffect(() => {
62
+ let abortController: AbortController;
63
+ if (isOpen) {
64
+ setSpinnerLoading(true);
65
+ abortController = new AbortController();
66
+ fetch(videoUrl, { signal: abortController.signal, mode: 'cors' })
67
+ .then((response) => {
68
+ if (!response.ok) {
69
+ throw new Error(`HTTP error! status: ${response.status}`);
70
+ }
71
+ return response.blob();
72
+ })
73
+ .then((blob) => {
74
+ const blobUrl = URL.createObjectURL(blob);
75
+ setVideoBlobUrl(blobUrl);
76
+ console.log('Blob URL created:', blobUrl); // Debug log - Blob URL creation success
77
+ setSpinnerLoading(false);
78
+ })
79
+ .catch((error) => {
80
+ console.error('Error fetching video blob:', error); // Enhanced error logging
81
+ console.error('Falling back to direct video URL.');
82
+ setVideoBlobUrl('');
83
+ setSpinnerLoading(false);
84
+ });
85
+ }
86
+ return () => {
87
+ if (abortController) {
88
+ setTimeout(() => {
89
+ // Delayed abort
90
+ abortController.abort();
91
+ console.log('Blob fetch aborted (delayed)'); // Optional: Log delayed abort
92
+ }, 100);
93
+ }
94
+ if (videoBlobUrl) {
95
+ URL.revokeObjectURL(videoBlobUrl);
96
+ console.log('Blob URL revoked:', videoBlobUrl); // Debug log - Blob URL revocation
97
+ }
98
+ setVideoBlobUrl('');
99
+ };
100
+ }, [isOpen, videoUrl]);
101
+
102
+ // Show the content rating overlay when video starts playing
103
  useEffect(() => {
104
+ if (contentRatings.length > 0 && isPlaying) {
105
  setShowRatingOverlay(true);
106
+ const timer = setTimeout(() => {
107
  setShowRatingOverlay(false);
108
  }, 4000);
109
+ return () => clearTimeout(timer);
 
 
110
  }
111
+ }, [contentRatings, isPlaying]);
112
 
113
+ // Listen for fullscreen changes
114
  useEffect(() => {
115
  const handleFullScreenChange = () => {
116
  if (document.fullscreenElement === containerRef.current) {
 
125
  };
126
  }, []);
127
 
 
128
  useEffect(() => {
129
  if (isOpen) {
130
  setIsPlaying(false);
 
133
  setVolume(1);
134
  setIsFullscreen(false);
135
  resetInactivityTimer();
136
+ console.log(
137
+ 'Modal opened, videoBlobUrl:',
138
+ videoBlobUrl ? 'using blob' : 'using direct URL',
139
+ videoBlobUrl,
140
+ ); // Debug log - Modal opened, check blob usage
141
  } else {
142
  videoRef.current?.pause();
143
+ console.log('Modal closed, pausing video'); // Debug log - Modal closed
144
  }
145
  return () => {
146
  if (inactivityRef.current) clearTimeout(inactivityRef.current);
147
  };
148
+ }, [isOpen, videoBlobUrl]); // Added videoBlobUrl to dependency array to log on blob URL change
149
 
 
150
  const handleLoadedMetadata = () => {
151
  if (videoRef.current) {
152
  setDuration(videoRef.current.duration);
 
181
  setIsSeeking(true);
182
  };
183
 
184
+ const handleSeekEnd = (e: React.ChangeEvent<HTMLInputElement>) => {
 
 
 
 
 
 
185
  const newTime = Number(e.target.value);
186
  if (videoRef.current) {
187
  videoRef.current.currentTime = newTime;
 
229
  onClose();
230
  };
231
 
232
+ const formatTime = (time: number): string => {
233
+ const hours = Math.floor(time / 3600);
234
+ const minutes = Math.floor((time % 3600) / 60);
235
+ const seconds = Math.floor(time % 60);
236
+
237
+ const minutesStr = minutes.toString().padStart(2, '0');
238
+ const secondsStr = seconds.toString().padStart(2, '0');
239
+
240
+ if (hours > 0) {
241
+ const hoursStr = hours.toString(); // No need to pad hours to 1 digit, can just use toString()
242
+ return `${hoursStr}:${minutesStr}:${secondsStr}`;
243
+ } else {
244
+ return `${minutesStr}:${secondsStr}`;
245
+ }
246
  };
247
 
248
  if (!isOpen) return null;
249
 
250
  return (
251
  <div
252
+ className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
253
  onMouseMove={resetInactivityTimer}
254
+ data-oid="gr7.4q9"
255
  >
256
+ <div className="relative w-full h-full" ref={containerRef} data-oid="4g9fqds">
 
257
  <video
258
  ref={videoRef}
259
+ src={videoBlobUrl || videoUrl}
260
+ className="w-full h-full bg-black object-cover"
261
  onLoadedMetadata={handleLoadedMetadata}
262
  onTimeUpdate={handleTimeUpdate}
263
  onPlay={handlePlay}
 
266
  onCanPlay={() => setSpinnerLoading(false)}
267
  onPlaying={() => setSpinnerLoading(false)}
268
  autoPlay
269
+ data-oid="s8jxm43"
270
  />
271
 
272
+ {showRatingOverlay && contentRatings.length > 0 && (
273
+ <div
274
+ className="absolute z-30 pointer-events-none"
275
+ style={{ top: '80px', left: '20px' }}
276
+ data-oid="26uj.jk"
277
+ >
278
+ <div
279
+ className="px-6 py-3 bg-black bg-opacity-70 rounded-lg border border-white text-white text-3xl font-bold animate-fade-out"
280
+ data-oid="o4huudt"
281
+ >
282
+ {contentRatings[0].country ? `[${contentRatings[0].country}] ` : ''}
283
+ {contentRatings[0].name}
284
+ </div>
285
+ </div>
286
+ )}
287
  <div
288
+ className={`absolute inset-0 flex flex-col justify-between transition-opacity duration-300 z-40 ${
289
  showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'
290
  }`}
291
+ data-oid="gwt99if"
292
  >
293
+ <div
294
+ className="flex items-center justify-between p-6 bg-gradient-to-b from-black/90 to-transparent"
295
+ data-oid="y-ywqt."
296
+ >
297
+ <div className="flex flex-col" data-oid="jageq1l">
298
+ <h1 className="text-white text-3xl font-extrabold" data-oid=":mrvbt_">
299
+ {videoTitle}
300
+ </h1>
301
  {contentRatings.length > 0 && (
302
+ <div
303
+ className="mt-2 inline-block bg-gray-700 text-white text-sm px-3 py-1 rounded-md"
304
+ data-oid="o3qr03s"
305
+ >
306
  {contentRatings[0].country
307
  ? `[${contentRatings[0].country}] `
308
  : ''}
 
310
  </div>
311
  )}
312
  </div>
313
+ <button
314
+ onClick={handleClose}
315
+ className="text-white hover:text-red-400 transition-colors"
316
+ data-oid="330owz4"
317
+ >
318
+ <XCircleIcon className="w-10 h-10" data-oid="y5b.7gv" />
319
  </button>
320
  </div>
321
+ <div
322
+ className="flex flex-col p-6 bg-gradient-to-t from-black/90 to-transparent"
323
+ data-oid="t2hi_:9"
324
+ >
325
+ <div className="flex items-center justify-between mb-4" data-oid="xockgo8">
326
+ <span className="text-white text-sm" data-oid="_r0wwui">
327
+ {formatTime(currentTime)}
328
+ </span>
329
  <input
330
  type="range"
331
  min="0"
 
335
  onTouchStart={handleSeekStart}
336
  onChange={handleSeekEnd}
337
  className="w-full mx-4 accent-purple-500 cursor-pointer"
338
+ data-oid="jxplunv"
339
  />
340
 
341
+ <span className="text-white text-sm" data-oid=":.a9omh">
342
+ {formatTime(duration)}
343
+ </span>
344
  </div>
345
+ <div className="flex items-center justify-between" data-oid="5mh.64r">
346
+ <div className="flex items-center space-x-6" data-oid="v1-s8y8">
 
347
  <button
348
  onClick={togglePlay}
349
+ className="text-white hover:text-purple-300 transition-colors"
350
+ data-oid=".msovrz"
351
  >
352
  {isPlaying ? (
353
+ <PauseIcon className="w-10 h-10" data-oid="y-_g:m0" />
354
  ) : (
355
+ <PlayIcon className="w-10 h-10" data-oid="gubg__s" />
356
  )}
357
  </button>
358
  <button
359
  onClick={toggleMute}
360
+ className="text-white hover:text-purple-300 transition-colors"
361
+ data-oid="acvgb5x"
362
  >
363
  {isMuted ? (
364
+ <SpeakerXMarkIcon
365
+ className="w-10 h-10"
366
+ data-oid="6:qnknc"
367
+ />
368
  ) : (
369
+ <SpeakerWaveIcon className="w-10 h-10" data-oid="edsvj72" />
370
  )}
371
  </button>
372
  <input
 
377
  value={volume}
378
  onChange={handleVolume}
379
  className="w-24 accent-purple-500"
380
+ data-oid="kob7ehe"
381
  />
382
  </div>
383
  <button
384
  onClick={toggleFullscreen}
385
  className="text-white hover:text-purple-300 transition-colors"
386
+ data-oid="q6togk5"
387
  >
388
  {!isFullscreen ? (
389
+ <ArrowsPointingOutIcon
390
+ className="w-10 h-10"
391
+ data-oid="m5oa5si"
392
+ />
393
  ) : (
394
+ <ArrowsPointingInIcon
395
+ className="w-10 h-10"
396
+ data-oid=":rxbd5:"
397
+ />
398
  )}
399
  </button>
400
  </div>
401
  </div>
402
  </div>
403
  </div>
404
+ <style jsx data-oid="9q-00-m">{`
405
+ @keyframes fade-out {
406
+ 0% {
407
+ opacity: 1;
408
+ }
409
+ 80% {
410
+ opacity: 1;
411
+ }
412
+ 100% {
413
+ opacity: 0;
414
+ }
415
+ }
416
+ .animate-fade-out {
417
+ animation: fade-out 4s forwards;
418
+ }
419
+ `}</style>
420
  </div>
421
  );
422
  };
frontend/components/navigation/DesktopMenu.tsx CHANGED
@@ -2,17 +2,29 @@ import Link from 'next/link';
2
 
3
  export const DesktopMenu = () => {
4
  return (
5
- <div className="hidden md:flex space-x-8">
6
- <Link href="/" className="hover:text-purple-400 transition-colors">
7
  Home
8
  </Link>
9
- <Link href="/movies" className="hover:text-purple-400 transition-colors">
 
 
 
 
10
  Movies
11
  </Link>
12
- <Link href="/tvshows" className="hover:text-purple-400 transition-colors">
 
 
 
 
13
  TV Shows
14
  </Link>
15
- <Link href="/mylist" className="hover:text-purple-400 transition-colors">
 
 
 
 
16
  My List
17
  </Link>
18
  </div>
 
2
 
3
  export const DesktopMenu = () => {
4
  return (
5
+ <div className="hidden md:flex space-x-8" data-oid="8clvgxs">
6
+ <Link href="/" className="hover:text-purple-400 transition-colors" data-oid="en._w4y">
7
  Home
8
  </Link>
9
+ <Link
10
+ href="/browse/movies"
11
+ className="hover:text-purple-400 transition-colors"
12
+ data-oid=".4cp8fx"
13
+ >
14
  Movies
15
  </Link>
16
+ <Link
17
+ href="/browse/tvshows"
18
+ className="hover:text-purple-400 transition-colors"
19
+ data-oid="s0:-:vi"
20
+ >
21
  TV Shows
22
  </Link>
23
+ <Link
24
+ href="/mylist"
25
+ className="hover:text-purple-400 transition-colors"
26
+ data-oid="aci1ac:"
27
+ >
28
  My List
29
  </Link>
30
  </div>
frontend/components/navigation/MobileMenu.tsx CHANGED
@@ -17,22 +17,45 @@ export const MobileMenu = ({ isOpen }: MobileMenuProps) => {
17
  leave="transition ease-in duration-200"
18
  leaveFrom="opacity-100 transform scale-100"
19
  leaveTo="opacity-0 transform scale-95"
 
20
  >
21
- <div className="md:hidden bg-gray-900 border-t border-gray-900 bg-opacity-50 backdrop-blur-lg">
22
- <div className="px-6 py-4 space-y-4">
23
- <Link href="/" className="block hover:text-purple-400 transition-colors">
 
 
 
 
 
 
 
24
  Home
25
  </Link>
26
- <Link href="/movies" className="block hover:text-purple-400 transition-colors">
 
 
 
 
27
  Movies
28
  </Link>
29
- <Link href="/tvshows" className="block hover:text-purple-400 transition-colors">
 
 
 
 
30
  TV Shows
31
  </Link>
32
- <Link href="/mylist" className="block hover:text-purple-400 transition-colors">
 
 
 
 
33
  My List
34
  </Link>
35
- <button className="w-full bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-6 py-2 rounded-full transition-colors">
 
 
 
36
  Sign In
37
  </button>
38
  </div>
 
17
  leave="transition ease-in duration-200"
18
  leaveFrom="opacity-100 transform scale-100"
19
  leaveTo="opacity-0 transform scale-95"
20
+ data-oid="nwd51ke"
21
  >
22
+ <div
23
+ className="md:hidden bg-gray-900 border-t border-gray-900 bg-opacity-50 backdrop-blur-lg"
24
+ data-oid="wxr9fto"
25
+ >
26
+ <div className="px-6 py-4 space-y-4" data-oid="vk_jznl">
27
+ <Link
28
+ href="/"
29
+ className="block hover:text-purple-400 transition-colors"
30
+ data-oid="di7h_s8"
31
+ >
32
  Home
33
  </Link>
34
+ <Link
35
+ href="/browse/movies"
36
+ className="block hover:text-purple-400 transition-colors"
37
+ data-oid="::e1v3l"
38
+ >
39
  Movies
40
  </Link>
41
+ <Link
42
+ href="/browse/tvshows"
43
+ className="block hover:text-purple-400 transition-colors"
44
+ data-oid="sckwvhe"
45
+ >
46
  TV Shows
47
  </Link>
48
+ <Link
49
+ href="/mylist"
50
+ className="block hover:text-purple-400 transition-colors"
51
+ data-oid="tam_u1k"
52
+ >
53
  My List
54
  </Link>
55
+ <button
56
+ className="w-full bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-6 py-2 rounded-full transition-colors"
57
+ data-oid="k8swkjc"
58
+ >
59
  Sign In
60
  </button>
61
  </div>
frontend/components/navigation/Navbar.tsx CHANGED
@@ -10,40 +10,48 @@ export const Navbar = () => {
10
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
11
 
12
  return (
13
- <nav className="fixed w-full z-20 bg-gradient-to-b from-gray-900 to-transparent backdrop-blur-lg">
14
- <div className="container mx-auto px-6 py-4">
15
- <div className="flex items-center justify-between">
 
 
 
16
  <Link
17
  href={'/'}
18
  className="text-3xl font-bold bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent"
 
19
  >
20
  NEXORA
21
  </Link>
22
- <DesktopMenu />
23
- <div className="flex gap-4">
24
- <button className="text-white">
25
- <Link href={'#search'}>
26
- <MagnifyingGlassIcon className="size-6" />
27
  </Link>
28
  </button>
29
  <button
30
  className="md:hidden text-white"
31
  onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
 
32
  >
33
  {isMobileMenuOpen ? (
34
- <XMarkIcon className="size-6" />
35
  ) : (
36
- <Bars3Icon className="size-6" />
37
  )}
38
  </button>
39
 
40
- <button className="hidden md:block bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-6 py-2 rounded-full">
 
 
 
41
  Sign In
42
  </button>
43
  </div>
44
  </div>
45
  </div>
46
- <MobileMenu isOpen={isMobileMenuOpen} />
47
  </nav>
48
  );
49
  };
 
10
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
11
 
12
  return (
13
+ <nav
14
+ className="fixed w-full z-20 bg-gradient-to-b from-gray-900 to-transparent backdrop-blur-lg"
15
+ data-oid="90uin_x"
16
+ >
17
+ <div className="container mx-auto px-6 py-4" data-oid="qdbt3n8">
18
+ <div className="flex items-center justify-between" data-oid="7j-7ngy">
19
  <Link
20
  href={'/'}
21
  className="text-3xl font-bold bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent"
22
+ data-oid="t-_qli-"
23
  >
24
  NEXORA
25
  </Link>
26
+ <DesktopMenu data-oid="12hlixc" />
27
+ <div className="flex gap-4" data-oid="4t7:7ph">
28
+ <button className="text-white" data-oid="8_f3qsv">
29
+ <Link href={'/search'} data-oid="qbms7nq">
30
+ <MagnifyingGlassIcon className="size-6" data-oid="0y0pv8e" />
31
  </Link>
32
  </button>
33
  <button
34
  className="md:hidden text-white"
35
  onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
36
+ data-oid="ul.haxw"
37
  >
38
  {isMobileMenuOpen ? (
39
+ <XMarkIcon className="size-6" data-oid="z3215xb" />
40
  ) : (
41
+ <Bars3Icon className="size-6" data-oid="kj5xto2" />
42
  )}
43
  </button>
44
 
45
+ <button
46
+ className="hidden md:block bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 px-6 py-2 rounded-full"
47
+ data-oid="vjtb.l3"
48
+ >
49
  Sign In
50
  </button>
51
  </div>
52
  </div>
53
  </div>
54
+ <MobileMenu isOpen={isMobileMenuOpen} data-oid="32owrug" />
55
  </nav>
56
  );
57
  };
frontend/components/sections/CastSection.tsx CHANGED
@@ -20,23 +20,37 @@ export default function CastSection({ movie }: CastSectionProps) {
20
  const visibleCharacters = expanded ? movie.characters : movie.characters?.slice(0, 5);
21
 
22
  return (
23
- <div className="flex-1 bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50">
24
- <div className="flex justify-between items-center mb-2">
25
- <h2 className="text-xl font-semibold text-white">Cast</h2>
 
 
 
 
 
26
  {movie.characters && movie.characters.length > 5 && (
27
  <button
28
  onClick={() => setExpanded(!expanded)}
29
  className="text-white hover:text-gray-300 flex items-center"
 
30
  >
31
  {expanded ? 'Hide' : 'Show'}{' '}
32
- {expanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
 
 
 
 
33
  </button>
34
  )}
35
  </div>
36
 
37
- <div className="flex flex-wrap gap-4">
38
  {visibleCharacters?.map((character) => (
39
- <div key={character.id} className="flex items-center space-x-3">
 
 
 
 
40
  <img
41
  src={
42
  character.personImgURL ||
@@ -45,18 +59,22 @@ export default function CastSection({ movie }: CastSectionProps) {
45
  }
46
  alt={character.personName}
47
  className="w-12 h-12 rounded-full object-cover"
 
48
  />
49
 
50
- <div>
51
  <a
52
  target="_blank"
53
  href={`https://thetvdb.com/people/${character.url}`}
54
  className="text-white font-semibold hover:underline"
 
55
  >
56
  {character.personName}
57
  </a>
58
  {character.name && (
59
- <p className="text-sm text-gray-400">as {character.name}</p>
 
 
60
  )}
61
  </div>
62
  </div>
 
20
  const visibleCharacters = expanded ? movie.characters : movie.characters?.slice(0, 5);
21
 
22
  return (
23
+ <div
24
+ className="flex-1 bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50"
25
+ data-oid="el-dr0u"
26
+ >
27
+ <div className="flex justify-between items-center mb-2" data-oid="ungegze">
28
+ <h2 className="text-xl font-semibold text-white" data-oid="2nge4xt">
29
+ Cast
30
+ </h2>
31
  {movie.characters && movie.characters.length > 5 && (
32
  <button
33
  onClick={() => setExpanded(!expanded)}
34
  className="text-white hover:text-gray-300 flex items-center"
35
+ data-oid="p18:2hi"
36
  >
37
  {expanded ? 'Hide' : 'Show'}{' '}
38
+ {expanded ? (
39
+ <ChevronUp size={18} data-oid="4ob-a3h" />
40
+ ) : (
41
+ <ChevronDown size={18} data-oid="v0fnd16" />
42
+ )}
43
  </button>
44
  )}
45
  </div>
46
 
47
+ <div className="flex flex-wrap gap-4" data-oid="2i8wny_">
48
  {visibleCharacters?.map((character) => (
49
+ <div
50
+ key={character.id}
51
+ className="flex items-center space-x-3"
52
+ data-oid="z-rn0go"
53
+ >
54
  <img
55
  src={
56
  character.personImgURL ||
 
59
  }
60
  alt={character.personName}
61
  className="w-12 h-12 rounded-full object-cover"
62
+ data-oid="ll8y:xp"
63
  />
64
 
65
+ <div data-oid="9p-f8ar">
66
  <a
67
  target="_blank"
68
  href={`https://thetvdb.com/people/${character.url}`}
69
  className="text-white font-semibold hover:underline"
70
+ data-oid="dg_dhmp"
71
  >
72
  {character.personName}
73
  </a>
74
  {character.name && (
75
+ <p className="text-sm text-gray-400" data-oid="t4g7z9d">
76
+ as {character.name}
77
+ </p>
78
  )}
79
  </div>
80
  </div>
frontend/components/sections/EpisodesSection.tsx ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from 'next/link';
2
+ import { useEffect, useState } from 'react';
3
+
4
+ interface FileStructure {
5
+ contents?: any[];
6
+ }
7
+
8
+ export default function EpisodesSection({
9
+ fileStructure,
10
+ tvshow,
11
+ }: {
12
+ fileStructure: FileStructure;
13
+ tvshow: any;
14
+ }) {
15
+ const [activeSeasonName, setActiveSeasonName] = useState('');
16
+
17
+ useEffect(() => {
18
+ if (!activeSeasonName && fileStructure.contents && fileStructure.contents.length > 0) {
19
+ setActiveSeasonName(fileStructure.contents[0].path.split('/').pop() || '');
20
+ }
21
+ }, [fileStructure.contents, activeSeasonName]);
22
+
23
+ const activeSeasonContent = fileStructure.contents?.find(
24
+ (season) => season.path.split('/').pop() === activeSeasonName,
25
+ );
26
+
27
+ return (
28
+ <div
29
+ className="bg-gray-800/60 backdrop-blur-md rounded-2xl p-6 border border-gray-700/50"
30
+ data-oid="-u.7aal"
31
+ >
32
+ <div className="flex flex-col space-y-4" data-oid="mlieuhm">
33
+ <div className="flex items-center justify-between" data-oid="9bhr4nm">
34
+ <h3 className="text-lg font-semibold text-gray-200" data-oid="-oysqta">
35
+ Episodes
36
+ </h3>
37
+ </div>
38
+
39
+ {/* Season Buttons */}
40
+ <div
41
+ className="overflow-x-auto whitespace-nowrap flex gap-2 scrollbar-hide snap-x snap-mandatory pb-2"
42
+ data-oid="hqh:hnv"
43
+ >
44
+ {fileStructure.contents?.map((season, idx) => {
45
+ const seasonName = season.path.split('/').pop();
46
+ const isSpecials = seasonName === 'Specials';
47
+
48
+ return (
49
+ <button
50
+ key={idx}
51
+ onClick={() => setActiveSeasonName(seasonName)}
52
+ className={`px-4 py-2 min-w-fit rounded-full text-sm font-medium transition-colors snap-start ${
53
+ activeSeasonName === seasonName
54
+ ? 'bg-purple-500 text-white'
55
+ : isSpecials
56
+ ? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
57
+ : 'bg-purple-500/20 text-purple-300 hover:bg-purple-500/30'
58
+ }`}
59
+ data-oid="epw09b7"
60
+ >
61
+ {seasonName}
62
+ </button>
63
+ );
64
+ })}
65
+ </div>
66
+
67
+ {/* Episodes List */}
68
+ <div className="space-y-8 mt-4" data-oid="jehi09c">
69
+ {activeSeasonContent && (
70
+ <div key={activeSeasonName} className="space-y-4" data-oid="h23fi3y">
71
+ <h4
72
+ className={`text-base font-medium ${
73
+ activeSeasonContent.path.includes('Specials')
74
+ ? 'text-amber-300'
75
+ : 'text-purple-300'
76
+ }`}
77
+ data-oid="g_ta-8k"
78
+ >
79
+ {activeSeasonName}
80
+ </h4>
81
+ <div className="space-y-2" data-oid="4wxezna">
82
+ {activeSeasonContent.contents?.map(
83
+ (episode: any, episodeIdx: number) => {
84
+ const match = episode.path.match(
85
+ /[S](\d+)[E](\d+) - (.+?)\./,
86
+ );
87
+ if (!match) return null;
88
+
89
+ const [, , episodeNum, episodeTitle] = match;
90
+ const isSpecials =
91
+ activeSeasonContent.path.includes('Specials');
92
+
93
+ // Use the actual file name from the file structure for the Link href
94
+ const fileName = episode.path.split('/').pop();
95
+
96
+ return (
97
+ <div
98
+ key={episodeIdx}
99
+ className="group flex items-center gap-4 p-3 rounded-xl transition-colors hover:bg-gray-700/50 cursor-pointer"
100
+ data-oid="tsgeg2u"
101
+ >
102
+ {/* Episode Number */}
103
+ <div
104
+ className={`flex-shrink-0 w-12 h-12 flex items-center justify-center rounded-xl bg-gray-700/50 ${
105
+ isSpecials
106
+ ? 'group-hover:bg-amber-500/20'
107
+ : 'group-hover:bg-purple-500/20'
108
+ }`}
109
+ data-oid=":t-c1qy"
110
+ >
111
+ <span
112
+ className={`text-lg font-semibold text-gray-300 ${
113
+ isSpecials
114
+ ? 'group-hover:text-amber-300'
115
+ : 'group-hover:text-purple-300'
116
+ }`}
117
+ data-oid="l:36s8q"
118
+ >
119
+ {episodeNum}
120
+ </span>
121
+ </div>
122
+
123
+ {/* Episode Info */}
124
+ <div className="flex-grow" data-oid="8dem83b">
125
+ <h4
126
+ className="text-gray-200 font-medium"
127
+ data-oid="z3dxeg6"
128
+ >
129
+ {episodeTitle.replace(/_/g, ' ')}
130
+ </h4>
131
+ <div
132
+ className="flex items-center gap-3 text-sm text-gray-400"
133
+ data-oid="sbyuvx."
134
+ >
135
+ <span data-oid="km.dfmx">
136
+ {Math.round(episode.size / 1024 / 1024)}{' '}
137
+ MB
138
+ </span>
139
+ <span data-oid=".m7gvkj">•</span>
140
+ <span data-oid="4ae3wzk">
141
+ {episode.path.includes('720p')
142
+ ? 'HD'
143
+ : 'SD'}
144
+ </span>
145
+ </div>
146
+ </div>
147
+
148
+ {/* Play Button */}
149
+ <Link
150
+ href={`/watch/tvshow/${tvshow}/${activeSeasonName}/${fileName}`}
151
+ className={`flex-shrink-0 p-2 rounded-full text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity ${
152
+ isSpecials
153
+ ? 'bg-amber-500/20 hover:bg-amber-500/30'
154
+ : 'bg-purple-500/20 hover:bg-purple-500/30'
155
+ }`}
156
+ data-oid="ccsav_w"
157
+ >
158
+ <svg
159
+ className="w-5 h-5"
160
+ fill="currentColor"
161
+ viewBox="0 0 20 20"
162
+ data-oid="ygzyjqj"
163
+ >
164
+ <path
165
+ d="M4 4l12 6-12 6V4z"
166
+ data-oid="ei5lvf8"
167
+ />
168
+ </svg>
169
+ </Link>
170
+ </div>
171
+ );
172
+ },
173
+ )}
174
+ </div>
175
+ </div>
176
+ )}
177
+ </div>
178
+ </div>
179
+ </div>
180
+ );
181
+ }
frontend/components/sections/NewContentSection.tsx CHANGED
@@ -23,16 +23,21 @@ export default function NewContentSection() {
23
  }, []);
24
 
25
  return (
26
- <div className="space-y-2">
27
- <ScrollSection title="Discover Movies" link={'/movies'}>
28
  {movies.map((movie, index) => (
29
- <MovieCard key={index} title={movie.title} />
30
  ))}
31
  </ScrollSection>
32
 
33
- <ScrollSection title="Discover TV Shows" link={'/tvshows'}>
34
  {tvshows.map((tvshow, index) => (
35
- <TvShowCard key={index} title={tvshow.title} episodesCount={null} />
 
 
 
 
 
36
  ))}
37
  </ScrollSection>
38
  </div>
 
23
  }, []);
24
 
25
  return (
26
+ <div className="space-y-2" data-oid="oy_azwu">
27
+ <ScrollSection title="Discover Movies" link={'/browse/movies'} data-oid="ntbj5.r">
28
  {movies.map((movie, index) => (
29
+ <MovieCard key={index} title={movie.title} data-oid="k4q4huw" />
30
  ))}
31
  </ScrollSection>
32
 
33
+ <ScrollSection title="Discover TV Shows" link={'/browse/tvshows'} data-oid="n5ayj1:">
34
  {tvshows.map((tvshow, index) => (
35
+ <TvShowCard
36
+ key={index}
37
+ title={tvshow.title}
38
+ episodesCount={null}
39
+ data-oid="4kr.uwk"
40
+ />
41
  ))}
42
  </ScrollSection>
43
  </div>
frontend/components/sections/ScrollSection.tsx CHANGED
@@ -34,13 +34,18 @@ export default function ScrollSection({ title, link, children }: ScrollSectionPr
34
  };
35
 
36
  return (
37
- <section className="py-4 relative group">
38
- <div className="container mx-auto px-6">
39
- <div className="font-bold flex flex-row items-center mb-6 bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent">
40
- <Link href={link}>
41
- <h2 className="text-2xl">{title}</h2>
 
 
 
 
 
42
  </Link>
43
- <ChevronRightIcon className="size-6 text-pink-400" />
44
  </div>
45
 
46
  {/* Scroll Arrows */}
@@ -48,8 +53,12 @@ export default function ScrollSection({ title, link, children }: ScrollSectionPr
48
  <button
49
  onClick={() => scroll('left')}
50
  className="absolute left-0 top-1/2 z-10 transform -translate-y-1/6 bg-gradient-to-r from-transparent to-gray-900/50 hover:bg-gradient-to-r hover:from-transparent hover:to-purple-900 rounded-full p-1 ml-2 transition-all duration-500 ease-in-out"
 
51
  >
52
- <ChevronLeftIcon className="size-8 lg:size-10 text-gray-300/50 hover:text-purple-400/50 transition-colors" />
 
 
 
53
  </button>
54
  )}
55
 
@@ -57,8 +66,12 @@ export default function ScrollSection({ title, link, children }: ScrollSectionPr
57
  <button
58
  onClick={() => scroll('right')}
59
  className="absolute right-0 top-1/2 z-10 transform -translate-y-1/6 bg-gradient-to-r from-gray-600/50 to-transparent hover:bg-gradient-to-r hover:from-purple-900 hover:to-transparent rounded-full p-1 mr-2 transition-all duration-300 ease-in-out"
 
60
  >
61
- <ChevronRightIcon className="size-8 lg:size-10 text-gray-300/50 hover:text-purple-400/50 transition-colors" />
 
 
 
62
  </button>
63
  )}
64
 
@@ -73,6 +86,7 @@ export default function ScrollSection({ title, link, children }: ScrollSectionPr
73
  WebkitMaskImage:
74
  'linear-gradient(to right, transparent, black 2%, black 98%, transparent)',
75
  }}
 
76
  >
77
  {children}
78
  </div>
 
34
  };
35
 
36
  return (
37
+ <section className="py-4 relative group" data-oid="7.vb_gm">
38
+ <div className="container mx-auto px-6" data-oid="gmpcoe7">
39
+ <div
40
+ className="font-bold flex flex-row items-center mb-6 bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent"
41
+ data-oid="ica-vju"
42
+ >
43
+ <Link href={link} data-oid="nql64b9">
44
+ <h2 className="text-2xl" data-oid="x9k79:o">
45
+ {title}
46
+ </h2>
47
  </Link>
48
+ <ChevronRightIcon className="size-6 text-pink-400" data-oid="dly-0en" />
49
  </div>
50
 
51
  {/* Scroll Arrows */}
 
53
  <button
54
  onClick={() => scroll('left')}
55
  className="absolute left-0 top-1/2 z-10 transform -translate-y-1/6 bg-gradient-to-r from-transparent to-gray-900/50 hover:bg-gradient-to-r hover:from-transparent hover:to-purple-900 rounded-full p-1 ml-2 transition-all duration-500 ease-in-out"
56
+ data-oid="wt2v-yr"
57
  >
58
+ <ChevronLeftIcon
59
+ className="size-8 lg:size-10 text-gray-300/50 hover:text-purple-400/50 transition-colors"
60
+ data-oid="_hewksv"
61
+ />
62
  </button>
63
  )}
64
 
 
66
  <button
67
  onClick={() => scroll('right')}
68
  className="absolute right-0 top-1/2 z-10 transform -translate-y-1/6 bg-gradient-to-r from-gray-600/50 to-transparent hover:bg-gradient-to-r hover:from-purple-900 hover:to-transparent rounded-full p-1 mr-2 transition-all duration-300 ease-in-out"
69
+ data-oid="t1dk76c"
70
  >
71
+ <ChevronRightIcon
72
+ className="size-8 lg:size-10 text-gray-300/50 hover:text-purple-400/50 transition-colors"
73
+ data-oid="uah3r3a"
74
+ />
75
  </button>
76
  )}
77
 
 
86
  WebkitMaskImage:
87
  'linear-gradient(to right, transparent, black 2%, black 98%, transparent)',
88
  }}
89
+ data-oid="_f_ue5."
90
  >
91
  {children}
92
  </div>
frontend/components/shared/GenresFilter.tsx CHANGED
@@ -107,17 +107,20 @@ export default function GenresFilter({ mediaType, onFilterChange }: GenreCompPro
107
  };
108
 
109
  return (
110
- <div>
111
- <div className="flex flex-wrap gap-2">
112
  <button
113
  className={`${
114
  isSelected('All') ? 'bg-gray-500' : 'hover:bg-gray-700 bg-gray-800'
115
  } p-1 rounded-xl transition-all duration-300 ease-in-out flex text-center items-center`}
116
  disabled={spinnerLoading}
117
  onClick={handleFilter}
 
118
  >
119
- <FunnelIcon className="size-5" />
120
- <p className="text-sm">Filter</p>
 
 
121
  </button>
122
  {/* "All" Button */}
123
  <button
@@ -128,6 +131,7 @@ export default function GenresFilter({ mediaType, onFilterChange }: GenreCompPro
128
  ? 'bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 text-white'
129
  : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
130
  }`}
 
131
  >
132
  All
133
  </button>
@@ -142,59 +146,77 @@ export default function GenresFilter({ mediaType, onFilterChange }: GenreCompPro
142
  ? 'bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 text-white'
143
  : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
144
  }`}
 
145
  >
146
  {genre.name}{' '}
147
- <span className="text-xs text-gray-500">({genre.density})</span>
 
 
148
  </button>
149
  ))}
150
  {genreCategories?.length > 5 && (
151
  <button
152
  className="text-gray-500 hover:text-gray-400"
153
  onClick={() => setShowAll(!showAll)}
 
154
  >
155
  {showAll ? 'Show Less' : 'Show More'}
156
  </button>
157
  )}
158
  </div>
159
 
160
- <div className="portrait:p-2 p-4">
161
  {spinnerLoading ? (
162
  <></>
163
  ) : (
164
  <>
165
- <div className="flex flex-wrap justify-center items-center portrait:gap-2 gap-10">
 
 
 
166
  {genresItems.map((item, index) => (
167
  <div
168
  key={index}
169
  className="transform transition-transform duration-300 hover:scale-105 w-[fit-content]"
 
170
  >
171
  {mediaType === 'movie' ? (
172
- <MovieCard title={item.title} />
173
  ) : (
174
- <TvShowCard title={item.title} episodesCount={null} />
 
 
 
 
175
  )}
176
  </div>
177
  ))}
178
  </div>
179
  {genresItems.length > 0 && (
180
- <div className="mt-12 flex flex-row items-center justify-center gap-2">
181
- <div className="flex items-center gap-2">
 
 
 
182
  <button
183
  onClick={() => handlePageChange(1)}
184
  disabled={currentPage <= 1 || spinnerLoading}
185
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 transition-colors"
 
186
  >
187
  <svg
188
  className="w-5 h-5"
189
  fill="none"
190
  stroke="currentColor"
191
  viewBox="0 0 24 24"
 
192
  >
193
  <path
194
  strokeLinecap="round"
195
  strokeLinejoin="round"
196
  strokeWidth={2}
197
  d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
 
198
  />
199
  </svg>
200
  </button>
@@ -202,44 +224,53 @@ export default function GenresFilter({ mediaType, onFilterChange }: GenreCompPro
202
  onClick={() => handlePageChange(currentPage - 1)}
203
  disabled={currentPage <= 1 || spinnerLoading}
204
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 transition-colors"
 
205
  >
206
  <svg
207
  className="w-5 h-5"
208
  fill="none"
209
  stroke="currentColor"
210
  viewBox="0 0 24 24"
 
211
  >
212
  <path
213
  strokeLinecap="round"
214
  strokeLinejoin="round"
215
  strokeWidth={2}
216
  d="M15 19l-7-7 7-7"
 
217
  />
218
  </svg>
219
  </button>
220
  </div>
221
- <div className="flex items-center gap-2">
222
- <span className="px-4 py-2 rounded-lg bg-gray-800 text-white font-medium">
 
 
 
223
  Page {currentPage} of {totalPages}
224
  </span>
225
  </div>
226
- <div className="flex items-center gap-2">
227
  <button
228
  onClick={() => handlePageChange(currentPage + 1)}
229
  disabled={currentPage >= totalPages || spinnerLoading}
230
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 transition-colors"
 
231
  >
232
  <svg
233
  className="w-5 h-5"
234
  fill="none"
235
  stroke="currentColor"
236
  viewBox="0 0 24 24"
 
237
  >
238
  <path
239
  strokeLinecap="round"
240
  strokeLinejoin="round"
241
  strokeWidth={2}
242
  d="M9 5l7 7-7 7"
 
243
  />
244
  </svg>
245
  </button>
@@ -247,18 +278,21 @@ export default function GenresFilter({ mediaType, onFilterChange }: GenreCompPro
247
  onClick={() => handlePageChange(totalPages)}
248
  disabled={currentPage >= totalPages || spinnerLoading}
249
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 transition-colors"
 
250
  >
251
  <svg
252
  className="w-5 h-5"
253
  fill="none"
254
  stroke="currentColor"
255
  viewBox="0 0 24 24"
 
256
  >
257
  <path
258
  strokeLinecap="round"
259
  strokeLinejoin="round"
260
  strokeWidth={2}
261
  d="M13 5l7 7-7 7M5 5l7 7-7 7"
 
262
  />
263
  </svg>
264
  </button>
 
107
  };
108
 
109
  return (
110
+ <div data-oid="oqe_y_p">
111
+ <div className="flex flex-wrap gap-2" data-oid="ah3ubl1">
112
  <button
113
  className={`${
114
  isSelected('All') ? 'bg-gray-500' : 'hover:bg-gray-700 bg-gray-800'
115
  } p-1 rounded-xl transition-all duration-300 ease-in-out flex text-center items-center`}
116
  disabled={spinnerLoading}
117
  onClick={handleFilter}
118
+ data-oid="yl4kaix"
119
  >
120
+ <FunnelIcon className="size-5" data-oid="7ybmtue" />
121
+ <p className="text-sm" data-oid="6hjm88a">
122
+ Filter
123
+ </p>
124
  </button>
125
  {/* "All" Button */}
126
  <button
 
131
  ? 'bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 text-white'
132
  : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
133
  }`}
134
+ data-oid="-pf6h7t"
135
  >
136
  All
137
  </button>
 
146
  ? 'bg-gradient-to-r from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 text-white'
147
  : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
148
  }`}
149
+ data-oid="g1r:sb1"
150
  >
151
  {genre.name}{' '}
152
+ <span className="text-xs text-gray-500" data-oid=":mkli2i">
153
+ ({genre.density})
154
+ </span>
155
  </button>
156
  ))}
157
  {genreCategories?.length > 5 && (
158
  <button
159
  className="text-gray-500 hover:text-gray-400"
160
  onClick={() => setShowAll(!showAll)}
161
+ data-oid="vf6.5ov"
162
  >
163
  {showAll ? 'Show Less' : 'Show More'}
164
  </button>
165
  )}
166
  </div>
167
 
168
+ <div className="portrait:p-2 p-4" data-oid="7gguey_">
169
  {spinnerLoading ? (
170
  <></>
171
  ) : (
172
  <>
173
+ <div
174
+ className="flex flex-wrap justify-center items-center portrait:gap-2 gap-10"
175
+ data-oid="doqrqyx"
176
+ >
177
  {genresItems.map((item, index) => (
178
  <div
179
  key={index}
180
  className="transform transition-transform duration-300 hover:scale-105 w-[fit-content]"
181
+ data-oid="4am7nqj"
182
  >
183
  {mediaType === 'movie' ? (
184
+ <MovieCard title={item.title} data-oid="lw6e-fx" />
185
  ) : (
186
+ <TvShowCard
187
+ title={item.title}
188
+ episodesCount={null}
189
+ data-oid="m9p0gxh"
190
+ />
191
  )}
192
  </div>
193
  ))}
194
  </div>
195
  {genresItems.length > 0 && (
196
+ <div
197
+ className="mt-12 flex flex-row items-center justify-center gap-2"
198
+ data-oid="lw2i8wp"
199
+ >
200
+ <div className="flex items-center gap-2" data-oid="dv1m-uj">
201
  <button
202
  onClick={() => handlePageChange(1)}
203
  disabled={currentPage <= 1 || spinnerLoading}
204
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 transition-colors"
205
+ data-oid="oaqw:zg"
206
  >
207
  <svg
208
  className="w-5 h-5"
209
  fill="none"
210
  stroke="currentColor"
211
  viewBox="0 0 24 24"
212
+ data-oid="pjpu2__"
213
  >
214
  <path
215
  strokeLinecap="round"
216
  strokeLinejoin="round"
217
  strokeWidth={2}
218
  d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
219
+ data-oid="7_.pz8i"
220
  />
221
  </svg>
222
  </button>
 
224
  onClick={() => handlePageChange(currentPage - 1)}
225
  disabled={currentPage <= 1 || spinnerLoading}
226
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 transition-colors"
227
+ data-oid="xmljhv2"
228
  >
229
  <svg
230
  className="w-5 h-5"
231
  fill="none"
232
  stroke="currentColor"
233
  viewBox="0 0 24 24"
234
+ data-oid=".01drod"
235
  >
236
  <path
237
  strokeLinecap="round"
238
  strokeLinejoin="round"
239
  strokeWidth={2}
240
  d="M15 19l-7-7 7-7"
241
+ data-oid="ecujajp"
242
  />
243
  </svg>
244
  </button>
245
  </div>
246
+ <div className="flex items-center gap-2" data-oid="jld8ntq">
247
+ <span
248
+ className="px-4 py-2 rounded-lg bg-gray-800 text-white font-medium"
249
+ data-oid="8sgehe:"
250
+ >
251
  Page {currentPage} of {totalPages}
252
  </span>
253
  </div>
254
+ <div className="flex items-center gap-2" data-oid="kam4goe">
255
  <button
256
  onClick={() => handlePageChange(currentPage + 1)}
257
  disabled={currentPage >= totalPages || spinnerLoading}
258
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 transition-colors"
259
+ data-oid="avruout"
260
  >
261
  <svg
262
  className="w-5 h-5"
263
  fill="none"
264
  stroke="currentColor"
265
  viewBox="0 0 24 24"
266
+ data-oid="xyitw4w"
267
  >
268
  <path
269
  strokeLinecap="round"
270
  strokeLinejoin="round"
271
  strokeWidth={2}
272
  d="M9 5l7 7-7 7"
273
+ data-oid="bl:qtwo"
274
  />
275
  </svg>
276
  </button>
 
278
  onClick={() => handlePageChange(totalPages)}
279
  disabled={currentPage >= totalPages || spinnerLoading}
280
  className="p-2 rounded-lg bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-white disabled:opacity-50 transition-colors"
281
+ data-oid=".5dwk0i"
282
  >
283
  <svg
284
  className="w-5 h-5"
285
  fill="none"
286
  stroke="currentColor"
287
  viewBox="0 0 24 24"
288
+ data-oid="_sg3:_a"
289
  >
290
  <path
291
  strokeLinecap="round"
292
  strokeLinejoin="round"
293
  strokeWidth={2}
294
  d="M13 5l7 7-7 7M5 5l7 7-7 7"
295
+ data-oid="8vrmq5t"
296
  />
297
  </svg>
298
  </button>
frontend/components/shared/Trailers.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { ArrowLeftCircleIcon, ArrowRightCircleIcon } from '@heroicons/react/24/outline';
3
+
4
+ interface Trailer {
5
+ id: number;
6
+ name: string;
7
+ url: string;
8
+ language: string;
9
+ runtime: number;
10
+ }
11
+
12
+ interface TrailersProps {
13
+ trailers?: Trailer[];
14
+ }
15
+ const TrailersComp: React.FC<TrailersProps> = ({ trailers }) => {
16
+ const [currentIndex, setCurrentIndex] = useState(0);
17
+ const regex = /(?:[?&]v=|\/embed\/|\/1\/|\/v\/|https:\/\/(?:www\.)?youtu\.be\/)([^&\n?#]+)/;
18
+ if (!trailers || trailers.length === 0) {
19
+ return <p>No trailers available.</p>;
20
+ }
21
+
22
+ const currentTrailer = trailers[currentIndex];
23
+ const match = currentTrailer.url.match(regex);
24
+ const videoId = match ? match[1] : null;
25
+ if (!videoId) return <p>Invalid trailer URL.</p>;
26
+
27
+ // Use URL parameters to remove many of YouTube’s UI elements.
28
+ const embedUrl = `https://www.youtube.com/embed/${videoId}?controls=0&autoplay=1&loop=1&mute=1&playlist=${videoId}&rel=0&modestbranding=1&iv_load_policy=3`;
29
+
30
+ const handlePrev = () => {
31
+ setCurrentIndex((prev) => (prev - 1 + trailers.length) % trailers.length);
32
+ };
33
+
34
+ const handleNext = () => {
35
+ setCurrentIndex((prev) => (prev + 1) % trailers.length);
36
+ };
37
+
38
+ return (
39
+ <div className="relative h-60 w-full rounded-md mb-4">
40
+ <iframe
41
+ className="h-full"
42
+ key={currentTrailer.id}
43
+ width="100%"
44
+ src={embedUrl}
45
+ allow="autoplay"
46
+ title={currentTrailer.name}
47
+ ></iframe>
48
+ {trailers.length > 1 && (
49
+ <>
50
+ <button
51
+ onClick={handlePrev}
52
+ className="absolute left-2 top-1/2 transform -translate-y-1/2 bg-gray-900/70 hover:bg-gray-900/90 text-white rounded-full"
53
+ data-oid="z151qsy"
54
+ >
55
+ <ArrowLeftCircleIcon
56
+ className="size-10 text-violet-400"
57
+ data-oid="fpnh2w9"
58
+ />
59
+ </button>
60
+ <button
61
+ onClick={handleNext}
62
+ className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-gray-900/70 hover:bg-gray-900/90 text-white rounded-full"
63
+ data-oid="h.h66si"
64
+ >
65
+ <ArrowRightCircleIcon
66
+ className="size-10 text-violet-400"
67
+ data-oid="l56nlzc"
68
+ />
69
+ </button>
70
+ </>
71
+ )}
72
+ </div>
73
+ );
74
+ };
75
+
76
+ export default TrailersComp;
frontend/components/tvshow/TvShowCard.tsx CHANGED
@@ -11,6 +11,7 @@ import {
11
  } from '@heroicons/react/24/outline';
12
  import { StarIcon } from '@heroicons/react/24/solid';
13
  import Link from 'next/link';
 
14
 
15
  interface Artwork {
16
  id: number;
@@ -29,14 +30,23 @@ interface Artwork {
29
  tagOptions: any;
30
  }
31
 
32
- interface Card {
 
 
 
 
 
 
 
 
33
  title: string;
34
  year: string;
35
  image: string;
36
  portrait: Artwork[];
37
  banner: Artwork[];
38
  overview: string;
39
- }
 
40
 
41
  interface TvShowCardProps {
42
  title: string;
@@ -63,6 +73,7 @@ const BannerImage: React.FC<{ src: string; alt: string }> = ({ src, alt }) => {
63
  className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ease-in-out ${
64
  visible ? 'opacity-100' : 'opacity-0'
65
  }`}
 
66
  />
67
  );
68
  };
@@ -79,6 +90,8 @@ export const TvShowCard: React.FC<TvShowCardProps> = ({ title, episodesCount = n
79
  const cardRef = useRef<HTMLDivElement>(null);
80
  const slideshowTimer = useRef<NodeJS.Timeout | null>(null);
81
  const hoverTimer = useRef<NodeJS.Timeout | null>(null);
 
 
82
 
83
  // Fetch card data.
84
  useEffect(() => {
@@ -255,6 +268,7 @@ export const TvShowCard: React.FC<TvShowCardProps> = ({ title, episodesCount = n
255
  useEffect(() => {
256
  return () => {
257
  if (hoverTimer.current) clearTimeout(hoverTimer.current);
 
258
  };
259
  }, []);
260
 
@@ -268,94 +282,168 @@ export const TvShowCard: React.FC<TvShowCardProps> = ({ title, episodesCount = n
268
  }
269
  }, [showModal]);
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  // Render modal via portal when modalStyle is set.
272
  const modal =
273
  card && cardRef.current && modalStyle
274
  ? createPortal(
275
- <div
276
- style={modalStyle}
277
- className="bg-gray-800/60 backdrop-blur-md rounded-lg p-2.5 border border-gray-400/50 transition-all flex flex-col justify-between pt-0"
278
- // Keep modal open on hover (desktop).
279
- onMouseEnter={() => setIsHovering(true)}
280
- onMouseLeave={() => setIsHovering(false)}
281
- >
282
- {/* Close button for mobile */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  <button
284
- onClick={() => setShowModal(false)}
285
- className="absolute top-0 right-0 text-white bg-gray-800 hover:bg-gradient-to-r hover:from-violet-500/80 hover:to-purple-400/80 px-3 py-2 rounded-md z-10 border border-gray-400/50"
 
286
  >
287
- <XMarkIcon className="size-6" />
 
 
 
288
  </button>
289
- {/* Episodes Count Bubble */}
290
- {episodesCount !== null && (
291
- <div className="absolute top-0 left-0 z-10 bg-gradient-to-r font-mono from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 text-white font-bold px-2 py-1 rounded-md rounded-b-none rounded-r-none shadow-lg">
292
- <p className="text-gray-700 text-sm">{episodesCount}</p>{' '}
293
- <p className="text-xs">Ep{episodesCount > 1 ? 's' : ''}</p>
294
- </div>
295
- )}
296
- {/* Title and Year */}
297
- <div className="flex flex-col w-full">
298
- <h3 className="pl-8 h-11 lg:pb:2 pt-2 bg-gray-700/40 backdrop-blur-md text-md sm:text-lg md:text-xl font-semibold text-white text-clip line-clamp-1">
299
- {card?.title || 'Loading...'}
300
- </h3>
301
-
302
- <div className="flex pb-2 items-center space-x-2 mt-1 text-sm sm:text-md">
303
- <span className="text-yellow-400 flex gap-1 items-center">
304
- <StarIcon className="size-3" /> 0
305
- </span>
306
- <span className="text-gray-300">• {card?.year || '----'}</span>
307
- <span className="text-purple-300 text-sm">•</span>
308
- <span className="bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded text-sm">
309
- TV Series
310
- </span>
311
- </div>
312
- </div>
313
- {/* Banner slideshow preview with crossfade effect and controls */}
314
- {card.banner && (
315
- <div className="relative w-full h-56 overflow-hidden rounded-md mb-4">
316
- <BannerImage
317
- key={bannerIndex}
318
- src={
319
- card.banner[bannerIndex]?.thumbnail ||
320
- `https://placehold.co/640x360?text=Preview+Not+Available`
321
- }
322
- alt="Banner Preview"
323
- />
324
-
325
- {/* Slideshow controls */}
326
- {card.banner.length > 1 && (
327
- <>
328
- <button
329
- onClick={handlePrev}
330
- className="absolute left-2 top-1/2 transform -translate-y-1/2 bg-gray-900/70 hover:bg-gray-900/90 text-white rounded-full"
331
- >
332
- <ArrowLeftCircleIcon className="size-10 text-violet-400" />
333
- </button>
334
- <button
335
- onClick={handleNext}
336
- className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-gray-900/70 hover:bg-gray-900/90 text-white rounded-full"
337
- >
338
- <ArrowRightCircleIcon className="size-10 text-violet-400" />
339
- </button>
340
- </>
341
- )}
342
- </div>
343
- )}
344
- {/* Overview text */}
345
- <div className="text-gray-300 text-base sm:text-lg overflow-hidden line-clamp-4 transition-all duration-300 mb-4">
346
- {card.overview}
347
- </div>
348
- {/* View Details Button */}
349
- <div className="flex flex-row">
350
- <Link href={`/tvshow/${title}`}>
351
- <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">
352
- View Details <ChevronRightIcon className="size-4" />
353
- </button>
354
- </Link>
355
- </div>
356
- </div>,
357
- document.body,
358
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  : null;
360
 
361
  return (
@@ -366,21 +454,37 @@ export const TvShowCard: React.FC<TvShowCardProps> = ({ title, episodesCount = n
366
  onMouseEnter={handleCardMouseEnter}
367
  onMouseLeave={handleCardMouseLeave}
368
  onClick={handleCardClick}
 
 
 
 
 
369
  >
370
  <div
371
- className="rounded-lg border border-gray-400/50 overflow-hidden relative transition-transform duration-300 w-[140px] sm:w-[150px] md:w-[180px] lg:w-[180px] xl:w-[200px]
372
- h-[210px] sm:h-[220px] md:h-[250px] lg:h-[280px] xl:h-[300px]"
 
373
  >
374
  {/* Episodes Count Bubble */}
375
  {episodesCount !== null && (
376
- <div className="absolute top-0 right-0 z-10 bg-gradient-to-r font-mono from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 text-white font-bold px-2 py-1 rounded-md shadow-lg">
377
- <p className="text-gray-700 text-sm">{episodesCount}</p>{' '}
378
- <p className="text-xs">Ep{episodesCount > 1 ? 's' : ''}</p>
 
 
 
 
 
 
 
379
  </div>
380
  )}
381
  {/* Skeleton Loader */}
382
  {!imageLoaded && (
383
- <div className="absolute inset-0 bg-gray-700 animate-pulse rounded-lg" />
 
 
 
384
  )}
385
  {/* Card Image (fixed once randomly selected) */}
386
  {cardImage && (
@@ -391,19 +495,34 @@ export const TvShowCard: React.FC<TvShowCardProps> = ({ title, episodesCount = n
391
  imageLoaded ? 'opacity-100' : 'opacity-0'
392
  }`}
393
  onLoad={() => setImageLoaded(true)}
 
394
  />
395
  )}
396
  {/* Overlay */}
397
- <div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent">
398
- <div className="absolute bottom-0 p-4 w-full">
399
- <h3 className="text-sm sm:text-base md:text-lg font-semibold text-white">
 
 
 
 
 
 
400
  {card?.title || 'Loading...'}
401
  </h3>
402
- <div className="flex items-center space-x-2 mt-1 text-xs sm:text-sm">
403
- <span className="text-yellow-400 flex gap-1 items-center">
404
- <StarIcon className="size-3" /> 0
 
 
 
 
 
 
 
 
 
405
  </span>
406
- <span className="text-gray-300">• {card?.year || '----'}</span>
407
  </div>
408
  </div>
409
  </div>
@@ -412,4 +531,4 @@ export const TvShowCard: React.FC<TvShowCardProps> = ({ title, episodesCount = n
412
  {modal}
413
  </>
414
  );
415
- };
 
11
  } from '@heroicons/react/24/outline';
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;
 
30
  tagOptions: any;
31
  }
32
 
33
+ interface Trailer {
34
+ id: number;
35
+ name: string;
36
+ url: string;
37
+ language: string;
38
+ runtime: number;
39
+ }
40
+
41
+ interface Card {
42
  title: string;
43
  year: string;
44
  image: string;
45
  portrait: Artwork[];
46
  banner: Artwork[];
47
  overview: string;
48
+ trailers: Trailer[]; // Now trailers is an array of Trailer objects
49
+ }
50
 
51
  interface TvShowCardProps {
52
  title: string;
 
73
  className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ease-in-out ${
74
  visible ? 'opacity-100' : 'opacity-0'
75
  }`}
76
+ data-oid="ia4kus."
77
  />
78
  );
79
  };
 
90
  const cardRef = useRef<HTMLDivElement>(null);
91
  const slideshowTimer = useRef<NodeJS.Timeout | null>(null);
92
  const hoverTimer = useRef<NodeJS.Timeout | null>(null);
93
+ const touchTimer = useRef<NodeJS.Timeout | null>(null); // Timer for touch and hold detection
94
+
95
 
96
  // Fetch card data.
97
  useEffect(() => {
 
268
  useEffect(() => {
269
  return () => {
270
  if (hoverTimer.current) clearTimeout(hoverTimer.current);
271
+ if (touchTimer.current) clearTimeout(touchTimer.current); // Clear touch timer on unmount
272
  };
273
  }, []);
274
 
 
282
  }
283
  }, [showModal]);
284
 
285
+ // Handle touch start to initiate touch and hold detection
286
+ const handleTouchStart = () => {
287
+ touchTimer.current = setTimeout(() => {
288
+ setShowModal(true); // Show modal after holding for some time
289
+ }, 300); // 300ms delay for touch and hold
290
+ };
291
+
292
+ // Handle touch end or cancel to clear the timer if touch is short
293
+ const handleTouchEnd = () => {
294
+ if (touchTimer.current) {
295
+ clearTimeout(touchTimer.current); // Clear the timer if touch ends before 300ms
296
+ touchTimer.current = null;
297
+ }
298
+ };
299
+
300
+ const handleTouchCancel = () => {
301
+ if (touchTimer.current) {
302
+ clearTimeout(touchTimer.current); // Clear the timer if touch is cancelled
303
+ touchTimer.current = null;
304
+ }
305
+ };
306
+
307
+ // Prevent default context menu on right click and long press
308
+ const handleContextMenu = (e: React.MouseEvent) => {
309
+ e.preventDefault();
310
+ };
311
+
312
+
313
  // Render modal via portal when modalStyle is set.
314
  const modal =
315
  card && cardRef.current && modalStyle
316
  ? createPortal(
317
+ <div
318
+ style={modalStyle}
319
+ className="bg-gray-800/60 backdrop-blur-md rounded-lg p-2.5 border border-gray-400/50 transition-all flex flex-col justify-between pt-0"
320
+ // Keep modal open on hover (desktop).
321
+ onMouseEnter={() => setIsHovering(true)}
322
+ onMouseLeave={() => setIsHovering(false)}
323
+ data-oid="62_r6ny"
324
+ >
325
+ {/* Close button for mobile */}
326
+ <button
327
+ onClick={() => setShowModal(false)}
328
+ className="absolute top-0 right-0 text-white bg-gray-800 hover:bg-gradient-to-r hover:from-violet-500/80 hover:to-purple-400/80 px-3 py-2 rounded-md z-10 border border-gray-400/50"
329
+ data-oid="2xa9juf"
330
+ >
331
+ <XMarkIcon className="size-6" data-oid=".o05-n6" />
332
+ </button>
333
+ {/* Episodes Count Bubble */}
334
+ {episodesCount !== null && (
335
+ <div
336
+ className="absolute top-0 left-0 z-10 bg-gradient-to-r font-mono from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 text-white font-bold px-2 py-1 rounded-md rounded-b-none rounded-r-none shadow-lg"
337
+ data-oid="8:a6w2r"
338
+ >
339
+ <p className="text-gray-700 text-sm" data-oid="h5_kec8">
340
+ {episodesCount}
341
+ </p>
342
+ <p className="text-xs" data-oid="a-q6gbi">
343
+ Ep{episodesCount > 1 ? 's' : ''}
344
+ </p>
345
+ </div>
346
+ )}
347
+ {/* Title and Year */}
348
+ <div className="flex flex-col w-full" data-oid="ja4_4rt">
349
+ <h3
350
+ className="pl-8 h-11 lg:pb:2 pt-2 bg-gray-700/40 backdrop-blur-md text-md sm:text-lg md:text-xl font-semibold text-white text-clip line-clamp-1"
351
+ data-oid="_m52qkf"
352
+ >
353
+ {card?.title || 'Loading...'}
354
+ </h3>
355
+
356
+ <div
357
+ className="flex pb-2 items-center space-x-2 mt-1 text-sm sm:text-md"
358
+ data-oid="gfad6kg"
359
+ >
360
+ <span
361
+ className="text-yellow-400 flex gap-1 items-center"
362
+ data-oid="zw-h.fz"
363
+ >
364
+ <StarIcon className="size-3" data-oid=":y4ilwk" /> 0
365
+ </span>
366
+ <span className="text-gray-300" data-oid="b-1izto">
367
+ • {card?.year || '----'}
368
+ </span>
369
+ <span className="text-purple-300 text-sm" data-oid="r5f0nac">
370
+
371
+ </span>
372
+ <span
373
+ className="bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded text-sm"
374
+ data-oid="7ht_3pu"
375
+ >
376
+ TV Series
377
+ </span>
378
+ </div>
379
+ </div>
380
+ {/* Conditionally render TrailersComp if trailers exist, otherwise show banner slideshow */}
381
+ {card?.trailers && card.trailers.length > 0 ? (
382
+ <TrailersComp trailers={card.trailers} />
383
+ ) : (
384
+ card.banner && (
385
+ <div
386
+ className="relative w-full h-56 overflow-hidden rounded-md mb-4"
387
+ data-oid="h__x1wg"
388
+ >
389
+ <BannerImage
390
+ key={bannerIndex}
391
+ src={
392
+ card.banner[bannerIndex]?.thumbnail ||
393
+ `https://placehold.co/640x360?text=Preview+Not+Available`
394
+ }
395
+ alt="Banner Preview"
396
+ data-oid="9kczgk8"
397
+ />
398
+ {card.banner.length > 1 && (
399
+ <>
400
  <button
401
+ onClick={handlePrev}
402
+ className="absolute left-2 top-1/2 transform -translate-y-1/2 bg-gray-900/70 hover:bg-gray-900/90 text-white rounded-full"
403
+ data-oid="z151qsy"
404
  >
405
+ <ArrowLeftCircleIcon
406
+ className="size-10 text-violet-400"
407
+ data-oid="fpnh2w9"
408
+ />
409
  </button>
410
+ <button
411
+ onClick={handleNext}
412
+ className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-gray-900/70 hover:bg-gray-900/90 text-white rounded-full"
413
+ data-oid="h.h66si"
414
+ >
415
+ <ArrowRightCircleIcon
416
+ className="size-10 text-violet-400"
417
+ data-oid="l56nlzc"
418
+ />
419
+ </button>
420
+ </>
421
+ )}
422
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  )
424
+ )}
425
+ {/* Overview text */}
426
+ <div
427
+ className="text-gray-300 text-base sm:text-lg overflow-hidden line-clamp-4 transition-all duration-300 mb-4"
428
+ data-oid="k_8v6dq"
429
+ >
430
+ {card.overview}
431
+ </div>
432
+ {/* View Details Button */}
433
+ <div className="flex flex-row" data-oid="pgts_xn">
434
+ <Link href={`/tvshow/${title}`} data-oid="bg-:bsv">
435
+ <button
436
+ 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"
437
+ data-oid="sl6wajo"
438
+ >
439
+ View Details
440
+ <ChevronRightIcon className="size-4" data-oid="cnht:jj" />
441
+ </button>
442
+ </Link>
443
+ </div>
444
+ </div>,
445
+ document.body,
446
+ )
447
  : null;
448
 
449
  return (
 
454
  onMouseEnter={handleCardMouseEnter}
455
  onMouseLeave={handleCardMouseLeave}
456
  onClick={handleCardClick}
457
+ data-oid="ud-dhj."
458
+ onContextMenu={handleContextMenu} // Prevent default context menu
459
+ onTouchStart={handleTouchStart} // Detect touch start
460
+ onTouchEnd={handleTouchEnd} // Detect touch end
461
+ onTouchCancel={handleTouchCancel} // Detect touch cancel
462
  >
463
  <div
464
+ className="rounded-lg border border-gray-400/50 overflow-hidden relative transition-transform duration-300 w-[140px] sm:w-[150px] md:w-[180px] lg:w-[180px] xl:w-[200px]
465
+ h-[210px] sm:h-[220px] md:h-[250px] lg:h-[280px] xl:h-[300px]"
466
+ data-oid="ukn:8pp"
467
  >
468
  {/* Episodes Count Bubble */}
469
  {episodesCount !== null && (
470
+ <div
471
+ className="absolute top-0 right-0 z-10 bg-gradient-to-r font-mono from-violet-600 to-purple-500 hover:bg-gradient-to-r hover:from-violet-500 hover:to-purple-400 text-white font-bold px-2 py-1 rounded-md shadow-lg"
472
+ data-oid="f3o:paw"
473
+ >
474
+ <p className="text-gray-700 text-sm" data-oid="xg012zc">
475
+ {episodesCount}
476
+ </p>
477
+ <p className="text-xs" data-oid="zfm-c-2">
478
+ Ep{episodesCount > 1 ? 's' : ''}
479
+ </p>
480
  </div>
481
  )}
482
  {/* Skeleton Loader */}
483
  {!imageLoaded && (
484
+ <div
485
+ className="absolute inset-0 bg-gray-700 animate-pulse rounded-lg"
486
+ data-oid="8n4dvf0"
487
+ />
488
  )}
489
  {/* Card Image (fixed once randomly selected) */}
490
  {cardImage && (
 
495
  imageLoaded ? 'opacity-100' : 'opacity-0'
496
  }`}
497
  onLoad={() => setImageLoaded(true)}
498
+ data-oid="5v6i7xs"
499
  />
500
  )}
501
  {/* Overlay */}
502
+ <div
503
+ className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent"
504
+ data-oid="o8k0fd0"
505
+ >
506
+ <div className="absolute bottom-0 p-4 w-full" data-oid="tuk:gjp">
507
+ <h3
508
+ className="text-sm sm:text-base md:text-lg font-semibold text-white"
509
+ data-oid="ang88pi"
510
+ >
511
  {card?.title || 'Loading...'}
512
  </h3>
513
+ <div
514
+ className="flex items-center space-x-2 mt-1 text-xs sm:text-sm"
515
+ data-oid="xc7:386"
516
+ >
517
+ <span
518
+ className="text-yellow-400 flex gap-1 items-center"
519
+ data-oid="vbg52ma"
520
+ >
521
+ <StarIcon className="size-3" data-oid="_b_pzrl" /> 0
522
+ </span>
523
+ <span className="text-gray-300" data-oid="c8.qvjj">
524
+ • {card?.year || '----'}
525
  </span>
 
526
  </div>
527
  </div>
528
  </div>
 
531
  {modal}
532
  </>
533
  );
534
+ };
frontend/components/tvshow/TvshowPlayer.tsx ADDED
@@ -0,0 +1,695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { useSpinnerLoading } from '@/components/loading/Spinner';
3
+ import {
4
+ PlayIcon,
5
+ PauseIcon,
6
+ SpeakerWaveIcon,
7
+ SpeakerXMarkIcon,
8
+ ArrowsPointingOutIcon,
9
+ ArrowsPointingInIcon,
10
+ RectangleStackIcon,
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'; // Import HLS.js
15
+
16
+ interface ContentRating {
17
+ country: string;
18
+ name: string;
19
+ description: string;
20
+ }
21
+
22
+ interface TvShowPlayerProps {
23
+ videoTitle: string;
24
+ season: string;
25
+ episode: string;
26
+ contentRatings?: ContentRating[];
27
+ onClosePlayer?: () => void;
28
+ }
29
+
30
+ interface ProgressData {
31
+ status: string;
32
+ progress: number;
33
+ downloaded: number;
34
+ total: number;
35
+ }
36
+
37
+ const TvShowPlayer: React.FC<TvShowPlayerProps> = ({
38
+ videoTitle,
39
+ season,
40
+ episode,
41
+ contentRatings = [],
42
+ onClosePlayer,
43
+ }) => {
44
+ // Extract season and episode info using regex
45
+ const episodeRegex = /.*S(\d+)E(\d+).*?-\s*(.*?)(?=\..*|$)/i;
46
+ const episodeMatch = episode.match(episodeRegex);
47
+ const seasonNumber = episodeMatch ? episodeMatch[1] : season;
48
+ const episodeNumber = episodeMatch ? episodeMatch[2] : '';
49
+ const episodeTitleClean = episodeMatch
50
+ ? decodeURIComponent(episodeMatch[3])
51
+ : decodeURIComponent(episode);
52
+
53
+ // Spinner
54
+ const { spinnerLoading, setSpinnerLoading } = useSpinnerLoading();
55
+
56
+ // Refs
57
+ const containerRef = useRef<HTMLDivElement>(null);
58
+ const videoRef = useRef<HTMLVideoElement>(null);
59
+ const inactivityRef = useRef<NodeJS.Timeout | null>(null);
60
+ const progressBarRef = useRef<HTMLDivElement>(null); // Ref for progress bar container
61
+ const hlsRef = useRef<Hls | null>(null); // Ref to HLS.js instance
62
+
63
+ // Video URL & blob state
64
+ const [videoUrl, setVideoUrl] = useState<string | null>(null);
65
+ const [videoBlobUrl, setVideoBlobUrl] = useState<string>('');
66
+ const [isVideoTs, setIsVideoTs] = useState<boolean>(false); // State to track if video is .ts
67
+
68
+ // Player UI states
69
+ const [showControls, setShowControls] = useState(true);
70
+ const [isPlaying, setIsPlaying] = useState(false);
71
+ const [isSeeking, setIsSeeking] = useState(false);
72
+ const [duration, setDuration] = useState(0);
73
+ const [currentTime, setCurrentTime] = useState(0);
74
+ const [volume, setVolume] = useState(1);
75
+ const [isFullscreen, setIsFullscreen] = useState(false);
76
+ const isMuted = volume === 0;
77
+ const [buffered, setBuffered] = useState(0);
78
+
79
+ // Progress bar hover preview state
80
+ const [hoverTime, setHoverTime] = useState<number | null>(null);
81
+ const [hoverPos, setHoverPos] = useState<number | null>(null);
82
+
83
+ // Link fetching states
84
+ const [progress, setProgress] = useState<ProgressData | null>(null);
85
+ const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(null);
86
+ const [videoFetched, setVideoFetched] = useState(false);
87
+ const videoFetchedRef = useRef(videoFetched);
88
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null);
89
+
90
+ // Keep ref in sync
91
+ useEffect(() => {
92
+ videoFetchedRef.current = videoFetched;
93
+ }, [videoFetched]);
94
+
95
+ // --- Link Fetching & Polling ---
96
+ const fetchMovieLink = async () => {
97
+ if (videoFetchedRef.current) return;
98
+ try {
99
+ const response = await getEpisodeLinkByTitle(videoTitle, season, episode);
100
+ if (response.url) {
101
+ if (pollingInterval) {
102
+ clearInterval(pollingInterval);
103
+ setPollingInterval(null);
104
+ }
105
+ setVideoUrl(response.url);
106
+ setVideoFetched(true);
107
+ console.log('Video URL fetched:', response.url);
108
+ } else if (response.progress_url) {
109
+ startPolling(response.progress_url);
110
+ }
111
+ } catch (error) {
112
+ console.error('Error fetching movie link:', error);
113
+ }
114
+ };
115
+
116
+ const pollProgress = async (progressUrl: string) => {
117
+ try {
118
+ const res = await fetch(progressUrl);
119
+ const data: { progress: ProgressData } = await res.json();
120
+ setProgress(data.progress);
121
+ if (data.progress.progress >= 100) {
122
+ if (pollingInterval) {
123
+ clearInterval(pollingInterval);
124
+ setPollingInterval(null);
125
+ }
126
+ if (!videoFetchedRef.current) {
127
+ timeoutRef.current = setTimeout(fetchMovieLink, 5000);
128
+ }
129
+ }
130
+ } catch (error) {
131
+ console.error('Error polling progress:', error);
132
+ }
133
+ };
134
+
135
+ const startPolling = (progressUrl: string) => {
136
+ if (!pollingInterval) {
137
+ const interval = setInterval(() => pollProgress(progressUrl), 2000);
138
+ setPollingInterval(interval);
139
+ }
140
+ };
141
+
142
+ useEffect(() => {
143
+ fetchMovieLink();
144
+ return () => {
145
+ if (pollingInterval) clearInterval(pollingInterval);
146
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
147
+ if (hlsRef.current) {
148
+ // Destroy HLS instance on unmount
149
+ hlsRef.current.destroy();
150
+ hlsRef.current = null;
151
+ }
152
+ };
153
+ // eslint-disable-next-line react-hooks/exhaustive-deps
154
+ }, [videoTitle]);
155
+
156
+ // --- Player Control Helpers ---
157
+ const resetInactivityTimer = () => {
158
+ setShowControls(true);
159
+ if (inactivityRef.current) clearTimeout(inactivityRef.current);
160
+ inactivityRef.current = setTimeout(() => setShowControls(false), 5000);
161
+ };
162
+
163
+ const updateBuffered = () => {
164
+ if (videoRef.current) {
165
+ const bufferedTime =
166
+ videoRef.current.buffered.length > 0
167
+ ? videoRef.current.buffered.end(videoRef.current.buffered.length - 1)
168
+ : 0;
169
+ setBuffered(bufferedTime);
170
+ }
171
+ };
172
+
173
+ useEffect(() => {
174
+ const interval = setInterval(updateBuffered, 1000);
175
+ return () => clearInterval(interval);
176
+ }, []);
177
+
178
+ // --- Fullscreen Listener ---
179
+ useEffect(() => {
180
+ const handleFullScreenChange = () => {
181
+ setIsFullscreen(document.fullscreenElement === containerRef.current);
182
+ };
183
+ document.addEventListener('fullscreenchange', handleFullScreenChange);
184
+ return () => document.removeEventListener('fullscreenchange', handleFullScreenChange);
185
+ }, []);
186
+
187
+ // --- Video Source Handling (Blob or HLS) ---
188
+ useEffect(() => {
189
+ if (videoUrl) {
190
+ const isTsVideo = videoUrl.toLowerCase().endsWith('.ts');
191
+ setIsVideoTs(isTsVideo);
192
+
193
+ if (isTsVideo) {
194
+ // HLS.js setup for .ts videos
195
+ if (Hls.isSupported()) {
196
+ setSpinnerLoading(true);
197
+ const hls = new Hls();
198
+ hlsRef.current = hls; // Store HLS instance in ref
199
+ hls.on(Hls.Events.ERROR, (event, data) => {
200
+ console.error('HLS.js error:', event, data);
201
+ setSpinnerLoading(false);
202
+ // Optionally handle error, e.g., fallback to direct URL or display error message
203
+ });
204
+ hls.loadSource(videoUrl);
205
+ hls.attachMedia(videoRef.current as HTMLVideoElement); // Attach to video element
206
+ if (videoRef.current) {
207
+ videoRef.current.play();
208
+ }
209
+ } else if (videoRef.current?.canPlayType('application/vnd.apple.mpegurl')) {
210
+ // For Safari, which might have native HLS support
211
+ videoRef.current.src = videoUrl;
212
+ setSpinnerLoading(false); // If Safari plays it natively, consider it loaded
213
+ } else {
214
+ console.error(
215
+ 'HLS is not supported and not a Safari browser, cannot play .ts video.',
216
+ );
217
+ setSpinnerLoading(false);
218
+ // Optionally display an error message to the user about .ts not being supported
219
+ }
220
+ setVideoBlobUrl(''); // Clear blob URL as it's not used for .ts
221
+ } else {
222
+ // Fallback to Blob URL for other video types (like webm, mp4)
223
+ setSpinnerLoading(true);
224
+ if (hlsRef.current) {
225
+ // Destroy HLS instance if switching to blob
226
+ hlsRef.current.destroy();
227
+ hlsRef.current = null;
228
+ }
229
+ const abortController = new AbortController();
230
+ const fetchBlob = async () => {
231
+ try {
232
+ const response = await fetch(videoUrl, {
233
+ signal: abortController.signal,
234
+ mode: 'cors',
235
+ });
236
+ if (!response.ok) {
237
+ throw new Error(`HTTP error! status: ${response.status}`);
238
+ }
239
+ const blob = await response.blob();
240
+ const blobUrl = URL.createObjectURL(blob);
241
+ setVideoBlobUrl(blobUrl);
242
+ console.log('Blob URL created:', blobUrl);
243
+ setSpinnerLoading(false);
244
+ if (videoRef.current && isPlaying) {
245
+ // Autoplay if was playing before URL change
246
+ videoRef.current.play();
247
+ }
248
+ } catch (error: any) {
249
+ if (error.name === 'AbortError') {
250
+ console.log('Blob fetch aborted.');
251
+ } else {
252
+ console.error('Error fetching video blob:', error);
253
+ console.error('Falling back to direct video URL.');
254
+ setVideoBlobUrl('');
255
+ setSpinnerLoading(false);
256
+ }
257
+ }
258
+ };
259
+ fetchBlob();
260
+ return () => {
261
+ abortController.abort();
262
+ if (videoBlobUrl) {
263
+ URL.revokeObjectURL(videoBlobUrl);
264
+ console.log('Blob URL revoked:', videoBlobUrl);
265
+ }
266
+ setVideoBlobUrl('');
267
+ };
268
+ }
269
+ }
270
+ // eslint-disable-next-line react-hooks/exhaustive-deps
271
+ }, [videoUrl]);
272
+
273
+ // --- Reset Player on New URL ---
274
+ useEffect(() => {
275
+ setIsPlaying(false);
276
+ setCurrentTime(0);
277
+ setDuration(0);
278
+ setVolume(1);
279
+ setIsFullscreen(false);
280
+ resetInactivityTimer();
281
+ console.log(
282
+ 'Video player loaded using',
283
+ isVideoTs ? 'HLS.js' : videoBlobUrl ? 'blob' : 'direct URL',
284
+ videoUrl,
285
+ );
286
+ return () => {
287
+ if (inactivityRef.current) clearTimeout(inactivityRef.current);
288
+ };
289
+ // eslint-disable-next-line react-hooks/exhaustive-deps
290
+ }, [videoUrl, isVideoTs]);
291
+
292
+ // --- Video Event Handlers ---
293
+ const handleLoadedMetadata = () => {
294
+ if (videoRef.current) {
295
+ setDuration(videoRef.current.duration);
296
+ }
297
+ };
298
+
299
+ const handleTimeUpdate = () => {
300
+ if (videoRef.current && !isSeeking) {
301
+ setCurrentTime(videoRef.current.currentTime);
302
+ updateBuffered();
303
+ }
304
+ };
305
+
306
+ const handleProgress = () => {
307
+ updateBuffered();
308
+ };
309
+
310
+ // Show content rating overlay briefly when playing
311
+ const [showRatingOverlay, setShowRatingOverlay] = useState(false);
312
+ const triggerRatingOverlay = () => {
313
+ if (contentRatings.length > 0) {
314
+ setShowRatingOverlay(true);
315
+ setTimeout(() => setShowRatingOverlay(false), 5000);
316
+ }
317
+ };
318
+
319
+ const handlePlay = () => {
320
+ setIsPlaying(true);
321
+ setSpinnerLoading(false);
322
+ triggerRatingOverlay();
323
+ };
324
+
325
+ const handlePause = () => {
326
+ setIsPlaying(false);
327
+ };
328
+
329
+ const togglePlay = () => {
330
+ if (!videoRef.current) return;
331
+ videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause();
332
+ };
333
+
334
+ const handleSeekStart = () => setIsSeeking(true);
335
+ const handleSeekEnd = (e: React.ChangeEvent<HTMLInputElement>) => {
336
+ const newTime = Number(e.target.value);
337
+ if (videoRef.current) videoRef.current.currentTime = newTime;
338
+ setCurrentTime(newTime);
339
+ setIsSeeking(false);
340
+ };
341
+
342
+ const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
343
+ const newVolume = Number(e.target.value);
344
+ setVolume(newVolume);
345
+ if (videoRef.current) videoRef.current.volume = newVolume;
346
+ };
347
+
348
+ const toggleMute = () => {
349
+ if (!videoRef.current) return;
350
+ if (volume > 0) {
351
+ setVolume(0);
352
+ videoRef.current.volume = 0;
353
+ } else {
354
+ setVolume(1);
355
+ videoRef.current.volume = 1;
356
+ }
357
+ };
358
+
359
+ const toggleFullscreen = async () => {
360
+ if (!containerRef.current) return;
361
+ if (!isFullscreen) {
362
+ await containerRef.current.requestFullscreen?.();
363
+ setIsFullscreen(true);
364
+ } else {
365
+ await document.exitFullscreen?.();
366
+ setIsFullscreen(false);
367
+ }
368
+ };
369
+
370
+ const toggleEpisodesContainer = () => {
371
+ console.log('Episodes container toggled');
372
+ };
373
+
374
+ const handleClose = () => {
375
+ onClosePlayer && onClosePlayer();
376
+ };
377
+
378
+ const formatTime = (time: number): string => {
379
+ const hours = Math.floor(time / 3600);
380
+ const minutes = Math.floor((time % 3600) / 60);
381
+ const seconds = Math.floor(time % 60);
382
+ const minutesStr = minutes.toString().padStart(2, '0');
383
+ const secondsStr = seconds.toString().padStart(2, '0');
384
+ return hours > 0 ? `${hours}:${minutesStr}:${secondsStr}` : `${minutesStr}:${secondsStr}`;
385
+ };
386
+
387
+ const playedPercent = duration ? (currentTime / duration) * 100 : 0;
388
+ const bufferedPercent = duration ? (buffered / duration) * 100 : 0;
389
+
390
+ // --- Progress Bar Hover Handlers ---
391
+ const handleProgressBarHover = (e: React.MouseEvent<HTMLDivElement>) => {
392
+ if (!videoRef.current || !progressBarRef.current) return;
393
+ const rect = progressBarRef.current.getBoundingClientRect();
394
+ const hoverX = e.clientX - rect.left;
395
+ const hoverPercentage = hoverX / rect.width;
396
+ const hoverTimeCalc = duration * hoverPercentage;
397
+
398
+ setHoverTime(hoverTimeCalc);
399
+ setHoverPos(hoverX);
400
+ };
401
+
402
+ const handleProgressBarLeave = () => {
403
+ setHoverTime(null);
404
+ setHoverPos(null);
405
+ };
406
+
407
+ return (
408
+ <div
409
+ className="z-30 absolute flex w-full h-full"
410
+ ref={containerRef}
411
+ onMouseMove={resetInactivityTimer}
412
+ data-oid="isgsztg"
413
+ >
414
+ {videoUrl ? (
415
+ <>
416
+ <video
417
+ ref={videoRef}
418
+ crossOrigin="anonymous"
419
+ className="w-full h-auto bg-black object-contain"
420
+ style={{ pointerEvents: 'none' }}
421
+ onLoadedMetadata={handleLoadedMetadata}
422
+ onTimeUpdate={handleTimeUpdate}
423
+ onProgress={handleProgress}
424
+ onPlay={handlePlay}
425
+ onPause={handlePause}
426
+ onWaiting={() => setSpinnerLoading(true)}
427
+ onCanPlay={() => setSpinnerLoading(false)}
428
+ onPlaying={() => setSpinnerLoading(false)}
429
+ autoPlay
430
+ data-oid="bokrys8"
431
+ >
432
+ {!isVideoTs && (
433
+ <source
434
+ type="video/webm"
435
+ src={videoBlobUrl || videoUrl}
436
+ data-oid="ctulub7"
437
+ />
438
+ )}
439
+ </video>
440
+
441
+ {showRatingOverlay && contentRatings.length > 0 && (
442
+ <div
443
+ className="absolute z-50 pointer-events-none"
444
+ style={{ top: '80px', left: '20px' }}
445
+ data-oid="-j89csn"
446
+ >
447
+ <div
448
+ className="px-6 py-3 bg-black bg-opacity-70 rounded-lg border border-white text-white text-3xl font-bold animate-fade-out"
449
+ data-oid="-b7thm6"
450
+ >
451
+ {contentRatings[0].country ? `[${contentRatings[0].country}] ` : ''}
452
+ {contentRatings[0].name}
453
+ </div>
454
+ </div>
455
+ )}
456
+
457
+ {/* Controls Overlay */}
458
+ <div
459
+ className={`absolute inset-0 flex flex-col justify-between transition-opacity duration-300 z-40 ${
460
+ showControls
461
+ ? 'opacity-100 pointer-events-auto'
462
+ : 'opacity-0 pointer-events-none'
463
+ }`}
464
+ data-oid="k_kclok"
465
+ >
466
+ <div
467
+ className="flex items-center justify-between p-6 bg-gradient-to-b from-black/90 to-transparent"
468
+ data-oid="ckd923j"
469
+ >
470
+ <div className="flex flex-col" data-oid="tkshse.">
471
+ <h1
472
+ className="text-white text-3xl font-extrabold"
473
+ data-oid="jrgzk6d"
474
+ >
475
+ {videoTitle || 'Video Player'}
476
+ </h1>
477
+ {episodeMatch && (
478
+ <p className="text-white text-xl mt-2" data-oid="3.ijpjz">
479
+ Season {seasonNumber} - Episode {episodeNumber}:{' '}
480
+ {episodeTitleClean.replace(/_/g, ' ')}
481
+ </p>
482
+ )}
483
+ {contentRatings.length > 0 && (
484
+ <div
485
+ className="mt-2 inline-block bg-gray-700 text-white text-sm px-3 py-1 rounded-md"
486
+ data-oid="rreuy6-"
487
+ >
488
+ {contentRatings[0].country
489
+ ? `[${contentRatings[0].country}] `
490
+ : ''}
491
+ {contentRatings[0].name}
492
+ </div>
493
+ )}
494
+ </div>
495
+ {onClosePlayer && (
496
+ <button
497
+ onClick={handleClose}
498
+ className="text-white hover:text-red-400 transition-colors"
499
+ data-oid="ii761xf"
500
+ >
501
+ <XCircleIcon className="w-10 h-10" data-oid="lk9v0b7" />
502
+ </button>
503
+ )}
504
+ </div>
505
+ <div
506
+ className="flex flex-col p-6 bg-gradient-to-t from-black/90 to-transparent"
507
+ data-oid="u5xh7y8"
508
+ >
509
+ <div
510
+ className="flex items-center justify-between mb-4"
511
+ data-oid="f5_1cr-"
512
+ >
513
+ <span className="text-white text-sm" data-oid="bt011xf">
514
+ {formatTime(currentTime)}
515
+ </span>
516
+ {/* Custom Progress Bar */}
517
+ <div
518
+ className="relative w-full mx-4"
519
+ ref={progressBarRef}
520
+ onMouseMove={handleProgressBarHover}
521
+ onMouseLeave={handleProgressBarLeave}
522
+ data-oid="428m9qx"
523
+ >
524
+ <div className="h-2 rounded bg-gray-700" data-oid="rrwocs4">
525
+ <div
526
+ className="h-2 rounded bg-purple-300"
527
+ style={{
528
+ width: `${bufferedPercent}%`,
529
+ pointerEvents: 'none',
530
+ }} // Make buffer click-through
531
+ data-oid="dtm4j3_"
532
+ ></div>
533
+ <div
534
+ className="h-2 rounded bg-purple-700 absolute top-0 left-0"
535
+ style={{ width: `${playedPercent}%` }}
536
+ data-oid="f3n26dy"
537
+ ></div>
538
+ </div>
539
+ <input
540
+ type="range"
541
+ min="0"
542
+ max={duration}
543
+ value={
544
+ isSeeking
545
+ ? currentTime
546
+ : videoRef.current?.currentTime || 0
547
+ }
548
+ onMouseDown={handleSeekStart}
549
+ onTouchStart={handleSeekStart}
550
+ onChange={handleSeekEnd}
551
+ className="absolute top-0 left-0 w-full h-2 opacity-0 cursor-pointer"
552
+ data-oid="bz9_3k9"
553
+ />
554
+
555
+ {/* Hover time tooltip */}
556
+ {hoverTime !== null && hoverPos !== null && (
557
+ <div
558
+ className="absolute top-[-30px] bg-black bg-opacity-70 text-white text-sm px-2 py-1 rounded pointer-events-none"
559
+ style={{
560
+ left: `${hoverPos}px`,
561
+ transform: 'translateX(-50%)',
562
+ }}
563
+ data-oid="sasbntz"
564
+ >
565
+ {formatTime(hoverTime)}
566
+ </div>
567
+ )}
568
+ </div>
569
+ <span className="text-white text-sm" data-oid="jjjke09">
570
+ {formatTime(duration)}
571
+ </span>
572
+ </div>
573
+ <div className="flex items-center justify-between" data-oid="ylwy8qb">
574
+ <div className="flex items-center space-x-6" data-oid="g_0ggfc">
575
+ <button
576
+ onClick={togglePlay}
577
+ className="text-white hover:text-purple-300 transition-colors"
578
+ data-oid="g0_b6ag"
579
+ >
580
+ {isPlaying ? (
581
+ <PauseIcon className="w-10 h-10" data-oid="l7auwq_" />
582
+ ) : (
583
+ <PlayIcon className="w-10 h-10" data-oid="kk-oa9c" />
584
+ )}
585
+ </button>
586
+ <button
587
+ onClick={toggleMute}
588
+ className="text-white hover:text-purple-300 transition-colors"
589
+ data-oid="vvs_inp"
590
+ >
591
+ {isMuted ? (
592
+ <SpeakerXMarkIcon
593
+ className="w-10 h-10"
594
+ data-oid="1k5xnuh"
595
+ />
596
+ ) : (
597
+ <SpeakerWaveIcon
598
+ className="w-10 h-10"
599
+ data-oid="9kxfosj"
600
+ />
601
+ )}
602
+ </button>
603
+ <input
604
+ type="range"
605
+ min={0}
606
+ max={1}
607
+ step={0.01}
608
+ value={volume}
609
+ onChange={handleVolumeChange}
610
+ className="w-24 accent-purple-500"
611
+ data-oid="uw3g5nx"
612
+ />
613
+ </div>
614
+ <div className="flex items-center space-x-6" data-oid="_5k4fj3">
615
+ <button
616
+ onClick={toggleEpisodesContainer}
617
+ className="text-white hover:text-purple-300 transition-colors"
618
+ data-oid="6_53ef2"
619
+ >
620
+ <RectangleStackIcon
621
+ className="w-10 h-10"
622
+ data-oid="ad_9rnr"
623
+ />
624
+ </button>
625
+ <button
626
+ onClick={toggleFullscreen}
627
+ className="text-white hover:text-purple-300 transition-colors"
628
+ data-oid="eapqw1:"
629
+ >
630
+ {!isFullscreen ? (
631
+ <ArrowsPointingOutIcon
632
+ className="w-10 h-10"
633
+ data-oid="5wyw-0_"
634
+ />
635
+ ) : (
636
+ <ArrowsPointingInIcon
637
+ className="w-10 h-10"
638
+ data-oid="2gu-1__"
639
+ />
640
+ )}
641
+ </button>
642
+ </div>
643
+ </div>
644
+ </div>
645
+ </div>
646
+ </>
647
+ ) : (
648
+ <div
649
+ className="p-6 bg-gray-800 text-gray-200 rounded-lg shadow-md"
650
+ data-oid="1th-4qq"
651
+ >
652
+ <h2 className="text-xl font-semibold mb-4" data-oid="2kcidjw">
653
+ Fetching Video Link
654
+ </h2>
655
+ {progress ? (
656
+ <div className="space-y-2" data-oid="200h7pi">
657
+ <p className="text-sm" data-oid="bco2m0u">
658
+ Status: {progress.status}
659
+ </p>
660
+ <p className="text-sm" data-oid="21i-5iy">
661
+ Progress: {progress.progress.toFixed(2)}%
662
+ </p>
663
+ <p className="text-sm" data-oid="pks0l83">
664
+ Downloaded: {progress.downloaded} / {progress.total}
665
+ </p>
666
+ </div>
667
+ ) : (
668
+ <p className="text-sm" data-oid="eug0g3t">
669
+ Initializing link fetching...
670
+ </p>
671
+ )}
672
+ </div>
673
+ )}
674
+
675
+ <style jsx data-oid=".a1gcx0">{`
676
+ @keyframes fade-out {
677
+ 0% {
678
+ opacity: 1;
679
+ }
680
+ 80% {
681
+ opacity: 1;
682
+ }
683
+ 100% {
684
+ opacity: 0;
685
+ }
686
+ }
687
+ .animate-fade-out {
688
+ animation: fade-out 4s forwards;
689
+ }
690
+ `}</style>
691
+ </div>
692
+ );
693
+ };
694
+
695
+ export default TvShowPlayer;
frontend/lib/LoadbalancerAPI.js CHANGED
@@ -65,6 +65,10 @@ class LoadBalancerAPI {
65
  return await this._get(`/api/get/series/card/${encodeURIComponent(title)}`);
66
  }
67
 
 
 
 
 
68
  async getSeasonMetadataBySeriesId(series_id, season) {
69
  return await this._get(`/api/get/series/metadata/${series_id}/${season}`);
70
  }
 
65
  return await this._get(`/api/get/series/card/${encodeURIComponent(title)}`);
66
  }
67
 
68
+ async getSeasonMetadataByTitleAndSeason(title, season) {
69
+ return await this._get(`/api/get/series/metadata/${encodeURIComponent(title)}/${encodeURIComponent(season)}`);
70
+ }
71
+
72
  async getSeasonMetadataBySeriesId(series_id, season) {
73
  return await this._get(`/api/get/series/metadata/${series_id}/${season}`);
74
  }
frontend/lib/config.ts CHANGED
@@ -1 +1,2 @@
1
- export const WEB_VERSION = 'v0.2.0 beta';
 
 
1
+ export const WEB_VERSION = 'v0.2.1 beta';
2
+ export const SEARCH_API_URL = 'https://hans-den-search.hf.space';
frontend/lib/lb.js CHANGED
@@ -104,6 +104,11 @@ export async function getMovieLinkByTitle(title){
104
  return response
105
  }
106
 
 
 
 
 
 
107
 
108
  export async function getMovieCard(title){
109
  const movie = await lb.getMovieCard(title);
@@ -129,6 +134,12 @@ export async function getTvShowMetadata(title){
129
  return tvshow
130
  }
131
 
 
 
 
 
 
 
132
  export async function getGenreCategories(mediaType){
133
  const gc = await lb.getGenreCategories(mediaType);
134
  console.debug(gc);
 
104
  return response
105
  }
106
 
107
+ export async function getEpisodeLinkByTitle(title, season, episode){
108
+ const response = await lb.getSeriesEpisode(title, season, episode);
109
+ console.debug(response);
110
+ return response
111
+ }
112
 
113
  export async function getMovieCard(title){
114
  const movie = await lb.getMovieCard(title);
 
134
  return tvshow
135
  }
136
 
137
+ export async function getSeasonMetadata(title,season){
138
+ const data = await lb.getSeasonMetadataByTitleAndSeason(title, season);
139
+ console.debug(data);
140
+ return data
141
+ }
142
+
143
  export async function getGenreCategories(mediaType){
144
  const gc = await lb.getGenreCategories(mediaType);
145
  console.debug(gc);
frontend/package.json CHANGED
@@ -16,6 +16,7 @@
16
  "class-variance-authority": "^0.7.0",
17
  "clsx": "^2.1.1",
18
  "framer-motion": "^12.4.2",
 
19
  "lucide-react": "^0.438.0",
20
  "next": "14.2.23",
21
  "next-nprogress-bar": "^2.4.4",
@@ -34,5 +35,6 @@
34
  "prettier": "^3.3.3",
35
  "tailwindcss": "^3.4.1",
36
  "typescript": "^5"
37
- }
 
38
  }
 
16
  "class-variance-authority": "^0.7.0",
17
  "clsx": "^2.1.1",
18
  "framer-motion": "^12.4.2",
19
+ "hls.js": "^1.5.20",
20
  "lucide-react": "^0.438.0",
21
  "next": "14.2.23",
22
  "next-nprogress-bar": "^2.4.4",
 
35
  "prettier": "^3.3.3",
36
  "tailwindcss": "^3.4.1",
37
  "typescript": "^5"
38
+ },
39
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
40
  }
frontend/yarn.lock CHANGED
@@ -165,6 +165,46 @@
165
  dependencies:
166
  fast-glob "3.3.1"
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  "@next/swc-win32-x64-msvc@14.2.23":
169
  version "14.2.23"
170
  resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.23.tgz"
@@ -178,7 +218,7 @@
178
  "@nodelib/fs.stat" "2.0.5"
179
  run-parallel "^1.1.9"
180
 
181
- "@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
182
  version "2.0.5"
183
  resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
184
  integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -279,7 +319,7 @@
279
  resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz"
280
  integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==
281
 
282
- "@swc/helpers@^0.5.0", "@swc/helpers@0.5.5":
283
  version "0.5.5"
284
  resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz"
285
  integrity sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==
@@ -346,7 +386,7 @@
346
  natural-compare "^1.4.0"
347
  ts-api-utils "^2.0.1"
348
 
349
- "@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser@^8.0.0 || ^8.0.0-alpha.0":
350
  version "8.24.0"
351
  resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.0.tgz"
352
  integrity sha512-MFDaO9CYiard9j9VepMNa9MTcqVvSny2N4hkY6roquzj8pdCBRENhErrteaQuu7Yjn1ppk0v1/ZF9CG3KIlrTA==
@@ -422,7 +462,7 @@ acorn-jsx@^5.3.2:
422
  resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
423
  integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
424
 
425
- "acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.9.0:
426
  version "8.12.1"
427
  resolved "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz"
428
  integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==
@@ -717,16 +757,16 @@ client-only@0.0.1:
717
  resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"
718
  integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
719
 
720
- clsx@^2.0.0, clsx@^2.1.1:
721
- version "2.1.1"
722
- resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
723
- integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
724
-
725
  clsx@2.0.0:
726
  version "2.0.0"
727
  resolved "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz"
728
  integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==
729
 
 
 
 
 
 
730
  color-convert@^2.0.1:
731
  version "2.0.1"
732
  resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
@@ -1066,7 +1106,7 @@ eslint-module-utils@^2.12.0, eslint-module-utils@^2.8.1:
1066
  dependencies:
1067
  debug "^3.2.7"
1068
 
1069
- eslint-plugin-import@*, eslint-plugin-import@^2.31.0:
1070
  version "2.31.0"
1071
  resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz"
1072
  integrity sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==
@@ -1159,7 +1199,7 @@ eslint-visitor-keys@^4.2.0:
1159
  resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz"
1160
  integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==
1161
 
1162
- eslint@*, "eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", "eslint@^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9", "eslint@^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7", "eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^7.23.0 || ^8.0.0 || ^9.0.0", eslint@^8, "eslint@^8.57.0 || ^9.0.0":
1163
  version "8.57.0"
1164
  resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz"
1165
  integrity sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==
@@ -1241,10 +1281,10 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
1241
  resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
1242
  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
1243
 
1244
- fast-glob@^3.3.0, fast-glob@^3.3.2:
1245
- version "3.3.2"
1246
- resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz"
1247
- integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
1248
  dependencies:
1249
  "@nodelib/fs.stat" "^2.0.2"
1250
  "@nodelib/fs.walk" "^1.2.3"
@@ -1252,10 +1292,10 @@ fast-glob@^3.3.0, fast-glob@^3.3.2:
1252
  merge2 "^1.3.0"
1253
  micromatch "^4.0.4"
1254
 
1255
- fast-glob@3.3.1:
1256
- version "3.3.1"
1257
- resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz"
1258
- integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
1259
  dependencies:
1260
  "@nodelib/fs.stat" "^2.0.2"
1261
  "@nodelib/fs.walk" "^1.2.3"
@@ -1345,6 +1385,11 @@ fs.realpath@^1.0.0:
1345
  resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
1346
  integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
1347
 
 
 
 
 
 
1348
  function-bind@^1.1.2:
1349
  version "1.1.2"
1350
  resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
@@ -1407,7 +1452,7 @@ get-tsconfig@^4.7.5:
1407
  dependencies:
1408
  resolve-pkg-maps "^1.0.0"
1409
 
1410
- glob-parent@^5.1.2:
1411
  version "5.1.2"
1412
  resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
1413
  integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@@ -1421,13 +1466,6 @@ glob-parent@^6.0.2:
1421
  dependencies:
1422
  is-glob "^4.0.3"
1423
 
1424
- glob-parent@~5.1.2:
1425
- version "5.1.2"
1426
- resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
1427
- integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
1428
- dependencies:
1429
- is-glob "^4.0.1"
1430
-
1431
  glob@^10.3.10:
1432
  version "10.4.5"
1433
  resolved "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz"
@@ -1525,6 +1563,11 @@ hasown@^2.0.0, hasown@^2.0.2:
1525
  dependencies:
1526
  function-bind "^1.1.2"
1527
 
 
 
 
 
 
1528
  ignore@^5.2.0, ignore@^5.3.1:
1529
  version "5.3.2"
1530
  resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
@@ -1971,7 +2014,7 @@ motion-utils@^12.0.0:
1971
  resolved "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz"
1972
  integrity sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==
1973
 
1974
- ms@^2.1.1, ms@2.1.2:
1975
  version "2.1.2"
1976
  resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
1977
  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
@@ -2256,15 +2299,6 @@ postcss-value-parser@^4.0.0:
2256
  resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
2257
  integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
2258
 
2259
- postcss@^8, postcss@^8.0.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.23, postcss@>=8.0.9:
2260
- version "8.4.44"
2261
- resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.44.tgz"
2262
- integrity sha512-Aweb9unOEpQ3ezu4Q00DPvvM2ZTUitJdNKeP/+uQgr1IBIqu574IaZoURId7BKtWMREwzKa9OgzPzezWGPWFQw==
2263
- dependencies:
2264
- nanoid "^3.3.7"
2265
- picocolors "^1.0.1"
2266
- source-map-js "^1.2.0"
2267
-
2268
  postcss@8.4.31:
2269
  version "8.4.31"
2270
  resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz"
@@ -2274,6 +2308,15 @@ postcss@8.4.31:
2274
  picocolors "^1.0.0"
2275
  source-map-js "^1.0.2"
2276
 
 
 
 
 
 
 
 
 
 
2277
  prelude-ls@^1.2.1:
2278
  version "1.2.1"
2279
  resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
@@ -2303,7 +2346,7 @@ queue-microtask@^1.2.2:
2303
  resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
2304
  integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
2305
 
2306
- "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", react-dom@^18, "react-dom@^18 || ^19 || ^19.0.0-rc", "react-dom@^18.0.0 || ^19.0.0", react-dom@^18.2.0, react-dom@>=16.8.0:
2307
  version "18.3.1"
2308
  resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz"
2309
  integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
@@ -2316,7 +2359,7 @@ react-is@^16.13.1:
2316
  resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
2317
  integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
2318
 
2319
- "react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc", "react@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", react@^18, "react@^18 || ^19 || ^19.0.0-rc", "react@^18.0.0 || ^19.0.0", react@^18.2.0, react@^18.3.1, "react@>= 16 || ^19.0.0-rc", "react@>= 16.8.0 || 17.x.x || ^18.0.0-0", react@>=16.8.0:
2320
  version "18.3.1"
2321
  resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"
2322
  integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
@@ -2560,6 +2603,8 @@ streamsearch@^1.1.0:
2560
 
2561
  "string-width-cjs@npm:string-width@^4.2.0":
2562
  version "4.2.3"
 
 
2563
  dependencies:
2564
  emoji-regex "^8.0.0"
2565
  is-fullwidth-code-point "^3.0.0"
@@ -2653,6 +2698,8 @@ string.prototype.trimstart@^1.0.8:
2653
 
2654
  "strip-ansi-cjs@npm:strip-ansi@^6.0.1":
2655
  version "6.0.1"
 
 
2656
  dependencies:
2657
  ansi-regex "^5.0.1"
2658
 
@@ -2727,7 +2774,7 @@ tailwindcss-animate@^1.0.7:
2727
  resolved "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz"
2728
  integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
2729
 
2730
- tailwindcss@^3.4.1, "tailwindcss@>=3.0.0 || insiders":
2731
  version "3.4.10"
2732
  resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz"
2733
  integrity sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==
@@ -2868,7 +2915,7 @@ typed-array-length@^1.0.7:
2868
  possible-typed-array-names "^1.0.0"
2869
  reflect.getprototypeof "^1.0.6"
2870
 
2871
- typescript@^5, typescript@>=3.3.1, typescript@>=4.8.4, "typescript@>=4.8.4 <5.8.0":
2872
  version "5.5.4"
2873
  resolved "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz"
2874
  integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
@@ -2966,6 +3013,8 @@ word-wrap@^1.2.5:
2966
 
2967
  "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
2968
  version "7.0.0"
 
 
2969
  dependencies:
2970
  ansi-styles "^4.0.0"
2971
  string-width "^4.1.0"
 
165
  dependencies:
166
  fast-glob "3.3.1"
167
 
168
+ "@next/swc-darwin-arm64@14.2.23":
169
+ version "14.2.23"
170
+ resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.23.tgz#6d83f03e35e163e8bbeaf5aeaa6bf55eed23d7a1"
171
+ integrity sha512-WhtEntt6NcbABA8ypEoFd3uzq5iAnrl9AnZt9dXdO+PZLACE32z3a3qA5OoV20JrbJfSJ6Sd6EqGZTrlRnGxQQ==
172
+
173
+ "@next/swc-darwin-x64@14.2.23":
174
+ version "14.2.23"
175
+ resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.23.tgz#e02abc35d5e36ce1550f674f8676999f293ba54f"
176
+ integrity sha512-vwLw0HN2gVclT/ikO6EcE+LcIN+0mddJ53yG4eZd0rXkuEr/RnOaMH8wg/sYl5iz5AYYRo/l6XX7FIo6kwbw1Q==
177
+
178
+ "@next/swc-linux-arm64-gnu@14.2.23":
179
+ version "14.2.23"
180
+ resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.23.tgz#f13516ad2d665950951b59e7c239574bb8504d63"
181
+ integrity sha512-uuAYwD3At2fu5CH1wD7FpP87mnjAv4+DNvLaR9kiIi8DLStWSW304kF09p1EQfhcbUI1Py2vZlBO2VaVqMRtpg==
182
+
183
+ "@next/swc-linux-arm64-musl@14.2.23":
184
+ version "14.2.23"
185
+ resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.23.tgz#10d05a1c161dc8426d54ccf6d9bbed6953a3252a"
186
+ integrity sha512-Mm5KHd7nGgeJ4EETvVgFuqKOyDh+UMXHXxye6wRRFDr4FdVRI6YTxajoV2aHE8jqC14xeAMVZvLqYqS7isHL+g==
187
+
188
+ "@next/swc-linux-x64-gnu@14.2.23":
189
+ version "14.2.23"
190
+ resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.23.tgz#7f5856df080f58ba058268b30429a2ab52500536"
191
+ integrity sha512-Ybfqlyzm4sMSEQO6lDksggAIxnvWSG2cDWnG2jgd+MLbHYn2pvFA8DQ4pT2Vjk3Cwrv+HIg7vXJ8lCiLz79qoQ==
192
+
193
+ "@next/swc-linux-x64-musl@14.2.23":
194
+ version "14.2.23"
195
+ resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.23.tgz#d494ebdf26421c91be65f9b1d095df0191c956d8"
196
+ integrity sha512-OSQX94sxd1gOUz3jhhdocnKsy4/peG8zV1HVaW6DLEbEmRRtUCUQZcKxUD9atLYa3RZA+YJx+WZdOnTkDuNDNA==
197
+
198
+ "@next/swc-win32-arm64-msvc@14.2.23":
199
+ version "14.2.23"
200
+ resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.23.tgz#62786e7ba4822a20b6666e3e03e5a389b0e7eb3b"
201
+ integrity sha512-ezmbgZy++XpIMTcTNd0L4k7+cNI4ET5vMv/oqNfTuSXkZtSA9BURElPFyarjjGtRgZ9/zuKDHoMdZwDZIY3ehQ==
202
+
203
+ "@next/swc-win32-ia32-msvc@14.2.23":
204
+ version "14.2.23"
205
+ resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.23.tgz#ef028af91e1c40a4ebba0d2c47b23c1eeb299594"
206
+ integrity sha512-zfHZOGguFCqAJ7zldTKg4tJHPJyJCOFhpoJcVxKL9BSUHScVDnMdDuOU1zPPGdOzr/GWxbhYTjyiEgLEpAoFPA==
207
+
208
  "@next/swc-win32-x64-msvc@14.2.23":
209
  version "14.2.23"
210
  resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.23.tgz"
 
218
  "@nodelib/fs.stat" "2.0.5"
219
  run-parallel "^1.1.9"
220
 
221
+ "@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
222
  version "2.0.5"
223
  resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
224
  integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
 
319
  resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz"
320
  integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==
321
 
322
+ "@swc/helpers@0.5.5", "@swc/helpers@^0.5.0":
323
  version "0.5.5"
324
  resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz"
325
  integrity sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==
 
386
  natural-compare "^1.4.0"
387
  ts-api-utils "^2.0.1"
388
 
389
+ "@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0":
390
  version "8.24.0"
391
  resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.0.tgz"
392
  integrity sha512-MFDaO9CYiard9j9VepMNa9MTcqVvSny2N4hkY6roquzj8pdCBRENhErrteaQuu7Yjn1ppk0v1/ZF9CG3KIlrTA==
 
462
  resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
463
  integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
464
 
465
+ acorn@^8.9.0:
466
  version "8.12.1"
467
  resolved "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz"
468
  integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==
 
757
  resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"
758
  integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
759
 
 
 
 
 
 
760
  clsx@2.0.0:
761
  version "2.0.0"
762
  resolved "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz"
763
  integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==
764
 
765
+ clsx@^2.0.0, clsx@^2.1.1:
766
+ version "2.1.1"
767
+ resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
768
+ integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
769
+
770
  color-convert@^2.0.1:
771
  version "2.0.1"
772
  resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
 
1106
  dependencies:
1107
  debug "^3.2.7"
1108
 
1109
+ eslint-plugin-import@^2.31.0:
1110
  version "2.31.0"
1111
  resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz"
1112
  integrity sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==
 
1199
  resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz"
1200
  integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==
1201
 
1202
+ eslint@^8:
1203
  version "8.57.0"
1204
  resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz"
1205
  integrity sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==
 
1281
  resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
1282
  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
1283
 
1284
+ fast-glob@3.3.1:
1285
+ version "3.3.1"
1286
+ resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz"
1287
+ integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
1288
  dependencies:
1289
  "@nodelib/fs.stat" "^2.0.2"
1290
  "@nodelib/fs.walk" "^1.2.3"
 
1292
  merge2 "^1.3.0"
1293
  micromatch "^4.0.4"
1294
 
1295
+ fast-glob@^3.3.0, fast-glob@^3.3.2:
1296
+ version "3.3.2"
1297
+ resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz"
1298
+ integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
1299
  dependencies:
1300
  "@nodelib/fs.stat" "^2.0.2"
1301
  "@nodelib/fs.walk" "^1.2.3"
 
1385
  resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
1386
  integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
1387
 
1388
+ fsevents@~2.3.2:
1389
+ version "2.3.3"
1390
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
1391
+ integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
1392
+
1393
  function-bind@^1.1.2:
1394
  version "1.1.2"
1395
  resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
 
1452
  dependencies:
1453
  resolve-pkg-maps "^1.0.0"
1454
 
1455
+ glob-parent@^5.1.2, glob-parent@~5.1.2:
1456
  version "5.1.2"
1457
  resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
1458
  integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
 
1466
  dependencies:
1467
  is-glob "^4.0.3"
1468
 
 
 
 
 
 
 
 
1469
  glob@^10.3.10:
1470
  version "10.4.5"
1471
  resolved "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz"
 
1563
  dependencies:
1564
  function-bind "^1.1.2"
1565
 
1566
+ hls.js@^1.5.20:
1567
+ version "1.5.20"
1568
+ resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.20.tgz#7eb23bb5e2595311d4e2761038ca6882673de7e2"
1569
+ integrity sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==
1570
+
1571
  ignore@^5.2.0, ignore@^5.3.1:
1572
  version "5.3.2"
1573
  resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
 
2014
  resolved "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz"
2015
  integrity sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==
2016
 
2017
+ ms@2.1.2, ms@^2.1.1:
2018
  version "2.1.2"
2019
  resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
2020
  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
2299
  resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
2300
  integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
2301
 
 
 
 
 
 
 
 
 
 
2302
  postcss@8.4.31:
2303
  version "8.4.31"
2304
  resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz"
 
2308
  picocolors "^1.0.0"
2309
  source-map-js "^1.0.2"
2310
 
2311
+ postcss@^8, postcss@^8.4.23:
2312
+ version "8.4.44"
2313
+ resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.44.tgz"
2314
+ integrity sha512-Aweb9unOEpQ3ezu4Q00DPvvM2ZTUitJdNKeP/+uQgr1IBIqu574IaZoURId7BKtWMREwzKa9OgzPzezWGPWFQw==
2315
+ dependencies:
2316
+ nanoid "^3.3.7"
2317
+ picocolors "^1.0.1"
2318
+ source-map-js "^1.2.0"
2319
+
2320
  prelude-ls@^1.2.1:
2321
  version "1.2.1"
2322
  resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
 
2346
  resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
2347
  integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
2348
 
2349
+ react-dom@^18:
2350
  version "18.3.1"
2351
  resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz"
2352
  integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
 
2359
  resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
2360
  integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
2361
 
2362
+ react@^18:
2363
  version "18.3.1"
2364
  resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"
2365
  integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
 
2603
 
2604
  "string-width-cjs@npm:string-width@^4.2.0":
2605
  version "4.2.3"
2606
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
2607
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
2608
  dependencies:
2609
  emoji-regex "^8.0.0"
2610
  is-fullwidth-code-point "^3.0.0"
 
2698
 
2699
  "strip-ansi-cjs@npm:strip-ansi@^6.0.1":
2700
  version "6.0.1"
2701
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
2702
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
2703
  dependencies:
2704
  ansi-regex "^5.0.1"
2705
 
 
2774
  resolved "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz"
2775
  integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
2776
 
2777
+ tailwindcss@^3.4.1:
2778
  version "3.4.10"
2779
  resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz"
2780
  integrity sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==
 
2915
  possible-typed-array-names "^1.0.0"
2916
  reflect.getprototypeof "^1.0.6"
2917
 
2918
+ typescript@^5:
2919
  version "5.5.4"
2920
  resolved "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz"
2921
  integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
 
3013
 
3014
  "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
3015
  version "7.0.0"
3016
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
3017
+ integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
3018
  dependencies:
3019
  ansi-styles "^4.0.0"
3020
  string-width "^4.1.0"