mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-20 15:32:13 +00:00
rewrite discover using a single hook
This commit is contained in:
parent
b1e4b77b13
commit
185555a960
7 changed files with 1091 additions and 1692 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
519
src/pages/discover/hooks/useDiscoverMedia.ts
Normal file
519
src/pages/discover/hooks/useDiscoverMedia.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue