Overhaul Discover Page and Featured

Add Featured Modal
Removed Individual Carousels for each genre
Recommended Carousel
View More page for viewing all
Improve several minor visuals
Update search and navigation

Full Commit Log:

add more carousel skeleton dots

bug fix and languages

remove provider translations

Add change button for recommended more content

add buttons to moreContent page

dropdown for changing recommended

Increase genres and providers

add home/search button to discover

Update FeaturedCarousel.tsx

fix recommended load more pages

increase number of featured items

clean up featured image fetch

maybe fix ff bug?

add dynamic blur to header

Update Dropdown.tsx

fix dropdown

add recommended carousel

animate dropdown

fix some visuals

random button

fix padding

reset timer when manually switching slides

fix editor picks more titles

add store for discover

fix editor picks

Update FeaturedCarousel.tsx

add view more card

move view more link

update carousel buttons and dropdown

finish 5 carousels

use 5 carousels

init carousel nav buttons

update dropdown

update featured sizing

update blurs

add clear blur to navigation

update padding and sizing

Update FeaturedCarousel.tsx

add loading skeleton

update discover navigation again

simplify featured media

Update SearchBar.tsx

tweak some minor visual stuff

fix button sizes

update carousel gradient

fix sticky

fix safari overlay bug

make search transparent

use secondary buttons on featured

fix up negative margins

fix searching classes

fix buttons because of the overlay

make it shorter

add featured section to home page

add toggle for image logos

fix details modal title overlay position

clean up some buttons

improve fed setup status check

update grid

Update FeaturedCarousel.tsx

dont show more content for providers

more stuff

clean and bugfix

update editor picks more content page

Update DetailsModal.tsx

more more more!

shuffle editor picks

discover update part 2

fix more info button

init discover v3
This commit is contained in:
Pas 2025-05-28 11:59:34 -06:00
parent 12b3002b5a
commit 3ce5053af5
35 changed files with 3854 additions and 1580 deletions

View file

@ -821,5 +821,50 @@
"episodes": "Folgen",
"season": "Staffel",
"runtime": "Laufzeit:"
},
"discover": {
"tabs": {
"movies": "Filme",
"tvshows": "Serien",
"editorpicks": "Empfehlungen der Redaktion"
},
"carousel": {
"title": {
"movies": "{{category}} Filme",
"tvshows": "{{category}} Serien",
"inCinemas": "Jetzt im Kino",
"popularOn": "Beliebte {{type}} auf {{provider}}",
"editorPicksMovies": "Redaktionsempfehlungen Filme",
"editorPicksShows": "Redaktionsempfehlungen Serien",
"moviesOn": "Filme auf {{provider}}",
"tvshowsOn": "Serien auf {{provider}}",
"recommended": "Weil du geschaut hast: {{title}}",
"genreMovies": "{{genre}} Filme",
"genreShows": "{{genre}} Serien",
"categoryMovies": "{{category}} Filme",
"categoryShows": "{{category}} Serien"
},
"change": "Ändern",
"more": "Mehr anzeigen"
},
"featured": {
"playNow": "Jetzt abspielen",
"moreInfo": "Mehr Infos"
},
"randomMovie": {
"button": "Zufälligen Titel abspielen",
"cancel": "Countdown abbrechen",
"countdown": "{{countdown}}s",
"nowPlaying": "Jetzt läuft",
"in": "in"
},
"page": {
"title": "Filme & Serien entdecken",
"subtitle": "Entdecke aktuelle Highlights und zeitlose Klassiker.",
"loadMore": "Mehr laden",
"loading": "Lade...",
"back": "Zurück"
},
"scrollToTop": "Nach oben"
}
}

File diff suppressed because it is too large Load diff

View file

@ -114,7 +114,10 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
return (
<div className="relative is-dropdown">
<div
className="cursor-pointer tabbable rounded-full flex gap-2 text-white items-center py-2 px-3 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover backdrop-blur-lg transition-[background,transform] duration-100 hover:scale-105"
className={classNames(
"cursor-pointer tabbable rounded-full flex gap-2 text-white items-center py-2 px-3 bg-pill-background hover:bg-pill-backgroundHover backdrop-blur-lg transition-all duration-100 hover:scale-105",
open ? "bg-opacity-100" : "bg-opacity-50",
)}
tabIndex={0}
onClick={toggleOpen}
onKeyUp={(evt) => evt.key === "Enter" && toggleOpen()}

View file

@ -11,7 +11,7 @@ interface Props {
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement, MouseEvent>,
) => void;
children?: ReactNode;
theme?: "white" | "purple" | "secondary" | "danger";
theme?: "white" | "purple" | "secondary" | "danger" | "glass";
padding?: string;
className?: string;
href?: string;
@ -45,12 +45,17 @@ export function Button(props: Props) {
let colorClasses = "bg-white hover:bg-gray-200 text-black";
if (props.theme === "purple")
colorClasses = "bg-buttons-purple hover:bg-buttons-purpleHover text-white";
colorClasses =
"bg-buttons-purple hover:bg-buttons-purpleHover text-white gap-2";
if (props.theme === "secondary")
colorClasses =
"bg-buttons-cancel hover:bg-buttons-cancelHover transition-colors duration-100 text-white";
"bg-buttons-cancel hover:bg-buttons-cancelHover transition-colors duration-100 text-white gap-2";
if (props.theme === "danger")
colorClasses = "bg-buttons-danger hover:bg-buttons-dangerHover text-white";
colorClasses =
"bg-buttons-danger hover:bg-buttons-dangerHover text-white gap-2";
if (props.theme === "glass")
colorClasses =
"text-white hover:scale-105 bg-buttons-purple hover:bg-buttons-purpleHover bg-opacity-45 hover:bg-opacity-60 !backdrop-blur-md border-2 border-gray-400 border-opacity-20 gap-2";
let classes = classNames(
"tabbable cursor-pointer inline-flex items-center justify-center rounded-lg font-medium transition-[transform,background-color] duration-100 active:scale-105 md:px-8",

View file

@ -8,6 +8,7 @@ export interface IconPatchProps {
icon: Icons;
transparent?: boolean;
downsized?: boolean;
navigation?: boolean;
}
export function IconPatch(props: IconPatchProps) {
@ -17,6 +18,9 @@ export function IconPatch(props: IconPatchProps) {
const transparentClasses = props.transparent
? "bg-opacity-0 hover:bg-opacity-50"
: "";
const navigationClasses = props.navigation
? "bg-opacity-50 hover:bg-opacity-100"
: "";
const activeClasses = props.active
? "bg-pill-backgroundHover text-white"
: "";
@ -25,7 +29,7 @@ export function IconPatch(props: IconPatchProps) {
return (
<div className={props.className || undefined} onClick={props.onClick}>
<div
className={`flex items-center justify-center rounded-full border-2 border-transparent bg-pill-background bg-opacity-100 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses} ${sizeClasses}`}
className={`flex items-center justify-center rounded-full border-2 border-transparent bg-pill-background bg-opacity-100 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${navigationClasses} ${clickableClasses} ${activeClasses} ${sizeClasses}`}
>
<Icon icon={props.icon} />
</div>

View file

@ -1,7 +1,8 @@
import { Listbox, Transition } from "@headlessui/react";
import { Listbox } from "@headlessui/react";
import React, { Fragment } from "react";
import { Icon, Icons } from "@/components/Icon";
import { Transition } from "@/components/utils/Transition";
export interface OptionItem {
id: string;
@ -14,56 +15,70 @@ interface DropdownProps {
setSelectedItem: (value: OptionItem) => void;
options: Array<OptionItem>;
direction?: "up" | "down";
side?: "left" | "right";
customButton?: React.ReactNode;
customMenu?: React.ReactNode;
className?: string;
preventWrap?: boolean;
}
export function Dropdown(props: DropdownProps) {
const { direction = "down" } = props;
const { direction = "down", customButton, customMenu } = props;
return (
<div className="relative my-4 max-w-[25rem]">
<div className={`relative my-4 w-fit max-w-[25rem] ${props.className}`}>
<Listbox value={props.selectedItem} onChange={props.setSelectedItem}>
{() => (
{({ open }) => (
<>
<Listbox.Button className="relative w-full rounded-lg bg-dropdown-background hover:bg-dropdown-hoverBackground py-3 pl-3 pr-10 text-left text-white shadow-md focus:outline-none tabbable cursor-pointer">
<span className="flex gap-4 items-center truncate">
{props.selectedItem.leftIcon
? props.selectedItem.leftIcon
: null}
{props.selectedItem.name}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<Icon
icon={Icons.UP_DOWN_ARROW}
className={`transform transition-transform text-xl text-dropdown-secondary ${direction === "up" ? "rotate-180" : ""}`}
/>
</span>
</Listbox.Button>
{customButton ? (
<Listbox.Button as={Fragment}>{customButton}</Listbox.Button>
) : (
<Listbox.Button className="relative z-[101] w-full rounded-lg bg-dropdown-background hover:bg-dropdown-hoverBackground py-3 pl-3 pr-10 text-left text-white shadow-md focus:outline-none tabbable cursor-pointer">
<span className="flex gap-4 items-center truncate">
{props.selectedItem.leftIcon
? props.selectedItem.leftIcon
: null}
{props.selectedItem.name}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<Icon
icon={Icons.UP_DOWN_ARROW}
className={`transform transition-transform text-xl text-dropdown-secondary ${direction === "up" ? "rotate-180" : ""}`}
/>
</span>
</Listbox.Button>
)}
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
animation="slide-down"
show={open}
className={`absolute z-[102] min-w-[20px] w-fit max-h-60 overflow-auto rounded-lg bg-dropdown-background py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-background-secondary scrollbar-thumb-type-secondary focus:outline-none ${
direction === "up" ? "bottom-full mb-4" : "top-full mt-1"
} ${props.side === "right" ? "right-0" : "left-0"}`}
>
<Listbox.Options
className={`absolute left-0 right-0 z-[100] mt-4 max-h-60 overflow-auto rounded-md bg-dropdown-background py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-background-secondary scrollbar-thumb-type-secondary focus:outline-none ${direction === "up" ? "bottom-full mb-4" : "top-full"}`}
>
{props.options.map((opt) => (
<Listbox.Option
className={({ active }) =>
`cursor-pointer flex gap-4 items-center relative select-none py-3 pl-4 pr-4 ${
active
? "bg-background-secondaryHover text-type-link"
: "text-white"
}`
}
key={opt.id}
value={opt}
>
{opt.leftIcon ? opt.leftIcon : null}
{opt.name}
</Listbox.Option>
))}
</Listbox.Options>
{customMenu ? (
<Listbox.Options static as={Fragment}>
{customMenu}
</Listbox.Options>
) : (
<Listbox.Options static className="py-1">
{props.options.map((opt) => (
<Listbox.Option
className={({ active }) =>
`cursor-pointer flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
active
? "bg-background-secondaryHover text-type-link"
: "text-type-secondary"
} ${props.preventWrap ? "whitespace-nowrap" : ""}`
}
key={opt.id}
value={opt}
>
{opt.leftIcon ? opt.leftIcon : null}
{opt.name}
</Listbox.Option>
))}
</Listbox.Options>
)}
</Transition>
</>
)}

View file

@ -1,5 +1,5 @@
import c from "classnames";
import { forwardRef, useRef, useState } from "react";
import { forwardRef, useEffect, useRef, useState } from "react";
import { Flare } from "@/components/utils/Flare";
@ -11,11 +11,16 @@ export interface SearchBarProps {
onChange: (value: string, force: boolean) => void;
onUnFocus: (newSearch?: string) => void;
value: string;
isSticky?: boolean;
isInFeatured?: boolean;
}
export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
(props, ref) => {
const [focused, setFocused] = useState(false);
const [lightTheme, setLightTheme] = useState(
Boolean(props.isInFeatured) && window.scrollY < 600,
);
const containerRef = useRef<HTMLDivElement>(null);
const [showTooltip, setShowTooltip] = useState(false);
@ -23,14 +28,24 @@ export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
props.onChange(value, true);
}
useEffect(() => {
const handleScroll = () => {
setLightTheme(Boolean(props.isInFeatured) && window.scrollY < 600);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [props.isInFeatured]);
return (
<div ref={containerRef}>
<Flare.Base
className={c({
"hover:flare-enabled group flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center relative":
"hover:flare-enabled group flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center relative backdrop-blur-sm":
true,
"bg-search-background": !focused,
"bg-search-focused": focused,
"transition-colors duration-300": true,
"bg-search-background/50": !focused && lightTheme,
"bg-search-background":
focused || props.isSticky || !props.isInFeatured,
})}
>
<Flare.Light
@ -45,7 +60,15 @@ export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
/>
<Flare.Child className="flex flex-1 flex-col">
<div
className="absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon cursor-pointer z-10"
className={c(
"absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon cursor-pointer z-10",
"transition-colors duration-300",
props.isInFeatured
? lightTheme
? "text-white/50"
: ""
: "text-search-icon",
)}
onClick={(e) => {
e.preventDefault();
setShowTooltip(!showTooltip);
@ -66,7 +89,15 @@ export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
onFocus={() => setFocused(true)}
onChange={(val) => setSearch(val)}
value={props.value}
className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-search-text placeholder-search-placeholder focus:outline-none sm:py-4 sm:pr-2"
className={c(
"w-full flex-1 bg-transparent px-4 py-4 pl-12 !text-search-text focus:outline-none sm:py-4 sm:pr-2 transition-colors duration-300",
"transition-colors duration-300",
props.isInFeatured
? lightTheme
? "text-white/50"
: "placeholder-search-placeholder"
: "placeholder-search-placeholder",
)}
placeholder={props.placeholder}
/>

View file

@ -1,4 +1,5 @@
import classNames from "classnames";
import { useEffect, useState } from "react";
import { Link, To, useNavigate } from "react-router-dom";
import { NoUserAvatar, UserAvatar } from "@/components/Avatar";
@ -17,18 +18,40 @@ export interface NavigationProps {
bg?: boolean;
noLightbar?: boolean;
doBackground?: boolean;
clearBackground?: boolean;
}
export function Navigation(props: NavigationProps) {
const bannerHeight = useBannerSize();
const navigate = useNavigate();
const { loggedIn } = useAuth();
const [scrollPosition, setScrollPosition] = useState(0);
useEffect(() => {
const handleScroll = () => {
setScrollPosition(window.scrollY);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const handleClick = (path: To) => {
window.scrollTo(0, 0);
navigate(path);
};
// Calculate mask length based on scroll position
const getMaskLength = () => {
// When at top (0), use longer mask (200px)
// When scrolled down (300px+), use shorter mask (100px)
const maxScroll = 300;
const minLength = 100;
const maxLength = 180;
const scrollFactor = Math.min(scrollPosition, maxScroll) / maxScroll;
return minLength + (maxLength - minLength) * (1 - scrollFactor);
};
return (
<>
{/* lightbar */}
@ -54,10 +77,13 @@ export function Navigation(props: NavigationProps) {
>
<div
className={classNames(
"fixed left-0 right-0 top-0 flex items-center",
"fixed left-0 right-0 top-0 flex items-center", // border-b border-utils-divider border-opacity-50
"transition-[background-color,backdrop-filter] duration-300 ease-in-out",
props.doBackground
? "bg-background-main border-b border-utils-divider border-opacity-50"
: null,
? props.clearBackground
? "backdrop-blur-md bg-transparent"
: "bg-background-main"
: "bg-transparent",
)}
>
{props.doBackground ? (
@ -67,12 +93,29 @@ export function Navigation(props: NavigationProps) {
) : null}
<div className="opacity-0 absolute inset-0 block h-20 pointer-events-auto" />
<div
className={`${
props.bg ? "opacity-100" : "opacity-0"
} absolute inset-0 block h-24 bg-background-main transition-opacity duration-300`}
>
<div className="absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" />
</div>
className={classNames(
"transition-[background-color,backdrop-filter,opacity] duration-300 ease-in-out",
props.bg ? "opacity-100" : "opacity-0",
"absolute inset-0 block h-[11rem]",
props.clearBackground
? "backdrop-blur-md bg-transparent"
: "bg-background-main",
)}
style={{
maskImage: `linear-gradient(
to bottom,
rgba(0, 0, 0, 1),
rgba(0, 0, 0, 1) calc(100% - ${getMaskLength()}px),
rgba(0, 0, 0, 0) 100%
)`,
WebkitMaskImage: `linear-gradient(
to bottom,
rgba(0, 0, 0, 1),
rgba(0, 0, 0, 1) calc(100% - ${getMaskLength()}px),
rgba(0, 0, 0, 0) 100%
)`,
}}
/>
</div>
</div>
@ -99,15 +142,40 @@ export function Navigation(props: NavigationProps) {
rel="noreferrer"
className="text-xl text-white tabbable rounded-full"
>
<IconPatch icon={Icons.DISCORD} clickable downsized />
</a>
<a
onClick={() => handleClick("/discover")}
rel="noreferrer"
className="text-xl text-white tabbable rounded-full"
>
<IconPatch icon={Icons.RISING_STAR} clickable downsized />
<IconPatch
icon={Icons.DISCORD}
clickable
downsized
navigation
/>
</a>
{window.location.pathname !== "/discover" ? (
<a
onClick={() => handleClick("/discover")}
rel="noreferrer"
className="text-xl text-white tabbable rounded-full"
>
<IconPatch
icon={Icons.RISING_STAR}
clickable
downsized
navigation
/>
</a>
) : (
<a
onClick={() => handleClick("/")}
rel="noreferrer"
className="text-lg text-white tabbable rounded-full"
>
<IconPatch
icon={Icons.SEARCH}
clickable
downsized
navigation
/>
</a>
)}
</div>
<div className="relative pointer-events-auto">
<LinksDropdown>

View file

@ -13,7 +13,7 @@ export function SectionHeading(props: SectionHeadingProps) {
return (
<div className={props.className}>
<div className="mb-5 flex items-center">
<p className="flex flex-1 items-center font-bold uppercase text-type-text">
<p className="flex flex-1 items-center font-bold uppercase text-type-text z-[19]">
{props.icon ? (
<span className="mr-2 text-xl">
<Icon icon={props.icon} />

View file

@ -12,7 +12,7 @@ export function WideContainer(props: WideContainerProps) {
className={`mx-auto max-w-full px-8 ${
props.ultraWide
? "w-[1300px] xl:w-[18000px] 3xl:w-[2400px] 4xl:w-[2800px]"
: "w-[900px] xl:w-[1200px] 3xl:w-[1600px] 4xl:w-[1800px]"
: "w-[950px] xl:w-[1250px] 3xl:w-[1650px] 4xl:w-[1850px]"
} ${props.classNames || ""}`}
>
{props.children}

View file

@ -254,13 +254,8 @@ function MediaCardContent({
<div>
<div className="absolute inset-0 flex flex-col items-center justify-start gap-y-2 pt-8 md:pt-12">
<Button
theme="secondary"
className={classNames(
"w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
"text-md text-white flex items-center justify-center",
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
"border-2 border-gray-400 border-opacity-20",
)}
theme="glass"
className="w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -272,13 +267,8 @@ function MediaCardContent({
{canLink ? (
<Button
theme="secondary"
className={classNames(
"w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
"text-md text-white flex items-center justify-center",
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
"border-2 border-gray-400 border-opacity-20",
)}
theme="glass"
className="w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1"
href={link}
onClick={handleCopyClick}
>
@ -294,13 +284,8 @@ function MediaCardContent({
) : null}
<Button
theme="secondary"
className={classNames(
"w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
"text-md text-white flex items-center justify-center",
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
"border-2 border-gray-400 border-opacity-20",
)}
theme="glass"
className="w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1"
onClick={() => setOverlayVisible(false)}
>
{t("home.mediaCard.close")}

View file

@ -8,7 +8,7 @@ export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
(props, ref) => {
return (
<div
className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10"
className="grid grid-cols-2 gap-7 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10"
ref={ref}
>
{props.children}

View file

@ -20,6 +20,7 @@ import { Icon, Icons } from "@/components/Icon";
import { hasAired } from "@/components/player/utils/aired";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useLanguageStore } from "@/stores/language";
import { usePreferencesStore } from "@/stores/preferences";
import { useProgressStore } from "@/stores/progress";
import { shouldShowProgress } from "@/stores/progress/utils";
import { scrapeIMDb } from "@/utils/imdbScraper";
@ -151,6 +152,9 @@ function DetailsContent({
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const isBookmarked = !!bookmarks[data.id?.toString() ?? ""];
const enableImageLogos = usePreferencesStore(
(state) => state.enableImageLogos,
);
const showProgress = useMemo(() => {
if (!data.id) return null;
@ -533,23 +537,28 @@ function DetailsContent({
)}
</div>
{/* Content */}
<div className="px-6 pb-6 mt-[-70px] flex-grow">
<div
className={classNames(
"px-6 pb-6 flex-grow relative",
enableImageLogos ? "-mt-32" : "",
)}
>
{/* Title and Genres Row */}
<div className="pb-2">
{data.logoUrl ? (
<div className="pb-4 relative z-10">
{data.logoUrl && enableImageLogos ? (
<img
src={data.logoUrl}
alt={data.title}
className="max-w-[12rem] md:max-w-[20rem] object-contain drop-shadow-lg bg-transparent"
className="max-w-[12rem] md:max-w-[20rem] max-h-[14vh] object-contain drop-shadow-lg bg-transparent"
style={{ background: "none" }}
/>
) : (
<h3 className="text-2xl font-bold text-white z-[999]">
<h3 className="text-3xl font-bold text-white z-[999]">
{data.title}
</h3>
)}
</div>
<div className="flex flex-col sm:flex-row justify-between items-start mb-6">
<div className="flex flex-col sm:flex-row justify-between items-start mb-6 relative z-10">
<div className="flex items-center gap-4">
{!minimal && (
<Button
@ -571,13 +580,7 @@ function DetailsContent({
}
}
}}
theme="secondary"
className={classNames(
"gap-2 h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
"text-md text-white flex items-center justify-center",
"bg-buttons-purple bg-opacity-45 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
"border-2 border-gray-400 border-opacity-20",
)}
theme="glass"
>
<Icon icon={Icons.PLAY} className="text-white" />
<span className="text-white text-sm pr-1">

View file

@ -339,44 +339,42 @@ export function CaptionSettingsView({
<Menu.FieldTitle>
{t("settings.subtitles.textStyle.title") || "Font Style"}
</Menu.FieldTitle>
<div className="w-64">
<Dropdown
options={[
{
id: "default",
name: t("settings.subtitles.textStyle.default"),
},
{
id: "raised",
name: t("settings.subtitles.textStyle.raised"),
},
{
id: "depressed",
name: t("settings.subtitles.textStyle.depressed"),
},
{
id: "uniform",
name: t("settings.subtitles.textStyle.uniform"),
},
{
id: "dropShadow",
name: t("settings.subtitles.textStyle.dropShadow"),
},
]}
selectedItem={{
id: styling.fontStyle,
name:
t(`settings.subtitles.textStyle.${styling.fontStyle}`) ||
styling.fontStyle,
}}
setSelectedItem={(item) =>
handleStylingChange({
...styling,
fontStyle: item.id,
})
}
/>
</div>
<Dropdown
options={[
{
id: "default",
name: t("settings.subtitles.textStyle.default"),
},
{
id: "raised",
name: t("settings.subtitles.textStyle.raised"),
},
{
id: "depressed",
name: t("settings.subtitles.textStyle.depressed"),
},
{
id: "uniform",
name: t("settings.subtitles.textStyle.uniform"),
},
{
id: "dropShadow",
name: t("settings.subtitles.textStyle.dropShadow"),
},
]}
selectedItem={{
id: styling.fontStyle,
name:
t(`settings.subtitles.textStyle.${styling.fontStyle}`) ||
styling.fontStyle,
}}
setSelectedItem={(item) =>
handleStylingChange({
...styling,
fontStyle: item.id,
})
}
/>
</div>
<div className="flex justify-between items-center">
<Menu.FieldTitle>

View file

@ -54,11 +54,13 @@ export function useSettingsState(
enableThumbnails: boolean,
enableAutoplay: boolean,
enableDiscover: boolean,
enableFeatured: boolean,
enableDetailsModal: boolean,
sourceOrder: string[],
enableSourceOrder: boolean,
proxyTmdb: boolean,
enableSkipCredits: boolean,
enableImageLogos: boolean,
) {
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
useDerived(proxyUrls);
@ -116,12 +118,24 @@ export function useSettingsState(
resetEnableDiscover,
enableDiscoverChanged,
] = useDerived(enableDiscover);
const [
enableFeaturedState,
setEnableFeaturedState,
resetEnableFeatured,
enableFeaturedChanged,
] = useDerived(enableFeatured);
const [
enableDetailsModalState,
setEnableDetailsModalState,
resetEnableDetailsModal,
enableDetailsModalChanged,
] = useDerived(enableDetailsModal);
const [
enableImageLogosState,
setEnableImageLogosState,
resetEnableImageLogos,
enableImageLogosChanged,
] = useDerived(enableImageLogos);
const [
sourceOrderState,
setSourceOrderState,
@ -151,7 +165,9 @@ export function useSettingsState(
resetEnableAutoplay();
resetEnableSkipCredits();
resetEnableDiscover();
resetEnableFeatured();
resetEnableDetailsModal();
resetEnableImageLogos();
resetSourceOrder();
resetEnableSourceOrder();
resetProxyTmdb();
@ -170,7 +186,9 @@ export function useSettingsState(
enableAutoplayChanged ||
enableSkipCreditsChanged ||
enableDiscoverChanged ||
enableFeaturedChanged ||
enableDetailsModalChanged ||
enableImageLogosChanged ||
sourceOrderChanged ||
enableSourceOrderChanged ||
proxyTmdbChanged;
@ -238,11 +256,21 @@ export function useSettingsState(
set: setEnableDiscoverState,
changed: enableDiscoverChanged,
},
enableFeatured: {
state: enableFeaturedState,
set: setEnableFeaturedState,
changed: enableFeaturedChanged,
},
enableDetailsModal: {
state: enableDetailsModalState,
set: setEnableDetailsModalState,
changed: enableDetailsModalChanged,
},
enableImageLogos: {
state: enableImageLogosState,
set: setEnableImageLogosState,
changed: enableImageLogosChanged,
},
sourceOrder: {
state: sourceOrderState,
set: setSourceOrderState,

View file

@ -9,6 +9,8 @@ import { useModal } from "@/components/overlays/Modal";
import { useDebounce } from "@/hooks/useDebounce";
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
import { useSearchQuery } from "@/hooks/useSearchQuery";
import { FeaturedCarousel } from "@/pages/discover/components/FeaturedCarousel";
import type { FeaturedMedia } from "@/pages/discover/components/FeaturedCarousel";
import DiscoverContent from "@/pages/discover/discoverContent";
import { HomeLayout } from "@/pages/layouts/HomeLayout";
import { BookmarksPart } from "@/pages/parts/home/BookmarksPart";
@ -16,10 +18,12 @@ import { HeroPart } from "@/pages/parts/home/HeroPart";
import { WatchingPart } from "@/pages/parts/home/WatchingPart";
import { SearchListPart } from "@/pages/parts/search/SearchListPart";
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
import { conf } from "@/setup/config";
import { usePreferencesStore } from "@/stores/preferences";
import { MediaItem } from "@/utils/mediaTypes";
import { Button } from "./About";
import { AdsPart } from "./parts/home/AdsPart";
function useSearch(search: string) {
const [searching, setSearching] = useState<boolean>(false);
@ -29,6 +33,9 @@ function useSearch(search: string) {
useEffect(() => {
setSearching(search !== "");
setLoading(search !== "");
if (search !== "") {
window.scrollTo(0, 0);
}
}, [search]);
useEffect(() => {
setLoading(false);
@ -54,17 +61,16 @@ export function HomePage() {
const [showBookmarks, setShowBookmarks] = useState(false);
const [showWatching, setShowWatching] = useState(false);
const [detailsData, setDetailsData] = useState<any>();
// const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const detailsModal = useModal("details");
const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
const enableFeatured = usePreferencesStore((state) => state.enableFeatured);
const handleClick = (path: To) => {
window.scrollTo(0, 0);
navigate(path);
};
const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
const handleShowDetails = async (media: MediaItem) => {
const handleShowDetails = async (media: MediaItem | FeaturedMedia) => {
setDetailsData({
id: Number(media.id),
type: media.type === "movie" ? "movie" : "show",
@ -89,7 +95,7 @@ export function HomePage() {
<span className="font-bold select-none">READ</span>
</IconPill>
</a> */}
<div className="mb-16 sm:mb-24">
<div className="mb-2">
<Helmet>
<style type="text/css">{`
html, body {
@ -190,8 +196,27 @@ export function HomePage() {
</div>
</FancyModal>
*/}
<HeroPart searchParams={searchParams} setIsSticky={setShowBg} />
{enableFeatured ? (
<FeaturedCarousel
forcedCategory="editorpicks"
onShowDetails={handleShowDetails}
searching={s.searching}
shorter
>
<HeroPart
searchParams={searchParams}
setIsSticky={setShowBg}
isInFeatured
/>
</FeaturedCarousel>
) : (
<HeroPart
searchParams={searchParams}
setIsSticky={setShowBg}
showTitle
/>
)}
{conf().SHOW_AD ? <AdsPart /> : null}
</div>
<WideContainer>
{s.loading ? (
@ -214,24 +239,35 @@ export function HomePage() {
</div>
)}
{!(showBookmarks || showWatching) && !enableDiscover ? (
<div className="flex flex-col translate-y-[-30px] items-center justify-center">
<div className="flex flex-col translate-y-[-30px] items-center justify-center pt-20">
<p className="text-[18.5px] pb-3">{emptyText}</p>
</div>
) : null}
{enableDiscover &&
(enableFeatured ? (
<div className="pb-4" />
) : showBookmarks || showWatching ? (
<div className="pb-10" />
) : (
<div className="pb-20" />
))}
{/* there... perfect. */}
</WideContainer>
{enableDiscover ? (
<div className="pt-12 w-full max-w-[100dvw] justify-center items-center">
{enableDiscover && !search ? (
<div className="w-full max-w-[100dvw] justify-center items-center">
<DiscoverContent />
</div>
) : (
<div className="flex flex-col justify-center items-center h-40 space-y-4">
<div className="flex flex-col items-center justify-center">
<Button
className="px-py p-[0.35em] mt-3 rounded-xl text-type-dimmed box-content text-[18px] bg-largeCard-background justify-center items-center"
onClick={() => handleClick("/discover")}
>
{t("home.search.discover")}
</Button>
{!search && (
<Button
className="px-py p-[0.35em] mt-3 rounded-xl text-type-dimmed box-content text-[18px] bg-largeCard-background justify-center items-center"
onClick={() => handleClick("/discover")}
>
{t("home.search.discover")}
</Button>
)}
</div>
</div>
)}

View file

@ -151,11 +151,17 @@ export function SettingsPage() {
const enableDiscover = usePreferencesStore((s) => s.enableDiscover);
const setEnableDiscover = usePreferencesStore((s) => s.setEnableDiscover);
const enableFeatured = usePreferencesStore((s) => s.enableFeatured);
const setEnableFeatured = usePreferencesStore((s) => s.setEnableFeatured);
const enableDetailsModal = usePreferencesStore((s) => s.enableDetailsModal);
const setEnableDetailsModal = usePreferencesStore(
(s) => s.setEnableDetailsModal,
);
const enableImageLogos = usePreferencesStore((s) => s.enableImageLogos);
const setEnableImageLogos = usePreferencesStore((s) => s.setEnableImageLogos);
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
const setEnableSourceOrder = usePreferencesStore(
(s) => s.setEnableSourceOrder,
@ -201,11 +207,13 @@ export function SettingsPage() {
enableThumbnails,
enableAutoplay,
enableDiscover,
enableFeatured,
enableDetailsModal,
sourceOrder,
enableSourceOrder,
proxyTmdb,
enableSkipCredits,
enableImageLogos,
);
const availableSources = useMemo(() => {
@ -279,7 +287,9 @@ export function SettingsPage() {
setEnableAutoplay(state.enableAutoplay.state);
setEnableSkipCredits(state.enableSkipCredits.state);
setEnableDiscover(state.enableDiscover.state);
setEnableFeatured(state.enableFeatured.state);
setEnableDetailsModal(state.enableDetailsModal.state);
setEnableImageLogos(state.enableImageLogos.state);
setSourceOrder(state.sourceOrder.state);
setAppLanguage(state.appLanguage.state);
setTheme(state.theme.state);
@ -313,7 +323,9 @@ export function SettingsPage() {
setEnableAutoplay,
setEnableSkipCredits,
setEnableDiscover,
setEnableFeatured,
setEnableDetailsModal,
setEnableImageLogos,
setSourceOrder,
setAppLanguage,
setTheme,
@ -382,8 +394,12 @@ export function SettingsPage() {
setTheme={setThemeWithPreview}
enableDiscover={state.enableDiscover.state}
setEnableDiscover={state.enableDiscover.set}
enableFeatured={state.enableFeatured.state}
setEnableFeatured={state.enableFeatured.set}
enableDetailsModal={state.enableDetailsModal.state}
setEnableDetailsModal={state.enableDetailsModal.set}
enableImageLogos={state.enableImageLogos.state}
setEnableImageLogos={state.enableImageLogos.set}
/>
</div>
<div id="settings-captions" className="mt-28">

View file

@ -1,13 +1,33 @@
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import DiscoverContent from "@/pages/discover/discoverContent";
import { DetailsModal } from "@/components/overlays/DetailsModal";
import { useModal } from "@/components/overlays/Modal";
import { SubPageLayout } from "../layouts/SubPageLayout";
import { FeaturedCarousel } from "./components/FeaturedCarousel";
import type { FeaturedMedia } from "./components/FeaturedCarousel";
import DiscoverContent from "./discoverContent";
import { PageTitle } from "../parts/util/PageTitle";
export function Discover() {
const { t } = useTranslation();
const [detailsData, setDetailsData] = useState<any>();
const detailsModal = useModal("discover-details");
// Clear details data when modal is closed
useEffect(() => {
if (!detailsModal.isShown) {
setDetailsData(undefined);
}
}, [detailsModal.isShown]);
const handleShowDetails = (media: FeaturedMedia) => {
setDetailsData({
id: Number(media.id),
type: media.type,
});
detailsModal.show();
};
return (
<SubPageLayout>
@ -23,27 +43,17 @@ export function Discover() {
<PageTitle subpage k="global.pages.discover" />
<div className="relative w-full max-w-screen-xl mx-auto px-4 text-center mt-12 mb-12">
<div
className="absolute inset-0 mx-auto h-[400px] max-w-[800px] rounded-full blur-[100px] opacity-20 transform -translate-y-[100px] pointer-events-none"
style={{
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
}}
/>
<h1
className="relative text-4xl md:text-5xl font-extrabold text-transparent bg-clip-text z-10"
style={{
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
}}
>
{t("discover.page.title")}
</h1>
<p className="relative text-lg mt-4 text-gray-400 z-10">
{t("discover.page.subtitle")}
</p>
<div className="!mt-[-170px]">
{/* Featured Carousel */}
<FeaturedCarousel onShowDetails={handleShowDetails} />
</div>
<DiscoverContent />
{/* Main Content */}
<div className="relative z-20">
<DiscoverContent />
</div>
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
</SubPageLayout>
);
}

View file

@ -0,0 +1,669 @@
import { Listbox } from "@headlessui/react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { useWindowSize } from "react-use";
import { get } from "@/backend/metadata/tmdb";
import { Button } from "@/components/buttons/Button";
import { Dropdown, OptionItem } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon";
import { WideContainer } from "@/components/layout/WideContainer";
import { MediaCard } from "@/components/media/MediaCard";
import { MediaGrid } from "@/components/media/MediaGrid";
import { DetailsModal } from "@/components/overlays/DetailsModal";
import { useModal } from "@/components/overlays/Modal";
import { Heading1 } from "@/components/utils/Text";
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
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 { Genre, categories, tvCategories } from "./common";
import {
EDITOR_PICKS_MOVIES,
EDITOR_PICKS_TV_SHOWS,
MOVIE_PROVIDERS,
TV_PROVIDERS,
} from "./discoverContent";
interface MoreContentProps {
onShowDetails?: (media: MediaItem) => void;
}
interface Provider {
id: string;
name: string;
}
export function MoreContent({ onShowDetails }: MoreContentProps) {
const { category, type: contentType, id, mediaType } = useParams();
const [medias, setMedias] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [detailsData, setDetailsData] = useState<any>();
const [genres, setGenres] = useState<Genre[]>([]);
const [tvGenres, setTVGenres] = useState<Genre[]>([]);
const [selectedProvider, setSelectedProvider] = useState<OptionItem | null>(
null,
);
const [selectedGenre, setSelectedGenre] = useState<OptionItem | null>(null);
const { t } = useTranslation();
const navigate = useNavigate();
const detailsModal = useModal("discover-details");
const { lastView } = useDiscoverStore();
const userLanguage = useLanguageStore.getState().language;
const formattedLanguage = getTmdbLanguageCode(userLanguage);
const [sourceTitle, setSourceTitle] = useState("");
const progressStore = useProgressStore();
const { width: windowWidth } = useWindowSize();
const [recommendationSources, setRecommendationSources] = useState<
Array<{ id: string; title: string }>
>([]);
const [selectedRecommendationSource, setSelectedRecommendationSource] =
useState<string>("");
const handleBack = () => {
if (lastView) {
navigate(lastView.url);
window.scrollTo(0, lastView.scrollPosition);
} else {
navigate(-1);
}
};
useEffect(() => {
window.scrollTo(0, 0);
}, []);
// Fetch genres when component mounts
useEffect(() => {
const fetchGenres = async () => {
try {
const [movieData, tvData] = await Promise.all([
get<any>("/genre/movie/list", {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
}),
get<any>("/genre/tv/list", {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
}),
]);
setGenres(movieData.genres);
setTVGenres(tvData.genres);
} catch (error) {
console.error("Error fetching genres:", error);
}
};
fetchGenres();
}, [formattedLanguage]);
const handleShowDetails = async (media: MediaItem) => {
if (onShowDetails) {
onShowDetails(media);
return;
}
setDetailsData({
id: Number(media.id),
type: media.type === "movie" ? "movie" : "show",
});
detailsModal.show();
};
const fetchContent = useCallback(
async (page: number, append: boolean = false) => {
try {
const isTVShow = mediaType === "tv";
let endpoint = "";
// Handle recommendations separately
if (contentType === "recommendations") {
// Get title from progress store instead of fetching details
const progressItem = progressStore.items[id || ""];
if (progressItem) {
setSourceTitle(progressItem.title || "");
}
// Get recommendations with proper page number
const results = await get<any>(
`/${isTVShow ? "tv" : "movie"}/${id}/recommendations`,
{
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
page,
},
);
if (append) {
setMedias((prev) => [...prev, ...results.results]);
} else {
setMedias(results.results);
}
setHasMore(page < results.total_pages);
setCurrentPage(page);
return;
}
// Handle editor picks separately
if (category?.includes("editor-picks")) {
const editorPicks = isTVShow
? EDITOR_PICKS_TV_SHOWS
: EDITOR_PICKS_MOVIES;
// Fetch details for all editor picks
const promises = editorPicks.map((item) =>
get<any>(`/${isTVShow ? "tv" : "movie"}/${item.id}`, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
}),
);
const results = await Promise.all(promises);
setMedias(results);
setHasMore(false);
return;
}
// Determine the correct endpoint based on the type
if (contentType === "category") {
const categoryList = isTVShow ? tvCategories : categories;
const categoryData = categoryList.find((c) => c.urlPath === id);
if (categoryData) {
endpoint = categoryData.endpoint;
} else {
endpoint = isTVShow ? "/discover/tv" : "/discover/movie";
}
} else {
endpoint = isTVShow ? "/discover/tv" : "/discover/movie";
}
const allResults: any[] = [];
const pagesToFetch = 2; // Fetch 2 pages at a time
for (let i = 0; i < pagesToFetch; i += 1) {
const currentPageNum = page + i;
const params: any = {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
page: currentPageNum,
};
if (contentType === "provider") {
params.with_watch_providers = id;
params.watch_region = "US";
} else if (contentType === "genre") {
params.with_genres = id;
}
const data = await get<any>(endpoint, params);
allResults.push(...data.results);
// Check if we've reached the end
if (currentPageNum >= data.total_pages) {
setHasMore(false);
break;
}
}
if (append) {
setMedias((prev) => [...prev, ...allResults]);
} else {
setMedias(allResults);
}
} catch (error) {
console.error("Error fetching content:", error);
}
},
[
contentType,
id,
mediaType,
category,
formattedLanguage,
progressStore.items,
],
);
useEffect(() => {
const loadInitialContent = async () => {
setLoading(true);
await fetchContent(1);
setLoading(false);
};
loadInitialContent();
}, [contentType, id, mediaType, category, formattedLanguage, fetchContent]);
const handleLoadMore = async () => {
setLoadingMore(true);
const nextPage =
contentType === "recommendations" ? currentPage + 1 : currentPage + 2;
await fetchContent(nextPage, true);
setCurrentPage(nextPage);
setLoadingMore(false);
};
const getDisplayTitle = () => {
const isTVShow = mediaType === "tv";
if (contentType === "recommendations") {
return t("discover.carousel.title.recommended", {
title: sourceTitle,
});
}
if (category === "editor-picks-tv" || category === "editor-picks-movie") {
return category === "editor-picks-tv"
? t("discover.carousel.title.editorPicksShows")
: t("discover.carousel.title.editorPicksMovies");
}
if (!contentType || !id) return "";
if (contentType === "provider") {
const providers = isTVShow ? TV_PROVIDERS : MOVIE_PROVIDERS;
const provider = providers.find((p: Provider) => p.id === id);
return isTVShow
? t("discover.carousel.title.tvshowsOn", {
provider: provider?.name,
})
: t("discover.carousel.title.moviesOn", {
provider: provider?.name,
});
}
if (contentType === "genre") {
const genreList = isTVShow ? tvGenres : genres;
const genre = genreList.find((g: Genre) => g.id.toString() === id);
return isTVShow
? t("discover.carousel.title.genreShows", { genre: genre?.name || id })
: t("discover.carousel.title.genreMovies", {
genre: genre?.name || id,
});
}
if (contentType === "category") {
const categoryList = isTVShow ? tvCategories : categories;
const categoryData = categoryList.find((c) => c.urlPath === id);
if (categoryData) {
return isTVShow
? t("discover.carousel.title.categoryShows", {
category: categoryData.name,
})
: t("discover.carousel.title.categoryMovies", {
category: categoryData.name,
});
}
}
};
useEffect(() => {
if (contentType === "provider" && selectedProvider) {
navigate(`/discover/more/provider/${selectedProvider.id}/${mediaType}`);
} else if (contentType === "genre" && selectedGenre) {
navigate(`/discover/more/genre/${selectedGenre.id}/${mediaType}`);
}
}, [selectedProvider, selectedGenre, contentType, mediaType, navigate]);
useEffect(() => {
if (contentType === "provider" && id) {
const providers = mediaType === "tv" ? TV_PROVIDERS : MOVIE_PROVIDERS;
const provider = providers.find((p) => p.id === id);
if (provider) {
setSelectedProvider({ id: provider.id, name: provider.name });
}
} else if (contentType === "genre" && id) {
const genreList = mediaType === "tv" ? tvGenres : genres;
const genre = genreList.find((g) => g.id.toString() === id);
if (genre) {
setSelectedGenre({ id: genre.id.toString(), name: genre.name });
}
}
}, [contentType, id, mediaType, genres, tvGenres]);
const providerButtons = useMemo(() => {
if (contentType !== "provider")
return { visibleButtons: [], dropdownButtons: [] };
const providers = mediaType === "tv" ? TV_PROVIDERS : MOVIE_PROVIDERS;
const visible =
windowWidth > 850 ? providers.slice(0, 7) : providers.slice(0, 2);
const dropdown =
windowWidth > 850 ? providers.slice(5) : providers.slice(0);
return { visibleButtons: visible, dropdownButtons: dropdown };
}, [contentType, mediaType, windowWidth]);
const genreButtons = useMemo(() => {
if (contentType !== "genre")
return { visibleButtons: [], dropdownButtons: [] };
const genreList = mediaType === "tv" ? tvGenres : genres;
const visible =
windowWidth > 850 ? genreList.slice(0, 7) : genreList.slice(0, 2);
const dropdown =
windowWidth > 850 ? genreList.slice(5) : genreList.slice(0);
return { visibleButtons: visible, dropdownButtons: dropdown };
}, [contentType, mediaType, windowWidth, tvGenres, genres]);
const renderProviderButtons = () => {
if (contentType !== "provider") return null;
const { visibleButtons, dropdownButtons } = providerButtons;
return (
<div className="flex items-center space-x-2">
{visibleButtons.map((provider) => (
<button
type="button"
key={provider.id}
onClick={() =>
setSelectedProvider({ id: provider.id, name: provider.name })
}
className="px-3 py-1 text-sm rounded-full transition-colors whitespace-nowrap flex-shrink-0 bg-mediaCard-hoverBackground hover:bg-mediaCard-background"
>
{provider.name}
</button>
))}
{dropdownButtons.length > 0 && (
<div className="relative">
<Dropdown
selectedItem={selectedProvider || { id: "", name: "..." }}
setSelectedItem={(item) => setSelectedProvider(item)}
options={dropdownButtons.map((p) => ({ id: p.id, name: p.name }))}
customButton={
<button
type="button"
className="px-3 py-1 text-sm bg-mediaCard-hoverBackground hover:bg-mediaCard-background rounded-full transition-colors flex items-center gap-1"
>
<span>...</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="right"
/>
</div>
)}
</div>
);
};
const renderGenreButtons = () => {
if (contentType !== "genre") return null;
const { visibleButtons, dropdownButtons } = genreButtons;
return (
<div className="flex items-center space-x-2">
{visibleButtons.map((genre) => (
<button
type="button"
key={genre.id}
onClick={() =>
setSelectedGenre({ id: genre.id.toString(), name: genre.name })
}
className="px-3 py-1 text-sm rounded-full transition-colors whitespace-nowrap flex-shrink-0 bg-mediaCard-hoverBackground hover:bg-mediaCard-background"
>
{genre.name}
</button>
))}
{dropdownButtons.length > 0 && (
<div className="relative">
<Dropdown
selectedItem={selectedGenre || { id: "", name: "..." }}
setSelectedItem={(item) => setSelectedGenre(item)}
options={dropdownButtons.map((g) => ({
id: g.id.toString(),
name: g.name,
}))}
customButton={
<button
type="button"
className="px-3 py-1 text-sm bg-mediaCard-hoverBackground hover:bg-mediaCard-background rounded-full transition-colors flex items-center gap-1"
>
<span>...</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="right"
/>
</div>
)}
</div>
);
};
// Add effect to set up recommendation sources
useEffect(() => {
const setupRecommendationSources = async () => {
if (
contentType !== "recommendations" ||
!progressStore.items ||
Object.keys(progressStore.items).length === 0
)
return;
try {
const progressItems = Object.entries(progressStore.items) as [
string,
ProgressMediaItem,
][];
const items = progressItems.filter(
([_, item]) => item.type === (mediaType === "tv" ? "show" : "movie"),
);
if (items.length > 0) {
const sources = items.map(([itemId, item]) => ({
id: itemId,
title: item.title || "",
}));
setRecommendationSources(sources);
// Set initial source if not set
if (!selectedRecommendationSource && sources.length > 0) {
setSelectedRecommendationSource(sources[0].id);
}
}
} catch (error) {
console.error("Error setting up recommendation sources:", error);
}
};
setupRecommendationSources();
}, [
contentType,
mediaType,
progressStore.items,
selectedRecommendationSource,
]);
// Add effect to handle recommendation source changes
useEffect(() => {
if (contentType === "recommendations" && selectedRecommendationSource) {
navigate(
`/discover/more/recommendations/${selectedRecommendationSource}/${mediaType}`,
);
}
}, [selectedRecommendationSource, contentType, mediaType, navigate]);
const renderRecommendationSourceDropdown = () => {
if (contentType !== "recommendations" || recommendationSources.length === 0)
return null;
return (
<div className="flex items-center gap-2">
<div className="relative pr-4">
<Dropdown
selectedItem={
recommendationSources.find(
(s) => s.id === selectedRecommendationSource,
)
? {
id: selectedRecommendationSource || "",
name:
recommendationSources.find(
(s) => s.id === selectedRecommendationSource,
)?.title || "",
}
: {
id: "",
name: recommendationSources[0]?.title || "",
}
}
setSelectedItem={(item) => setSelectedRecommendationSource(item.id)}
options={recommendationSources.map((source) => ({
id: source.id,
name: source.title,
}))}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{t("discover.carousel.change")}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="right"
customMenu={
<Listbox.Options static className="py-1">
{recommendationSources.map((opt) => (
<Listbox.Option
className={({ active }) =>
`cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
active
? "bg-background-secondaryHover text-type-link"
: "text-type-secondary"
}`
}
key={opt.id}
value={{ id: opt.id, name: opt.title }}
>
{({ selected }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.title}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
</div>
);
};
if (loading) {
return (
<SubPageLayout>
<WideContainer>
<div className="animate-pulse">
<div className="h-8 bg-gray-700 rounded w-1/4 mb-8" />
<MediaGrid>
{Array.from({ length: 20 }).map(() => (
<div
key={crypto.randomUUID()}
className="aspect-[2/3] bg-gray-700 rounded-lg"
/>
))}
</MediaGrid>
</div>
</WideContainer>
</SubPageLayout>
);
}
return (
<SubPageLayout>
<WideContainer>
<div className="flex items-center justify-between gap-8">
<Heading1 className="text-2xl font-bold text-white">
{getDisplayTitle()}
</Heading1>
{renderRecommendationSourceDropdown()}
</div>
<div className="flex items-center gap-4 mb-2">
<button
type="button"
onClick={handleBack}
className="flex items-center text-white hover:text-gray-300 transition-colors"
>
<Icon className="text-xl" icon={Icons.ARROW_LEFT} />
<span className="ml-2">{t("discover.page.back")}</span>
</button>
</div>
{renderProviderButtons()}
{renderGenreButtons()}
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10 pt-8">
{medias.map((media) => (
<div
key={media.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
>
<MediaCard
media={{
id: media.id.toString(),
title: media.title || media.name || "",
poster: `https://image.tmdb.org/t/p/w342${media.poster_path}`,
type: mediaType === "tv" ? "show" : "movie",
year:
mediaType === "tv"
? media.first_air_date
? parseInt(media.first_air_date.split("-")[0], 10)
: undefined
: media.release_date
? parseInt(media.release_date.split("-")[0], 10)
: undefined,
}}
onShowDetails={handleShowDetails}
linkable={!category?.includes("upcoming")}
/>
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center mt-8">
<Button
theme="purple"
onClick={handleLoadMore}
disabled={loadingMore}
>
{loadingMore
? t("discover.page.loading")
: t("discover.page.loadMore")}
</Button>
</div>
)}
</WideContainer>
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
</SubPageLayout>
);
}

View file

@ -29,31 +29,49 @@ export interface Genre {
export interface Category {
name: string;
endpoint: string;
urlPath: string;
mediaType: "movie" | "tv";
}
// Define the categories
export const categories: Category[] = [
{
name: "Now Playing",
endpoint: "/movie/now_playing?language=en-US",
endpoint: "/movie/now_playing",
urlPath: "now-playing",
mediaType: "movie",
},
{
name: "Top Rated",
endpoint: "/movie/top_rated?language=en-US",
endpoint: "/movie/top_rated",
urlPath: "top-rated",
mediaType: "movie",
},
{
name: "Most Popular",
endpoint: "/movie/popular?language=en-US",
endpoint: "/movie/popular",
urlPath: "popular",
mediaType: "movie",
},
];
export const tvCategories: Category[] = [
{
name: "On The Air",
endpoint: "/tv/on_the_air",
urlPath: "on-air",
mediaType: "tv",
},
{
name: "Top Rated",
endpoint: "/tv/top_rated?language=en-US",
endpoint: "/tv/top_rated",
urlPath: "top-rated",
mediaType: "tv",
},
{
name: "Most Popular",
endpoint: "/tv/popular?language=en-US",
endpoint: "/tv/popular",
urlPath: "popular",
mediaType: "tv",
},
];

View file

@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next";
interface DiscoverNavigationProps {
selectedCategory: string;
onCategoryChange: (category: string) => void;
}
export function DiscoverNavigation({
selectedCategory,
onCategoryChange,
}: DiscoverNavigationProps) {
const { t } = useTranslation();
return (
<div className="pb-4 w-full max-w-screen-xl mx-auto">
<div className="relative flex justify-center">
<div className="flex space-x-4">
{["movies", "tvshows", "editorpicks"].map((category) => (
<button
key={category}
type="button"
className={`text-xl md:text-2xl font-bold p-2 bg-transparent text-center rounded-full cursor-pointer flex items-center transition-transform duration-200 ${
selectedCategory === category
? "transform scale-105 text-type-link"
: "text-type-secondary"
}`}
onClick={() => onCategoryChange(category)}
>
{t(`discover.tabs.${category}`)}
</button>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,521 @@
import classNames from "classnames";
import { t } from "i18next";
import { ReactNode, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useWindowSize } from "react-use";
import { get, getMediaLogo } from "@/backend/metadata/tmdb";
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon";
import { Movie, TVShow } from "@/pages/discover/common";
import { conf } from "@/setup/config";
import { useDiscoverStore } from "@/stores/discover";
import { useLanguageStore } from "@/stores/language";
import { usePreferencesStore } from "@/stores/preferences";
import { getTmdbLanguageCode } from "@/utils/language";
import { EDITOR_PICKS_MOVIES, EDITOR_PICKS_TV_SHOWS } from "../discoverContent";
import { RandomMovieButton } from "./RandomMovieButton";
export interface FeaturedMedia extends Partial<Movie & TVShow> {
children?: ReactNode;
backdrop_path: string;
overview: string;
title?: string;
name?: string;
type: "movie" | "show";
}
interface FeaturedCarouselProps {
onShowDetails: (media: FeaturedMedia) => void;
children?: ReactNode;
searching?: boolean;
shorter?: boolean;
forcedCategory?: "movies" | "tvshows" | "editorpicks";
}
function FeaturedCarouselSkeleton({ shorter }: { shorter?: boolean }) {
return (
<div
className={classNames(
"relative w-full transition-[height] duration-300 ease-in-out",
shorter ? "h-[75vh]" : "h-[75vh] md:h-[100vh]",
)}
>
<div className="relative w-full h-full overflow-hidden">
<div
className="absolute inset-0 bg-gray-800"
style={{
maskImage:
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 500px)",
WebkitMaskImage:
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 500px)",
}}
/>
</div>
{/* Navigation Buttons Skeleton */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30">
<div className="w-8 h-8 bg-gray-600 rounded-full animate-pulse" />
</div>
<div className="absolute right-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30">
<div className="w-8 h-8 bg-gray-600 rounded-full animate-pulse" />
</div>
{/* Navigation Dots Skeleton */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[19] flex gap-2">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => (
<div
key={i}
className="w-2.5 h-2.5 rounded-full bg-gray-600 animate-pulse"
/>
))}
</div>
{/* Content Overlay Skeleton */}
<div className="absolute inset-0 flex items-end pb-20 z-10">
<div className="container mx-auto px-8 md:px-4">
<div className="max-w-3xl">
<div className="h-12 w-48 bg-gray-600 rounded animate-pulse mb-6" />
<div className="space-y-2 mb-6">
<div className="h-4 bg-gray-600 rounded animate-pulse w-3/4" />
<div className="h-4 bg-gray-600 rounded animate-pulse w-1/2" />
</div>
<div className="flex gap-4 justify-center items-center sm:justify-start">
<div className="h-10 w-32 bg-gray-600 rounded animate-pulse" />
<div className="h-10 w-32 bg-gray-600 rounded animate-pulse" />
</div>
</div>
</div>
</div>
</div>
);
}
export function FeaturedCarousel({
onShowDetails,
children,
searching,
shorter,
forcedCategory,
}: FeaturedCarouselProps) {
const { selectedCategory } = useDiscoverStore();
const effectiveCategory = forcedCategory || selectedCategory;
const [currentIndex, setCurrentIndex] = useState(0);
const [isAutoPlaying, setIsAutoPlaying] = useState(true);
const [media, setMedia] = useState<FeaturedMedia[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [logoUrl, setLogoUrl] = useState<string | undefined>();
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const logoFetchController = useRef<AbortController | null>(null);
const autoPlayInterval = useRef<NodeJS.Timeout | null>(null);
const navigate = useNavigate();
const enableImageLogos = usePreferencesStore(
(state) => state.enableImageLogos,
);
const userLanguage = useLanguageStore.getState().language;
const formattedLanguage = getTmdbLanguageCode(userLanguage);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const SLIDE_QUANTITY = 10;
const SLIDE_QUANTITY_EDITOR_PICKS_MOVIES = 6;
const SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS = 4;
const SLIDE_DURATION = 8000;
// Fetch featured media
useEffect(() => {
const fetchFeaturedMedia = async () => {
setIsLoading(true);
setLogoUrl(undefined); // Clear logo when media changes
if (logoFetchController.current) {
logoFetchController.current.abort(); // Cancel any in-progress logo fetches
}
try {
if (effectiveCategory === "movies") {
const data = await get<any>("/movie/popular", {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
setMedia(
data.results.slice(0, SLIDE_QUANTITY).map((movie: any) => ({
...movie,
type: "movie" as const,
})),
);
} else if (effectiveCategory === "tvshows") {
const data = await get<any>("/tv/popular", {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
setMedia(
data.results.slice(0, SLIDE_QUANTITY).map((show: any) => ({
...show,
type: "show" as const,
})),
);
} else if (effectiveCategory === "editorpicks") {
// Fetch editor picks movies
const moviePromises = EDITOR_PICKS_MOVIES.slice(
0,
SLIDE_QUANTITY_EDITOR_PICKS_MOVIES,
).map((item) =>
get<any>(`/movie/${item.id}`, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
}),
);
// Fetch editor picks TV shows
const showPromises = EDITOR_PICKS_TV_SHOWS.slice(
0,
SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS,
).map((item) =>
get<any>(`/tv/${item.id}`, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
}),
);
const [movieResults, showResults] = await Promise.all([
Promise.all(moviePromises),
Promise.all(showPromises),
]);
const movies = movieResults.map((movie) => ({
...movie,
type: "movie" as const,
}));
const shows = showResults.map((show) => ({
...show,
type: "show" as const,
}));
// Combine and shuffle
const combined = [...movies, ...shows].sort(
() => 0.5 - Math.random(),
);
setMedia(combined);
}
} catch (error) {
console.error("Error fetching featured media:", error);
} finally {
setIsLoading(false);
}
};
fetchFeaturedMedia();
}, [formattedLanguage, effectiveCategory]);
const handlePrevSlide = () => {
setCurrentIndex((prev) => (prev - 1 + media.length) % media.length);
// Reset autoplay timer
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
}
if (isAutoPlaying) {
autoPlayInterval.current = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % media.length);
}, 5000);
}
};
const handleNextSlide = () => {
setCurrentIndex((prev) => (prev + 1) % media.length);
// Reset autoplay timer
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
}
if (isAutoPlaying) {
autoPlayInterval.current = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % media.length);
}, 5000);
}
};
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStart(e.targetTouches[0].clientX);
};
const handleTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.targetTouches[0].clientX);
};
const handleTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const minSwipeDistance = 50;
if (Math.abs(distance) > minSwipeDistance) {
if (distance > 0) {
handleNextSlide();
} else {
handlePrevSlide();
}
}
setTouchStart(null);
setTouchEnd(null);
};
// Fetch logo when current media changes
useEffect(() => {
const fetchLogo = async () => {
// Cancel any in-progress logo fetch
if (logoFetchController.current) {
logoFetchController.current.abort();
}
// Create new abort controller for this fetch
logoFetchController.current = new AbortController();
const currentMediaId = media[currentIndex]?.id;
if (!currentMediaId) {
setLogoUrl(undefined);
return;
}
try {
const logo = await getMediaLogo(
currentMediaId.toString(),
media[currentIndex].type === "movie"
? TMDBContentTypes.MOVIE
: TMDBContentTypes.TV,
);
// Only update if this is still the current media
if (media[currentIndex]?.id === currentMediaId) {
setLogoUrl(logo);
}
} catch (error: unknown) {
if (error instanceof Error && error.name === "AbortError") {
// Ignore abort errors
return;
}
console.error("Error fetching logo:", error);
setLogoUrl(undefined);
}
};
fetchLogo();
return () => {
if (logoFetchController.current) {
logoFetchController.current.abort();
}
};
}, [currentIndex, media]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (logoFetchController.current) {
logoFetchController.current.abort();
}
};
}, []);
useEffect(() => {
if (isAutoPlaying && media.length > 0) {
autoPlayInterval.current = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % media.length);
}, SLIDE_DURATION);
}
return () => {
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
}
};
}, [isAutoPlaying, media.length]);
if (isLoading) {
return <FeaturedCarouselSkeleton shorter={shorter} />;
}
if (media.length === 0) {
return <FeaturedCarouselSkeleton shorter={shorter} />;
}
const currentMedia = media[currentIndex];
const mediaTitle = currentMedia.title || currentMedia.name;
let searchClasses = "";
if (searching) searchClasses = "opacity-0 transition-opacity duration-300";
else searchClasses = "opacity-100 transition-opacity duration-300";
return (
<div
className={classNames(
"relative w-full transition-[height] duration-300 ease-in-out",
searching
? "h-24"
: shorter
? windowHeight > 600
? "h-[40rem] md:h-[85vh]"
: "h-[100vh]"
: "h-[40rem] md:h-[100vh]",
)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
className={classNames(
"relative w-full h-full overflow-hidden",
searchClasses,
)}
>
{media.map((item, index) => (
<div
key={item.id}
className={`absolute inset-0 transition-opacity duration-1000 ${
index === currentIndex ? "opacity-100" : "opacity-0"
}`}
style={{
backgroundImage: `url(https://image.tmdb.org/t/p/original${item.backdrop_path})`,
backgroundSize: "cover",
backgroundPosition: "center top",
maskImage:
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 700px)",
WebkitMaskImage:
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 700px)",
}}
/>
))}
</div>
{/* Navigation Buttons */}
<button
type="button"
onClick={handlePrevSlide}
className={classNames(
"absolute left-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors",
searchClasses,
)}
aria-label="Previous slide"
>
<Icon icon={Icons.CHEVRON_LEFT} className="text-white w-8 h-8" />
</button>
<button
type="button"
onClick={handleNextSlide}
className={classNames(
"absolute right-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors",
searchClasses,
)}
aria-label="Next slide"
>
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white w-8 h-8" />
</button>
{/* Navigation Dots */}
<div
className={classNames(
"absolute bottom-8 left-1/2 -translate-x-1/2 z-[19] flex gap-2",
searchClasses,
)}
>
{media.map((item, index) => (
<button
key={`dot-${item.id}`}
type="button"
onClick={() => {
setCurrentIndex(index);
// Reset autoplay timer when clicking dots
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
}
if (isAutoPlaying) {
autoPlayInterval.current = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % media.length);
}, 5000);
}
}}
className={`w-2.5 h-2.5 rounded-full transition-all ${
index === currentIndex
? "bg-white scale-125"
: "bg-white/50 hover:bg-white/75"
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
{/* Content Overlay */}
<div
className={classNames(
"absolute inset-0 flex items-end pb-20 z-10",
searchClasses,
)}
>
<div className="container mx-auto px-8 md:px-4 flex justify-between items-end w-full">
<div className="max-w-3xl">
{logoUrl && enableImageLogos ? (
<img
src={logoUrl}
alt={mediaTitle}
className="max-w-[14rem] md:max-w-[22rem] max-h-[20vh] object-contain drop-shadow-lg bg-transparent mb-6"
style={{ background: "none" }}
/>
) : (
<h1 className="text-4xl md:text-6xl font-bold text-white mb-4">
{mediaTitle}
</h1>
)}
<p className="text-lg text-white mb-6 line-clamp-3 md:line-clamp-4">
{currentMedia.overview}
</p>
<div
className="flex gap-4 justify-center items-center sm:justify-start"
onMouseEnter={() => setIsAutoPlaying(false)}
onMouseLeave={() => setIsAutoPlaying(true)}
>
<Button
onClick={() =>
navigate(
`/media/tmdb-${currentMedia.type}-${currentMedia.id}-${mediaTitle?.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
)
}
theme="secondary"
className="w-full sm:w-auto text-base"
>
<Icon icon={Icons.PLAY} className="text-white" />
<span className="text-white">
{t("discover.featured.playNow")}
</span>
</Button>
<Button
onClick={() => onShowDetails(currentMedia)}
theme="secondary"
className="w-full sm:w-auto text-base"
>
<Icon
icon={Icons.CIRCLE_QUESTION}
className="text-white scale-100"
/>
<span className="text-white">
{t("discover.featured.moreInfo")}
</span>
</Button>
</div>
</div>
<div className="hidden lg:block">
<RandomMovieButton />
</div>
</div>
</div>
{children && (
<div
className={classNames(
"absolute inset-0 pointer-events-none",
windowWidth > 1280 ? "pt-0" : "pt-14",
)}
>
<div className="pointer-events-auto z-50">{children}</div>
</div>
)}
</div>
);
}

View file

@ -1,23 +1,36 @@
import { useEffect, useState } from "react";
import { Category, Genre, Media } from "@/pages/discover/common";
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,
Genre,
Media,
Movie,
TVShow,
categories,
tvCategories,
} from "../common";
interface LazyMediaCarouselProps {
category?: Category | null;
genre?: Genre | null;
category?: Category;
genre?: Genre;
mediaType: "movie" | "tv";
isMobile: boolean;
carouselRefs: React.MutableRefObject<{
[key: string]: HTMLDivElement | null;
}>;
preloadedMedia?: Media[];
title?: string;
onShowDetails?: (media: MediaItem) => void;
preloadedMedia?: Movie[] | TVShow[];
genreId?: number;
title?: string;
relatedButtons?: Array<{ name: string; id: string }>;
onButtonClick?: (id: string, name: string) => void;
moreContent?: boolean;
}
export function LazyMediaCarousel({
@ -26,11 +39,20 @@ export function LazyMediaCarousel({
mediaType,
isMobile,
carouselRefs,
preloadedMedia,
title,
onShowDetails,
preloadedMedia,
genreId,
title,
relatedButtons,
onButtonClick,
moreContent,
}: LazyMediaCarouselProps) {
const [medias, setMedias] = useState<Media[]>([]);
const [loading, setLoading] = useState(!preloadedMedia);
const categoryData = (mediaType === "movie" ? categories : tvCategories).find(
(c: Category) => c.name === (category?.name || genre?.name || title || ""),
);
// Use intersection observer to detect when this component is visible
const { targetRef, isIntersecting } = useIntersectionObserver(
@ -38,7 +60,7 @@ export function LazyMediaCarousel({
);
// Use the lazy loading hook only if we don't have preloaded media
const { media, isLoading } = useLazyTMDBData(
const { media } = useLazyTMDBData(
!preloadedMedia ? genre || null : null,
!preloadedMedia ? category || null : null,
mediaType,
@ -49,21 +71,48 @@ export function LazyMediaCarousel({
useEffect(() => {
if (preloadedMedia) {
setMedias(preloadedMedia);
setLoading(false);
} else if (media.length > 0) {
setMedias(media);
setLoading(false);
}
}, [media, preloadedMedia]);
const categoryName = title || category?.name || genre?.name || "";
// Only fetch category content if we don't have preloaded media
useEffect(() => {
if (preloadedMedia || !categoryData) 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);
} catch (error) {
console.error("Error fetching content:", error);
} finally {
setLoading(false);
}
};
fetchContent();
}, [categoryData, preloadedMedia]);
const categoryName = category?.name || genre?.name || title || "";
const categorySlug = `${categoryName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${mediaType}`;
// Test intersection observer
// useEffect(() => {
// // eslint-disable-next-line no-console
// console.log(
// `Carousel ${categoryName}: ${isIntersecting ? "loaded ✅" : "unloaded ❌"}`,
// );
// }, [isIntersecting, categoryName]);
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>}>
@ -75,6 +124,15 @@ export function LazyMediaCarousel({
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={onShowDetails}
genreId={genreId}
relatedButtons={relatedButtons}
onButtonClick={onButtonClick}
moreContent={moreContent}
moreLink={
categoryData
? `/discover/more/category/${categoryData.urlPath}/${categoryData.mediaType}`
: undefined
}
/>
) : (
<div className="relative overflow-hidden carousel-container">
@ -84,7 +142,7 @@ export function LazyMediaCarousel({
</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">
{isLoading ? "Loading..." : ""}
Loading...
</div>
</div>
</div>

View file

@ -1,9 +1,18 @@
import { Listbox } from "@headlessui/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useWindowSize } from "react-use";
import { Dropdown, OptionItem } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon";
import { MediaCard } from "@/components/media/MediaCard";
import { Flare } from "@/components/utils/Flare";
import { Media } from "@/pages/discover/common";
import { useDiscoverStore } from "@/stores/discover";
import { MediaItem } from "@/utils/mediaTypes";
import { MOVIE_PROVIDERS, TV_PROVIDERS } from "../discoverContent";
import { CarouselNavButtons } from "./CarouselNavButtons";
interface MediaCarouselProps {
@ -15,6 +24,14 @@ interface MediaCarouselProps {
[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;
}
function MediaCardSkeleton() {
@ -28,6 +45,36 @@ function MediaCardSkeleton() {
);
}
function MoreCard({ link }: { link: string }) {
const { t } = useTranslation();
return (
<div 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">
<Link to={link} className="block">
<Flare.Base className="group -m-[0.705em] h-[20rem] hover:scale-95 transition-all rounded-xl bg-background-main duration-300 hover:bg-mediaCard-hoverBackground tabbable">
<Flare.Light
flareSize={300}
cssColorVar="--colors-mediaCard-hoverAccent"
backgroundClass="bg-mediaCard-hoverBackground duration-100"
className="rounded-xl bg-background-main group-hover:opacity-100"
/>
<Flare.Child className="pointer-events-auto h-[20rem] relative mb-2 p-[0.4em] transition-transform duration-300">
<div className="flex absolute inset-0 flex-col items-center justify-center">
<Icon
icon={Icons.ARROW_RIGHT}
className="text-4xl mb-2 transition-transform duration-300"
/>
<span className="text-sm text-center px-2">
{t("discover.carousel.more")}
</span>
</div>
</Flare.Child>
</Flare.Base>
</Link>
</div>
);
}
export function MediaCarousel({
medias,
category,
@ -35,8 +82,21 @@ export function MediaCarousel({
isMobile,
carouselRefs,
onShowDetails,
genreId,
moreContent,
moreLink,
relatedButtons,
onButtonClick,
recommendationSources,
selectedRecommendationSource,
onRecommendationSourceChange,
}: MediaCarouselProps) {
const { t } = useTranslation();
const { width: windowWidth } = useWindowSize();
const { setLastView } = useDiscoverStore();
const [selectedGenre, setSelectedGenre] = React.useState<OptionItem | null>(
null,
);
const categorySlug = `${category.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
const browser = !!window.chrome;
let isScrolling = false;
@ -81,7 +141,36 @@ export function MediaCarousel({
}
if (categoryName === "Editor Picks") {
return t("discover.carousel.title.editorPicks");
return isTVShow
? t("discover.carousel.title.editorPicksShows")
: t("discover.carousel.title.editorPicksMovies");
}
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 });
}
if (categoryName.includes("Because You Watched")) {
return t("discover.carousel.title.recommended", {
title: categoryName.split("Because You Watched:")[1],
});
}
return isTVShowCondition
@ -101,15 +190,208 @@ export function MediaCarousel({
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,
scrollPosition: window.scrollY,
});
};
return (
<>
<h2 className="ml-2 md:ml-8 mt-2 text-2xl cursor-default font-bold text-white md:text-2xl mx-auto pl-5 text-balance">
{displayCategory}
</h2>
<div className="relative overflow-hidden carousel-container">
<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}
</h2>
{recommendationSources &&
recommendationSources.length > 0 &&
onRecommendationSourceChange && (
<div className="relative pr-4">
<Dropdown
selectedItem={
recommendationSources.find(
(s) => s.id === selectedRecommendationSource,
)
? {
id: selectedRecommendationSource || "",
name:
recommendationSources.find(
(s) => s.id === selectedRecommendationSource,
)?.title || "",
}
: {
id: "",
name: recommendationSources[0]?.title || "",
}
}
setSelectedItem={(item) =>
onRecommendationSourceChange(item.id)
}
options={recommendationSources.map((source) => ({
id: source.id,
name: source.title,
}))}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{t("discover.carousel.change")}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="right"
customMenu={
<Listbox.Options static className="py-1">
{recommendationSources.map((opt) => (
<Listbox.Option
className={({ active }) =>
`cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
active
? "bg-background-secondaryHover text-type-link"
: "text-type-secondary"
}`
}
key={opt.id}
value={{ id: opt.id, name: opt.title }}
>
{({ selected }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.title}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
</div>
{moreContent && (
<Link
to={
moreLink ||
`/discover/more/${categorySlug}${genreId ? `/${genreId}` : ""}`
}
onClick={handleMoreClick}
className="flex px-5 items-center hover:text-type-link transition-colors"
>
<span className="text-sm">{t("discover.carousel.more")}</span>
<Icon className="text-sm ml-1" icon={Icons.ARROW_RIGHT} />
</Link>
)}
</div>
{relatedButtons && relatedButtons.length > 0 && (
<div className="flex items-center space-x-2 mr-6">
{visibleButtons?.map((button) => (
<button
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"
>
{button.name}
</button>
))}
{dropdownButtons && dropdownButtons.length > 0 && (
<div className="relative my-0">
<Dropdown
selectedItem={
selectedGenre || {
id: "",
name:
activeButton &&
!visibleButtons.find(
(btn) => btn.id === activeButton.id,
)
? activeButton.name
: "...",
}
}
setSelectedItem={(item) => {
setSelectedGenre(item);
onButtonClick?.(item.id, item.name);
}}
options={dropdownOptions}
customButton={
<button
type="button"
className="px-3 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>
{activeButton &&
!visibleButtons.find(
(btn) => btn.id === activeButton.id,
)
? activeButton.name
: "..."}
</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="right"
preventWrap
/>
</div>
)}
</div>
)}
</div>
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${categorySlug}`}
className="grid grid-flow-col auto-cols-max gap-4 pt-0 pb-4 overflow-x-scroll scrollbar rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
ref={(el) => {
carouselRefs.current[categorySlug] = el;
}}
@ -152,6 +434,15 @@ export function MediaCarousel({
/>
))}
{moreContent && (
<MoreCard
link={
moreLink ||
`/discover/more/${categorySlug}${genreId ? `/${genreId}` : ""}`
}
/>
)}
<div className="md:w-12" />
</div>

View file

@ -1,63 +1,124 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { get } from "@/backend/metadata/tmdb";
import { Movie } from "@/pages/discover/common";
import { conf } from "@/setup/config";
import { useLanguageStore } from "@/stores/language";
import { getTmdbLanguageCode } from "@/utils/language";
interface RandomMovieButtonProps {
countdown: number | null;
onClick: () => void;
randomMovieTitle: string | null;
interface TMDBMovieResponse {
results: Movie[];
}
export function RandomMovieButton({
countdown,
onClick,
randomMovieTitle,
}: RandomMovieButtonProps) {
const { t } = useTranslation();
export function RandomMovieButton() {
const [randomMovie, setRandomMovie] = useState<Movie | null>(null);
const [countdown, setCountdown] = useState<number | null>(null);
const [countdownTimeout, setCountdownTimeout] =
useState<NodeJS.Timeout | null>(null);
const [movies, setMovies] = useState<Movie[]>([]);
const navigate = useNavigate();
const userLanguage = useLanguageStore.getState().language;
const formattedLanguage = getTmdbLanguageCode(userLanguage);
// Fetch popular movies for random selection
useEffect(() => {
const fetchMovies = async () => {
try {
const data = await get<TMDBMovieResponse>("/movie/popular", {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
page: 2,
});
setMovies(data.results);
} catch (error) {
console.error("Error fetching popular movies:", error);
}
};
fetchMovies();
}, [formattedLanguage]);
useEffect(() => {
let countdownInterval: NodeJS.Timeout;
if (countdown !== null && countdown > 0) {
countdownInterval = setInterval(() => {
setCountdown((prev) => (prev !== null ? prev - 1 : prev));
}, 1000);
}
return () => clearInterval(countdownInterval);
}, [countdown]);
const handleRandomMovieClick = () => {
if (movies.length === 0) return;
const uniqueTitles = new Set(movies.map((movie) => movie.title));
const uniqueTitlesArray = Array.from(uniqueTitles);
const randomIndex = Math.floor(Math.random() * uniqueTitlesArray.length);
const selectedMovie = movies.find(
(movie) => movie.title === uniqueTitlesArray[randomIndex],
);
if (selectedMovie) {
if (countdown !== null && countdown > 0) {
setCountdown(null);
if (countdownTimeout) {
clearTimeout(countdownTimeout);
setCountdownTimeout(null);
setRandomMovie(null);
}
} else {
setRandomMovie(selectedMovie);
setCountdown(5);
const timeoutId = setTimeout(() => {
navigate(`/media/tmdb-movie-${selectedMovie.id}-random`);
}, 5000);
setCountdownTimeout(timeoutId);
}
}
};
return (
<div className="w-full max-w-screen-xl mx-auto px-4">
<div className="flex items-center justify-center">
<button
type="button"
className="flex items-center space-x-2 rounded-full px-4 text-white py-2 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover transition-[background,transform] duration-100 hover:scale-105"
onClick={onClick}
<div className="flex justify-center items-center">
<button
type="button"
className={`
relative flex items-center overflow-hidden
rounded-full text-white h-10
bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover
transition-all duration-300 ease-in-out
${countdown !== null && countdown > 0 ? "min-w-[10px] pl-3" : "w-10"}
`}
onClick={handleRandomMovieClick}
>
{/* Title container that slides in */}
<div
className={`
relative whitespace-nowrap
transition-all duration-300 ease-in-out
${countdown !== null && countdown > 0 ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-4"}
`}
>
<span className="flex items-center">
{countdown !== null && countdown > 0 ? (
<div className="flex items-center">
<span>{t("discover.randomMovie.cancel")}</span>
<Icon
icon={Icons.X}
className="text-2xl ml-[4.5px] mb-[-0.7px]"
/>
</div>
) : (
<div className="flex items-center">
<span>{t("discover.randomMovie.button")}</span>
<img
src="/lightbar-images/dice.svg"
alt="Dice"
style={{ marginLeft: "8px" }}
/>
</div>
)}
</span>
</button>
</div>
{/* Random Movie Countdown */}
{randomMovieTitle && countdown !== null && (
<div className="mt-4 mb-4 text-center">
<p>
{t("discover.randomMovie.nowPlaying")}{" "}
<span className="font-bold">{randomMovieTitle}</span>{" "}
{t("discover.randomMovie.in")}{" "}
{t("discover.randomMovie.countdown", { countdown })}
</p>
{countdown !== null && countdown > 0 && (
<span className="font-bold">{randomMovie?.title}</span>
)}
</div>
)}
{/* Icon container that stays fixed on the right */}
<div className="ml-auto flex items-center justify-center w-10 h-10">
{countdown !== null && countdown > 0 ? (
<div className="animate-[pulse_1s_ease-in-out_infinite] text-lg font-bold">
{countdown}
</div>
) : (
<img
src="/lightbar-images/dice.svg"
alt="Dice"
className="w-6 h-6"
/>
)}
</div>
</button>
</div>
);
}

View file

@ -40,7 +40,7 @@ export function ScrollToTopButton() {
<button
type="button"
onClick={scrollToTop}
className={`relative flex items-center justify-center space-x-2 rounded-full px-4 py-3 text-lg font-semibold text-white bg-pill-background bg-opacity-80 hover:bg-pill-backgroundHover transition-opacity hover:scale-105 duration-500 ease-in-out ${
className={`relative backdrop-blur-sm flex items-center justify-center space-x-2 rounded-full px-4 py-3 text-lg font-semibold text-white bg-pill-background bg-opacity-80 hover:bg-pill-backgroundHover transition-opacity hover:scale-105 duration-500 ease-in-out ${
isVisible ? "opacity-100 visible" : "opacity-0 invisible"
}`}
style={{

View file

@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { get } from "@/backend/metadata/tmdb";
import { DetailsModal } from "@/components/overlays/DetailsModal";
@ -9,46 +8,72 @@ import { useIsMobile } from "@/hooks/useIsMobile";
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 { CategoryButtons } from "./components/CategoryButtons";
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 { RandomMovieButton } from "./components/RandomMovieButton";
import { ScrollToTopButton } from "./components/ScrollToTopButton";
import { useTMDBData } from "./hooks/useTMDBData";
const MOVIE_PROVIDERS = [
// 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: "Disney Plus", id: "337" },
{ 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" },
];
const TV_PROVIDERS = [
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
const EDITOR_PICKS_MOVIES = [
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
@ -80,9 +105,9 @@ const EDITOR_PICKS_MOVIES = [
{ id: 18971, type: "movie" }, // Rosencrantz and Guildenstern Are Dead
{ id: 26388, type: "movie" }, // Buried
{ id: 152601, type: "movie" }, // Her
];
]);
const EDITOR_PICKS_TV_SHOWS = [
export const EDITOR_PICKS_TV_SHOWS = shuffleArray([
{ id: 456, type: "show" }, // The Simpsons
{ id: 73021, type: "show" }, // Disenchantment
{ id: 1434, type: "show" }, // Family Guy
@ -99,41 +124,50 @@ const EDITOR_PICKS_TV_SHOWS = [
{ id: 93405, type: "show" }, // Squid Game
{ id: 87108, type: "show" }, // Chernobyl
{ id: 105248, type: "show" }, // Cyberpunk: Edgerunners
];
]);
export function DiscoverContent() {
// State management
const [selectedCategory, setSelectedCategory] = useState("movies");
const [genres, setGenres] = useState<Genre[]>([]);
const [tvGenres, setTVGenres] = useState<Genre[]>([]);
const [randomMovie, setRandomMovie] = useState<Movie | null>(null);
const [countdown, setCountdown] = useState<number | null>(null);
const [countdownTimeout, setCountdownTimeout] =
useState<NodeJS.Timeout | null>(null);
const { 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<any[]>([]);
const [editorPicksMovies, setEditorPicksMovies] = useState<Movie[]>([]);
const [editorPicksTVShows, setEditorPicksTVShows] = useState<any[]>([]);
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();
// Refs
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
// Hooks
const navigate = useNavigate();
const { isMobile } = useIsMobile();
const { genreMedia: genreMovies } = useTMDBData(genres, categories, "movie");
// const { genreMedia: genreTVShows } = useTMDBData(
// tvGenres,
// tvCategories,
// "tv",
// );
const { t } = useTranslation();
const userLanguage = useLanguageStore.getState().language;
const formattedLanguage = getTmdbLanguageCode(userLanguage);
@ -143,6 +177,93 @@ export function DiscoverContent() {
const isTVShowsTab = selectedCategory === "tvshows";
const isEditorPicksTab = selectedCategory === "editorpicks";
const handleCategoryChange = (category: string) => {
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;
@ -153,8 +274,7 @@ export function DiscoverContent() {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
// Fetch only the first 10 TV show genres
setTVGenres(data.genres.slice(0, 10));
setTVGenres(data.genres.slice(0, 50));
} catch (error) {
console.error("Error fetching TV show genres:", error);
}
@ -173,9 +293,7 @@ export function DiscoverContent() {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
// Fetch only the first 12 genres
setGenres(data.genres.slice(0, 12));
setGenres(data.genres.slice(0, 50));
} catch (error) {
console.error("Error fetching genres:", error);
}
@ -199,9 +317,11 @@ export function DiscoverContent() {
);
const results = await Promise.all(moviePromises);
// Shuffle the results to display them randomly
const shuffled = [...results].sort(() => 0.5 - Math.random());
setEditorPicksMovies(shuffled);
const moviesWithType = results.map((movie) => ({
...movie,
type: "movie" as const,
}));
setFilteredGenreMovies(moviesWithType);
} catch (error) {
console.error("Error fetching editor picks movies:", error);
}
@ -225,9 +345,11 @@ export function DiscoverContent() {
);
const results = await Promise.all(tvShowPromises);
// Shuffle the results to display them randomly
const shuffled = [...results].sort(() => 0.5 - Math.random());
setEditorPicksTVShows(shuffled);
const showsWithType = results.map((show) => ({
...show,
type: "show" as const,
}));
setFilteredGenreTVShows(showsWithType);
} catch (error) {
console.error("Error fetching editor picks TV shows:", error);
}
@ -236,95 +358,137 @@ export function DiscoverContent() {
fetchEditorPicksTVShows();
}, [isEditorPicksTab, formattedLanguage]);
// Update recommendations effect to store multiple sources
useEffect(() => {
let countdownInterval: NodeJS.Timeout;
if (countdown !== null && countdown > 0) {
countdownInterval = setInterval(() => {
setCountdown((prev) => (prev !== null ? prev - 1 : prev));
}, 1000);
}
return () => clearInterval(countdownInterval);
}, [countdown]);
const fetchRecommendations = async () => {
if (!progressStore.items || Object.keys(progressStore.items).length === 0)
return;
// Handlers
const handleCategoryChange = (
eventOrValue: React.ChangeEvent<HTMLSelectElement> | string,
) => {
const value =
typeof eventOrValue === "string"
? eventOrValue
: eventOrValue.target.value;
setSelectedCategory(value);
};
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",
);
const handleRandomMovieClick = () => {
const allMovies = Object.values(genreMovies).flat();
const uniqueTitles = new Set(allMovies.map((movie) => movie.title));
const uniqueTitlesArray = Array.from(uniqueTitles);
const randomIndex = Math.floor(Math.random() * uniqueTitlesArray.length);
const selectedMovie = allMovies.find(
(movie) => movie.title === uniqueTitlesArray[randomIndex],
);
// Store all movie sources
if (movies.length > 0) {
const movieSources = movies.map(([id, item]) => ({
id,
title: item.title || "",
}));
setMovieRecommendationSources(movieSources);
if (selectedMovie) {
if (countdown !== null && countdown > 0) {
setCountdown(null);
if (countdownTimeout) {
clearTimeout(countdownTimeout);
setCountdownTimeout(null);
setRandomMovie(null);
// Set initial source if not set
if (!selectedMovieSource && movieSources.length > 0) {
setSelectedMovieSource(movieSources[0].id);
}
}
} else {
setRandomMovie(selectedMovie as Movie);
setCountdown(5);
const timeoutId = setTimeout(() => {
navigate(`/media/tmdb-movie-${selectedMovie.id}-discover-random`);
}, 5000);
setCountdownTimeout(timeoutId);
// 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);
}
}
};
};
const handleProviderClick = async (id: string, name: string) => {
try {
setSelectedProvider({ name, id });
const endpoint =
selectedCategory === "movies" ? "/discover/movie" : "/discover/tv";
const setData =
selectedCategory === "movies" ? setProviderMovies : setProviderTVShows;
const data = await get<any>(endpoint, {
api_key: conf().TMDB_READ_API_KEY,
with_watch_providers: id,
watch_region: "US",
language: formattedLanguage,
});
setData(data.results);
} catch (error) {
console.error("Error fetching provider movies/shows:", error);
}
};
fetchRecommendations();
}, [progressStore.items, selectedMovieSource, selectedTVSource]);
const handleCategoryClick = (id: string, name: string) => {
// Try both movie and tv versions of the category slug
const categorySlugBase = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
const movieElement = document.getElementById(
`carousel-${categorySlugBase}-movie`,
);
const tvElement = document.getElementById(
`carousel-${categorySlugBase}-tv`,
);
// Add new effect to fetch recommendations when source changes
useEffect(() => {
const fetchRecommendationsForSource = async () => {
if (!selectedMovieSource && !selectedTVSource) return;
// Scroll to the first element that exists
const element = movieElement || tvElement;
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
};
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,
},
);
const handleShowDetails = async (media: MediaItem) => {
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,
]);
const handleShowDetails = async (media: MediaItem | FeaturedMedia) => {
setDetailsData({
id: Number(media.id),
type: media.type === "movie" ? "movie" : "show",
@ -337,20 +501,22 @@ export function DiscoverContent() {
return (
<>
<LazyMediaCarousel
preloadedMedia={editorPicksMovies}
preloadedMedia={filteredGenreMovies}
title="Editor Picks"
mediaType="movie"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
<LazyMediaCarousel
preloadedMedia={editorPicksTVShows}
preloadedMedia={filteredGenreTVShows}
title="Editor Picks"
mediaType="tv"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
</>
);
@ -360,41 +526,86 @@ export function DiscoverContent() {
const renderMoviesContent = () => {
return (
<>
{/* Provider Movies */}
{providerMovies.length > 0 && (
{/* Movie Recommendations */}
{movieRecommendations.length > 0 && (
<MediaCarousel
medias={providerMovies}
category={selectedProvider.name}
medias={movieRecommendations}
category={movieRecommendationTitle}
isTVShow={false}
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreLink={`/discover/more/recommendations/${movieRecommendationSourceId}/movie`}
moreContent
recommendationSources={movieRecommendationSources}
selectedRecommendationSource={selectedMovieSource}
onRecommendationSourceChange={setSelectedMovieSource}
/>
)}
{/* Categories */}
{categories.map((category) => (
<LazyMediaCarousel
key={category.name}
category={category}
mediaType="movie"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
))}
{/* In Cinemas */}
<LazyMediaCarousel
category={categories[0]}
mediaType="movie"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Genres */}
{genres.map((genre) => (
<LazyMediaCarousel
key={genre.id}
genre={genre}
mediaType="movie"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
))}
{/* Top Rated */}
<LazyMediaCarousel
category={categories[1]}
mediaType="movie"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Popular */}
<LazyMediaCarousel
category={categories[2]}
mediaType="movie"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Provider Movies */}
<MediaCarousel
medias={providerMovies}
category={`Movies on ${selectedProvider.name || ""}`}
isTVShow={false}
isMobile={isMobile}
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`}
moreContent
/>
{/* Genre Movies */}
<MediaCarousel
medias={filteredGenreMovies}
category={`${selectedGenre.name || ""}`}
isTVShow={false}
isMobile={isMobile}
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`}
moreContent
/>
</>
);
};
@ -403,106 +614,96 @@ export function DiscoverContent() {
const renderTVShowsContent = () => {
return (
<>
{/* Provider TV Shows */}
{providerTVShows.length > 0 && (
{/* TV Show Recommendations */}
{tvRecommendations.length > 0 && (
<MediaCarousel
medias={providerTVShows}
category={selectedProvider.name}
medias={tvRecommendations}
category={tvRecommendationTitle}
isTVShow
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreLink={`/discover/more/recommendations/${tvRecommendationSourceId}/tv`}
moreContent
recommendationSources={tvRecommendationSources}
selectedRecommendationSource={selectedTVSource}
onRecommendationSourceChange={setSelectedTVSource}
/>
)}
{/* Categories */}
{tvCategories.map((category) => (
<LazyMediaCarousel
key={category.name}
category={category}
mediaType="tv"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
))}
{/* On Air */}
<LazyMediaCarousel
category={tvCategories[0]}
mediaType="tv"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Genres */}
{tvGenres.map((genre) => (
<LazyMediaCarousel
key={genre.id}
genre={genre}
mediaType="tv"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
))}
{/* Top Rated */}
<LazyMediaCarousel
category={tvCategories[1]}
mediaType="tv"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Popular */}
<LazyMediaCarousel
category={tvCategories[2]}
mediaType="tv"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Provider TV Shows */}
<MediaCarousel
medias={providerTVShows}
category={`Shows on ${selectedProvider.name || ""}`}
isTVShow
isMobile={isMobile}
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`}
moreContent
/>
{/* Genre TV Shows */}
<MediaCarousel
medias={filteredGenreTVShows}
category={`${selectedGenre.name || ""}`}
isTVShow
isMobile={isMobile}
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`}
moreContent
/>
</>
);
};
return (
<div className="pt-6">
{/* Random Movie Button */}
<RandomMovieButton
countdown={countdown}
onClick={handleRandomMovieClick}
randomMovieTitle={randomMovie ? randomMovie.title : null}
<div className="relative min-h-screen">
<DiscoverNavigation
selectedCategory={selectedCategory}
onCategoryChange={handleCategoryChange}
/>
{/* Category Tabs */}
<div className="mt-8 pb-2 w-full max-w-screen-xl mx-auto">
<div className="relative flex justify-center mb-4">
<div className="flex space-x-4">
{["movies", "tvshows", "editorpicks"].map((category) => (
<button
key={category}
type="button"
className={`text-xl md:text-2xl font-bold p-2 bg-transparent text-center rounded-full cursor-pointer flex items-center transition-transform duration-200 ${
selectedCategory === category
? "transform scale-105 text-type-link"
: "text-type-secondary"
}`}
onClick={() => handleCategoryChange(category)}
>
{t(`discover.tabs.${category}`)}
</button>
))}
</div>
</div>
{/* Only show provider and genre buttons for movies and tvshows categories */}
{selectedCategory !== "editorpicks" && (
<>
<div className="flex justify-center overflow-x-auto">
<CategoryButtons
categories={
selectedCategory === "movies" ? MOVIE_PROVIDERS : TV_PROVIDERS
}
onCategoryClick={handleProviderClick}
categoryType="providers"
isMobile={isMobile}
showAlwaysScroll={false}
/>
</div>
<div className="flex overflow-x-auto">
<CategoryButtons
categories={
selectedCategory === "movies"
? [...categories, ...genres]
: [...tvCategories, ...tvGenres]
}
onCategoryClick={handleCategoryClick}
categoryType="movies"
isMobile={isMobile}
showAlwaysScroll
/>
</div>
</>
)}
</div>
{/* Content Section with Lazy Loading Tabs */}
<div className="w-full md:w-[90%] max-w-[2400px] mx-auto">
{/* Movies Tab */}

View file

@ -1,13 +1,36 @@
import { useEffect, useState } from "react";
import { FooterView } from "@/components/layout/Footer";
import { Navigation } from "@/components/layout/Navigation";
import { usePreferencesStore } from "@/stores/preferences";
export function HomeLayout(props: {
showBg: boolean;
children: React.ReactNode;
}) {
const enableFeatured = usePreferencesStore((state) => state.enableFeatured);
const [clearBackground, setClearBackground] = useState(false);
useEffect(() => {
const handleScroll = () => {
setClearBackground(Boolean(enableFeatured) && window.scrollY < 600);
};
window.addEventListener("scroll", handleScroll);
// Initial check
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [enableFeatured]);
return (
<FooterView>
<Navigation bg={props.showBg} />
<Navigation
bg={enableFeatured ? true : props.showBg}
clearBackground={clearBackground}
noLightbar={enableFeatured}
/>
{props.children}
</FooterView>
);

View file

@ -0,0 +1,140 @@
import { useCallback, useState } from "react";
import { Icon, Icons } from "@/components/Icon";
import { conf } from "@/setup/config";
function getCookie(name: string): string | null {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i += 1) {
const cookie = cookies[i].trim();
if (cookie.startsWith(`${name}=`)) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function setCookie(name: string, value: string, expiryDays: number): void {
const date = new Date();
date.setTime(date.getTime() + expiryDays * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/`;
}
export function AdsPart(): JSX.Element | null {
const [isAdDismissed, setIsAdDismissed] = useState(() => {
return getCookie("adDismissed") === "true";
});
const dismissAd = useCallback(() => {
setIsAdDismissed(true);
setCookie("adDismissed", "true", 2); // Expires after 2 days
}, []);
if (isAdDismissed) return null;
return (
<div className="w-fit max-w-[32rem] mx-auto relative group pb-4">
{(() => {
const adContentUrl = conf().AD_CONTENT_URL;
// VITE_AD_CONTENT_URL=default message (null will be nothing),referal link,image link, card message
// Ensure adContentUrl is an array. If not, render nothing for ads.
if (!Array.isArray(adContentUrl)) {
return null;
}
const ad1LinkIsValid =
typeof adContentUrl[1] === "string" && adContentUrl[1].length > 0;
const ad1ImageIsProvided = typeof adContentUrl[2] === "string";
const showAd1 =
adContentUrl.length >= 2 && ad1LinkIsValid && ad1ImageIsProvided;
const ad2LinkIsValid =
typeof adContentUrl[3] === "string" && adContentUrl[3].length > 0;
const ad2ImageIsProvided = typeof adContentUrl[4] === "string";
const showAd2 =
adContentUrl.length >= 5 && ad2LinkIsValid && ad2ImageIsProvided;
return (
<>
<div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0 justify-center w-full items-center md:items-start">
{showAd1 ? (
<div className="rounded-xl bg-background-main hover:scale-[1.02] max-w-[16rem] transition-all duration-300 md:flex-1 relative group">
<div className="bg-opacity-10 bg-buttons-purple rounded-xl border-2 border-buttons-purple border-opacity-30 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20">
{" "}
<button
onClick={dismissAd}
type="button"
className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label="Dismiss ad"
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.X}
/>
</button>
<a href={adContentUrl[1]} className="block">
<div className="overflow-hidden rounded-t-xl">
<img
src={adContentUrl[2]}
alt="ad banner"
className="w-full h-auto transition-transform duration-300"
/>
</div>
<p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary">
<span>{adContentUrl[3]}</span>
</p>
</a>
</div>
</div>
) : null}
{showAd2 ? (
<div className="rounded-xl bg-background-main hover:scale-[1.02] max-w-[16rem] transition-all duration-300 md:flex-1 relative group">
<div className="bg-opacity-10 bg-buttons-purple rounded-xl border-2 border-buttons-purple border-opacity-30 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20">
<button
onClick={dismissAd}
type="button"
className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label="Dismiss ad"
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.X}
/>
</button>
<a href={adContentUrl[4]} className="block">
<div className="overflow-hidden rounded-t-xl">
<img
src={adContentUrl[5]}
alt="ad banner"
className="w-full h-auto transition-transform duration-300"
/>
</div>
<p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary">
<span>{adContentUrl[6]}</span>
</p>
</a>
</div>
</div>
) : null}
</div>
{adContentUrl[0] !== "null" && (
<div>
<p className="text-xs text-type-dimmed text-center pt-2 mx-4">
<a
href="https://discord.gg/mcjnJK98Gd"
target="_blank"
rel="noreferrer"
>
{adContentUrl[0]}
</a>
</p>
</div>
)}
</>
);
})()}
</div>
);
}

View file

@ -1,21 +1,22 @@
import classNames from "classnames";
import { useCallback, useEffect, useRef, useState } from "react";
import Sticky from "react-sticky-el";
import { useWindowSize } from "react-use";
import { SearchBarInput } from "@/components/form/SearchBar";
import { Icon, Icons } from "@/components/Icon";
import { ThinContainer } from "@/components/layout/ThinContainer";
import { useSlashFocus } from "@/components/player/hooks/useSlashFocus";
import { HeroTitle } from "@/components/text/HeroTitle";
import { useIsTV } from "@/hooks/useIsTv";
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
import { useSearchQuery } from "@/hooks/useSearchQuery";
import { conf } from "@/setup/config";
import { useBannerSize } from "@/stores/banner";
export interface HeroPartProps {
setIsSticky: (val: boolean) => void;
searchParams: ReturnType<typeof useSearchQuery>;
showTitle?: boolean;
isInFeatured?: boolean;
}
function getTimeOfDay(date: Date): "night" | "morning" | "day" | "420" | "69" {
@ -30,32 +31,17 @@ function getTimeOfDay(date: Date): "night" | "morning" | "day" | "420" | "69" {
return "night";
}
function getCookie(name: string): string | null {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i += 1) {
const cookie = cookies[i].trim();
if (cookie.startsWith(`${name}=`)) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function setCookie(name: string, value: string, expiryDays: number): void {
const date = new Date();
date.setTime(date.getTime() + expiryDays * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/`;
}
export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
export function HeroPart({
setIsSticky,
searchParams,
showTitle,
isInFeatured,
}: HeroPartProps) {
const { t: randomT } = useRandomTranslation();
const [search, setSearch, setSearchUnFocus] = searchParams;
const [, setShowBg] = useState(false);
const [isAdDismissed, setIsAdDismissed] = useState(() => {
return getCookie("adDismissed") === "true";
});
const [showBg, setShowBg] = useState(false);
const bannerSize = useBannerSize();
const stickStateChanged = useCallback(
(isFixed: boolean) => {
setShowBg(isFixed);
@ -63,6 +49,7 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
},
[setShowBg, setIsSticky],
);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { isTV } = useIsTV();
@ -80,11 +67,6 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
? -40 // landscape
: 0; // portrait
const dismissAd = useCallback(() => {
setIsAdDismissed(true);
setCookie("adDismissed", "true", 2); // Expires after 2 days
}, []);
useEffect(() => {
if (windowWidth > 1280) {
// On large screens the bar goes inline with the nav elements
@ -103,12 +85,18 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
return (
<ThinContainer>
<div className="mt-44 space-y-16 text-center">
<div className="relative z-10 mb-16">
{isTV && search.length > 0 ? null : (
<div
className={classNames(
"space-y-16 text-center",
showTitle ? "mt-44" : `mt-4`,
)}
>
{showTitle && (!isTV || search.length === 0) ? (
<div className="relative z-10 mb-16">
<HeroTitle className="mx-auto max-w-md">{title}</HeroTitle>
)}
</div>
</div>
) : null}
<div className="relative h-20 z-30">
<Sticky
topOffset={stickyOffset * -1 + bannerSize}
@ -123,112 +111,12 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
value={search}
onUnFocus={setSearchUnFocus}
placeholder={placeholder ?? ""}
isSticky={showBg}
isInFeatured={isInFeatured}
/>
</Sticky>
</div>
</div>
{/* Optional ad */}
{conf().SHOW_AD && !isAdDismissed ? (
<div className="-mb-10 md:-mb-20 w-fit max-w-[32rem] mx-auto relative group pb-4">
{(() => {
const adContentUrl = conf().AD_CONTENT_URL;
// VITE_AD_CONTENT_URL=default message (null will be nothing),referal link,image link, card message
// Ensure adContentUrl is an array. If not, render nothing for ads.
if (!Array.isArray(adContentUrl)) {
return null;
}
const ad1LinkIsValid =
typeof adContentUrl[1] === "string" && adContentUrl[1].length > 0;
const ad1ImageIsProvided = typeof adContentUrl[2] === "string";
const showAd1 =
adContentUrl.length >= 2 && ad1LinkIsValid && ad1ImageIsProvided;
const ad2LinkIsValid =
typeof adContentUrl[3] === "string" && adContentUrl[3].length > 0;
const ad2ImageIsProvided = typeof adContentUrl[4] === "string";
const showAd2 =
adContentUrl.length >= 5 && ad2LinkIsValid && ad2ImageIsProvided;
return (
<>
<div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0 justify-center w-full items-center md:items-start">
{showAd1 ? (
<div className="hover:scale-[1.02] max-w-[16rem] bg-opacity-10 bg-buttons-purple backdrop-blur-sm rounded-xl border-2 border-buttons-purple border-opacity-30 transition-all duration-300 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20 md:flex-1 relative group">
<button
onClick={dismissAd}
type="button"
className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label="Dismiss ad"
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.X}
/>
</button>
<a href={adContentUrl[1]} className="block">
<div className="overflow-hidden rounded-t-xl">
<img
src={adContentUrl[2]}
alt="ad banner"
className="w-full h-auto transition-transform duration-300"
/>
</div>
<p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary">
<span>{adContentUrl[3]}</span>
</p>
</a>
</div>
) : null}
{showAd2 ? (
<div className="hover:scale-[1.02] max-w-[16rem] bg-opacity-10 bg-buttons-purple backdrop-blur-sm rounded-xl border-2 border-buttons-purple border-opacity-30 transition-all duration-300 hover:border-opacity-70 hover:shadow-lg hover:shadow-buttons-purple/20 md:flex-1 relative group">
<button
onClick={dismissAd}
type="button"
className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label="Dismiss ad"
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.X}
/>
</button>
<a href={adContentUrl[4]} className="block">
<div className="overflow-hidden rounded-t-xl">
<img
src={adContentUrl[5]}
alt="ad banner"
className="w-full h-auto transition-transform duration-300"
/>
</div>
<p className="text-xs text-type-dimmed text-center py-2 transition-colors duration-300 group-hover:text-type-secondary">
<span>{adContentUrl[6]}</span>
</p>
</a>
</div>
) : null}
</div>
{adContentUrl[0] !== "null" && (
<div>
<p className="text-xs text-type-dimmed text-center pt-2 mx-4">
<a
href="https://discord.gg/mcjnJK98Gd"
target="_blank"
rel="noreferrer"
>
{adContentUrl[0]}
</a>
</p>
</div>
)}
</>
);
})()}
</div>
) : null}
{/* End of ad */}
</ThinContainer>
);
}

View file

@ -205,8 +205,14 @@ export function AppearancePart(props: {
enableDiscover: boolean;
setEnableDiscover: (v: boolean) => void;
enableFeatured: boolean;
setEnableFeatured: (v: boolean) => void;
enableDetailsModal: boolean;
setEnableDetailsModal: (v: boolean) => void;
enableImageLogos: boolean;
setEnableImageLogos: (v: boolean) => void;
}) {
const { t } = useTranslation();
@ -263,6 +269,7 @@ export function AppearancePart(props: {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* First Column - Preferences */}
<div className="space-y-8">
{/* Discover */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.discover")}
@ -271,7 +278,13 @@ export function AppearancePart(props: {
{t("settings.appearance.options.discoverDescription")}
</p>
<div
onClick={() => props.setEnableDiscover(!props.enableDiscover)}
onClick={() => {
const newDiscoverValue = !props.enableDiscover;
props.setEnableDiscover(newDiscoverValue);
if (!newDiscoverValue) {
props.setEnableFeatured(false);
}
}}
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg"
>
<Toggle enabled={props.enableDiscover} />
@ -280,6 +293,27 @@ export function AppearancePart(props: {
</p>
</div>
</div>
{/* Featured Carousel */}
{props.enableDiscover && (
<div className="pt-4 pl-4 border-l-8 border-dropdown-background">
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.featured")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.featuredDescription")}
</p>
<div
onClick={() => props.setEnableFeatured(!props.enableFeatured)}
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg"
>
<Toggle enabled={props.enableFeatured} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.featuredLabel")}
</p>
</div>
</div>
)}
{/* Detials Modal */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.modal")}
@ -302,6 +336,32 @@ export function AppearancePart(props: {
</p>
</div>
</div>
{/* Image Logos */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.logos")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.logosDescription")}
</p>
<p className="max-w-[25rem] font-medium pt-2 items-center flex gap-4">
<Icon icon={Icons.CIRCLE_EXCLAMATION} className="" />
{t("settings.appearance.options.logosNotice")}
</p>
<div
onClick={() => props.setEnableImageLogos(!props.enableImageLogos)}
className={classNames(
"bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg",
"cursor-pointer opacity-100 pointer-events-auto",
)}
>
<Toggle enabled={props.enableImageLogos} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.logosLabel")}
</p>
</div>
</div>
</div>
{/* Second Column - Themes */}

View file

@ -73,6 +73,7 @@ export function PreferencesPart(props: {
{t("settings.preferences.languageDescription")}
</p>
<Dropdown
className="w-full"
options={options}
selectedItem={selected || options[0]}
setSelectedItem={(opt) => props.setLanguage(opt.id)}
@ -127,7 +128,7 @@ export function PreferencesPart(props: {
{/* Skip End Credits Preference */}
{props.enableAutoplay && allowAutoplay && (
<div className="pt-4">
<div className="pt-4 pl-4 border-l-8 border-dropdown-background">
<p className="text-white font-bold mb-3">
{t("settings.preferences.skipCredits")}
</p>

View file

@ -16,6 +16,7 @@ import { AboutPage } from "@/pages/About";
import { AdminPage } from "@/pages/admin/AdminPage";
import VideoTesterView from "@/pages/developer/VideoTesterView";
import { Discover } from "@/pages/discover/Discover";
import { MoreContent } from "@/pages/discover/MoreContent";
import { DmcaPage } from "@/pages/Dmca";
import MaintenancePage from "@/pages/errors/MaintenancePage";
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
@ -166,8 +167,16 @@ function App() {
{/* Support page */}
<Route path="/support" element={<SupportPage />} />
<Route path="/jip" element={<JipPage />} />
{/* Discover page */}
{/* Discover pages */}
<Route path="/discover" element={<Discover />} />
<Route
path="/discover/more/:type/:id/:mediaType"
element={<MoreContent />}
/>
<Route
path="/discover/more/:category/:genreId?"
element={<MoreContent />}
/>
{/* Settings page */}
<Route
path="/settings"

View file

@ -0,0 +1,32 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
type Category = "movies" | "tvshows" | "editorpicks";
interface DiscoverView {
url: string;
scrollPosition: number;
}
interface DiscoverState {
selectedCategory: Category;
lastView: DiscoverView | null;
setSelectedCategory: (category: Category) => void;
setLastView: (view: DiscoverView) => void;
clearLastView: () => void;
}
export const useDiscoverStore = create<DiscoverState>()(
persist(
(set) => ({
selectedCategory: "movies",
lastView: null,
setSelectedCategory: (category) => set({ selectedCategory: category }),
setLastView: (view) => set({ lastView: view }),
clearLastView: () => set({ lastView: null }),
}),
{
name: "__MW::discover",
},
),
);

View file

@ -7,7 +7,9 @@ export interface PreferencesStore {
enableAutoplay: boolean;
enableSkipCredits: boolean;
enableDiscover: boolean;
enableFeatured: boolean;
enableDetailsModal: boolean;
enableImageLogos: boolean;
sourceOrder: string[];
enableSourceOrder: boolean;
proxyTmdb: boolean;
@ -16,7 +18,9 @@ export interface PreferencesStore {
setEnableAutoplay(v: boolean): void;
setEnableSkipCredits(v: boolean): void;
setEnableDiscover(v: boolean): void;
setEnableFeatured(v: boolean): void;
setEnableDetailsModal(v: boolean): void;
setEnableImageLogos(v: boolean): void;
setSourceOrder(v: string[]): void;
setEnableSourceOrder(v: boolean): void;
setProxyTmdb(v: boolean): void;
@ -29,7 +33,9 @@ export const usePreferencesStore = create(
enableAutoplay: true,
enableSkipCredits: true,
enableDiscover: true,
enableFeatured: true, // enabled for testing
enableDetailsModal: false,
enableImageLogos: true,
sourceOrder: [],
enableSourceOrder: false,
proxyTmdb: false,
@ -53,11 +59,21 @@ export const usePreferencesStore = create(
s.enableDiscover = v;
});
},
setEnableFeatured(v) {
set((s) => {
s.enableFeatured = v;
});
},
setEnableDetailsModal(v) {
set((s) => {
s.enableDetailsModal = v;
});
},
setEnableImageLogos(v) {
set((s) => {
s.enableImageLogos = v;
});
},
setSourceOrder(v) {
set((s) => {
s.sourceOrder = v;