rewrite discover using a single hook

This commit is contained in:
Pas 2025-06-04 11:46:22 -06:00
parent b1e4b77b13
commit 185555a960
7 changed files with 1091 additions and 1692 deletions

File diff suppressed because it is too large Load diff

View file

@ -21,8 +21,11 @@ import { usePreferencesStore } from "@/stores/preferences";
import { scrapeIMDb } from "@/utils/imdbScraper";
import { getTmdbLanguageCode } from "@/utils/language";
import { EDITOR_PICKS_MOVIES, EDITOR_PICKS_TV_SHOWS } from "../discoverContent";
import { RandomMovieButton } from "./RandomMovieButton";
import {
EDITOR_PICKS_MOVIES,
EDITOR_PICKS_TV_SHOWS,
} from "../hooks/useDiscoverMedia";
export interface FeaturedMedia extends Partial<Movie & TVShow> {
children?: ReactNode;

View file

@ -1,169 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { get } from "@/backend/metadata/tmdb";
import { useIntersectionObserver } from "@/pages/discover/hooks/useIntersectionObserver";
import { useLazyTMDBData } from "@/pages/discover/hooks/useTMDBData";
import { MediaItem } from "@/utils/mediaTypes";
import { MediaCarousel } from "./MediaCarousel";
import {
Category,
Media,
Movie,
TVShow,
categories,
tvCategories,
} from "../common";
interface LazyMediaCarouselProps {
medias?: Media[];
category?: string;
isTVShow: boolean;
carouselRefs: React.MutableRefObject<{
[key: string]: HTMLDivElement | null;
}>;
onShowDetails?: (media: MediaItem) => void;
genreId?: number;
moreContent?: boolean;
moreLink?: string;
relatedButtons?: Array<{ name: string; id: string }>;
onButtonClick?: (id: string, name: string) => void;
recommendationSources?: Array<{ id: string; title: string }>;
selectedRecommendationSource?: string;
onRecommendationSourceChange?: (id: string) => void;
preloadedMedia?: Movie[] | TVShow[];
title?: string;
}
export function LazyMediaCarousel({
medias: propMedias,
category,
isTVShow,
carouselRefs,
onShowDetails,
genreId,
moreContent,
moreLink,
relatedButtons,
onButtonClick,
recommendationSources,
selectedRecommendationSource,
onRecommendationSourceChange,
preloadedMedia,
title,
}: LazyMediaCarouselProps) {
const [medias, setMedias] = useState<Media[]>(propMedias || []);
const [loading, setLoading] = useState(!preloadedMedia && !propMedias);
const hasLoaded = useRef(false);
const categoryData = (isTVShow ? tvCategories : categories).find(
(c: Category) => c.name === (category || title || ""),
);
// Use intersection observer to detect when this component is visible
const { targetRef, isIntersecting } = useIntersectionObserver(
{ rootMargin: "200px" }, // Load when within 200px of viewport
);
// Use the lazy loading hook only if we don't have preloaded media or prop medias
// and haven't loaded yet
const { media } = useLazyTMDBData(
null, // We don't use genre anymore since we're using category directly
!preloadedMedia && !propMedias && !hasLoaded.current
? categoryData || null
: null,
isTVShow ? "tv" : "movie",
isIntersecting && !hasLoaded.current,
);
// Update medias when data is loaded or preloaded
useEffect(() => {
if (preloadedMedia) {
setMedias(preloadedMedia);
setLoading(false);
hasLoaded.current = true;
} else if (propMedias) {
setMedias(propMedias);
setLoading(false);
hasLoaded.current = true;
} else if (media.length > 0) {
setMedias(media);
setLoading(false);
hasLoaded.current = true;
}
}, [media, preloadedMedia, propMedias]);
// Only fetch category content if we don't have preloaded media or prop medias
// and haven't loaded yet
useEffect(() => {
if (preloadedMedia || propMedias || !categoryData || hasLoaded.current)
return;
const fetchContent = async () => {
try {
const data = await get<any>(categoryData.endpoint, {
api_key: process.env.TMDB_READ_API_KEY,
language: "en-US",
});
setMedias(data.results);
hasLoaded.current = true;
} catch (error) {
console.error("Error fetching content:", error);
} finally {
setLoading(false);
}
};
fetchContent();
}, [categoryData, preloadedMedia, propMedias]);
const categoryName = category || title || "";
const categorySlug = `${categoryName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
if (loading) {
return (
<div className="flex items-center justify-between ml-2 md:ml-8 mt-2">
<div className="flex gap-4 items-center">
<h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance">
{categoryName}
</h2>
</div>
</div>
);
}
return (
<div ref={targetRef as React.RefObject<HTMLDivElement>}>
{isIntersecting ? (
<MediaCarousel
medias={medias}
category={categoryName}
isTVShow={isTVShow}
carouselRefs={carouselRefs}
onShowDetails={onShowDetails}
genreId={genreId}
relatedButtons={relatedButtons}
onButtonClick={onButtonClick}
moreContent={moreContent}
moreLink={moreLink}
recommendationSources={recommendationSources}
selectedRecommendationSource={selectedRecommendationSource}
onRecommendationSourceChange={onRecommendationSourceChange}
/>
) : (
<div className="relative overflow-hidden carousel-container">
<div id={`carousel-${categorySlug}`}>
<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">
{categoryName} {isTVShow ? "Shows" : "Movies"}
</h2>
<div className="flex whitespace-nowrap pt-0 pb-4 overflow-auto scrollbar rounded-xl overflow-y-hidden h-[300px] animate-pulse bg-background-secondary/20">
<div className="w-full text-center flex items-center justify-center">
Loading...
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -1,5 +1,5 @@
import { Listbox } from "@headlessui/react";
import React from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useWindowSize } from "react-use";
@ -9,29 +9,47 @@ import { Icon, Icons } from "@/components/Icon";
import { MediaCard } from "@/components/media/MediaCard";
import { Flare } from "@/components/utils/Flare";
import { useIsMobile } from "@/hooks/useIsMobile";
import { Media } from "@/pages/discover/common";
import {
DiscoverContentType,
MediaType,
useDiscoverMedia,
useDiscoverOptions,
} from "@/pages/discover/hooks/useDiscoverMedia";
import { useIntersectionObserver } from "@/pages/discover/hooks/useIntersectionObserver";
import { useDiscoverStore } from "@/stores/discover";
import { useProgressStore } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes";
import { MOVIE_PROVIDERS, TV_PROVIDERS } from "../discoverContent";
import { CarouselNavButtons } from "./CarouselNavButtons";
interface ContentConfig {
/** Primary content type to fetch */
type: DiscoverContentType;
/** Fallback content type if primary fails */
fallback?: DiscoverContentType;
}
interface MediaCarouselProps {
medias: Media[];
category: string;
/** Content configuration for the carousel */
content: ContentConfig;
/** Whether this is a TV show carousel */
isTVShow: boolean;
/** Refs for carousel navigation */
carouselRefs: React.MutableRefObject<{
[key: string]: HTMLDivElement | null;
}>;
/** Callback when media details should be shown */
onShowDetails?: (media: MediaItem) => void;
genreId?: number;
/** Whether to show more content button/link */
moreContent?: boolean;
/** Custom more content link */
moreLink?: string;
relatedButtons?: Array<{ name: string; id: string }>;
onButtonClick?: (id: string, name: string) => void;
recommendationSources?: Array<{ id: string; title: string }>;
selectedRecommendationSource?: string;
onRecommendationSourceChange?: (id: string) => void;
/** Whether to show provider selection */
showProviders?: boolean;
/** Whether to show genre selection */
showGenres?: boolean;
/** Whether to show recommendations */
showRecommendations?: boolean;
}
function MediaCardSkeleton() {
@ -76,30 +94,167 @@ function MoreCard({ link }: { link: string }) {
}
export function MediaCarousel({
medias,
category,
content,
isTVShow,
carouselRefs,
onShowDetails,
genreId,
moreContent,
moreLink,
relatedButtons,
onButtonClick,
recommendationSources,
selectedRecommendationSource,
onRecommendationSourceChange,
showProviders = false,
showGenres = false,
showRecommendations = false,
}: MediaCarouselProps) {
const { t } = useTranslation();
const { width: windowWidth } = useWindowSize();
const { setLastView } = useDiscoverStore();
const { isMobile } = useIsMobile();
const browser = !!window.chrome;
// State for selected options
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
const [selectedProviderName, setSelectedProviderName] = useState<string>("");
const [selectedGenreId, setSelectedGenreId] = useState<string>("");
const [selectedGenreName, setSelectedGenreName] = useState<string>("");
const [selectedRecommendationId, setSelectedRecommendationId] =
useState<string>("");
const [selectedRecommendationTitle, setSelectedRecommendationTitle] =
useState<string>("");
const [selectedGenre, setSelectedGenre] = React.useState<OptionItem | null>(
null,
);
const categorySlug = `${category.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
const browser = !!window.chrome;
const { isMobile } = useIsMobile();
// Get available providers and genres
const mediaType: MediaType = isTVShow ? "tv" : "movie";
const { providers, genres } = useDiscoverOptions(mediaType);
// Get progress items for recommendations
const progressItems = useProgressStore((state) => state.items);
const recommendationSources = Object.entries(progressItems || {})
.filter(([_, item]) => item.type === (isTVShow ? "show" : "movie"))
.map(([id, item]) => ({
id,
title: item.title || "",
}));
// Set up intersection observer for lazy loading
const { targetRef, isIntersecting } = useIntersectionObserver({
rootMargin: "200px",
});
// Handle provider/genre selection
const handleProviderChange = (id: string, name: string) => {
setSelectedProviderId(id);
setSelectedProviderName(name);
};
const handleGenreChange = (id: string, name: string) => {
setSelectedGenreId(id);
setSelectedGenreName(name);
};
// Get related buttons based on type
const relatedButtons = showProviders
? providers.map((p) => ({ id: p.id, name: p.name }))
: showGenres
? genres.map((g) => ({ id: g.id.toString(), name: g.name }))
: undefined;
// Set initial provider/genre selection
useEffect(() => {
if (showProviders && providers.length > 0 && !selectedProviderId) {
handleProviderChange(providers[0].id, providers[0].name);
}
if (showGenres && genres.length > 0 && !selectedGenreId) {
handleGenreChange(genres[0].id.toString(), genres[0].name);
}
}, [
showProviders,
showGenres,
providers,
genres,
selectedProviderId,
selectedGenreId,
]);
// Get the appropriate button click handler
const onButtonClick = showProviders
? handleProviderChange
: showGenres
? handleGenreChange
: undefined;
// Split buttons into visible and dropdown based on window width
const { visibleButtons, dropdownButtons } = React.useMemo(() => {
if (!relatedButtons) return { visibleButtons: [], dropdownButtons: [] };
const visible = windowWidth > 850 ? relatedButtons.slice(0, 5) : [];
const dropdown =
windowWidth > 850 ? relatedButtons.slice(5) : relatedButtons;
return { visibleButtons: visible, dropdownButtons: dropdown };
}, [relatedButtons, windowWidth]);
// Determine content type and ID based on selection
const contentType =
showProviders && selectedProviderId
? "provider"
: showGenres && selectedGenreId
? "genre"
: showRecommendations && selectedRecommendationId
? "recommendations"
: content.type;
// Fetch media using our hook
const { media, sectionTitle } = useDiscoverMedia({
contentType,
mediaType,
id: selectedProviderId || selectedGenreId || selectedRecommendationId,
fallbackType: content.fallback,
genreName: selectedGenreName,
providerName: selectedProviderName,
mediaTitle: selectedRecommendationTitle,
});
// Find active button
const activeButton = relatedButtons?.find(
(btn) =>
btn.name === selectedGenre?.name ||
btn.name === sectionTitle.split(" on ")[1],
);
// Convert buttons to dropdown options
const dropdownOptions: OptionItem[] = dropdownButtons.map((button) => ({
id: button.id,
name: button.name,
}));
// Set selected genre if active button is in dropdown
React.useEffect(() => {
if (
activeButton &&
!visibleButtons.find((btn) => btn.id === activeButton.id)
) {
setSelectedGenre({ id: activeButton.id, name: activeButton.name });
}
}, [activeButton, visibleButtons]);
// Set initial recommendation source
useEffect(() => {
if (
showRecommendations &&
recommendationSources.length > 0 &&
!selectedRecommendationId
) {
const randomSource =
recommendationSources[
Math.floor(Math.random() * recommendationSources.length)
];
setSelectedRecommendationId(randomSource.id);
setSelectedRecommendationTitle(randomSource.title);
}
}, [showRecommendations, recommendationSources, selectedRecommendationId]);
const categorySlug = `${sectionTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
let isScrolling = false;
const handleWheel = (e: React.WheelEvent) => {
@ -120,120 +275,6 @@ export function MediaCarousel({
}
};
function getDisplayCategory(
categoryName: string,
isTVShowCondition: boolean,
): string {
// Handle provider-specific categories
const providerMatch = categoryName.match(
/^Popular (Movies|Shows) on (.+)$/,
);
if (providerMatch) {
const type = providerMatch[1].toLowerCase();
const provider = providerMatch[2];
return t("discover.carousel.title.popularOn", {
type:
type === "movies" ? t("media.types.movie") : t("media.types.show"),
provider,
});
}
// Handle special categories
const specialCategories: { [key: string]: string } = {
"Now Playing": "inCinemas",
"Editor Picks": isTVShowCondition
? "editorPicksShows"
: "editorPicksMovies",
"Latest Releases": "latestReleases",
"4K Releases": "4kReleases",
"Top Rated": "topRated",
"Most Popular": "popular",
"On The Air": "onTheAir",
};
if (specialCategories[categoryName]) {
return t(`discover.carousel.title.${specialCategories[categoryName]}`);
}
// Handle provider categories
if (
categoryName.includes("Movies on") ||
categoryName.includes("Shows on")
) {
const providerName = categoryName.split(" on ")[1];
const providers = isTVShowCondition ? TV_PROVIDERS : MOVIE_PROVIDERS;
const provider = providers.find(
(p) => p.name.toLowerCase() === providerName.toLowerCase(),
);
if (provider) {
return isTVShowCondition
? t("discover.carousel.title.tvshowsOn", { provider: provider.name })
: t("discover.carousel.title.moviesOn", { provider: provider.name });
}
// If provider not found, fall back to using the raw provider name
return isTVShowCondition
? t("discover.carousel.title.tvshowsOn", { provider: providerName })
: t("discover.carousel.title.moviesOn", { provider: providerName });
}
// Handle recommendations
if (categoryName.includes("Because You Watched")) {
return t("discover.carousel.title.recommended", {
title: categoryName.split("Because You Watched:")[1],
});
}
// Handle generic categories
return isTVShowCondition
? t("discover.carousel.title.tvshows", { category: categoryName })
: t("discover.carousel.title.movies", { category: categoryName });
}
const displayCategory = getDisplayCategory(category, isTVShow);
const filteredMedias = medias
.filter(
(media, index, self) =>
index ===
self.findIndex((m) => m.id === media.id && m.title === media.title),
)
.slice(0, 20);
const SKELETON_COUNT = 10;
const { visibleButtons, dropdownButtons } = React.useMemo(() => {
if (!relatedButtons) return { visibleButtons: [], dropdownButtons: [] };
const visible =
windowWidth > 850
? relatedButtons.slice(0, 5)
: relatedButtons.slice(0, 0);
const dropdown =
windowWidth > 850 ? relatedButtons.slice(5) : relatedButtons.slice(0);
return { visibleButtons: visible, dropdownButtons: dropdown };
}, [relatedButtons, windowWidth]);
const activeButton = relatedButtons?.find(
(btn) => btn.name === category.split(" on ")[1] || btn.name === category,
);
const dropdownOptions: OptionItem[] = dropdownButtons.map((button) => ({
id: button.id,
name: button.name,
}));
React.useEffect(() => {
if (
activeButton &&
!visibleButtons.find((btn) => btn.id === activeButton.id)
) {
setSelectedGenre({ id: activeButton.id, name: activeButton.name });
}
}, [activeButton, visibleButtons]);
const handleMoreClick = () => {
setLastView({
url: window.location.pathname,
@ -241,28 +282,60 @@ export function MediaCarousel({
});
};
// Generate more link
const generatedMoreLink =
moreLink ||
(() => {
const baseLink = `/discover/more`;
if (showProviders && selectedProviderId) {
return `${baseLink}/provider/${selectedProviderId}/${mediaType}`;
}
if (showGenres && selectedGenreId) {
return `${baseLink}/genre/${selectedGenreId}/${mediaType}`;
}
if (showRecommendations && selectedRecommendationId) {
return `${baseLink}/recommendations/${selectedRecommendationId}/${mediaType}`;
}
return `${baseLink}/${content.type}/${mediaType}`;
})();
// Loading state
if (!isIntersecting) {
return (
<div ref={targetRef as React.RefObject<HTMLDivElement>}>
<div className="flex items-center justify-between ml-2 md:ml-8 mt-2">
<div className="flex gap-4 items-center">
<h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance">
{sectionTitle}
</h2>
</div>
</div>
</div>
);
}
return (
<>
<div ref={targetRef as React.RefObject<HTMLDivElement>}>
<div className="flex items-center justify-between ml-2 md:ml-8 mt-2">
<div className="flex flex-col">
<div className="flex items-center gap-4">
<h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance">
{displayCategory}
{sectionTitle}
</h2>
{recommendationSources &&
recommendationSources.length > 0 &&
onRecommendationSourceChange && (
{showRecommendations &&
recommendationSources &&
recommendationSources.length > 0 && (
<div className="relative pr-4">
<Dropdown
selectedItem={
recommendationSources.find(
(s) => s.id === selectedRecommendationSource,
(s) => s.id === selectedRecommendationId,
)
? {
id: selectedRecommendationSource || "",
id: selectedRecommendationId || "",
name:
recommendationSources.find(
(s) => s.id === selectedRecommendationSource,
(s) => s.id === selectedRecommendationId,
)?.title || "",
}
: {
@ -270,9 +343,15 @@ export function MediaCarousel({
name: recommendationSources[0]?.title || "",
}
}
setSelectedItem={(item) =>
onRecommendationSourceChange(item.id)
}
setSelectedItem={(item) => {
const source = recommendationSources.find(
(s) => s.id === item.id,
);
if (source) {
setSelectedRecommendationId(item.id);
setSelectedRecommendationTitle(source.title);
}
}}
options={recommendationSources.map((source) => ({
id: source.id,
name: source.title,
@ -329,10 +408,7 @@ export function MediaCarousel({
</div>
{moreContent && (
<Link
to={
moreLink ||
`/discover/more/${categorySlug}${genreId ? `/${genreId}` : ""}`
}
to={generatedMoreLink}
onClick={handleMoreClick}
className="flex px-5 items-center hover:text-type-link transition-colors"
>
@ -348,7 +424,11 @@ export function MediaCarousel({
type="button"
key={button.id}
onClick={() => onButtonClick?.(button.id, button.name)}
className="px-3 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors whitespace-nowrap flex-shrink-0"
className={`px-3 py-1 text-sm rounded-full hover:bg-mediaCard-background transition-colors whitespace-nowrap flex-shrink-0 ${
button.id === (selectedProviderId || selectedGenreId)
? "bg-mediaCard-background"
: "bg-mediaCard-hoverBackground"
}`}
>
{button.name}
</button>
@ -411,48 +491,43 @@ export function MediaCarousel({
>
<div className="md:w-12" />
{filteredMedias.length > 0
? filteredMedias.map((media) => (
{media.length > 0
? media.map((item) => (
<div
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
key={media.id}
key={item.id}
className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
>
<MediaCard
linkable
key={media.id}
key={item.id}
media={{
id: media.id.toString(),
title: media.title || media.name || "",
poster: `https://image.tmdb.org/t/p/w342${media.poster_path}`,
id: item.id.toString(),
title: item.title || item.name || "",
poster: `https://image.tmdb.org/t/p/w342${item.poster_path}`,
type: isTVShow ? "show" : "movie",
year: isTVShow
? media.first_air_date
? parseInt(media.first_air_date.split("-")[0], 10)
? item.first_air_date
? parseInt(item.first_air_date.split("-")[0], 10)
: undefined
: media.release_date
? parseInt(media.release_date.split("-")[0], 10)
: item.release_date
? parseInt(item.release_date.split("-")[0], 10)
: undefined,
}}
onShowDetails={onShowDetails}
/>
</div>
))
: Array.from({ length: SKELETON_COUNT }).map(() => (
: Array.from({ length: 10 }).map(() => (
<MediaCardSkeleton
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
/>
))}
{moreContent && (
<MoreCard
link={
moreLink ||
`/discover/more/${categorySlug}${genreId ? `/${genreId}` : ""}`
}
/>
{moreContent && generatedMoreLink && (
<MoreCard link={generatedMoreLink} />
)}
<div className="md:w-12" />
@ -465,6 +540,6 @@ export function MediaCarousel({
/>
)}
</div>
</>
</div>
);
}

View file

@ -1,183 +1,23 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRef, useState } from "react";
import { get } from "@/backend/metadata/tmdb";
import {
getLatest4KReleases,
getLatestReleases,
} from "@/backend/metadata/traktApi";
import { WideContainer } from "@/components/layout/WideContainer";
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
import { useModal } from "@/components/overlays/Modal";
import {
Genre,
Movie,
TVShow,
categories,
tvCategories,
} from "@/pages/discover/common";
import { conf } from "@/setup/config";
import { useDiscoverStore } from "@/stores/discover";
import { useLanguageStore } from "@/stores/language";
import { ProgressMediaItem, useProgressStore } from "@/stores/progress";
import { getTmdbLanguageCode } from "@/utils/language";
import { MediaItem } from "@/utils/mediaTypes";
import { DiscoverNavigation } from "./components/DiscoverNavigation";
import type { FeaturedMedia } from "./components/FeaturedCarousel";
import { LazyMediaCarousel } from "./components/LazyMediaCarousel";
import { LazyTabContent } from "./components/LazyTabContent";
import { MediaCarousel } from "./components/MediaCarousel";
import { ScrollToTopButton } from "./components/ScrollToTopButton";
// Provider constants moved from DiscoverNavigation
export const MOVIE_PROVIDERS = [
{ name: "Netflix", id: "8" },
{ name: "Apple TV+", id: "2" },
{ name: "Amazon Prime Video", id: "10" },
{ name: "Hulu", id: "15" },
{ name: "Disney Plus", id: "337" },
{ name: "Max", id: "1899" },
{ name: "Paramount Plus", id: "531" },
{ name: "Shudder", id: "99" },
{ name: "Crunchyroll", id: "283" },
{ name: "fuboTV", id: "257" },
{ name: "AMC+", id: "526" },
{ name: "Starz", id: "43" },
{ name: "PBS", id: "209" },
{ name: "Lifetime", id: "157" },
{ name: "National Geographic", id: "1964" },
];
export const TV_PROVIDERS = [
{ name: "Netflix", id: "8" },
{ name: "Apple TV+", id: "350" },
{ name: "Amazon Prime Video", id: "10" },
{ name: "Paramount Plus", id: "531" },
{ name: "Hulu", id: "15" },
{ name: "Max", id: "1899" },
{ name: "Adult Swim", id: "318" },
{ name: "Disney Plus", id: "337" },
{ name: "fubuTV", id: "257" },
{ name: "Crunchyroll", id: "283" },
{ name: "fuboTV", id: "257" },
{ name: "Shudder", id: "99" },
{ name: "Discovery +", id: "520" },
{ name: "National Geographic", id: "1964" },
{ name: "Fox", id: "328" },
];
const shuffleArray = <T,>(array: T[]): T[] => {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
// Editor Picks lists
export const EDITOR_PICKS_MOVIES = shuffleArray([
{ id: 9342, type: "movie" }, // The Mask of Zorro
{ id: 293, type: "movie" }, // A River Runs Through It
{ id: 370172, type: "movie" }, // No Time To Die
{ id: 661374, type: "movie" }, // The Glass Onion
{ id: 207, type: "movie" }, // Dead Poets Society
{ id: 378785, type: "movie" }, // The Best of the Blues Brothers
{ id: 335984, type: "movie" }, // Blade Runner 2049
{ id: 13353, type: "movie" }, // It's the Great Pumpkin, Charlie Brown
{ id: 27205, type: "movie" }, // Inception
{ id: 106646, type: "movie" }, // The Wolf of Wall Street
{ id: 334533, type: "movie" }, // Captain Fantastic
{ id: 693134, type: "movie" }, // Dune: Part Two
{ id: 765245, type: "movie" }, // Swan Song
{ id: 264660, type: "movie" }, // Ex Machina
{ id: 92591, type: "movie" }, // Bernie
{ id: 976893, type: "movie" }, // Perfect Days
{ id: 13187, type: "movie" }, // A Charlie Brown Christmas
{ id: 11527, type: "movie" }, // Excalibur
{ id: 120, type: "movie" }, // LOTR: The Fellowship of the Ring
{ id: 157336, type: "movie" }, // Interstellar
{ id: 762, type: "movie" }, // Monty Python and the Holy Grail
{ id: 666243, type: "movie" }, // The Witcher: Nightmare of the Wolf
{ id: 545611, type: "movie" }, // Everything Everywhere All at Once
{ id: 329, type: "movie" }, // Jurrassic Park
{ id: 330459, type: "movie" }, // Rogue One: A Star Wars Story
{ id: 279, type: "movie" }, // Amadeus
{ id: 823219, type: "movie" }, // Flow
{ id: 22, type: "movie" }, // Pirates of the Caribbean: The Curse of the Black Pearl
{ id: 18971, type: "movie" }, // Rosencrantz and Guildenstern Are Dead
{ id: 26388, type: "movie" }, // Buried
{ id: 152601, type: "movie" }, // Her
]);
export const EDITOR_PICKS_TV_SHOWS = shuffleArray([
{ id: 456, type: "show" }, // The Simpsons
{ id: 73021, type: "show" }, // Disenchantment
{ id: 1434, type: "show" }, // Family Guy
{ id: 1695, type: "show" }, // Monk
{ id: 1408, type: "show" }, // House
{ id: 93740, type: "show" }, // Foundation
{ id: 60625, type: "show" }, // Rick and Morty
{ id: 1396, type: "show" }, // Breaking Bad
{ id: 44217, type: "show" }, // Vikings
{ id: 90228, type: "show" }, // Dune Prophecy
{ id: 13916, type: "show" }, // Death Note
{ id: 71912, type: "show" }, // The Witcher
{ id: 61222, type: "show" }, // Bojack Horseman
{ id: 93405, type: "show" }, // Squid Game
{ id: 87108, type: "show" }, // Chernobyl
{ id: 105248, type: "show" }, // Cyberpunk: Edgerunners
]);
export function DiscoverContent() {
const { selectedCategory, setSelectedCategory } = useDiscoverStore();
const [selectedProvider, setSelectedProvider] = useState({
name: "",
id: "",
});
const [selectedGenre, setSelectedGenre] = useState({
name: "",
id: "",
});
const [genres, setGenres] = useState<Genre[]>([]);
const [tvGenres, setTVGenres] = useState<Genre[]>([]);
const [providerMovies, setProviderMovies] = useState<Movie[]>([]);
const [providerTVShows, setProviderTVShows] = useState<TVShow[]>([]);
const [filteredGenreMovies, setFilteredGenreMovies] = useState<Movie[]>([]);
const [filteredGenreTVShows, setFilteredGenreTVShows] = useState<TVShow[]>(
[],
);
const [detailsData, setDetailsData] = useState<any>();
const detailsModal = useModal("discover-details");
const [movieRecommendations, setMovieRecommendations] = useState<any[]>([]);
const [tvRecommendations, setTVRecommendations] = useState<any[]>([]);
const [movieRecommendationTitle, setMovieRecommendationTitle] = useState("");
const [tvRecommendationTitle, setTVRecommendationTitle] = useState("");
const [movieRecommendationSourceId, setMovieRecommendationSourceId] =
useState<string>("");
const [tvRecommendationSourceId, setTVRecommendationSourceId] =
useState<string>("");
const [movieRecommendationSources, setMovieRecommendationSources] = useState<
Array<{ id: string; title: string }>
>([]);
const [tvRecommendationSources, setTVRecommendationSources] = useState<
Array<{ id: string; title: string }>
>([]);
const [selectedMovieSource, setSelectedMovieSource] = useState<string>("");
const [selectedTVSource, setSelectedTVSource] = useState<string>("");
const progressStore = useProgressStore();
const { t } = useTranslation();
const [latestReleases, setLatestReleases] = useState<Movie[]>([]);
const [latest4KReleases, setLatest4KReleases] = useState<Movie[]>([]);
const [isLoadingLatest, setIsLoadingLatest] = useState(false);
const [isLoading4K, setIsLoading4K] = useState(false);
const [isTraktAvailable, setIsTraktAvailable] = useState(false);
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
const userLanguage = useLanguageStore.getState().language;
const formattedLanguage = getTmdbLanguageCode(userLanguage);
// Only load data for the active tab
const isMoviesTab = selectedCategory === "movies";
const isTVShowsTab = selectedCategory === "tvshows";
@ -187,405 +27,6 @@ export function DiscoverContent() {
setSelectedCategory(category as "movies" | "tvshows" | "editorpicks");
};
// Set initial provider when component mounts or category changes
useEffect(() => {
const providers =
selectedCategory === "movies" ? MOVIE_PROVIDERS : TV_PROVIDERS;
if (providers.length > 0 && !selectedProvider.id) {
setSelectedProvider({
name: providers[0].name,
id: providers[0].id,
});
}
}, [selectedCategory, selectedProvider.id]);
// Set initial genre when component mounts or category changes
useEffect(() => {
const genreList = selectedCategory === "movies" ? genres : tvGenres;
if (genreList.length > 0) {
// Always reset genre when switching categories to ensure we use the correct genre IDs
if (selectedCategory === "movies") {
setSelectedGenre({
name: genres[0].name,
id: genres[0].id.toString(),
});
} else if (selectedCategory === "tvshows") {
setSelectedGenre({
name: tvGenres[0].name,
id: tvGenres[0].id.toString(),
});
}
}
}, [selectedCategory, genres, tvGenres]);
// Fetch provider content when selectedProvider changes
useEffect(() => {
const fetchProviderContent = async () => {
if (!selectedProvider.id) return;
try {
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: selectedProvider.id,
watch_region: "US",
language: formattedLanguage,
});
setData(data.results);
} catch (error) {
console.error("Error fetching provider movies/shows:", error);
}
};
fetchProviderContent();
}, [selectedProvider, selectedCategory, formattedLanguage]);
// Fetch genre content when selectedGenre changes
useEffect(() => {
const fetchGenreContent = async () => {
if (!selectedGenre.id) return;
try {
const endpoint =
selectedCategory === "movies" ? "/discover/movie" : "/discover/tv";
const setData =
selectedCategory === "movies"
? setFilteredGenreMovies
: setFilteredGenreTVShows;
const data = await get<any>(endpoint, {
api_key: conf().TMDB_READ_API_KEY,
with_genres: selectedGenre.id,
language: formattedLanguage,
});
setData(data.results);
} catch (error) {
console.error("Error fetching genre movies/shows:", error);
}
};
fetchGenreContent();
}, [selectedGenre, selectedCategory, formattedLanguage]);
// Fetch TV show genres
useEffect(() => {
if (!isTVShowsTab) return;
const fetchTVGenres = async () => {
try {
const data = await get<any>("/genre/tv/list", {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
setTVGenres(data.genres.slice(0, 50));
} catch (error) {
console.error("Error fetching TV show genres:", error);
}
};
fetchTVGenres();
}, [isTVShowsTab, formattedLanguage]);
// Fetch Movie genres
useEffect(() => {
if (!isMoviesTab) return;
const fetchGenres = async () => {
try {
const data = await get<any>("/genre/movie/list", {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
setGenres(data.genres.slice(0, 50));
} catch (error) {
console.error("Error fetching genres:", error);
}
};
fetchGenres();
}, [isMoviesTab, formattedLanguage]);
// Fetch Editor Picks Movies
useEffect(() => {
if (!isEditorPicksTab) return;
const fetchEditorPicksMovies = async () => {
try {
const moviePromises = EDITOR_PICKS_MOVIES.map((item) =>
get<any>(`/movie/${item.id}`, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
append_to_response: "videos,images",
}),
);
const results = await Promise.all(moviePromises);
const moviesWithType = results.map((movie) => ({
...movie,
type: "movie" as const,
}));
setFilteredGenreMovies(moviesWithType);
} catch (error) {
console.error("Error fetching editor picks movies:", error);
}
};
fetchEditorPicksMovies();
}, [isEditorPicksTab, formattedLanguage]);
// Fetch Editor Picks TV Shows
useEffect(() => {
if (!isEditorPicksTab) return;
const fetchEditorPicksTVShows = async () => {
try {
const tvShowPromises = EDITOR_PICKS_TV_SHOWS.map((item) =>
get<any>(`/tv/${item.id}`, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
append_to_response: "videos,images",
}),
);
const results = await Promise.all(tvShowPromises);
const showsWithType = results.map((show) => ({
...show,
type: "show" as const,
}));
setFilteredGenreTVShows(showsWithType);
} catch (error) {
console.error("Error fetching editor picks TV shows:", error);
}
};
fetchEditorPicksTVShows();
}, [isEditorPicksTab, formattedLanguage]);
// Update recommendations effect to store multiple sources
useEffect(() => {
const fetchRecommendations = async () => {
if (!progressStore.items || Object.keys(progressStore.items).length === 0)
return;
try {
// Get all movies and TV shows from progress
const progressItems = Object.entries(progressStore.items) as [
string,
ProgressMediaItem,
][];
const movies = progressItems.filter(
([_, item]) => item.type === "movie",
);
const tvShows = progressItems.filter(
([_, item]) => item.type === "show",
);
// Store all movie sources
if (movies.length > 0) {
const movieSources = movies.map(([id, item]) => ({
id,
title: item.title || "",
}));
setMovieRecommendationSources(movieSources);
// Set initial source if not set
if (!selectedMovieSource && movieSources.length > 0) {
setSelectedMovieSource(movieSources[0].id);
}
}
// Store all TV show sources
if (tvShows.length > 0) {
const tvSources = tvShows.map(([id, item]) => ({
id,
title: item.title || "",
}));
setTVRecommendationSources(tvSources);
// Set initial source if not set
if (!selectedTVSource && tvSources.length > 0) {
setSelectedTVSource(tvSources[0].id);
}
}
} catch (error) {
console.error("Error setting up recommendation sources:", error);
}
};
fetchRecommendations();
}, [progressStore.items, selectedMovieSource, selectedTVSource]);
// Add new effect to fetch recommendations when source changes
useEffect(() => {
const fetchRecommendationsForSource = async () => {
if (!selectedMovieSource && !selectedTVSource) return;
try {
// Fetch movie recommendations if we have a selected movie source
if (selectedMovieSource) {
const movieResults = await get<any>(
`/movie/${selectedMovieSource}/recommendations`,
{
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
},
);
if (movieResults.results?.length > 0) {
setMovieRecommendations(movieResults.results);
const sourceMovie = movieRecommendationSources.find(
(m) => m.id === selectedMovieSource,
);
if (sourceMovie) {
setMovieRecommendationTitle(
t("discover.carousel.title.recommended", {
title: sourceMovie.title,
}),
);
setMovieRecommendationSourceId(selectedMovieSource);
}
}
}
// Fetch TV show recommendations if we have a selected TV source
if (selectedTVSource) {
const tvResults = await get<any>(
`/tv/${selectedTVSource}/recommendations`,
{
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
},
);
if (tvResults.results?.length > 0) {
setTVRecommendations(tvResults.results);
const sourceTV = tvRecommendationSources.find(
(show) => show.id === selectedTVSource,
);
if (sourceTV) {
setTVRecommendationTitle(
t("discover.carousel.title.recommended", {
title: sourceTV.title,
}),
);
setTVRecommendationSourceId(selectedTVSource);
}
}
}
} catch (error) {
console.error("Error fetching recommendations:", error);
}
};
fetchRecommendationsForSource();
}, [
selectedMovieSource,
selectedTVSource,
movieRecommendationSources,
tvRecommendationSources,
formattedLanguage,
setMovieRecommendationTitle,
setTVRecommendationTitle,
setMovieRecommendationSourceId,
setTVRecommendationSourceId,
t,
]);
// Fetch latest releases from Trakt
useEffect(() => {
const fetchLatestReleases = async () => {
if (!isMoviesTab) return;
setIsLoadingLatest(true);
try {
const traktData = await getLatestReleases();
const moviePromises = traktData.tmdb_ids.slice(0, 20).map(
(id) =>
get<any>(`/movie/${id}`, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
}).catch(() => null), // Handle failed TMDB fetches gracefully
);
const results = await Promise.all(moviePromises);
const validMovies = results
.filter((movie): movie is Movie => movie !== null)
.map((movie) => ({
...movie,
type: "movie" as const,
}));
setLatestReleases(validMovies);
setIsTraktAvailable(true);
} catch (error) {
console.error("Error fetching latest releases:", error);
setIsTraktAvailable(false);
// Fallback to TMDB if Trakt fails
const data = await get<any>("/movie/now_playing", {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
setLatestReleases(
data.results.map((movie: any) => ({
...movie,
type: "movie" as const,
})),
);
} finally {
setIsLoadingLatest(false);
}
};
fetchLatestReleases();
}, [isMoviesTab, formattedLanguage]);
// Fetch 4K releases from Trakt
useEffect(() => {
const fetch4KReleases = async () => {
if (!isMoviesTab) return;
setIsLoading4K(true);
try {
const traktData = await getLatest4KReleases();
const moviePromises = traktData.tmdb_ids.slice(0, 20).map(
(id) =>
get<any>(`/movie/${id}`, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
}).catch(() => null), // Handle failed TMDB fetches gracefully
);
const results = await Promise.all(moviePromises);
const validMovies = results
.filter((movie): movie is Movie => movie !== null)
.map((movie) => ({
...movie,
type: "movie" as const,
}));
setLatest4KReleases(validMovies);
setIsTraktAvailable(true);
} catch (error) {
console.error("Error fetching 4K releases:", error);
setIsTraktAvailable(false);
// Fallback to TMDB if Trakt fails
const data = await get<any>("/movie/popular", {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
setLatest4KReleases(
data.results.map((movie: any) => ({
...movie,
type: "movie" as const,
})),
);
} finally {
setIsLoading4K(false);
}
};
fetch4KReleases();
}, [isMoviesTab, formattedLanguage]);
const handleShowDetails = async (media: MediaItem | FeaturedMedia) => {
setDetailsData({
id: Number(media.id),
@ -599,64 +40,36 @@ export function DiscoverContent() {
return (
<>
{/* Movie Recommendations */}
{movieRecommendations.length > 0 && (
<LazyMediaCarousel
medias={movieRecommendations}
category={movieRecommendationTitle}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreLink={`/discover/more/recommendations/${movieRecommendationSourceId}/movie`}
moreContent
recommendationSources={movieRecommendationSources}
selectedRecommendationSource={selectedMovieSource}
onRecommendationSourceChange={setSelectedMovieSource}
/>
)}
<MediaCarousel
content={{ type: "recommendations" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
showRecommendations
/>
{/* Latest Releases or In Cinemas */}
{isTraktAvailable ? (
<LazyMediaCarousel
medias={isLoadingLatest ? undefined : latestReleases}
category="Latest Releases"
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
) : (
<LazyMediaCarousel
category={categories[0].name}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
)}
{/* Latest Releases */}
<MediaCarousel
content={{ type: "latest", fallback: "nowPlaying" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* 4K Releases or Popular */}
{isTraktAvailable ? (
<LazyMediaCarousel
medias={isLoading4K ? undefined : latest4KReleases}
category="4K Releases"
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
) : (
<LazyMediaCarousel
category={categories[2].name}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
)}
{/* 4K Releases */}
<MediaCarousel
content={{ type: "latest4k", fallback: "popular" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Top Rated */}
<LazyMediaCarousel
category={categories[1].name}
<MediaCarousel
content={{ type: "topRated" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
@ -664,34 +77,22 @@ export function DiscoverContent() {
/>
{/* Provider Movies */}
<LazyMediaCarousel
medias={providerMovies}
category={`Movies on ${selectedProvider.name || ""}`}
<MediaCarousel
content={{ type: "provider" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
relatedButtons={MOVIE_PROVIDERS.map((p) => ({
name: p.name,
id: p.id,
}))}
onButtonClick={(id, name) => setSelectedProvider({ id, name })}
moreLink={`/discover/more/provider/${selectedProvider.id}/movie`}
showProviders
moreContent
/>
{/* Genre Movies */}
<LazyMediaCarousel
medias={filteredGenreMovies}
category={`${selectedGenre.name || ""}`}
<MediaCarousel
content={{ type: "genre" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
relatedButtons={genres.map((g) => ({
name: g.name,
id: g.id.toString(),
}))}
onButtonClick={(id, name) => setSelectedGenre({ id, name })}
moreLink={`/discover/more/genre/${selectedGenre.id}/movie`}
showGenres
moreContent
/>
</>
@ -703,24 +104,18 @@ export function DiscoverContent() {
return (
<>
{/* TV Show Recommendations */}
{tvRecommendations.length > 0 && (
<LazyMediaCarousel
medias={tvRecommendations}
category={tvRecommendationTitle}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreLink={`/discover/more/recommendations/${tvRecommendationSourceId}/tv`}
moreContent
recommendationSources={tvRecommendationSources}
selectedRecommendationSource={selectedTVSource}
onRecommendationSourceChange={setSelectedTVSource}
/>
)}
<MediaCarousel
content={{ type: "recommendations" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
showRecommendations
/>
{/* On Air */}
<LazyMediaCarousel
category={tvCategories[0].name}
<MediaCarousel
content={{ type: "onTheAir" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
@ -728,8 +123,8 @@ export function DiscoverContent() {
/>
{/* Top Rated */}
<LazyMediaCarousel
category={tvCategories[1].name}
<MediaCarousel
content={{ type: "topRated" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
@ -737,8 +132,8 @@ export function DiscoverContent() {
/>
{/* Popular */}
<LazyMediaCarousel
category={tvCategories[2].name}
<MediaCarousel
content={{ type: "popular" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
@ -746,34 +141,22 @@ export function DiscoverContent() {
/>
{/* Provider TV Shows */}
<LazyMediaCarousel
medias={providerTVShows}
category={`Shows on ${selectedProvider.name || ""}`}
<MediaCarousel
content={{ type: "provider" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
relatedButtons={TV_PROVIDERS.map((p) => ({
name: p.name,
id: p.id,
}))}
onButtonClick={(id, name) => setSelectedProvider({ id, name })}
moreLink={`/discover/more/provider/${selectedProvider.id}/tv`}
showProviders
moreContent
/>
{/* Genre TV Shows */}
<LazyMediaCarousel
medias={filteredGenreTVShows}
category={`${selectedGenre.name || ""}`}
<MediaCarousel
content={{ type: "genre" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
relatedButtons={tvGenres.map((g) => ({
name: g.name,
id: g.id.toString(),
}))}
onButtonClick={(id, name) => setSelectedGenre({ id, name })}
moreLink={`/discover/more/genre/${selectedGenre.id}/tv`}
showGenres
moreContent
/>
</>
@ -784,17 +167,15 @@ export function DiscoverContent() {
const renderEditorPicksContent = () => {
return (
<>
<LazyMediaCarousel
preloadedMedia={filteredGenreMovies}
title="Editor Picks"
<MediaCarousel
content={{ type: "editorPicks" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
<LazyMediaCarousel
preloadedMedia={filteredGenreTVShows}
title="Editor Picks"
<MediaCarousel
content={{ type: "editorPicks" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}

View file

@ -0,0 +1,519 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { get } from "@/backend/metadata/tmdb";
import {
TraktLatestResponse,
getLatest4KReleases,
getLatestReleases,
} from "@/backend/metadata/traktApi";
import { conf } from "@/setup/config";
import { useLanguageStore } from "@/stores/language";
import { getTmdbLanguageCode } from "@/utils/language";
// Shuffle array utility
const shuffleArray = <T>(array: T[]): T[] => {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
// Editor Picks lists
export const EDITOR_PICKS_MOVIES = shuffleArray([
{ id: 9342, type: "movie" }, // The Mask of Zorro
{ id: 293, type: "movie" }, // A River Runs Through It
{ id: 370172, type: "movie" }, // No Time To Die
{ id: 661374, type: "movie" }, // The Glass Onion
{ id: 207, type: "movie" }, // Dead Poets Society
{ id: 378785, type: "movie" }, // The Best of the Blues Brothers
{ id: 335984, type: "movie" }, // Blade Runner 2049
{ id: 13353, type: "movie" }, // It's the Great Pumpkin, Charlie Brown
{ id: 27205, type: "movie" }, // Inception
{ id: 106646, type: "movie" }, // The Wolf of Wall Street
{ id: 334533, type: "movie" }, // Captain Fantastic
{ id: 693134, type: "movie" }, // Dune: Part Two
{ id: 765245, type: "movie" }, // Swan Song
{ id: 264660, type: "movie" }, // Ex Machina
{ id: 92591, type: "movie" }, // Bernie
{ id: 976893, type: "movie" }, // Perfect Days
{ id: 13187, type: "movie" }, // A Charlie Brown Christmas
{ id: 11527, type: "movie" }, // Excalibur
{ id: 120, type: "movie" }, // LOTR: The Fellowship of the Ring
{ id: 157336, type: "movie" }, // Interstellar
{ id: 762, type: "movie" }, // Monty Python and the Holy Grail
{ id: 666243, type: "movie" }, // The Witcher: Nightmare of the Wolf
{ id: 545611, type: "movie" }, // Everything Everywhere All at Once
{ id: 329, type: "movie" }, // Jurrassic Park
{ id: 330459, type: "movie" }, // Rogue One: A Star Wars Story
{ id: 279, type: "movie" }, // Amadeus
{ id: 823219, type: "movie" }, // Flow
{ id: 22, type: "movie" }, // Pirates of the Caribbean: The Curse of the Black Pearl
{ id: 18971, type: "movie" }, // Rosencrantz and Guildenstern Are Dead
{ id: 26388, type: "movie" }, // Buried
{ id: 152601, type: "movie" }, // Her
]);
export const EDITOR_PICKS_TV_SHOWS = shuffleArray([
{ id: 456, type: "show" }, // The Simpsons
{ id: 73021, type: "show" }, // Disenchantment
{ id: 1434, type: "show" }, // Family Guy
{ id: 1695, type: "show" }, // Monk
{ id: 1408, type: "show" }, // House
{ id: 93740, type: "show" }, // Foundation
{ id: 60625, type: "show" }, // Rick and Morty
{ id: 1396, type: "show" }, // Breaking Bad
{ id: 44217, type: "show" }, // Vikings
{ id: 90228, type: "show" }, // Dune Prophecy
{ id: 13916, type: "show" }, // Death Note
{ id: 71912, type: "show" }, // The Witcher
{ id: 61222, type: "show" }, // Bojack Horseman
{ id: 93405, type: "show" }, // Squid Game
{ id: 87108, type: "show" }, // Chernobyl
{ id: 105248, type: "show" }, // Cyberpunk: Edgerunners
]);
/**
* The type of content to fetch from various endpoints
*/
export type DiscoverContentType =
| "popular"
| "topRated"
| "onTheAir"
| "nowPlaying"
| "latest"
| "latest4k"
| "genre"
| "provider"
| "editorPicks"
| "recommendations";
/**
* The type of media to fetch (movie or TV show)
*/
export type MediaType = "movie" | "tv";
/**
* Props for the useDiscoverMedia hook
*/
export interface UseDiscoverMediaProps {
/** The type of content to fetch */
contentType: DiscoverContentType;
/** Whether to fetch movies or TV shows */
mediaType: MediaType;
/** ID used for genre, provider, or recommendations */
id?: string;
/** Fallback content type if primary fails */
fallbackType?: DiscoverContentType;
/** Page number for paginated results */
page?: number;
/** Genre name for display in title */
genreName?: string;
/** Provider name for display in title */
providerName?: string;
/** Media title for recommendations display */
mediaTitle?: string;
}
/**
* Media item returned from discover endpoints
*/
export interface DiscoverMedia {
/** TMDB ID of the media */
id: number;
/** Title for movies */
title: string;
/** Title for TV shows */
name?: string;
/** Poster image path */
poster_path: string;
/** Backdrop image path */
backdrop_path: string;
/** Release date for movies */
release_date?: string;
/** First air date for TV shows */
first_air_date?: string;
/** Media overview/description */
overview: string;
/** Average vote score (0-10) */
vote_average: number;
/** Number of votes */
vote_count: number;
/** Type of media (movie or show) */
type?: "movie" | "show";
}
/**
* Return type of the useDiscoverMedia hook
*/
export interface UseDiscoverMediaReturn {
/** Array of media items */
media: DiscoverMedia[];
/** Whether media is currently being fetched */
isLoading: boolean;
/** Error message if fetch failed */
error: string | null;
/** Whether there are more pages to load */
hasMore: boolean;
/** Function to refetch the current media */
refetch: () => Promise<void>;
/** Localized section title for the media carousel */
sectionTitle: string;
}
/**
* Provider interface for streaming services
*/
export interface Provider {
/** Provider name (e.g., "Netflix", "Hulu") */
name: string;
/** Provider ID from TMDB */
id: string;
}
/**
* Genre interface for media categorization
*/
export interface Genre {
/** Genre ID from TMDB */
id: number;
/** Genre name (e.g., "Action", "Drama") */
name: string;
}
// Static provider lists
export const MOVIE_PROVIDERS: Provider[] = [
{ name: "Netflix", id: "8" },
{ name: "Apple TV+", id: "2" },
{ name: "Amazon Prime Video", id: "10" },
{ name: "Hulu", id: "15" },
{ name: "Disney Plus", id: "337" },
{ name: "Max", id: "1899" },
{ name: "Paramount Plus", id: "531" },
{ name: "Shudder", id: "99" },
{ name: "Crunchyroll", id: "283" },
{ name: "fuboTV", id: "257" },
{ name: "AMC+", id: "526" },
{ name: "Starz", id: "43" },
{ name: "Lifetime", id: "157" },
{ name: "National Geographic", id: "1964" },
];
export const TV_PROVIDERS: Provider[] = [
{ name: "Netflix", id: "8" },
{ name: "Apple TV+", id: "350" },
{ name: "Amazon Prime Video", id: "10" },
{ name: "Paramount Plus", id: "531" },
{ name: "Hulu", id: "15" },
{ name: "Max", id: "1899" },
{ name: "Adult Swim", id: "318" },
{ name: "Disney Plus", id: "337" },
{ name: "Crunchyroll", id: "283" },
{ name: "fuboTV", id: "257" },
{ name: "Shudder", id: "99" },
{ name: "Discovery +", id: "520" },
{ name: "National Geographic", id: "1964" },
{ name: "Fox", id: "328" },
];
/**
* Hook for managing providers and genres
*/
export function useDiscoverOptions(mediaType: MediaType) {
const [genres, setGenres] = useState<Genre[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const userLanguage = useLanguageStore.getState().language;
const formattedLanguage = getTmdbLanguageCode(userLanguage);
const providers = mediaType === "movie" ? MOVIE_PROVIDERS : TV_PROVIDERS;
useEffect(() => {
const fetchGenres = async () => {
setIsLoading(true);
setError(null);
try {
const data = await get<any>(`/genre/${mediaType}/list`, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
setGenres(data.genres.slice(0, 50));
} catch (err) {
console.error(`Error fetching ${mediaType} genres:`, err);
setError((err as Error).message);
} finally {
setIsLoading(false);
}
};
fetchGenres();
}, [mediaType, formattedLanguage]);
return {
genres,
providers,
isLoading,
error,
};
}
export function useDiscoverMedia({
contentType,
mediaType,
id,
fallbackType,
page = 1,
genreName,
providerName,
mediaTitle,
}: UseDiscoverMediaProps): UseDiscoverMediaReturn {
const [media, setMedia] = useState<DiscoverMedia[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [sectionTitle, setSectionTitle] = useState<string>("");
const { t } = useTranslation();
const userLanguage = useLanguageStore.getState().language;
const formattedLanguage = getTmdbLanguageCode(userLanguage);
const fetchTMDBMedia = useCallback(
async (endpoint: string, params: Record<string, any> = {}) => {
try {
const data = await get<any>(endpoint, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
page: page.toString(),
...params,
});
return {
results: data.results.map((item: any) => ({
...item,
type: mediaType === "movie" ? "movie" : "show",
})),
hasMore: page < data.total_pages,
};
} catch (err) {
console.error("Error fetching TMDB media:", err);
throw err;
}
},
[formattedLanguage, page, mediaType],
);
const fetchTraktMedia = useCallback(
async (traktFunction: () => Promise<TraktLatestResponse>) => {
try {
const { tmdb_ids: tmdbIds } = await traktFunction();
// Fetch details for each TMDB ID
const mediaPromises = tmdbIds.map(async (tmdbId) => {
const endpoint = `/${mediaType}/${tmdbId}`;
const data = await get<any>(endpoint, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
return {
...data,
type: mediaType === "movie" ? "movie" : "show",
};
});
const results = await Promise.all(mediaPromises);
return {
results,
hasMore: false, // Trakt endpoints don't support pagination
};
} catch (err) {
console.error("Error fetching Trakt media:", err);
throw err;
}
},
[mediaType, formattedLanguage],
);
const fetchEditorPicks = useCallback(async () => {
const picks =
mediaType === "movie" ? EDITOR_PICKS_MOVIES : EDITOR_PICKS_TV_SHOWS;
try {
const mediaPromises = picks.map(async (item) => {
const endpoint = `/${mediaType}/${item.id}`;
const data = await get<any>(endpoint, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
append_to_response: "videos,images",
});
return {
...data,
type: item.type,
};
});
const results = await Promise.all(mediaPromises);
return {
results,
hasMore: false,
};
} catch (err) {
console.error("Error fetching editor picks:", err);
throw err;
}
}, [mediaType, formattedLanguage]);
const fetchMedia = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
let data;
// Map content types to their endpoints and handling logic
switch (contentType) {
case "popular":
data = await fetchTMDBMedia(`/${mediaType}/popular`);
setSectionTitle(
mediaType === "movie"
? t("discover.carousel.title.moviesOn", { provider: "Popular" })
: t("discover.carousel.title.tvshowsOn", { provider: "Popular" }),
);
break;
case "topRated":
data = await fetchTMDBMedia(`/${mediaType}/top_rated`);
setSectionTitle(t("discover.carousel.title.topRated"));
break;
case "onTheAir":
if (mediaType === "tv") {
data = await fetchTMDBMedia("/tv/on_the_air");
setSectionTitle(t("discover.carousel.title.onTheAir"));
} else {
throw new Error("onTheAir is only available for TV shows");
}
break;
case "nowPlaying":
if (mediaType === "movie") {
data = await fetchTMDBMedia("/movie/now_playing");
setSectionTitle(t("discover.carousel.title.inCinemas"));
} else {
throw new Error("nowPlaying is only available for movies");
}
break;
case "latest":
data = await fetchTraktMedia(getLatestReleases);
setSectionTitle(t("discover.carousel.title.latestReleases"));
break;
case "latest4k":
data = await fetchTraktMedia(getLatest4KReleases);
setSectionTitle(t("discover.carousel.title.4kReleases"));
break;
case "genre":
if (!id) throw new Error("Genre ID is required");
data = await fetchTMDBMedia(`/discover/${mediaType}`, {
with_genres: id,
});
setSectionTitle(
mediaType === "movie"
? t("discover.carousel.title.movies", { category: genreName })
: t("discover.carousel.title.tvshows", { category: genreName }),
);
break;
case "provider":
if (!id) throw new Error("Provider ID is required");
data = await fetchTMDBMedia(`/discover/${mediaType}`, {
with_watch_providers: id,
watch_region: "US",
});
setSectionTitle(
mediaType === "movie"
? t("discover.carousel.title.moviesOn", {
provider: providerName,
})
: t("discover.carousel.title.tvshowsOn", {
provider: providerName,
}),
);
break;
case "recommendations":
if (!id) throw new Error("Media ID is required for recommendations");
data = await fetchTMDBMedia(`/${mediaType}/${id}/recommendations`);
setSectionTitle(
t("discover.carousel.title.recommended", { title: mediaTitle }),
);
break;
case "editorPicks":
data = await fetchEditorPicks();
setSectionTitle(
mediaType === "movie"
? t("discover.carousel.title.editorPicksMovies")
: t("discover.carousel.title.editorPicksShows"),
);
break;
default:
throw new Error(`Unsupported content type: ${contentType}`);
}
setMedia(data.results);
setHasMore(data.hasMore);
} catch (err) {
console.error("Error fetching media:", err);
setError((err as Error).message);
// Try fallback content type if available
if (fallbackType && fallbackType !== contentType) {
try {
const fallbackData = await fetchTMDBMedia(
`/${mediaType}/${fallbackType === "popular" ? "popular" : "top_rated"}`,
);
setMedia(fallbackData.results);
setHasMore(fallbackData.hasMore);
setError(null); // Clear error if fallback succeeds
} catch (fallbackErr) {
console.error("Error fetching fallback media:", fallbackErr);
setError((fallbackErr as Error).message);
}
}
} finally {
setIsLoading(false);
}
}, [
contentType,
mediaType,
id,
fallbackType,
fetchTMDBMedia,
fetchTraktMedia,
fetchEditorPicks,
t,
genreName,
providerName,
mediaTitle,
]);
useEffect(() => {
fetchMedia();
}, [fetchMedia]);
return {
media,
isLoading,
error,
hasMore,
refetch: fetchMedia,
sectionTitle,
};
}

View file

@ -170,13 +170,14 @@ function App() {
{/* Discover pages */}
<Route path="/discover" element={<Discover />} />
<Route
path="/discover/more/:type/:id/:mediaType"
path="/discover/more/:contentType/:mediaType"
element={<MoreContent />}
/>
<Route
path="/discover/more/:category/:genreId?"
path="/discover/more/:contentType/:id/:mediaType"
element={<MoreContent />}
/>
<Route path="/discover/more/:category" element={<MoreContent />} />
{/* Settings page */}
<Route
path="/settings"