diff --git a/package.json b/package.json index 4d9218c7..85a012fc 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "sudo-flix", - "version": "4.6.2", + "name": "movie-web", + "version": "4.6.5", "private": true, - "homepage": "https://sudo-flix.lol", + "homepage": "https://github.com/movie-web/movie-web", "scripts": { "dev": "vite", "build": "vite build", @@ -29,14 +29,13 @@ "@formkit/auto-animate": "^0.8.1", "@headlessui/react": "^1.7.17", "@ladjs/country-language": "^1.0.3", - "@movie-web/providers": "^2.2.3", + "@movie-web/providers": "^2.2.7", "@noble/hashes": "^1.3.3", "@plasmohq/messaging": "^0.6.1", "@react-spring/web": "^9.7.3", "@scure/bip39": "^1.2.2", "@sozialhelden/ietf-language-tags": "^5.4.2", "@types/node-forge": "^1.3.10", - "@vercel/analytics": "^1.2.2", "classnames": "^2.3.2", "core-js": "^3.34.0", "detect-browser": "^5.3.0", @@ -63,6 +62,7 @@ "react-i18next": "^14.0.0", "react-lazy-with-preload": "^2.2.1", "react-router-dom": "^6.21.1", + "react-lazy-load-image-component": "^1.6.0", "react-sticky-el": "^2.1.0", "react-turnstile": "^1.1.2", "react-use": "^17.4.2", @@ -91,6 +91,7 @@ "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "@types/react-stickynode": "^4.0.3", + "@types/react-lazy-load-image-component": "^1.6.3", "@types/react-transition-group": "^4.4.10", "@types/semver": "^7.5.6", "@typescript-eslint/eslint-plugin": "^6.15.0", @@ -126,7 +127,8 @@ "vite-plugin-package-version": "^1.1.0", "vite-plugin-pwa": "^0.17.4", "vite-plugin-static-copy": "^1.0.0", - "vitest": "^1.1.0" + "vitest": "^1.1.0", + "workbox-window": "^7.0.0" }, "pnpm": { "overrides": { diff --git a/src/backend/metadata/getmeta.ts b/src/backend/metadata/getmeta.ts index add1089e..3839d0f9 100644 --- a/src/backend/metadata/getmeta.ts +++ b/src/backend/metadata/getmeta.ts @@ -135,13 +135,15 @@ export async function getLegacyMetaFromId( throw err; } - let imdbId = data.external_ids.find((v) => v.provider === "imdb_latest") - ?.external_id; + let imdbId = data.external_ids.find( + (v) => v.provider === "imdb_latest", + )?.external_id; if (!imdbId) imdbId = data.external_ids.find((v) => v.provider === "imdb")?.external_id; - let tmdbId = data.external_ids.find((v) => v.provider === "tmdb_latest") - ?.external_id; + let tmdbId = data.external_ids.find( + (v) => v.provider === "tmdb_latest", + )?.external_id; if (!tmdbId) tmdbId = data.external_ids.find((v) => v.provider === "tmdb")?.external_id; diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts index 7f0ccfa4..ddd484ca 100644 --- a/src/backend/metadata/tmdb.ts +++ b/src/backend/metadata/tmdb.ts @@ -151,7 +151,7 @@ const headers = { Authorization: `Bearer ${apiKey}`, }; -async function get(url: string, params?: object): Promise { +export async function get(url: string, params?: object): Promise { if (!apiKey) throw new Error("TMDB API key not set"); const res = await proxiedFetch(encodeURI(url), { diff --git a/src/pages/TopFlix.tsx b/src/pages/TopFlix.tsx index c621e9ec..4121fe79 100644 --- a/src/pages/TopFlix.tsx +++ b/src/pages/TopFlix.tsx @@ -1,13 +1,65 @@ import classNames from "classnames"; -import { ReactNode, useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; // Import Link from react-router-dom +import React, { useEffect, useRef, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { LazyLoadImage } from "react-lazy-load-image-component"; +import { useNavigate } from "react-router-dom"; -import { ThiccContainer } from "@/components/layout/ThinContainer"; -import { Divider } from "@/components/utils/Divider"; -import { Heading1, Paragraph } from "@/components/utils/Text"; +import "react-lazy-load-image-component/src/effects/blur.css"; +import { ThinContainer } from "@/components/layout/ThinContainer"; +import { WideContainer } from "@/components/layout/WideContainer"; +import { HomeLayout } from "@/pages/layouts/HomeLayout"; +import { conf } from "@/setup/config"; -import { SubPageLayout } from "./layouts/SubPageLayout"; -import { PageTitle } from "./parts/util/PageTitle"; +import { get } from "../backend/metadata/tmdb"; +import { Icon, Icons } from "../components/Icon"; + +const pagesToFetch = 5; + +// Define the Media type +interface Media { + id: number; + poster_path: string; + title?: string; + name?: string; +} + +// Update the Movie and TVShow interfaces to extend the Media interface +interface Movie extends Media { + title: string; +} + +interface TVShow extends Media { + name: string; +} + +// Define the Genre type +interface Genre { + id: number; + name: string; +} + +// Define the Category type +interface Category { + name: string; + endpoint: string; +} + +// Define the categories +const categories: Category[] = [ + { + name: "Now Playing", + endpoint: "/movie/now_playing?language=en-US", + }, + { + name: "Popular", + endpoint: "/movie/popular?language=en-US", + }, + { + name: "Top Rated", + endpoint: "/movie/top_rated?language=en-US", + }, +]; export function Button(props: { className: string; @@ -30,297 +82,411 @@ export function Button(props: { ); } -function isShowOrMovie(tmdbFullId: string): "series" | "movie" | "unknown" { - if (tmdbFullId.includes("show-")) { - return "series"; - } - if (tmdbFullId.includes("movie-")) { - return "movie"; - } - return "unknown"; -} - -function directLinkToContent(tmdbFullId: string) { - if (isShowOrMovie(tmdbFullId) === "series") { - return `/media/tmdb-tv-${tmdbFullId.split("-")[1]}`; - } - if (isShowOrMovie(tmdbFullId) === "movie") { - return `/media/tmdb-movie-${tmdbFullId.split("-")[1]}`; - } - return null; -} - -function ConfigValue(props: { - name: string; - type: string; - id: string; - children?: ReactNode; -}) { - const navigate = useNavigate(); - const link = directLinkToContent(props.id); - return ( - <> -
-

- {link ? ( -

navigate(link)} - className="transition duration-200 hover:underline cursor-pointer" - > - {props.name} -

- ) : ( -

{props.name}

- )} -

-

{props.children}

-
-

- {props.type.charAt(0).toUpperCase() + props.type.slice(1)} -

- - - ); -} - -async function getRecentPlayedItems() { - const response = await fetch("https://backend.sudo-flix.lol/metrics"); - const text = await response.text(); - - const regex = - /mw_media_watch_count{tmdb_full_id="([^"]+)",provider_id="([^"]+)",title="([^"]+)",success="([^"]+)"} (\d+)/g; - let match; - const loop = true; - const items: { [key: string]: any } = {}; - - while (loop) { - match = regex.exec(text); - if (match === null) break; - - const [_, tmdbFullId, providerId, title, success, count] = match; - if (items[tmdbFullId]) { - items[tmdbFullId].count += parseInt(count, 10); - } else { - items[tmdbFullId] = { - tmdbFullId, - providerId, - title, - success: success === "true", - count: parseInt(count, 10), - }; - } - } - - if (Object.keys(items).length > 0) { - return Object.values(items); - } - throw new Error("RECENT_PLAYED_ITEMS not found"); -} - -async function getTotalViews() { - const response = await fetch("https://backend.sudo-flix.lol/metrics"); - const text = await response.text(); - - // Add up all mw_media_watch_count entries - const regex = /mw_media_watch_count{[^}]*} (\d+)/g; - let totalViews = 0; - let match = regex.exec(text); - - while (match !== null) { - totalViews += parseInt(match[1], 10); - match = regex.exec(text); - } - - if (totalViews > 0) { - return totalViews.toString(); - } - throw new Error("TOTAL_VIEWS not found"); -} - -function getProcessStartTime(): Promise { - return fetch("https://backend.sudo-flix.lol/metrics") - .then((response) => response.text()) - .then((text) => { - const regex = /process_start_time_seconds (\d+)/; - const match = text.match(regex); - - if (match) { - const parsedNum = parseInt(match[1], 10); - const date = new Date(parsedNum * 1000); - return date.toISOString(); - } - throw new Error("PROCESS_START_TIME_SECONDS not found"); - }); -} - -async function getTimeSinceProcessStart(): Promise { - const processStartTime = await getProcessStartTime(); - const currentTime = new Date(); - const timeDifference = - currentTime.getTime() - new Date(processStartTime).getTime(); - - const hours = Math.floor(timeDifference / (1000 * 60 * 60)); - const minutes = Math.floor((timeDifference % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((timeDifference % (1000 * 60)) / 1000); - const days = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); - - if (days > 0) { - if (days === 1) { - return `${days} day`; - } - return `${days} days`; - } - if (hours > 0) { - return `${hours} hours`; - } - if (minutes > 0) { - return `${minutes} minutes`; - } - return `${seconds} seconds`; -} - export function TopFlix() { - const [recentPlayedItems, setRecentPlayedItems] = useState([]); - const [totalViews, setTotalViews] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 10; - const maxItemsToShow = 100; // Maximum items to show - const maxPageCount = Math.ceil(maxItemsToShow / itemsPerPage); // Calculate max page count based on maxItemsToShow - const [timeSinceProcessStart, setTimeSinceProcessStart] = useState< - string | null - >(null); + const { t } = useTranslation(); + const [showBg] = useState(false); + const [genres, setGenres] = useState([]); + const [randomMovie, setRandomMovie] = useState(null); // Add this line + const [genreMovies, setGenreMovies] = useState<{ + [genreId: number]: Movie[]; + }>({}); + const [countdown, setCountdown] = useState(null); const navigate = useNavigate(); - useEffect(() => { - getRecentPlayedItems() - .then((items) => { - // Limit the items to the first 100 to ensure we don't exceed the max page count - const limitedItems = items - .slice(0, maxItemsToShow) - .filter( - (item, index, self) => - index === - self.findIndex((t2) => t2.tmdbFullId === item.tmdbFullId), - ); - - setRecentPlayedItems(limitedItems); - }) - .catch((error) => { - console.error("Error fetching recent played items:", error); - }); - getTotalViews() - .then((views) => { - setTotalViews(parseInt(views, 10).toLocaleString()); - }) - .catch((error) => { - console.error("Error fetching total views:", error); - }); - }, []); + // Add a new state variable for the category movies + const [categoryMovies, setCategoryMovies] = useState<{ + [categoryName: string]: Movie[]; + }>({}); useEffect(() => { - getTimeSinceProcessStart() - .then((time) => { - setTimeSinceProcessStart(time); - }) - .catch((error) => { - console.error("Error fetching time since process start:", error); - }); + const fetchMoviesForCategory = async (category: Category) => { + try { + const movies: any[] = []; + for (let page = 1; page <= pagesToFetch; page += 1) { + const data = await get(category.endpoint, { + api_key: conf().TMDB_READ_API_KEY, + language: "en-US", + page: page.toString(), + }); + + movies.push(...data.results); + } + setCategoryMovies((prevCategoryMovies) => ({ + ...prevCategoryMovies, + [category.name]: movies, + })); + } catch (error) { + console.error( + `Error fetching movies for category ${category.name}:`, + error, + ); + } + }; + categories.forEach(fetchMoviesForCategory); }, []); - function getItemsForCurrentPage() { - const sortedItems = recentPlayedItems.sort((a, b) => b.count - a.count); - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; + // Add a new state variable for the TV show genres + const [tvGenres, setTVGenres] = useState([]); - return sortedItems.slice(startIndex, endIndex).map((item, index) => ({ - ...item, - rank: startIndex + index + 1, - })); + // Add a new state variable for the TV shows + const [tvShowGenres, setTVShowGenres] = useState<{ + [genreId: number]: TVShow[]; + }>({}); + + // Fetch TV show genres + useEffect(() => { + const fetchTVGenres = async () => { + try { + const data = await get("/genre/tv/list", { + api_key: conf().TMDB_READ_API_KEY, + language: "en-US", + }); + + setTVGenres(data.genres); + } 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 tvShows: any[] = []; + for (let page = 1; page <= pagesToFetch; page += 1) { + const data = await get("/discover/tv", { + api_key: conf().TMDB_READ_API_KEY, + with_genres: genreId.toString(), + language: "en-US", + page: page.toString(), + }); + + tvShows.push(...data.results); + } + setTVShowGenres((prevTVShowGenres) => ({ + ...prevTVShowGenres, + [genreId]: tvShows, + })); + } catch (error) { + console.error(`Error fetching TV shows for genre ${genreId}:`, error); + } + }; + + tvGenres.forEach((genre) => fetchTVShowsForGenre(genre.id)); + }, [tvGenres]); + + // Move the hooks outside of the renderMovies function + const carouselRef = useRef(null); + const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); + const gradientRef = useRef(null); + + // Update the scrollCarousel function to use the new ref map + 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; + if (direction === "left") { + carousel.scrollBy({ left: -scrollAmount, behavior: "smooth" }); + } else { + carousel.scrollBy({ left: scrollAmount, behavior: "smooth" }); + } + } + } } - return ( - - - -
- Top flix - - The top 100 most-watched movies on sudo-flix.lol, sourced directly - from the most recent sudo-backend deployment. The backend is - redeployed frequently which may result in low numbers being shown - here. - -
-
-
-

- Server Lifetime: {timeSinceProcessStart} -

-
-
-

- Overall Views: {totalViews} -

-
-
-
- -
-
-
+ const [movieWidth, setMovieWidth] = useState( + window.innerWidth < 600 ? "150px" : "200px", + ); -
- - {getItemsForCurrentPage().map((item) => { - return ( - - {`${ - item.providerId.charAt(0).toUpperCase() + - item.providerId.slice(1) - }`}{" "} - - {`Views: `} - {parseInt(item.count, 10).toLocaleString()} - - ); - })} -
+ 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]); // Added movieWidth to the dependency array + + function renderMovies(medias: Media[], category: string, isTVShow = false) { + const categorySlug = category.toLowerCase().replace(/ /g, "-"); // Convert the category to a slug + const displayCategory = + category === "Now Playing" + ? "In Cinemas" + : category.includes("Movie") + ? `${category}s` + : isTVShow + ? `${category} Programmes` + : `${category} Movies`; + return ( +
+

+ {displayCategory} +

{ + carouselRefs.current[categorySlug] = el; + }} > - -
- {currentPage}/{maxPageCount} -
- + {medias.slice(0, 100).map((media) => ( + + +
+

+ {isTVShow ? media.name : media.title} +

+
+
+ ))}
- - +
+
+ + +
+ ); + } + + const handleRandomMovieClick = () => { + const allMovies = Object.values(genreMovies).flat(); // Flatten all movie arrays + const randomIndex = Math.floor(Math.random() * allMovies.length); + const selectedMovie = allMovies[randomIndex]; + setRandomMovie(selectedMovie); + + // Start a 5-second countdown + setCountdown(5); + + // Schedule navigation after 5 seconds + setTimeout(() => { + navigate(`/media/tmdb-movie-${selectedMovie.id}-${selectedMovie.title}`); + }, 5000); + }; + + // Fetch Movie genres + useEffect(() => { + const fetchGenres = async () => { + try { + const data = await get("/genre/movie/list", { + api_key: conf().TMDB_READ_API_KEY, + language: "en-US", + }); + + setGenres(data.genres); + } 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 <= pagesToFetch; page += 1) { + const data = await get("/discover/movie", { + api_key: conf().TMDB_READ_API_KEY, + with_genres: genreId.toString(), + language: "en-US", + page: page.toString(), + }); + + movies.push(...data.results); + } + setGenreMovies((prevGenreMovies) => ({ + ...prevGenreMovies, + [genreId]: movies, + })); + } catch (error) { + console.error(`Error fetching movies for genre ${genreId}:`, error); + } + }; + + genres.forEach((genre) => fetchMoviesForGenre(genre.id)); + }, [genres]); + 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 ( + +
+ + {t("global.name")} + + {/* Removed HeroPart component */} + +
+
+

Explore

+

+ Credits to{" "} + + TecEash1 + {" "} + and{" "} + + Aqua Rose + {" "} + for making this page possible. +

+
+
+
+
+ + <> +
+ +
+ {randomMovie && ( +
+

Now Playing {randomMovie.title}

+ {/* You can add additional details or play functionality here */} +
+ )} +
+ {categories.map((category) => ( +
+ {renderMovies( + categoryMovies[category.name] || [], + category.name, + )} +
+ ))} + {genres.map((genre) => ( +
+ {renderMovies(genreMovies[genre.id] || [], genre.name)} +
+ ))} + {tvGenres.map((genre) => ( +
+ {renderMovies(tvShowGenres[genre.id] || [], genre.name, true)} +
+ ))} +
+ +
+
); }