components!

This commit is contained in:
Pas 2025-06-02 00:00:16 -06:00
parent 366cf3e99f
commit ac344e8ce9
14 changed files with 1264 additions and 4 deletions

View file

@ -17,7 +17,7 @@ import { MediaBookmarkButton } from "./MediaBookmark";
import { Button } from "../buttons/Button";
import { IconPatch } from "../buttons/IconPatch";
import { Icon, Icons } from "../Icon";
import { DetailsModal } from "../overlays/DetailsModal";
import { DetailsModal } from "../overlays/details/DetailsModal";
import { useModal } from "../overlays/Modal";
export interface MediaCardProps {

View file

@ -0,0 +1,211 @@
import { t } from "i18next";
import { useEffect, useMemo, useState } from "react";
import { useCopyToClipboard } from "react-use";
import { Icon, Icons } from "@/components/Icon";
import { useLanguageStore } from "@/stores/language";
import { useProgressStore } from "@/stores/progress";
import { shouldShowProgress } from "@/stores/progress/utils";
import { scrapeIMDb } from "@/utils/imdbScraper";
import { getTmdbLanguageCode } from "@/utils/language";
import { scrapeRottenTomatoes } from "@/utils/rottenTomatoesScraper";
import { DetailsHeader } from "./DetailsHeader";
import { DetailsInfo } from "./DetailsInfo";
import { EpisodeCarousel } from "./EpisodeCarousel";
import { TrailerOverlay } from "./TrailerOverlay";
import { DetailsContentProps } from "./types";
export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
const [imdbData, setImdbData] = useState<any>(null);
const [rtData, setRtData] = useState<any>(null);
const [, setIsLoadingImdb] = useState(false);
const [showTrailer, setShowTrailer] = useState(false);
const [selectedSeason, setSelectedSeason] = useState<number>(1);
const [, copyToClipboard] = useCopyToClipboard();
const [hasCopiedShare, setHasCopiedShare] = useState(false);
const progress = useProgressStore((s) => s.items);
const showProgress = useMemo(() => {
if (!data.id) return null;
const item = progress[data.id.toString()];
if (!item) return null;
return shouldShowProgress(item);
}, [data.id, progress]);
// Set initial season based on current episode
useEffect(() => {
if (showProgress?.season?.number) {
setSelectedSeason(showProgress.season.number);
}
}, [showProgress]);
useEffect(() => {
const fetchExternalData = async () => {
if (!data.imdbId) return;
setIsLoadingImdb(true);
try {
// Get the user's selected language and format it properly
const userLanguage = useLanguageStore.getState().language;
const formattedLanguage = getTmdbLanguageCode(userLanguage);
// Fetch IMDb data
const imdbMetadata = await scrapeIMDb(
data.imdbId,
undefined,
undefined,
formattedLanguage,
);
setImdbData(imdbMetadata);
// Fetch Rotten Tomatoes data
if (data.type === "movie") {
const rtMetadata = await scrapeRottenTomatoes(
data.title,
data.releaseDate
? new Date(data.releaseDate).getFullYear()
: undefined,
);
setRtData(rtMetadata);
}
} catch (error) {
console.error("Failed to fetch external data:", error);
} finally {
setIsLoadingImdb(false);
}
};
fetchExternalData();
}, [data.imdbId, data.title, data.releaseDate, data.type]);
const handlePlayClick = () => {
if (data.type === "movie") {
window.location.assign(
`/media/tmdb-movie-${data.id}-${data.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
);
} else if (data.type === "show") {
if (showProgress?.season?.id && showProgress?.episode?.id) {
window.location.assign(
`/media/tmdb-tv-${data.id}-${data.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}/${showProgress.season.id}/${showProgress.episode.id}`,
);
} else {
// Start new show
window.location.assign(
`/media/tmdb-tv-${data.id}-${data.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
);
}
}
};
const handleShareClick = () => {
const shareUrl =
data.type === "movie"
? `${window.location.origin}/media/tmdb-movie-${data.id}-${data.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`
: `${window.location.origin}/media/tmdb-tv-${data.id}-${data.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
copyToClipboard(shareUrl);
setHasCopiedShare(true);
setTimeout(() => setHasCopiedShare(false), 2000);
};
return (
<div className="relative h-full flex flex-col">
{/* Share notification popup */}
{hasCopiedShare && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 px-4 py-2 bg-green-600 text-white rounded-lg shadow-lg transition-all duration-300 animate-[scaleIn_0.6s_ease-out_forwards]">
<div className="flex items-center gap-2">
<Icon icon={Icons.CHECKMARK} className="text-white" />
<span className="text-sm font-medium">
Link copied to clipboard!
</span>
</div>
</div>
)}
{/* Trailer Overlay */}
{showTrailer && imdbData?.trailer_url && (
<TrailerOverlay
trailerUrl={imdbData.trailer_url}
onClose={() => setShowTrailer(false)}
/>
)}
{/* Backdrop - Even taller */}
<div className="h-64 lg:h-80 xl:h-96 relative -mt-12">
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: data.backdrop
? `url(${data.backdrop})`
: undefined,
maskImage:
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 60px)",
WebkitMaskImage:
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 60px)",
}}
/>
</div>
{/* Content */}
<div className="px-6 pb-6 mt-[-70px] flex-grow">
<DetailsHeader
data={data}
onPlayClick={handlePlayClick}
onTrailerClick={() => setShowTrailer(true)}
onShareClick={handleShareClick}
showProgress={showProgress}
/>
{/* Two Column Layout - Stacked on Mobile */}
<div className="grid grid-cols-1 md:grid-cols-3 md:gap-6">
{/* Left Column - Description */}
<div className="md:col-span-2">
{data.overview && (
<p className="text-sm text-white/90 mb-6">{data.overview}</p>
)}
{/* Director and Cast */}
<div className="space-y-4 mb-6">
{data.director && (
<div className="text-xs">
<span className="font-medium text-white/80">
{t("details.director")}
</span>{" "}
<span className="text-white/70">{data.director}</span>
</div>
)}
{data.actors && data.actors.length > 0 && (
<div className="text-xs">
<span className="font-medium text-white/80">
{t("details.cast")}
</span>{" "}
<span className="text-white/70">
{data.actors.join(", ")}
</span>
</div>
)}
</div>
</div>
{/* Right Column - Details */}
<DetailsInfo data={data} imdbData={imdbData} rtData={rtData} />
</div>
{/* Episodes Carousel for TV Shows */}
{data.type === "show" && data.seasonData && !minimal && (
<EpisodeCarousel
episodes={data.seasonData.episodes}
showProgress={showProgress}
progress={progress}
selectedSeason={selectedSeason}
onSeasonChange={setSelectedSeason}
seasons={data.seasonData.seasons}
mediaId={data.id}
mediaTitle={data.title}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,136 @@
import classNames from "classnames";
import { t } from "i18next";
import { Button } from "@/components/buttons/Button";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icon, Icons } from "@/components/Icon";
import { MediaBookmarkButton } from "@/components/media/MediaBookmark";
import { DetailsHeaderProps } from "./types";
export function DetailsHeader({
data,
onPlayClick,
onTrailerClick,
onShareClick,
showProgress,
}: DetailsHeaderProps) {
return (
<>
{/* Title and Genres Row */}
<div className="pb-2">
{data.logoUrl ? (
<img
src={data.logoUrl}
alt={data.title}
className="max-w-[12rem] md:max-w-[20rem] object-contain drop-shadow-lg bg-transparent"
style={{ background: "none" }}
/>
) : (
<h3 className="text-2xl font-bold text-white z-[999]">
{data.title}
</h3>
)}
</div>
<div className="flex flex-col sm:flex-row justify-between items-start mb-6 w-full">
<div className="flex items-center gap-4 w-full sm:w-auto">
<Button
onClick={onPlayClick}
theme="purple"
className={classNames(
"flex-1 sm:flex-initial sm:w-auto",
"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",
)}
>
<Icon icon={Icons.PLAY} className="text-white" />
<span className="text-white text-sm pr-1">
{data.type === "movie"
? !data.releaseDate || new Date(data.releaseDate) > new Date()
? t("media.unreleased")
: showProgress
? t("details.resume")
: t("details.play")
: showProgress
? t("details.resume")
: t("details.play")}
</span>
</Button>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={onTrailerClick}
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
title={t("details.trailer")}
>
<IconPatch
icon={Icons.FILM}
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
/>
</button>
<MediaBookmarkButton
media={{
id: data.id?.toString() || "",
title: data.title,
year: data.releaseDate
? new Date(data.releaseDate).getFullYear()
: undefined,
poster: data.backdrop,
type: data.type || "movie",
}}
/>
<button
type="button"
onClick={onShareClick}
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
title="Share"
>
<IconPatch
icon={Icons.IOS_SHARE}
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
/>
</button>
</div>
</div>
{/* Genres on the right side of the button row for larger screens */}
{data.genres && data.genres.length > 0 && (
<div className="hidden sm:flex flex-wrap gap-2 justify-end items-center">
{data.genres.map((genre, index) => (
<span
key={genre.id}
className="text-[11px] px-2 py-0.5 rounded-full bg-white/20 text-white/80 transition-all duration-300 hover:scale-110 animate-[scaleIn_0.6s_ease-out_forwards]"
style={{
animationDelay: `${((data.genres?.length ?? 0) - 1 - index) * 60}ms`,
transform: "scale(0)",
opacity: 0,
}}
>
{genre.name}
</span>
))}
</div>
)}
</div>
{/* Genres below for small screens */}
{data.genres && data.genres.length > 0 && (
<div className="flex sm:hidden flex-wrap gap-2 justify-start items-center mb-6 -mt-3">
{data.genres.map((genre, index) => (
<span
key={genre.id}
className="text-[11px] px-2 py-0.5 rounded-full bg-white/20 text-white/80 transition-all duration-300 hover:scale-110 animate-[scaleIn_0.6s_ease-out_forwards]"
style={{
animationDelay: `${((data.genres?.length ?? 0) - 1 - index) * 60}ms`,
transform: "scale(0)",
opacity: 0,
}}
>
{genre.name}
</span>
))}
</div>
)}
</>
);
}

View file

@ -0,0 +1,88 @@
import { t } from "i18next";
import { Trans } from "react-i18next";
import { DetailsRatings } from "./DetailsRatings";
import { DetailsInfoProps } from "./types";
export function DetailsInfo({ data, imdbData, rtData }: DetailsInfoProps) {
const formatRuntime = (minutes?: number | null) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
};
const getEndTime = (runtime?: number | null) => {
if (!runtime) return null;
const now = new Date();
const endTime = new Date(now.getTime() + runtime * 60000);
return endTime.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
const formatDate = (dateString?: string) => {
if (!dateString) return null;
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
return (
<div className="md:col-span-1 bg-video-context-border p-4 rounded-lg border-buttons-primary bg-opacity-80">
<div className="space-y-3 text-xs">
{data.runtime && (
<div className="flex flex-wrap items-center gap-2 text-white/80">
<div className="flex items-center gap-1">
<span className="font-medium">{t("details.runtime")}</span>{" "}
{formatRuntime(data.runtime)}
</div>
{data.type === "movie" && (
<div className="flex items-center gap-1">
<span className="hidden lg:inline mx-1"></span>
<Trans
i18nKey="details.endsAt"
className="font-medium"
values={{ time: getEndTime(data.runtime) }}
/>
</div>
)}
</div>
)}
{data.language && (
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">{t("details.language")}</span>{" "}
{data.language.toUpperCase()}
</div>
)}
{data.releaseDate && (
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">{t("details.releaseDate")}</span>{" "}
{formatDate(data.releaseDate)}
</div>
)}
{data.rating && (
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">{t("details.rating")}</span>{" "}
{data.rating}
</div>
)}
{/* Ratings and External Links */}
<DetailsRatings
voteAverage={data.voteAverage}
voteCount={data.voteCount}
imdbData={imdbData}
rtData={rtData}
mediaId={data.id}
mediaType={data.type}
imdbId={data.imdbId}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,173 @@
import classNames from "classnames";
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import {
getMediaBackdrop,
getMediaDetails,
getMediaLogo,
} from "@/backend/metadata/tmdb";
import {
TMDBContentTypes,
TMDBMovieData,
TMDBShowData,
} from "@/backend/metadata/types/tmdb";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Flare } from "@/components/utils/Flare";
import { useModal } from "../Modal";
import { OverlayPortal } from "../OverlayDisplay";
import { DetailsContent } from "./DetailsContent";
import { DetailsSkeleton } from "./DetailsSkeleton";
import { DetailsModalProps } from "./types";
export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
const modal = useModal(id);
const [detailsData, setDetailsData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchDetails = async () => {
if (!data?.id || !data?.type) return;
setIsLoading(true);
try {
const type =
data.type === "movie" ? TMDBContentTypes.MOVIE : TMDBContentTypes.TV;
const details = await getMediaDetails(data.id.toString(), type);
const backdropUrl = getMediaBackdrop(details.backdrop_path);
const logoUrl = await getMediaLogo(data.id.toString(), type);
if (type === TMDBContentTypes.MOVIE) {
const movieDetails = details as TMDBMovieData;
setDetailsData({
title: movieDetails.title,
overview: movieDetails.overview,
backdrop: backdropUrl,
runtime: movieDetails.runtime,
genres: movieDetails.genres,
language: movieDetails.original_language,
voteAverage: movieDetails.vote_average,
voteCount: movieDetails.vote_count,
releaseDate: movieDetails.release_date,
rating: movieDetails.release_dates?.results?.find(
(r) => r.iso_3166_1 === "US",
)?.release_dates?.[0]?.certification,
director: movieDetails.credits?.crew?.find(
(person) => person.job === "Director",
)?.name,
actors: movieDetails.credits?.cast
?.slice(0, 5)
.map((actor) => actor.name),
type: "movie",
id: movieDetails.id,
imdbId: movieDetails.external_ids?.imdb_id,
logoUrl,
});
} else {
const showDetails = details as TMDBShowData & {
episodes: Array<{
id: number;
name: string;
episode_number: number;
overview: string;
still_path: string | null;
air_date: string;
season_number: number;
}>;
};
setDetailsData({
title: showDetails.name,
overview: showDetails.overview,
backdrop: backdropUrl,
episodes: showDetails.number_of_episodes,
seasons: showDetails.number_of_seasons,
genres: showDetails.genres,
language: showDetails.original_language,
voteAverage: showDetails.vote_average,
voteCount: showDetails.vote_count,
releaseDate: showDetails.first_air_date,
rating: showDetails.content_ratings?.results?.find(
(r) => r.iso_3166_1 === "US",
)?.rating,
director: showDetails.credits?.crew?.find(
(person) => person.job === "Director",
)?.name,
actors: showDetails.credits?.cast
?.slice(0, 5)
.map((actor) => actor.name),
type: "show",
id: showDetails.id,
imdbId: showDetails.external_ids?.imdb_id,
seasonData: {
seasons: showDetails.seasons,
episodes: showDetails.episodes,
},
logoUrl,
});
}
} catch (err) {
console.error("Failed to fetch media details:", err);
} finally {
setIsLoading(false);
}
};
if (modal.isShown && data?.id) {
fetchDetails();
}
}, [modal.isShown, data]);
useEffect(() => {
if (modal.isShown && !data?.id && !isLoading) {
modal.hide();
}
}, [modal, data, isLoading]);
return (
<OverlayPortal darken close={modal.hide} show={modal.isShown}>
<Helmet>
<html data-no-scroll />
</Helmet>
<div className="flex absolute inset-0 items-center justify-center">
<Flare.Base
className={classNames(
"group -m-[0.705em] rounded-3xl bg-background-main transition-colors duration-300 focus:relative focus:z-10",
"max-h-[900px] max-w-[1200px]",
"bg-mediaCard-hoverBackground bg-opacity-60 backdrop-filter backdrop-blur-lg shadow-lg overflow-hidden",
minimal
? "h-[90%] md:h-[70%] lg:h-fit w-[90%] md:w-[70%] lg:w-[50%]"
: "h-[90%] w-[90%] md:w-[70%] lg:w-[60%]",
)}
>
<div className="transition-transform duration-300 h-full">
<Flare.Light
flareSize={300}
cssColorVar="--colors-mediaCard-hoverAccent"
backgroundClass="bg-mediaCard-hoverBackground duration-100"
className="rounded-3xl bg-background-main group-hover:opacity-100"
/>
<Flare.Child className="pointer-events-auto relative h-full overflow-y-auto scrollbar-none select-text">
<div className="absolute right-4 top-4 z-10">
<button
type="button"
className="text-s font-semibold text-type-secondary hover:text-white transition-transform hover:scale-95 select-none"
onClick={modal.hide}
>
<IconPatch icon={Icons.X} />
</button>
</div>
<div className="pt-12 select-text">
{isLoading || !detailsData ? (
<DetailsSkeleton />
) : (
<DetailsContent data={detailsData} minimal={minimal} />
)}
</div>
</Flare.Child>
</div>
</Flare.Base>
</div>
</OverlayPortal>
);
}

View file

@ -0,0 +1,105 @@
import { t } from "i18next";
import { Icon, Icons } from "@/components/Icon";
import { getRTIcon } from "@/utils/rottenTomatoesScraper";
import { DetailsRatingsProps } from "./types";
export function DetailsRatings({
voteAverage,
voteCount,
imdbData,
rtData,
mediaId,
mediaType,
imdbId,
}: DetailsRatingsProps) {
const formatVoteCount = (count?: number) => {
if (!count) return "0";
if (count >= 1000) {
return `${Math.floor(count / 1000)}K+`;
}
return count.toString();
};
const getRatingColor = (rating: number) => {
if (rating >= 8) return "bg-green-500";
if (rating >= 6) return "bg-yellow-500";
if (rating >= 4) return "bg-orange-500";
return "bg-red-500";
};
return (
<div className="space-y-1">
{voteAverage !== undefined &&
voteCount !== undefined &&
voteCount > 0 && (
<>
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">{t("details.rating")}</span>{" "}
<span className="text-white/90">
{imdbData?.imdb_rating
? `${imdbData.imdb_rating.toFixed(1)}/10 (IMDb)`
: `${voteAverage.toFixed(1)}/10 (TMDB)`}
</span>
</div>
{/* Rating Progress Bar */}
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div
className={`h-full ${getRatingColor(imdbData?.imdb_rating || voteAverage)} transition-all duration-500`}
style={{
width: `${((imdbData?.imdb_rating || voteAverage) / 10) * 100}%`,
}}
/>
</div>
<div className="text-white/60 text-[10px] text-right">
{formatVoteCount(imdbData?.votes || voteCount)}{" "}
{t("details.votes")}
</div>
</>
)}
{/* External Links */}
<div className="flex gap-3 mt-2">
{mediaId && (
<a
href={`https://www.themoviedb.org/${mediaType === "show" ? "tv" : "movie"}/${mediaId}`}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 rounded-full bg-[#0d253f] flex items-center justify-center transition-transform hover:scale-110"
title={t("details.tmdb")}
>
<Icon icon={Icons.TMDB} className="text-white" />
</a>
)}
{imdbId && (
<a
href={`https://www.imdb.com/title/${imdbId}`}
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 rounded-full bg-yellow-500 flex items-center justify-center transition-transform hover:scale-110"
title={t("details.imdb")}
>
<Icon icon={Icons.IMDB} className="text-black" />
</a>
)}
{rtData && (
<div className="flex items-center gap-1">
<div className="flex flex-col items-center justify-center gap-1">
<div className="flex items-center gap-1" title="Tomatometer">
<img
src={getRTIcon(rtData.tomatoIcon)}
alt="Tomatometer"
className="w-8 h-8"
/>
<span className="text-sm pl-1 text-white/80">
{rtData.tomatoScore}%
</span>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
export function DetailsSkeleton() {
return (
<div className="animate-pulse">
<div className="relative">
{/* Backdrop */}
<div className="h-64 relative -mt-12">
<div
className="absolute inset-0 bg-mediaCard-hoverBackground"
style={{
maskImage:
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 60px)",
WebkitMaskImage:
"linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 60px)",
}}
/>
</div>
{/* Content */}
<div className="px-6 pb-6 mt-[-30px]">
<div className="h-8 w-3/4 bg-white/10 rounded mb-3" /> {/* Title */}
<div className="space-y-2 mb-6">
{/* Description */}
<div className="h-4 bg-white/10 rounded w-full" />
<div className="h-4 bg-white/10 rounded w-full" />
<div className="h-4 bg-white/10 rounded w-full" />
<div className="h-4 bg-white/10 rounded w-3/4" />
</div>
{/* Additional details */}
<div className="grid grid-cols-2 gap-3 mb-6">
<div className="h-4 bg-white/10 rounded w-3/4" />
<div className="h-4 bg-white/10 rounded w-3/4" />
<div className="h-4 bg-white/10 rounded w-3/4" />
<div className="h-4 bg-white/10 rounded w-3/4" />
</div>
{/* Genres */}
<div className="flex flex-wrap gap-2">
<div className="h-6 w-20 bg-white/10 rounded-full" />
<div className="h-6 w-24 bg-white/10 rounded-full" />
<div className="h-6 w-16 bg-white/10 rounded-full" />
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,346 @@
import classNames from "classnames";
import { t } from "i18next";
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/buttons/Button";
import { Dropdown } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon";
import { hasAired } from "@/components/player/utils/aired";
import { EpisodeCarouselProps } from "./types";
export function EpisodeCarousel({
episodes,
showProgress,
progress,
selectedSeason,
onSeasonChange,
seasons,
mediaId,
mediaTitle,
}: EpisodeCarouselProps) {
const [showEpisodeMenu, setShowEpisodeMenu] = useState(false);
const [customSeason, setCustomSeason] = useState("");
const [customEpisode, setCustomEpisode] = useState("");
const episodeMenuRef = useRef<HTMLDivElement>(null);
const carouselRef = useRef<HTMLDivElement>(null);
const activeEpisodeRef = useRef<HTMLAnchorElement>(null);
const handleScroll = (direction: "left" | "right") => {
if (!carouselRef.current) return;
const cardWidth = 256; // w-64 in pixels
const cardSpacing = 16; // space-x-4 in pixels
const scrollAmount = (cardWidth + cardSpacing) * 2;
const newScrollPosition =
carouselRef.current.scrollLeft +
(direction === "left" ? -scrollAmount : scrollAmount);
carouselRef.current.scrollTo({
left: newScrollPosition,
behavior: "smooth",
});
};
// Function to generate the episode URL
const getEpisodeUrl = (episode: any) => {
// Find the season ID for the current season
const season = seasons.find((s) => s.season_number === selectedSeason);
if (!season || !mediaId || !mediaTitle) return "#";
// Create the URL in the format: /media/tmdb-tv-{showId}-{showName}/{seasonId}/{episodeId}
return `/media/tmdb-tv-${mediaId}-${mediaTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-")}/${season.id}/${episode.id}`;
};
useEffect(() => {
if (carouselRef.current) {
if (activeEpisodeRef.current) {
// If there's an active episode, scroll to it
const containerLeft = carouselRef.current.getBoundingClientRect().left;
const containerWidth = carouselRef.current.clientWidth;
const elementLeft =
activeEpisodeRef.current.getBoundingClientRect().left;
const elementWidth = activeEpisodeRef.current.clientWidth;
const scrollPosition =
elementLeft - containerLeft - containerWidth / 2 + elementWidth / 2;
carouselRef.current.scrollTo({
left: carouselRef.current.scrollLeft + scrollPosition,
behavior: "smooth",
});
} else {
// If no active episode, scroll to the start
carouselRef.current.scrollTo({
left: 0,
behavior: "smooth",
});
}
}
}, [episodes, showProgress]);
// Add click outside handler for episode menu
useEffect(() => {
if (!showEpisodeMenu) return;
const handleClickOutside = (event: MouseEvent) => {
if (
episodeMenuRef.current &&
!episodeMenuRef.current.contains(event.target as Node)
) {
setShowEpisodeMenu(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showEpisodeMenu]);
const handleCustomNavigation = () => {
const season = parseInt(customSeason, 10);
const episode = parseInt(customEpisode, 10);
if (
Number.isNaN(season) ||
Number.isNaN(episode) ||
!mediaId ||
!mediaTitle
)
return;
// Find the season
const seasonData = seasons.find((s) => s.season_number === season);
if (!seasonData) return;
// Find the episode in the current season's episodes
const episodeData = episodes.find(
(e) => e.season_number === season && e.episode_number === episode,
);
if (!episodeData) {
console.error(
"No episode data found for season:",
season,
"episode:",
episode,
);
return;
}
// Navigate to the episode using the same URL format as getEpisodeUrl
const url = `/media/tmdb-tv-${mediaId}-${mediaTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-")}/${seasonData.id}/${episodeData.id}`;
window.location.href = url;
setShowEpisodeMenu(false);
};
const currentSeasonEpisodes = episodes.filter(
(ep) => ep.season_number === selectedSeason,
);
return (
<div className="mt-6 md:mt-0">
{/* Season Selector */}
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-3">
<h4 className="text-lg font-semibold text-white">
{t("details.episodes")}
</h4>
<div className="relative">
<button
type="button"
onClick={() => setShowEpisodeMenu(!showEpisodeMenu)}
className="p-2 rounded-full hover:bg-white/10 transition-colors"
title={t("details.goToEpisode")}
>
<Icon icon={Icons.SEARCH} className="text-white/80" />
</button>
{/* Episode Selection Menu */}
{showEpisodeMenu && (
<div
ref={episodeMenuRef}
className="absolute top-full left-0 mt-2 p-4 bg-background-main rounded-lg shadow-lg border border-white/10 z-50 min-w-[250px]"
>
<div className="space-y-4">
<div>
<label className="block text-sm text-white/80 mb-1">
{t("details.season")}
</label>
<input
type="number"
value={customSeason}
onChange={(e) => setCustomSeason(e.target.value)}
min="1"
max={seasons.length}
className="w-full px-3 py-2 bg-white/5 rounded border border-white/10 text-white focus:outline-none focus:border-white/30"
placeholder={t("details.season")}
/>
</div>
<div>
<label className="block text-sm text-white/80 mb-1">
{t("details.episode")}
</label>
<input
type="number"
value={customEpisode}
onChange={(e) => setCustomEpisode(e.target.value)}
min="1"
className="w-full px-3 py-2 bg-white/5 rounded border border-white/10 text-white focus:outline-none focus:border-white/30"
placeholder={t("details.episode")}
/>
</div>
<Button
theme="purple"
onClick={handleCustomNavigation}
className="w-full px-4 py-2"
>
{t("details.play")}
</Button>
</div>
</div>
)}
</div>
</div>
<Dropdown
options={seasons.map((season) => ({
id: season.season_number.toString(),
name: `${t("details.season")} ${season.season_number}`,
}))}
selectedItem={{
id: selectedSeason.toString(),
name: `${t("details.season")} ${selectedSeason}`,
}}
setSelectedItem={(item) => onSeasonChange(Number(item.id))}
/>
</div>
{/* Episodes Carousel */}
<div className="relative">
{/* Left scroll button */}
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
<button
type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
onClick={() => handleScroll("left")}
>
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
</button>
</div>
<div
ref={carouselRef}
className="flex overflow-x-auto space-x-4 pb-4 pt-2 lg:px-12 scrollbar-hide carousel-container"
style={{
scrollbarWidth: "none",
msOverflowStyle: "none",
}}
>
{/* Add padding before the first card */}
<div className="flex-shrink-0 w-4" />
{currentSeasonEpisodes.map((episode) => {
const isActive =
showProgress?.episode?.id === episode.id.toString();
const episodeProgress =
progress[mediaId?.toString() ?? ""]?.episodes?.[episode.id];
const percentage = episodeProgress
? (episodeProgress.progress.watched /
episodeProgress.progress.duration) *
100
: 0;
const isAired = hasAired(episode.air_date);
return (
<Link
key={episode.id}
to={getEpisodeUrl(episode)}
ref={isActive ? activeEpisodeRef : null}
className={classNames(
"flex-shrink-0 w-52 md:w-64 rounded-lg overflow-hidden transition-all duration-200 relative cursor-pointer hover:scale-95",
isActive
? "bg-video-context-hoverColor/50 hover:bg-white/5"
: "hover:bg-white/5",
!isAired ? "opacity-50" : "",
)}
>
{/* Thumbnail */}
<div className="relative aspect-video w-full bg-video-context-hoverColor">
{episode.still_path ? (
<img
src={`https://image.tmdb.org/t/p/w300${episode.still_path}`}
alt={episode.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-black bg-opacity-50">
<Icon
icon={Icons.FILM}
className="text-video-context-type-main opacity-50 text-3xl"
/>
</div>
)}
{/* Episode Number Badge */}
<div className="absolute top-2 left-2 flex items-center space-x-2">
<span className="p-0.5 px-2 rounded inline bg-video-context-hoverColor bg-opacity-80 text-video-context-type-main text-sm">
{t("media.episodeShort")}
{episode.episode_number}
</span>
{!isAired && (
<span className="text-video-context-type-main/70 text-sm">
{episode.air_date
? `(${t("details.airs")} - ${new Date(episode.air_date).toLocaleDateString()})`
: `(${t("media.unreleased")})`}
</span>
)}
</div>
</div>
{/* Content */}
<div className="p-3">
<h3 className="font-bold text-white line-clamp-1">
{episode.name}
</h3>
{episode.overview && (
<p className="text-sm text-white/80 mt-1.5 line-clamp-2">
{episode.overview}
</p>
)}
</div>
{/* Progress Bar */}
{percentage > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-progress-background/25">
<div
className="h-full bg-progress-filled"
style={{
width: `${percentage > 98 ? 100 : percentage}%`,
}}
/>
</div>
)}
</Link>
);
})}
{/* Add padding after the last card */}
<div className="flex-shrink-0 w-4" />
</div>
{/* Right scroll button */}
<div className="absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
<button
type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
onClick={() => handleScroll("right")}
>
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" />
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,35 @@
import { Icon, Icons } from "@/components/Icon";
import { TrailerOverlayProps } from "./types";
export function TrailerOverlay({ trailerUrl, onClose }: TrailerOverlayProps) {
return (
<div
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-50 flex items-center justify-center transition-opacity duration-300"
onClick={onClose}
>
<div
className="relative w-[90%] max-w-6xl aspect-video"
onClick={(e) => e.stopPropagation()}
>
<video
className="w-full h-full object-contain"
autoPlay
controls
playsInline
>
<source src={trailerUrl} type="video/mp4" />
</video>
{/* Close Button */}
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 p-2 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
>
<Icon icon={Icons.X} className="text-white" />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
export interface DetailsContent {
title: string;
overview?: string;
backdrop?: string;
runtime?: number | null;
genres?: Array<{ id: number; name: string }>;
language?: string;
voteAverage?: number;
voteCount?: number;
releaseDate?: string;
rating?: string;
director?: string;
actors?: string[];
type?: "movie" | "show";
id?: number;
episodes?: number;
seasons?: number;
imdbId?: string;
episode?: {
id: number;
number: number;
};
seasonData?: {
seasons: Array<{
id: number;
season_number: number;
name: string;
episode_count: number;
overview: string;
air_date: string;
poster_path: string | null;
}>;
episodes: Array<{
id: number;
name: string;
overview: string;
episode_number: number;
season_number: number;
still_path: string | null;
air_date: string;
vote_average: number;
vote_count: number;
}>;
};
logoUrl?: string;
}
export interface DetailsModalProps {
id: string;
data?: {
id: number;
type: "movie" | "show";
};
minimal?: boolean;
}
export interface DetailsContentProps {
data: DetailsContent;
minimal?: boolean;
}
export interface TrailerOverlayProps {
trailerUrl: string;
onClose: () => void;
}
export interface EpisodeCarouselProps {
episodes: Array<{
id: number;
name: string;
overview: string;
episode_number: number;
season_number: number;
still_path: string | null;
air_date: string;
vote_average: number;
vote_count: number;
}>;
showProgress?: {
episode?: {
id: string;
};
} | null;
progress: Record<string, any>;
selectedSeason: number;
onSeasonChange: (season: number) => void;
seasons: Array<{
id: number;
season_number: number;
name: string;
episode_count: number;
overview: string;
air_date: string;
poster_path: string | null;
}>;
mediaId?: number;
mediaTitle?: string;
}
export interface DetailsHeaderProps {
data: DetailsContent;
onPlayClick: () => void;
onTrailerClick: () => void;
onShareClick: () => void;
showProgress?: any;
}
export interface DetailsInfoProps {
data: DetailsContent;
imdbData?: any;
rtData?: any;
}
export interface DetailsRatingsProps {
voteAverage?: number;
voteCount?: number;
imdbData?: any;
rtData?: any;
mediaId?: number;
mediaType?: "movie" | "show";
imdbId?: string;
}

View file

@ -1,7 +1,7 @@
import { useState } from "react";
import { Icons } from "@/components/Icon";
import { DetailsModal } from "@/components/overlays/DetailsModal";
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
import { useModal } from "@/components/overlays/Modal";
import { usePlayerStore } from "@/stores/player/store";

View file

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { To, useNavigate } from "react-router-dom";
import { WideContainer } from "@/components/layout/WideContainer";
import { DetailsModal } from "@/components/overlays/DetailsModal";
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
import { useModal } from "@/components/overlays/Modal";
import { useDebounce } from "@/hooks/useDebounce";
import { useRandomTranslation } from "@/hooks/useRandomTranslation";

View file

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { get } from "@/backend/metadata/tmdb";
import { WideContainer } from "@/components/layout/WideContainer";
import { DetailsModal } from "@/components/overlays/DetailsModal";
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
import { useModal } from "@/components/overlays/Modal";
import {
Genre,