mirror of
https://github.com/sussy-code/smov.git
synced 2026-04-21 00:22:06 +00:00
Use media cards in discover
The code was written by me and with an anonymous author. (And Baddev for the initial discover page)
This commit is contained in:
parent
d62ec72c5f
commit
7a93410530
13 changed files with 961 additions and 587 deletions
|
|
@ -1,585 +0,0 @@
|
|||
// Based mfs only use only one 500 line file instead of ten 50 line files.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { get } from "@/backend/metadata/tmdb";
|
||||
import { ThiccContainer } from "@/components/layout/ThinContainer";
|
||||
import { Divider } from "@/components/utils/Divider";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { conf } from "@/setup/config";
|
||||
import {
|
||||
Category,
|
||||
Genre,
|
||||
Media,
|
||||
Movie,
|
||||
TVShow,
|
||||
categories,
|
||||
tvCategories,
|
||||
} from "@/utils/discover";
|
||||
|
||||
import { SubPageLayout } from "./layouts/SubPageLayout";
|
||||
import { Icon, Icons } from "../components/Icon";
|
||||
import { PageTitle } from "./parts/util/PageTitle";
|
||||
|
||||
export function Discover() {
|
||||
const { t } = useTranslation();
|
||||
const [genres, setGenres] = useState<Genre[]>([]);
|
||||
const [randomMovie, setRandomMovie] = useState<Movie | null>(null);
|
||||
const [genreMovies, setGenreMovies] = useState<{
|
||||
[genreId: number]: Movie[];
|
||||
}>({});
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const [categoryShows, setCategoryShows] = useState<{
|
||||
[categoryName: string]: Movie[];
|
||||
}>({});
|
||||
const [categoryMovies, setCategoryMovies] = useState<{
|
||||
[categoryName: string]: Movie[];
|
||||
}>({});
|
||||
const [tvGenres, setTVGenres] = useState<Genre[]>([]);
|
||||
const [tvShowGenres, setTVShowGenres] = useState<{
|
||||
[genreId: number]: TVShow[];
|
||||
}>({});
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
const gradientRef = useRef<HTMLDivElement>(null);
|
||||
const [countdownTimeout, setCountdownTimeout] =
|
||||
useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMoviesForCategory = async (category: Category) => {
|
||||
try {
|
||||
const data = await get<any>(category.endpoint, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: "en-US",
|
||||
});
|
||||
|
||||
// Shuffle the movies
|
||||
for (let i = data.results.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[data.results[i], data.results[j]] = [
|
||||
data.results[j],
|
||||
data.results[i],
|
||||
];
|
||||
}
|
||||
|
||||
setCategoryMovies((prevCategoryMovies) => ({
|
||||
...prevCategoryMovies,
|
||||
[category.name]: data.results,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error fetching movies for category ${category.name}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
categories.forEach(fetchMoviesForCategory);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchShowsForCategory = async (category: Category) => {
|
||||
try {
|
||||
const data = await get<any>(category.endpoint, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: "en-US",
|
||||
});
|
||||
|
||||
// Shuffle the TV shows
|
||||
for (let i = data.results.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[data.results[i], data.results[j]] = [
|
||||
data.results[j],
|
||||
data.results[i],
|
||||
];
|
||||
}
|
||||
|
||||
setCategoryShows((prevCategoryShows) => ({
|
||||
...prevCategoryShows,
|
||||
[category.name]: data.results,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error fetching movies for category ${category.name}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
tvCategories.forEach(fetchShowsForCategory);
|
||||
}, []);
|
||||
|
||||
// Fetch TV show genres
|
||||
useEffect(() => {
|
||||
const fetchTVGenres = async () => {
|
||||
try {
|
||||
const data = await get<any>("/genre/tv/list", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: "en-US",
|
||||
});
|
||||
|
||||
// Shuffle the array of genres
|
||||
for (let i = data.genres.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[data.genres[i], data.genres[j]] = [data.genres[j], data.genres[i]];
|
||||
}
|
||||
|
||||
// Fetch only the first 6 TV show genres
|
||||
setTVGenres(data.genres.slice(0, 6));
|
||||
} catch (error) {
|
||||
console.error("Error fetching TV show genres:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTVGenres();
|
||||
}, []);
|
||||
|
||||
// Fetch TV shows for each genre
|
||||
useEffect(() => {
|
||||
const fetchTVShowsForGenre = async (genreId: number) => {
|
||||
try {
|
||||
const data = await get<any>("/discover/tv", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
with_genres: genreId.toString(),
|
||||
language: "en-US",
|
||||
});
|
||||
|
||||
// Shuffle the TV shows
|
||||
for (let i = data.results.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[data.results[i], data.results[j]] = [
|
||||
data.results[j],
|
||||
data.results[i],
|
||||
];
|
||||
}
|
||||
|
||||
setTVShowGenres((prevTVShowGenres) => ({
|
||||
...prevTVShowGenres,
|
||||
[genreId]: data.results,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Error fetching TV shows for genre ${genreId}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
tvGenres.forEach((genre) => fetchTVShowsForGenre(genre.id));
|
||||
}, [tvGenres]);
|
||||
|
||||
// Fetch Movie genres
|
||||
useEffect(() => {
|
||||
const fetchGenres = async () => {
|
||||
try {
|
||||
const data = await get<any>("/genre/movie/list", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: "en-US",
|
||||
});
|
||||
|
||||
// Shuffle the array of genres
|
||||
for (let i = data.genres.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[data.genres[i], data.genres[j]] = [data.genres[j], data.genres[i]];
|
||||
}
|
||||
|
||||
// Fetch only the first 4 genres
|
||||
setGenres(data.genres.slice(0, 4));
|
||||
} catch (error) {
|
||||
console.error("Error fetching genres:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchGenres();
|
||||
}, []);
|
||||
|
||||
// Fetch movies for each genre
|
||||
useEffect(() => {
|
||||
const fetchMoviesForGenre = async (genreId: number) => {
|
||||
try {
|
||||
const movies: any[] = [];
|
||||
for (let page = 1; page <= 6; page += 1) {
|
||||
// Fetch only 6 pages
|
||||
const data = await get<any>("/discover/movie", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
with_genres: genreId.toString(),
|
||||
language: "en-US",
|
||||
page: page.toString(),
|
||||
});
|
||||
|
||||
movies.push(...data.results);
|
||||
}
|
||||
|
||||
// Shuffle the movies
|
||||
for (let i = movies.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[movies[i], movies[j]] = [movies[j], movies[i]];
|
||||
}
|
||||
|
||||
setGenreMovies((prevGenreMovies) => ({
|
||||
...prevGenreMovies,
|
||||
[genreId]: movies,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Error fetching movies for genre ${genreId}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
genres.forEach((genre) => fetchMoviesForGenre(genre.id));
|
||||
}, [genres]);
|
||||
|
||||
function scrollCarousel(categorySlug: string, direction: string) {
|
||||
const carousel = carouselRefs.current[categorySlug];
|
||||
if (carousel) {
|
||||
const movieElements = carousel.getElementsByTagName("a");
|
||||
if (movieElements.length > 0) {
|
||||
const movieWidth = movieElements[0].offsetWidth;
|
||||
const visibleMovies = Math.floor(carousel.offsetWidth / movieWidth);
|
||||
const scrollAmount = movieWidth * visibleMovies * 0.69; // Silly number :3
|
||||
|
||||
if (direction === "left") {
|
||||
carousel.scrollBy({ left: -scrollAmount, behavior: "smooth" });
|
||||
} else {
|
||||
carousel.scrollBy({ left: scrollAmount, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [movieWidth, setMovieWidth] = useState(
|
||||
window.innerWidth < 600 ? "150px" : "200px",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setMovieWidth(window.innerWidth < 600 ? "150px" : "200px");
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (carouselRef.current && gradientRef.current) {
|
||||
const carouselHeight = carouselRef.current.getBoundingClientRect().height;
|
||||
gradientRef.current.style.top = `${carouselHeight}px`;
|
||||
gradientRef.current.style.bottom = `${carouselHeight}px`;
|
||||
}
|
||||
}, [movieWidth]);
|
||||
|
||||
const browser = !!window.chrome; // detect chromium browser
|
||||
let isScrolling = false;
|
||||
|
||||
function handleWheel(e: React.WheelEvent, categorySlug: string) {
|
||||
if (isScrolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
isScrolling = true;
|
||||
const carousel = carouselRefs.current[categorySlug];
|
||||
if (carousel && !e.deltaX) {
|
||||
const movieElements = carousel.getElementsByTagName("a");
|
||||
if (movieElements.length > 0) {
|
||||
if (e.deltaY < 5) {
|
||||
scrollCarousel(categorySlug, "left");
|
||||
} else {
|
||||
scrollCarousel(categorySlug, "right");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
setTimeout(() => {
|
||||
isScrolling = false;
|
||||
}, 345); // disable scrolling after 345 milliseconds for chromium-based browsers
|
||||
} else {
|
||||
// immediately reset isScrolling for non-chromium browsers
|
||||
isScrolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const toggleHover = (isHovering: boolean) => setIsHovered(isHovering);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = isHovered ? "hidden" : "auto";
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
}, [isHovered]);
|
||||
|
||||
function renderMovies(medias: Media[], category: string, isTVShow = false) {
|
||||
const categorySlug = `${category.toLowerCase().replace(/ /g, "-")}${Math.random()}`; // Convert the category to a slug
|
||||
const displayCategory =
|
||||
category === "Now Playing"
|
||||
? "In Cinemas"
|
||||
: category.includes("Movie")
|
||||
? `${category}s`
|
||||
: isTVShow
|
||||
? `${category} Shows`
|
||||
: `${category} Movies`;
|
||||
|
||||
// https://tailwindcss.com/docs/border-style
|
||||
return (
|
||||
<div className="relative overflow-hidden mt-2">
|
||||
<h2 className="text-2xl cursor-default font-bold text-white sm:text-3xl md:text-2xl mx-auto pl-5">
|
||||
{displayCategory}
|
||||
</h2>
|
||||
<div
|
||||
id={`carousel-${categorySlug}`}
|
||||
className="flex whitespace-nowrap pt-4 overflow-auto scrollbar rounded-xl overflow-y-hidden"
|
||||
style={{
|
||||
scrollbarWidth: "thin",
|
||||
// scrollbarColor: `${bgColor} transparent`,
|
||||
scrollbarColor: "transparent transparent",
|
||||
}}
|
||||
ref={(el) => {
|
||||
carouselRefs.current[categorySlug] = el;
|
||||
}}
|
||||
onMouseEnter={() => toggleHover(true)}
|
||||
onMouseLeave={() => toggleHover(false)}
|
||||
onWheel={(e) => handleWheel(e, categorySlug)}
|
||||
>
|
||||
{medias
|
||||
.filter((media, index, self) => {
|
||||
return (
|
||||
index ===
|
||||
self.findIndex(
|
||||
(m) => m.id === media.id && m.title === media.title,
|
||||
)
|
||||
);
|
||||
})
|
||||
.slice(0, 20)
|
||||
.map((media) => (
|
||||
<a
|
||||
key={media.id}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/media/tmdb-${isTVShow ? "tv" : "movie"}-${media.id}-${
|
||||
isTVShow ? media.name : media.title
|
||||
}`,
|
||||
)
|
||||
}
|
||||
className="text-center relative mt-3 mx-[0.285em] mb-3 transition-transform hover:scale-105 duration-[0.45s]"
|
||||
style={{ flex: `0 0 ${movieWidth}` }} // Set a fixed width for each movie
|
||||
>
|
||||
<Flare.Base className="group cursor-pointer rounded-xl relative p-[0.65em] bg-background-main transition-colors duration-300 bg-transparent">
|
||||
<Flare.Light
|
||||
flareSize={300}
|
||||
cssColorVar="--colors-mediaCard-hoverAccent"
|
||||
backgroundClass="bg-mediaCard-hoverBackground duration-200"
|
||||
className="rounded-xl bg-background-main group-hover:opacity-100"
|
||||
/>
|
||||
<img
|
||||
src={
|
||||
media.poster_path
|
||||
? `https://image.tmdb.org/t/p/w500${media.poster_path}`
|
||||
: "/placeholder.png"
|
||||
}
|
||||
alt={media.poster_path ? "" : "failed to fetch :("}
|
||||
loading="lazy"
|
||||
className="rounded-xl relative"
|
||||
/>
|
||||
<h1 className="group relative pt-2 text-[13.5px] whitespace-normal duration-[0.35s] font-semibold text-white opacity-0 group-hover:opacity-100">
|
||||
{isTVShow
|
||||
? (media.name?.length ?? 0) > 32
|
||||
? `${media.name?.slice(0, 32)}...`
|
||||
: media.name
|
||||
: (media.title?.length ?? 0) > 32
|
||||
? `${media.title?.slice(0, 32)}...`
|
||||
: media.title}
|
||||
</h1>
|
||||
</Flare.Base>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
title="Back"
|
||||
className="absolute left-5 top-1/2 transform -translate-y-3/4 z-10"
|
||||
onClick={() => scrollCarousel(categorySlug, "left")}
|
||||
>
|
||||
<div className="cursor-pointer text-white flex justify-center items-center h-10 w-10 rounded-full bg-search-hoverBackground active:scale-110 transition-[transform,background-color] duration-200">
|
||||
<Icon icon={Icons.ARROW_LEFT} />
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Next"
|
||||
className="absolute right-5 top-1/2 transform -translate-y-3/4 z-10"
|
||||
onClick={() => scrollCarousel(categorySlug, "right")}
|
||||
>
|
||||
<div className="cursor-pointer text-white flex justify-center items-center h-10 w-10 rounded-full bg-search-hoverBackground active:scale-110 transition-[transform,background-color] duration-200">
|
||||
<Icon icon={Icons.ARROW_RIGHT} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRandomMovieClick = () => {
|
||||
const allMovies = Object.values(genreMovies).flat(); // Flatten all movie arrays
|
||||
const uniqueTitles = new Set<string>(); // Use a Set to store unique titles
|
||||
allMovies.forEach((movie) => uniqueTitles.add(movie.title)); // Add each title to the Set
|
||||
const uniqueTitlesArray = Array.from(uniqueTitles); // Convert the Set back to an array
|
||||
const randomIndex = Math.floor(Math.random() * uniqueTitlesArray.length);
|
||||
const selectedMovie = allMovies.find(
|
||||
(movie) => movie.title === uniqueTitlesArray[randomIndex],
|
||||
);
|
||||
|
||||
if (selectedMovie) {
|
||||
setRandomMovie(selectedMovie);
|
||||
|
||||
if (countdown !== null && countdown > 0) {
|
||||
// Clear the countdown
|
||||
setCountdown(null);
|
||||
if (countdownTimeout) {
|
||||
clearTimeout(countdownTimeout);
|
||||
setCountdownTimeout(null);
|
||||
setRandomMovie(null);
|
||||
}
|
||||
} else {
|
||||
setCountdown(5);
|
||||
|
||||
// Schedule navigation after 5 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
navigate(
|
||||
`/media/tmdb-movie-${selectedMovie.id}-${selectedMovie.title}`,
|
||||
);
|
||||
}, 5000);
|
||||
setCountdownTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval: NodeJS.Timeout;
|
||||
if (countdown !== null && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown((prevCountdown) =>
|
||||
prevCountdown !== null ? prevCountdown - 1 : prevCountdown,
|
||||
);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(countdownInterval);
|
||||
};
|
||||
}, [countdown]);
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<div className="mb-16 sm:mb-2">
|
||||
<Helmet>
|
||||
{/* Hide scrollbar lmao */}
|
||||
<style type="text/css">{`
|
||||
html, body {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
`}</style>
|
||||
</Helmet>
|
||||
<PageTitle subpage k="global.pages.discover" />
|
||||
<div className="mt-44 space-y-16 text-center">
|
||||
<div className="relative z-10 mb-16">
|
||||
<h1 className="text-4xl cursor-default font-bold text-white">
|
||||
{t("global.pages.discover")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ThiccContainer>
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center space-x-2 rounded-full px-4 text-white py-2 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover transition-[background,transform] duration-100 hover:scale-105"
|
||||
onClick={handleRandomMovieClick}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{countdown !== null && countdown > 0 ? (
|
||||
<div className="flex items-center inline-block">
|
||||
<span>Cancel Countdown</span>
|
||||
<Icon
|
||||
icon={Icons.X}
|
||||
className="text-2xl ml-[4.5px] mb-[-0.7px]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center inline-block">
|
||||
<span>Watch Something New</span>
|
||||
<img
|
||||
src="/lightbar-images/dice.svg"
|
||||
alt="Small Image"
|
||||
style={{
|
||||
marginLeft: "8px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{randomMovie && (
|
||||
<div className="mt-4 mb-4 text-center">
|
||||
<p>
|
||||
Now Playing <span className="font-bold">{randomMovie.title}</span>{" "}
|
||||
in {countdown}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.name}
|
||||
id={`carousel-${category.name.toLowerCase().replace(/ /g, "-")}`}
|
||||
className="mt-8"
|
||||
>
|
||||
{renderMovies(categoryMovies[category.name] || [], category.name)}
|
||||
</div>
|
||||
))}
|
||||
{genres.map((genre) => (
|
||||
<div
|
||||
key={`${genre.id}|${genre.name}`}
|
||||
id={`carousel-${genre.name.toLowerCase().replace(/ /g, "-")}`}
|
||||
className="mt-8"
|
||||
>
|
||||
{renderMovies(genreMovies[genre.id] || [], genre.name)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center">
|
||||
<Divider marginClass="mr-5" />
|
||||
<h1 className="text-4xl font-bold text-white mx-auto">Shows</h1>
|
||||
<Divider marginClass="ml-5" />
|
||||
</div>
|
||||
{tvCategories.map((category) => (
|
||||
<div
|
||||
key={category.name}
|
||||
id={`carousel-${category.name.toLowerCase().replace(/ /g, "-")}`}
|
||||
className="mt-8"
|
||||
>
|
||||
{renderMovies(
|
||||
categoryShows[category.name] || [],
|
||||
category.name,
|
||||
true,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{tvGenres.map((genre) => (
|
||||
<div
|
||||
key={`${genre.id}|${genre.name}`}
|
||||
id={`carousel-${genre.name.toLowerCase().replace(/ /g, "-")}`}
|
||||
className="mt-8"
|
||||
>
|
||||
{renderMovies(tvShowGenres[genre.id] || [], genre.name, true)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ThiccContainer>
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
83
src/pages/discover/Discover.tsx
Normal file
83
src/pages/discover/Discover.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import DiscoverContent from "@/pages/discover/discoverContent";
|
||||
|
||||
import { SubPageLayout } from "../layouts/SubPageLayout";
|
||||
import { PageTitle } from "../parts/util/PageTitle";
|
||||
|
||||
export function Discover() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Stupid method to "simulate" loading so the user understands this takes a white to load.
|
||||
// TO DO: Lazy load all the media cards 💀
|
||||
|
||||
// State to track whether content is loading or loaded
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Simulate loading media cards
|
||||
useEffect(() => {
|
||||
const simulateLoading = async () => {
|
||||
// Simulate a loading time with setTimeout or fetch data here
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 2000);
|
||||
}); // Simulate 2s loading time
|
||||
setLoading(false); // After loading, set loading to false
|
||||
};
|
||||
|
||||
simulateLoading();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<Helmet>
|
||||
{/* Hide scrollbar */}
|
||||
<style type="text/css">{`
|
||||
html, body {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
`}</style>
|
||||
</Helmet>
|
||||
|
||||
<PageTitle subpage k="global.pages.discover" />
|
||||
|
||||
<div className="relative w-full max-w-screen-xl mx-auto px-4 text-center mt-12 mb-12">
|
||||
<div
|
||||
className="absolute inset-0 mx-auto h-[400px] max-w-[800px] rounded-full blur-[100px] opacity-20 transform -translate-y-[100px] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
|
||||
}}
|
||||
/>
|
||||
<h1
|
||||
className="relative text-4xl md:text-5xl font-extrabold text-transparent bg-clip-text z-10"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
|
||||
}}
|
||||
>
|
||||
{t("global.pages.discover")} Movies & TV
|
||||
</h1>
|
||||
<p className="relative text-lg mt-4 text-gray-400 z-10">
|
||||
Explore the latest hits and timeless classics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Conditional rendering: show loading screen or the content */}
|
||||
{loading ? (
|
||||
<div className="flex flex-col justify-center items-center h-64 space-y-4">
|
||||
<Loading />
|
||||
<p className="text-lg font-medium text-gray-400 animate-pulse mt-4">
|
||||
Fetching the latest movies & TV shows...
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Please wait while we load the best recommendations for you.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DiscoverContent />
|
||||
)}
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
59
src/pages/discover/common.ts
Normal file
59
src/pages/discover/common.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/* Define shit here */
|
||||
|
||||
// Define the Media type
|
||||
export interface Media {
|
||||
id: number;
|
||||
poster_path: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
}
|
||||
|
||||
// Update the Movie and TVShow interfaces to extend the Media interface
|
||||
export interface Movie extends Media {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TVShow extends Media {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Define the Genre type
|
||||
export interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Define the Category type
|
||||
export interface Category {
|
||||
name: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
// Define the categories
|
||||
export const categories: Category[] = [
|
||||
{
|
||||
name: "Now Playing",
|
||||
endpoint: "/movie/now_playing?language=en-US",
|
||||
},
|
||||
{
|
||||
name: "Top Rated",
|
||||
endpoint: "/movie/top_rated?language=en-US",
|
||||
},
|
||||
{
|
||||
name: "Most Popular",
|
||||
endpoint: "/movie/popular?language=en-US",
|
||||
},
|
||||
];
|
||||
|
||||
export const tvCategories: Category[] = [
|
||||
{
|
||||
name: "Top Rated",
|
||||
endpoint: "/tv/top_rated?language=en-US",
|
||||
},
|
||||
{
|
||||
name: "Most Popular",
|
||||
endpoint: "/tv/popular?language=en-US",
|
||||
},
|
||||
];
|
||||
55
src/pages/discover/components/CarouselNavButtons.tsx
Normal file
55
src/pages/discover/components/CarouselNavButtons.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
interface CarouselNavButtonsProps {
|
||||
categorySlug: string;
|
||||
carouselRefs: React.MutableRefObject<{
|
||||
[key: string]: HTMLDivElement | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function CarouselNavButtons({
|
||||
categorySlug,
|
||||
carouselRefs,
|
||||
}: CarouselNavButtonsProps) {
|
||||
const handleScroll = (direction: "left" | "right") => {
|
||||
const carousel = carouselRefs.current[categorySlug];
|
||||
if (!carousel) return;
|
||||
|
||||
const movieElements = carousel.getElementsByTagName("a");
|
||||
if (movieElements.length === 0) return;
|
||||
|
||||
const movieWidth = movieElements[0].offsetWidth;
|
||||
const visibleMovies = Math.floor(carousel.offsetWidth / movieWidth);
|
||||
const scrollAmount = movieWidth * (visibleMovies > 5 ? 4 : 2);
|
||||
|
||||
carousel.scrollBy({
|
||||
left: direction === "left" ? -scrollAmount : scrollAmount,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-12 top-1/2 transform -translate-y-3/4 z-10"
|
||||
onClick={() => handleScroll("left")}
|
||||
>
|
||||
<Icon
|
||||
icon={Icons.CHEVRON_LEFT}
|
||||
className="cursor-pointer text-white flex justify-center items-center h-10 w-10 rounded-full bg-search-hoverBackground active:scale-110 transition-[transform,background-color] duration-200"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-12 top-1/2 transform -translate-y-3/4 z-10"
|
||||
onClick={() => handleScroll("right")}
|
||||
>
|
||||
<Icon
|
||||
icon={Icons.CHEVRON_RIGHT}
|
||||
className="cursor-pointer text-white flex justify-center items-center h-10 w-10 rounded-full bg-search-hoverBackground active:scale-110 transition-[transform,background-color] duration-200"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
src/pages/discover/components/CategoryButtons.tsx
Normal file
68
src/pages/discover/components/CategoryButtons.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
interface CategoryButtonsProps {
|
||||
categories: any[];
|
||||
onCategoryClick: (id: string, name: string) => void;
|
||||
categoryType: string;
|
||||
isMobile: boolean;
|
||||
showAlwaysScroll: boolean;
|
||||
}
|
||||
|
||||
export function CategoryButtons({
|
||||
categories,
|
||||
onCategoryClick,
|
||||
categoryType,
|
||||
isMobile,
|
||||
showAlwaysScroll,
|
||||
}: CategoryButtonsProps) {
|
||||
const renderScrollButton = (direction: "left" | "right") => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded-full px-4 text-white py-3"
|
||||
onClick={() => {
|
||||
const element = document.getElementById(
|
||||
`button-carousel-${categoryType}`,
|
||||
);
|
||||
if (element) {
|
||||
element.scrollBy({
|
||||
left: direction === "left" ? -200 : 200,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={direction === "left" ? Icons.CHEVRON_LEFT : Icons.CHEVRON_RIGHT}
|
||||
className="text-2xl rtl:-scale-x-100"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex mb-4 overflow-x-auto">
|
||||
{(showAlwaysScroll || isMobile) && renderScrollButton("left")}
|
||||
|
||||
<div
|
||||
id={`button-carousel-${categoryType}`}
|
||||
className="flex lg:px-4 mb-4 overflow-x-auto scroll-smooth"
|
||||
>
|
||||
<div className="flex space-x-2 py-1">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id || category.name}
|
||||
type="button"
|
||||
className="whitespace-nowrap flex items-center space-x-2 rounded-full px-4 text-white py-2 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover transition-[background,transform] duration-100 hover:scale-105"
|
||||
onClick={() => onCategoryClick(category.id, category.name)}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(showAlwaysScroll || isMobile) && renderScrollButton("right")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/pages/discover/components/MediaCarousel.tsx
Normal file
140
src/pages/discover/components/MediaCarousel.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { MediaCard } from "@/components/media/MediaCard";
|
||||
import { Media } from "@/pages/discover/common";
|
||||
|
||||
import { CarouselNavButtons } from "./CarouselNavButtons";
|
||||
|
||||
interface MediaCarouselProps {
|
||||
medias: Media[];
|
||||
category: string;
|
||||
isTVShow: boolean;
|
||||
movieWidth: string;
|
||||
isMobile: boolean;
|
||||
carouselRefs: React.MutableRefObject<{
|
||||
[key: string]: HTMLDivElement | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function MediaCarousel({
|
||||
medias,
|
||||
category,
|
||||
isTVShow,
|
||||
movieWidth,
|
||||
isMobile,
|
||||
carouselRefs,
|
||||
}: MediaCarouselProps) {
|
||||
const categorySlug = `${category.toLowerCase().replace(/ /g, "-")}`;
|
||||
const browser = !!window.chrome;
|
||||
let isScrolling = false;
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (isScrolling) return;
|
||||
isScrolling = true;
|
||||
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
setTimeout(() => {
|
||||
isScrolling = false;
|
||||
}, 345);
|
||||
} else {
|
||||
isScrolling = false;
|
||||
}
|
||||
};
|
||||
|
||||
function getDisplayCategory(
|
||||
categoryName: string,
|
||||
isTVShowCondition: boolean,
|
||||
): string {
|
||||
switch (categoryName) {
|
||||
case "Now Playing":
|
||||
return "In Cinemas";
|
||||
case categoryName.match(/^Popular (Movies|Shows) on .+/)?.input:
|
||||
return categoryName;
|
||||
default:
|
||||
return categoryName.endsWith("Movie")
|
||||
? `${categoryName}s`
|
||||
: isTVShowCondition
|
||||
? `${categoryName} Shows`
|
||||
: `${categoryName} Movies`;
|
||||
}
|
||||
}
|
||||
|
||||
const displayCategory = getDisplayCategory(category, isTVShow);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden carousel-container">
|
||||
<h2 className="ml-2 md:ml-8 mt-2 text-2xl cursor-default font-bold text-white md:text-2xl mx-auto pl-5 text-balance">
|
||||
{displayCategory}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
id={`carousel-${categorySlug}`}
|
||||
className="flex whitespace-nowrap pt-0 pb-4 overflow-auto scrollbar rounded-xl overflow-y-hidden"
|
||||
ref={(el) => {
|
||||
carouselRefs.current[categorySlug] = el;
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{medias
|
||||
.filter(
|
||||
(media, index, self) =>
|
||||
index ===
|
||||
self.findIndex(
|
||||
(m) => m.id === media.id && m.title === media.title,
|
||||
),
|
||||
)
|
||||
.slice(0, 25)
|
||||
.map((media, index, array) => (
|
||||
<div
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
key={media.id}
|
||||
className={`max-h-200 relative mt-3 transition-transform duration-[0.45s] hover:scale-105 ${
|
||||
index === 0
|
||||
? "md:ml-[6.5rem] mr-[0.2em] md:mr-[0.5em]"
|
||||
: index === array.length - 1
|
||||
? "md:mr-[6.5rem] ml-[0.2em] md:ml-[0.5em]"
|
||||
: "mx-[0.2em] md:mx-[0.5em]"
|
||||
} group cursor-pointer rounded-xl relative p-[0.65em] bg-background-main transition-colors duration-300 bg-transparent`}
|
||||
style={{
|
||||
flex: `0 0 ${movieWidth}`,
|
||||
userSelect: "none",
|
||||
aspectRatio: "2/3",
|
||||
width: movieWidth,
|
||||
height: "auto",
|
||||
}}
|
||||
>
|
||||
<MediaCard
|
||||
linkable
|
||||
key={media.id}
|
||||
media={{
|
||||
id: media.id.toString(),
|
||||
title: media.title || media.name || "",
|
||||
poster: `https://image.tmdb.org/t/p/w342${media.poster_path}`,
|
||||
type: isTVShow ? "show" : "movie",
|
||||
year: isTVShow
|
||||
? media.first_air_date
|
||||
? parseInt(media.first_air_date.split("-")[0], 10)
|
||||
: undefined
|
||||
: media.release_date
|
||||
? parseInt(media.release_date.split("-")[0], 10)
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<CarouselNavButtons
|
||||
categorySlug={categorySlug}
|
||||
carouselRefs={carouselRefs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/pages/discover/components/RandomMovieButton.tsx
Normal file
58
src/pages/discover/components/RandomMovieButton.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
interface RandomMovieButtonProps {
|
||||
countdown: number | null;
|
||||
onClick: () => void;
|
||||
randomMovieTitle: string | null;
|
||||
}
|
||||
|
||||
export function RandomMovieButton({
|
||||
countdown,
|
||||
onClick,
|
||||
randomMovieTitle,
|
||||
}: RandomMovieButtonProps) {
|
||||
return (
|
||||
<div className="w-full max-w-screen-xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center space-x-2 rounded-full px-4 text-white py-2 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover transition-[background,transform] duration-100 hover:scale-105"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{countdown !== null && countdown > 0 ? (
|
||||
<div className="flex items-center">
|
||||
<span>Cancel Countdown</span>
|
||||
<Icon
|
||||
icon={Icons.X}
|
||||
className="text-2xl ml-[4.5px] mb-[-0.7px]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<span>Watch Something Random</span>
|
||||
<img
|
||||
src="/lightbar-images/dice.svg"
|
||||
alt="Dice"
|
||||
style={{ marginLeft: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Random Movie Countdown */}
|
||||
{randomMovieTitle && countdown !== null && (
|
||||
<div className="mt-4 mb-4 text-center">
|
||||
<p>
|
||||
Now Playing <span className="font-bold">{randomMovieTitle}</span> in{" "}
|
||||
{countdown}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/pages/discover/components/ScrollToTopButton.tsx
Normal file
53
src/pages/discover/components/ScrollToTopButton.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
export function ScrollToTopButton() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const toggleVisibility = () => {
|
||||
const scrolled = window.scrollY > 300;
|
||||
setIsVisible(scrolled);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const timeout = setTimeout(toggleVisibility, 100);
|
||||
return () => clearTimeout(timeout);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50">
|
||||
<div
|
||||
className={`absolute inset-0 mx-auto h-[50px] w-[200px] rounded-full blur-[50px] opacity-50 pointer-events-none z-0 ${
|
||||
isVisible ? "opacity-100 visible" : "opacity-0 invisible"
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
|
||||
transition: "opacity 0.4s ease-in-out, transform 0.4s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className={`relative flex items-center justify-center space-x-2 rounded-full px-4 py-3 text-lg font-semibold text-white bg-pill-background bg-opacity-80 hover:bg-pill-backgroundHover transition-opacity hover:scale-105 duration-500 ease-in-out ${
|
||||
isVisible ? "opacity-100 visible" : "opacity-0 invisible"
|
||||
}`}
|
||||
style={{
|
||||
transition: "opacity 0.4s ease-in-out, transform 0.4s ease-in-out",
|
||||
}}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_UP} className="text-2xl z-10" />
|
||||
<span className="z-10">Back to top</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/pages/discover/discover.css
Normal file
44
src/pages/discover/discover.css
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
.carousel-container {
|
||||
position: relative;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, 0), /* Left edge */
|
||||
rgba(0, 0, 0, 1) 80px, /* visible after 80px */
|
||||
rgba(0, 0, 0, 1) calc(100% - 80px), /* invisible 80px from right */
|
||||
rgba(0, 0, 0, 0) 100% /* Right edge */
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, 0),
|
||||
rgba(0, 0, 0, 1) 80px,
|
||||
rgba(0, 0, 0, 1) calc(100% - 80px),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.carousel-container {
|
||||
mask-image: none;
|
||||
-webkit-mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: -ms-autohiding-scrollbar !important;
|
||||
}
|
||||
314
src/pages/discover/discoverContent.tsx
Normal file
314
src/pages/discover/discoverContent.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { get } from "@/backend/metadata/tmdb";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import {
|
||||
Genre,
|
||||
Movie,
|
||||
categories,
|
||||
tvCategories,
|
||||
} from "@/pages/discover/common";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
import "./discover.css";
|
||||
import { CategoryButtons } from "./components/CategoryButtons";
|
||||
import { MediaCarousel } from "./components/MediaCarousel";
|
||||
import { RandomMovieButton } from "./components/RandomMovieButton";
|
||||
import { ScrollToTopButton } from "./components/ScrollToTopButton";
|
||||
import { useTMDBData } from "./hooks/useTMDBData";
|
||||
|
||||
const MOVIE_PROVIDERS = [
|
||||
{ name: "Netflix", id: "8" },
|
||||
{ name: "Apple TV+", id: "2" },
|
||||
{ name: "Amazon Prime Video", id: "10" },
|
||||
{ name: "Hulu", id: "15" },
|
||||
{ name: "Max", id: "1899" },
|
||||
{ name: "Paramount Plus", id: "531" },
|
||||
{ name: "Disney Plus", id: "337" },
|
||||
{ name: "Shudder", id: "99" },
|
||||
];
|
||||
|
||||
const TV_PROVIDERS = [
|
||||
{ name: "Netflix", id: "8" },
|
||||
{ name: "Apple TV+", id: "350" },
|
||||
{ name: "Paramount Plus", id: "531" },
|
||||
{ name: "Hulu", id: "15" },
|
||||
{ name: "Max", id: "1899" },
|
||||
{ name: "Disney Plus", id: "337" },
|
||||
{ name: "fubuTV", id: "257" },
|
||||
];
|
||||
|
||||
export function DiscoverContent() {
|
||||
// State management
|
||||
const [selectedCategory, setSelectedCategory] = useState("movies");
|
||||
const [genres, setGenres] = useState<Genre[]>([]);
|
||||
const [tvGenres, setTVGenres] = useState<Genre[]>([]);
|
||||
const [randomMovie, setRandomMovie] = useState<Movie | null>(null);
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const [countdownTimeout, setCountdownTimeout] =
|
||||
useState<NodeJS.Timeout | null>(null);
|
||||
const [selectedProvider, setSelectedProvider] = useState({
|
||||
name: "",
|
||||
id: "",
|
||||
});
|
||||
const [movieWidth, setMovieWidth] = useState(
|
||||
window.innerWidth < 600 ? "150px" : "200px",
|
||||
);
|
||||
const [providerMovies, setProviderMovies] = useState<Movie[]>([]);
|
||||
const [providerTVShows, setProviderTVShows] = useState<any[]>([]);
|
||||
|
||||
// Refs
|
||||
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
|
||||
// Hooks
|
||||
const navigate = useNavigate();
|
||||
const { isMobile } = useIsMobile();
|
||||
const { genreMedia: genreMovies, categoryMedia: categoryMovies } =
|
||||
useTMDBData(genres, categories, "movie");
|
||||
const { genreMedia: genreTVShows, categoryMedia: categoryTVShows } =
|
||||
useTMDBData(tvGenres, tvCategories, "tv");
|
||||
|
||||
// Fetch TV show genres
|
||||
useEffect(() => {
|
||||
const fetchTVGenres = async () => {
|
||||
try {
|
||||
const data = await get<any>("/genre/tv/list", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: "en-US",
|
||||
});
|
||||
// Fetch only the first 10 TV show genres
|
||||
setTVGenres(data.genres.slice(0, 10));
|
||||
} catch (error) {
|
||||
console.error("Error fetching TV show genres:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTVGenres();
|
||||
}, []);
|
||||
|
||||
// Fetch Movie genres
|
||||
useEffect(() => {
|
||||
const fetchGenres = async () => {
|
||||
try {
|
||||
const data = await get<any>("/genre/movie/list", {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: "en-US",
|
||||
});
|
||||
|
||||
// Fetch only the first 12 genres
|
||||
setGenres(data.genres.slice(0, 12));
|
||||
} catch (error) {
|
||||
console.error("Error fetching genres:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchGenres();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setMovieWidth(window.innerWidth < 600 ? "150px" : "200px");
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval: NodeJS.Timeout;
|
||||
if (countdown !== null && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown((prev) => (prev !== null ? prev - 1 : prev));
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(countdownInterval);
|
||||
}, [countdown]);
|
||||
|
||||
// Handlers
|
||||
const handleCategoryChange = (
|
||||
eventOrValue: React.ChangeEvent<HTMLSelectElement> | string,
|
||||
) => {
|
||||
const value =
|
||||
typeof eventOrValue === "string"
|
||||
? eventOrValue
|
||||
: eventOrValue.target.value;
|
||||
setSelectedCategory(value);
|
||||
};
|
||||
|
||||
const handleRandomMovieClick = () => {
|
||||
const allMovies = Object.values(genreMovies).flat();
|
||||
const uniqueTitles = new Set(allMovies.map((movie) => movie.title));
|
||||
const uniqueTitlesArray = Array.from(uniqueTitles);
|
||||
const randomIndex = Math.floor(Math.random() * uniqueTitlesArray.length);
|
||||
const selectedMovie = allMovies.find(
|
||||
(movie) => movie.title === uniqueTitlesArray[randomIndex],
|
||||
);
|
||||
|
||||
if (selectedMovie) {
|
||||
if (countdown !== null && countdown > 0) {
|
||||
setCountdown(null);
|
||||
if (countdownTimeout) {
|
||||
clearTimeout(countdownTimeout);
|
||||
setCountdownTimeout(null);
|
||||
setRandomMovie(null);
|
||||
}
|
||||
} else {
|
||||
setRandomMovie(selectedMovie as Movie);
|
||||
setCountdown(5);
|
||||
const timeoutId = setTimeout(() => {
|
||||
navigate(`/media/tmdb-movie-${selectedMovie.id}-discover-random`);
|
||||
}, 5000);
|
||||
setCountdownTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderClick = async (id: string, name: string) => {
|
||||
try {
|
||||
setSelectedProvider({ name, id });
|
||||
const endpoint =
|
||||
selectedCategory === "movies" ? "/discover/movie" : "/discover/tv";
|
||||
const setData =
|
||||
selectedCategory === "movies" ? setProviderMovies : setProviderTVShows;
|
||||
const data = await get<any>(endpoint, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
with_watch_providers: id,
|
||||
watch_region: "US",
|
||||
language: "en-US",
|
||||
});
|
||||
setData(data.results);
|
||||
} catch (error) {
|
||||
console.error("Error fetching provider movies/shows:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Random Movie Button */}
|
||||
<RandomMovieButton
|
||||
countdown={countdown}
|
||||
onClick={handleRandomMovieClick}
|
||||
randomMovieTitle={randomMovie ? randomMovie.title : null}
|
||||
/>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className="mt-8 p-4 w-full max-w-screen-xl mx-auto">
|
||||
<div className="relative flex justify-center mb-4">
|
||||
<div className="flex space-x-4">
|
||||
{["movies", "tvshows"].map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
className={`text-2xl font-bold p-2 bg-transparent text-center rounded-full cursor-pointer flex items-center transition-transform duration-200 ${
|
||||
selectedCategory === category
|
||||
? "transform scale-105 text-type-link"
|
||||
: "text-type-secondary"
|
||||
}`}
|
||||
onClick={() => handleCategoryChange(category)}
|
||||
>
|
||||
{category === "movies" ? "Movies" : "TV Shows"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center overflow-x-auto">
|
||||
<CategoryButtons
|
||||
categories={
|
||||
selectedCategory === "movies" ? MOVIE_PROVIDERS : TV_PROVIDERS
|
||||
}
|
||||
onCategoryClick={handleProviderClick}
|
||||
categoryType="providers"
|
||||
isMobile={isMobile}
|
||||
showAlwaysScroll={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex overflow-x-auto">
|
||||
<CategoryButtons
|
||||
categories={
|
||||
selectedCategory === "movies"
|
||||
? [...categories, ...genres]
|
||||
: [...tvCategories, ...tvGenres]
|
||||
}
|
||||
onCategoryClick={(id, name) => {
|
||||
const element = document.getElementById(
|
||||
`carousel-${name.toLowerCase().replace(/ /g, "-")}`,
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
inline: "center",
|
||||
});
|
||||
}
|
||||
}}
|
||||
categoryType="movies"
|
||||
isMobile={isMobile}
|
||||
showAlwaysScroll
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content Section */}
|
||||
<div className="w-full md:w-[90%] max-w-[2400px] mx-auto">
|
||||
{(() => {
|
||||
const isMovieCategory = selectedCategory === "movies";
|
||||
const providerMedia = isMovieCategory
|
||||
? providerMovies
|
||||
: providerTVShows;
|
||||
const mediaGenres = isMovieCategory ? genres : tvGenres;
|
||||
const mediaCategories = isMovieCategory ? categories : tvCategories;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Media Carousels */}
|
||||
{providerMedia.length > 0 && (
|
||||
<MediaCarousel
|
||||
medias={providerMedia}
|
||||
category={selectedProvider.name}
|
||||
isTVShow={!isMovieCategory}
|
||||
movieWidth={movieWidth}
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
/>
|
||||
)}
|
||||
{/* Categories and Genres */}
|
||||
{mediaCategories.map((category) => (
|
||||
<MediaCarousel
|
||||
key={category.name}
|
||||
medias={
|
||||
isMovieCategory
|
||||
? categoryMovies[category.name] || []
|
||||
: categoryTVShows[category.name] || []
|
||||
}
|
||||
category={category.name}
|
||||
isTVShow={!isMovieCategory}
|
||||
movieWidth={movieWidth}
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
/>
|
||||
))}
|
||||
{mediaGenres.map((genre) => (
|
||||
<MediaCarousel
|
||||
key={genre.id}
|
||||
medias={
|
||||
isMovieCategory
|
||||
? genreMovies[genre.id] || []
|
||||
: genreTVShows[genre.id] || []
|
||||
}
|
||||
category={genre.name}
|
||||
isTVShow={!isMovieCategory}
|
||||
movieWidth={movieWidth}
|
||||
isMobile={isMobile}
|
||||
carouselRefs={carouselRefs}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<ScrollToTopButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DiscoverContent;
|
||||
85
src/pages/discover/hooks/useTMDBData.tsx
Normal file
85
src/pages/discover/hooks/useTMDBData.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { get } from "@/backend/metadata/tmdb";
|
||||
import { Category, Genre, Movie, TVShow } from "@/pages/discover/common";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
type MediaType = "movie" | "tv";
|
||||
|
||||
export function useTMDBData(
|
||||
genres: Genre[],
|
||||
categories: Category[],
|
||||
mediaType: MediaType,
|
||||
) {
|
||||
const [genreMedia, setGenreMedia] = useState<{
|
||||
[id: number]: Movie[] | TVShow[];
|
||||
}>({});
|
||||
const [categoryMedia, setCategoryMedia] = useState<{
|
||||
[categoryName: string]: Movie[] | TVShow[];
|
||||
}>({});
|
||||
|
||||
// Unified fetch function
|
||||
const fetchMedia = useCallback(
|
||||
async (endpoint: string, key: string, isGenre: boolean) => {
|
||||
try {
|
||||
const media: Movie[] | TVShow[] = [];
|
||||
for (let page = 1; page <= 6; page += 1) {
|
||||
const data = await get<any>(endpoint, {
|
||||
api_key: conf().TMDB_READ_API_KEY,
|
||||
language: "en-US",
|
||||
page: page.toString(),
|
||||
...(isGenre ? { with_genres: key } : {}),
|
||||
});
|
||||
media.push(...data.results);
|
||||
}
|
||||
|
||||
// Shuffle the media
|
||||
for (let i = media.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[media[i], media[j]] = [media[j], media[i]];
|
||||
}
|
||||
|
||||
return media;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error fetching ${mediaType} for ${isGenre ? "genre" : "category"} ${key}:`,
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[mediaType],
|
||||
);
|
||||
|
||||
// Fetch media for each genre
|
||||
useEffect(() => {
|
||||
const fetchMediaForGenres = async () => {
|
||||
const genrePromises = genres.map(async (genre) => {
|
||||
const media = await fetchMedia(
|
||||
`/discover/${mediaType}`,
|
||||
genre.id.toString(),
|
||||
true,
|
||||
);
|
||||
setGenreMedia((prev) => ({ ...prev, [genre.id]: media }));
|
||||
});
|
||||
await Promise.all(genrePromises);
|
||||
};
|
||||
|
||||
fetchMediaForGenres();
|
||||
}, [genres, mediaType, fetchMedia]);
|
||||
|
||||
// Fetch media for each category
|
||||
useEffect(() => {
|
||||
const fetchMediaForCategories = async () => {
|
||||
const categoryPromises = categories.map(async (category) => {
|
||||
const media = await fetchMedia(category.endpoint, category.name, false);
|
||||
setCategoryMedia((prev) => ({ ...prev, [category.name]: media }));
|
||||
});
|
||||
await Promise.all(categoryPromises);
|
||||
};
|
||||
|
||||
fetchMediaForCategories();
|
||||
}, [categories, mediaType, fetchMedia]);
|
||||
|
||||
return { genreMedia, categoryMedia };
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import { useOnlineListener } from "@/hooks/usePing";
|
|||
import { AboutPage } from "@/pages/About";
|
||||
import { AdminPage } from "@/pages/admin/AdminPage";
|
||||
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
||||
import { Discover } from "@/pages/Discover";
|
||||
import { Discover } from "@/pages/discover/Discover";
|
||||
import { DmcaPage, shouldHaveDmcaPage } from "@/pages/Dmca";
|
||||
import MaintenancePage from "@/pages/errors/MaintenancePage";
|
||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { useOnlineListener } from "@/hooks/usePing";
|
|||
import { AboutPage } from "@/pages/About";
|
||||
import { AdminPage } from "@/pages/admin/AdminPage";
|
||||
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
||||
import { Discover } from "@/pages/Discover";
|
||||
import { Discover } from "@/pages/discover/Discover";
|
||||
import { DmcaPage, shouldHaveDmcaPage } from "@/pages/Dmca";
|
||||
import MaintenancePage from "@/pages/errors/MaintenancePage";
|
||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||
|
|
|
|||
Loading…
Reference in a new issue