Commit
·
77b8fea
1
Parent(s):
3635b0f
v0.2.1 beta. search page, tvshow player and movie player
Browse files- frontend/TODO.md +1 -3
- frontend/app/{movies → browse/movies}/page.tsx +58 -19
- frontend/app/{tvshows → browse/tvshows}/page.tsx +62 -19
- frontend/app/layout.tsx +7 -6
- frontend/app/movie/[title]/LoadingSkeleton.tsx +43 -19
- frontend/app/movie/[title]/page.tsx +84 -74
- frontend/app/mylist/page.tsx +1 -1
- frontend/app/not-found.tsx +47 -22
- frontend/app/page.tsx +61 -18
- frontend/app/search/page.tsx +301 -0
- frontend/app/tvshow/[title]/LoadingSkeleton.tsx +43 -19
- frontend/app/tvshow/[title]/page.tsx +90 -178
- frontend/app/watch/movie/[title]/page.tsx +28 -0
- frontend/app/watch/tvshow/[title]/[season]/[episode]/page.tsx +30 -0
- frontend/components/loading/Spinner.tsx +13 -5
- frontend/components/loading/SplashScreen.tsx +28 -10
- frontend/components/movie/MovieCard.tsx +421 -340
- frontend/components/movie/MovieLinkFetcher.tsx +121 -0
- frontend/components/movie/MovieLinkFetcherModal.tsx +32 -11
- frontend/components/movie/MoviePlayer.tsx +566 -0
- frontend/components/movie/MoviePlayerModal.tsx +167 -67
- frontend/components/navigation/DesktopMenu.tsx +17 -5
- frontend/components/navigation/MobileMenu.tsx +30 -7
- frontend/components/navigation/Navbar.tsx +20 -12
- frontend/components/sections/CastSection.tsx +26 -8
- frontend/components/sections/EpisodesSection.tsx +181 -0
- frontend/components/sections/NewContentSection.tsx +10 -5
- frontend/components/sections/ScrollSection.tsx +22 -8
- frontend/components/shared/GenresFilter.tsx +48 -14
- frontend/components/shared/Trailers.tsx +76 -0
- frontend/components/tvshow/TvShowCard.tsx +215 -96
- frontend/components/tvshow/TvshowPlayer.tsx +695 -0
- frontend/lib/LoadbalancerAPI.js +4 -0
- frontend/lib/config.ts +2 -1
- frontend/lib/lb.js +11 -0
- frontend/package.json +3 -1
- 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 184 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
</div>
|
| 190 |
|
| 191 |
{/* Content Section */}
|
| 192 |
{!filterActive && (
|
| 193 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 194 |
<Suspense
|
| 195 |
fallback={
|
| 196 |
-
<div
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
</div>
|
| 76 |
))}
|
| 77 |
</div>
|
| 78 |
|
| 79 |
{/* Pagination */}
|
| 80 |
-
<div
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 184 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
</div>
|
| 190 |
|
| 191 |
{/* Content Section */}
|
| 192 |
{!filterActive && (
|
| 193 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 194 |
<Suspense
|
| 195 |
fallback={
|
| 196 |
-
<div
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
</div>
|
| 27 |
-
<div className="space-y-4">
|
| 28 |
-
<div className="h-8 bg-gray-800 rounded-lg w-full"></div>
|
| 29 |
-
<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
|
|
|
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
|
| 84 |
{/* Main Content Container */}
|
| 85 |
-
<div
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
{/* Left Column - Main Info */}
|
| 88 |
-
<div className="lg:col-span-2 space-y-6">
|
| 89 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 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
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
Movie
|
| 112 |
</span>
|
| 113 |
</div>
|
| 114 |
</div>
|
| 115 |
{/* Genres and Score */}
|
| 116 |
-
<div
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
{rating.name}
|
| 172 |
</span>
|
| 173 |
-
<span
|
|
|
|
|
|
|
|
|
|
| 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
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
</
|
| 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
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 45 |
</g>{' '}
|
| 46 |
</g>{' '}
|
| 47 |
-
<g>
|
| 48 |
{' '}
|
| 49 |
-
<g>
|
| 50 |
{' '}
|
| 51 |
-
<path
|
|
|
|
|
|
|
|
|
|
| 52 |
</g>{' '}
|
| 53 |
</g>{' '}
|
| 54 |
-
<g>
|
| 55 |
{' '}
|
| 56 |
-
<g>
|
| 57 |
{' '}
|
| 58 |
-
<path
|
|
|
|
|
|
|
|
|
|
| 59 |
</g>{' '}
|
| 60 |
</g>{' '}
|
| 61 |
-
<g>
|
| 62 |
{' '}
|
| 63 |
-
<g>
|
| 64 |
{' '}
|
| 65 |
-
<path
|
|
|
|
|
|
|
|
|
|
| 66 |
</g>{' '}
|
| 67 |
</g>{' '}
|
| 68 |
-
<g>
|
| 69 |
{' '}
|
| 70 |
-
<g>
|
| 71 |
{' '}
|
| 72 |
-
<path
|
|
|
|
|
|
|
|
|
|
| 73 |
</g>{' '}
|
| 74 |
</g>{' '}
|
| 75 |
-
<g>
|
| 76 |
{' '}
|
| 77 |
-
<g>
|
| 78 |
{' '}
|
| 79 |
-
<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
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
{slide.title}
|
| 89 |
</h1>
|
| 90 |
-
<span
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 113 |
{slide.description}
|
| 114 |
</p>
|
| 115 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 116 |
<Link
|
| 117 |
-
href={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
</div>
|
| 27 |
-
<div className="space-y-4">
|
| 28 |
-
<div className="h-8 bg-gray-800 rounded-lg w-full"></div>
|
| 29 |
-
<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
|
|
|
|
|
|
|
|
|
|
| 207 |
</div>
|
| 208 |
|
| 209 |
{/* Main Content Container */}
|
| 210 |
-
<div
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
| 212 |
{/* Left Column - Main Info */}
|
| 213 |
-
<div className="lg:col-span-2 space-y-6">
|
| 214 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 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
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
TV Series
|
| 237 |
</span>
|
| 238 |
</div>
|
| 239 |
</div>
|
| 240 |
{/* Genres and Score */}
|
| 241 |
-
<div
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
] || '🌍';
|
| 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">
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
{rating.name}
|
| 298 |
</span>
|
| 299 |
-
<span
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 338 |
-
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 350 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 44 |
{/* Inner Glow Effect */}
|
| 45 |
-
<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(
|
| 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
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
{/* Inner Background */}
|
| 63 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
{/* Text */}
|
| 66 |
-
<div
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
NEXORA
|
| 69 |
</span>
|
| 70 |
-
<p className="text-gray-300 font-mono">
|
|
|
|
|
|
|
| 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 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
interface Card {
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
interface MovieCardProps {
|
| 42 |
-
|
| 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 |
-
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
}, [src]);
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
| 67 |
};
|
| 68 |
|
| 69 |
export const MovieCard: React.FC<MovieCardProps> = ({ title }) => {
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
};
|
| 132 |
};
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
| 217 |
};
|
|
|
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
}
|
|
|
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
}
|
|
|
|
| 237 |
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
};
|
|
|
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
| 258 |
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
? 'brightness(0.6)'
|
| 265 |
-
: 'brightness(1)';
|
| 266 |
-
}
|
| 267 |
-
}, [showModal]);
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 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 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 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 |
-
|
| 315 |
-
|
| 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 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
<div
|
| 360 |
-
|
| 361 |
-
|
| 362 |
>
|
| 363 |
-
|
| 364 |
-
{
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
/>
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
</div>
|
| 391 |
-
</div>
|
| 392 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
</div>
|
| 394 |
-
|
| 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
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
{progress ? (
|
| 98 |
-
<div className="text-center space-y-2">
|
| 99 |
-
<p className="text-sm"
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
Downloaded: {progress.downloaded} / {progress.total}
|
| 103 |
</p>
|
| 104 |
</div>
|
| 105 |
) : (
|
| 106 |
-
<p className="text-center text-sm">
|
|
|
|
|
|
|
| 107 |
)}
|
| 108 |
|
| 109 |
{!videoFetched && (
|
| 110 |
-
<div
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
//
|
| 62 |
-
const [showRatingOverlay, setShowRatingOverlay] = useState(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
useEffect(() => {
|
| 64 |
-
if (contentRatings.length > 0) {
|
| 65 |
setShowRatingOverlay(true);
|
| 66 |
-
const
|
| 67 |
setShowRatingOverlay(false);
|
| 68 |
}, 4000);
|
| 69 |
-
return () => clearTimeout(
|
| 70 |
-
} else {
|
| 71 |
-
setShowRatingOverlay(false);
|
| 72 |
}
|
| 73 |
-
}, [contentRatings]);
|
| 74 |
|
| 75 |
-
// Listen for fullscreen changes
|
| 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 |
-
|
| 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 |
-
|
| 197 |
-
|
| 198 |
-
const
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
const
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
};
|
| 206 |
|
| 207 |
if (!isOpen) return null;
|
| 208 |
|
| 209 |
return (
|
| 210 |
<div
|
| 211 |
-
className="fixed inset-0 z-
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
<div
|
| 232 |
-
className={`absolute inset-0 flex flex-col justify-between transition-opacity duration-300 z-
|
| 233 |
showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
| 234 |
}`}
|
|
|
|
| 235 |
>
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
{contentRatings.length > 0 && (
|
| 241 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 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
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
</button>
|
| 252 |
</div>
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
<div className="flex items-center justify-between mb-
|
| 258 |
-
<span className="text-white text-
|
| 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-
|
|
|
|
|
|
|
| 272 |
</div>
|
| 273 |
-
|
| 274 |
-
|
| 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="
|
| 282 |
) : (
|
| 283 |
-
<PlayIcon className="
|
| 284 |
)}
|
| 285 |
</button>
|
| 286 |
<button
|
| 287 |
onClick={toggleMute}
|
| 288 |
-
className="text-white hover:text-purple-300"
|
|
|
|
| 289 |
>
|
| 290 |
{isMuted ? (
|
| 291 |
-
<SpeakerXMarkIcon
|
|
|
|
|
|
|
|
|
|
| 292 |
) : (
|
| 293 |
-
<SpeakerWaveIcon className="w-
|
| 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
|
|
|
|
|
|
|
|
|
|
| 312 |
) : (
|
| 313 |
-
<ArrowsPointingInIcon
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
Movies
|
| 11 |
</Link>
|
| 12 |
-
<Link
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
TV Shows
|
| 14 |
</Link>
|
| 15 |
-
<Link
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
Home
|
| 25 |
</Link>
|
| 26 |
-
<Link
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
Movies
|
| 28 |
</Link>
|
| 29 |
-
<Link
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
TV Shows
|
| 31 |
</Link>
|
| 32 |
-
<Link
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
My List
|
| 34 |
</Link>
|
| 35 |
-
<button
|
|
|
|
|
|
|
|
|
|
| 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
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
| 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={'
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ?
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
</button>
|
| 34 |
)}
|
| 35 |
</div>
|
| 36 |
|
| 37 |
-
<div className="flex flex-wrap gap-4">
|
| 38 |
{visibleCharacters?.map((character) => (
|
| 39 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
)}
|
| 176 |
</div>
|
| 177 |
))}
|
| 178 |
</div>
|
| 179 |
{genresItems.length > 0 && (
|
| 180 |
-
<div
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
<button
|
| 284 |
-
|
| 285 |
-
|
|
|
|
| 286 |
>
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
| 288 |
</button>
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 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 |
-
|
|
|
|
| 373 |
>
|
| 374 |
{/* Episodes Count Bubble */}
|
| 375 |
{episodesCount !== null && (
|
| 376 |
-
<div
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
</div>
|
| 380 |
)}
|
| 381 |
{/* Skeleton Loader */}
|
| 382 |
{!imageLoaded && (
|
| 383 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 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
|
| 398 |
-
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
{card?.title || 'Loading...'}
|
| 401 |
</h3>
|
| 402 |
-
<div
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
| 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
|
| 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
|
| 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"
|
| 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 |
-
|
| 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
|
| 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
|
| 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
|
| 1245 |
-
version "3.3.
|
| 1246 |
-
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.
|
| 1247 |
-
integrity sha512-
|
| 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
|
| 1256 |
-
version "3.3.
|
| 1257 |
-
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.
|
| 1258 |
-
integrity sha512-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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
|
| 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"
|